File 0001-Add-setting-to-enforce-two-factor-auth.patch of Package forgejo
From 3a769b2969945e9d5ff6fefbdd0ea55c9bc257f3 Mon Sep 17 00:00:00 2001
From: Mia Herkt <mia@0x0.st>
Date: Tue, 12 Nov 2024 03:11:22 +0100
Subject: [PATCH] Add setting to enforce two-factor auth
---
custom/conf/app.example.ini | 3 +++
models/perm/access/access.go | 7 ++++++
models/perm/access/access_test.go | 29 ++++++++++++++++++++++++
models/perm/access/repo_permission.go | 9 ++++++++
modules/setting/security.go | 2 ++
options/locale/locale_de-DE.ini | 1 +
options/locale/locale_en-US.ini | 1 +
options/locale/locale_ja-JP.ini | 1 +
routers/web/auth/auth.go | 7 ++++++
routers/web/user/setting/security/2fa.go | 13 +++++++++++
services/auth/session.go | 5 ++++
services/context/context.go | 4 ++++
templates/base/alert.tmpl | 5 ++++
13 files changed, 87 insertions(+)
diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini
index f8fa95bbab..5da79ede27 100644
--- a/custom/conf/app.example.ini
+++ b/custom/conf/app.example.ini
@@ -506,6 +506,9 @@ INTERNAL_TOKEN =
;; stemming from cached/logged plain-text API tokens.
;; In future releases, this will become the default behavior
;DISABLE_QUERY_AUTH_TOKEN = false
+;;
+;; Force users to enroll into Two-Factor Authentication. Users without 2FA have no access to any repositories.
+;ENFORCE_TWO_FACTOR_AUTH = false
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
diff --git a/models/perm/access/access.go b/models/perm/access/access.go
index 76b547f772..3f04c266af 100644
--- a/models/perm/access/access.go
+++ b/models/perm/access/access.go
@@ -8,11 +8,13 @@ import (
"context"
"fmt"
+ "forgejo.org/models/auth"
"forgejo.org/models/db"
"forgejo.org/models/organization"
"forgejo.org/models/perm"
repo_model "forgejo.org/models/repo"
user_model "forgejo.org/models/user"
+ "forgejo.org/modules/setting"
"xorm.io/builder"
)
@@ -37,6 +39,11 @@ func accessLevel(ctx context.Context, user *user_model.User, repo *repo_model.Re
restricted := false
if user != nil {
+ if setting.EnforceTwoFactorAuth {
+ if twoFactor, _ := auth.GetTwoFactorByUID(ctx, user.ID); twoFactor == nil {
+ return perm.AccessModeNone, nil
+ }
+ }
userID = user.ID
restricted = user.IsRestricted
}
diff --git a/models/perm/access/access_test.go b/models/perm/access/access_test.go
index 00939bced6..7f344fd986 100644
--- a/models/perm/access/access_test.go
+++ b/models/perm/access/access_test.go
@@ -12,6 +12,7 @@ import (
repo_model "forgejo.org/models/repo"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
+ "code.gitea.io/gitea/modules/setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -22,6 +23,7 @@ func TestAccessLevel(t *testing.T) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+ user24 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 24})
user29 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29})
// A public repository owned by User 2
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
@@ -66,6 +68,19 @@ func TestAccessLevel(t *testing.T) {
level, err = access_model.AccessLevel(db.DefaultContext, user29, repo24)
require.NoError(t, err)
assert.Equal(t, perm_model.AccessModeRead, level)
+
+ // test enforced two-factor authentication
+ setting.EnforceTwoFactorAuth = true
+ {
+ level, err = access_model.AccessLevel(user2, repo1)
+ require.NoError(t, err)
+ assert.Equal(t, perm_model.AccessModeNone, level)
+
+ level, err = access_model.AccessLevel(user24, repo1)
+ require.NoError(t, err)
+ assert.Equal(t, perm_model.AccessModeRead, level)
+ }
+ setting.EnforceTwoFactorAuth = false
}
func TestHasAccess(t *testing.T) {
@@ -73,6 +88,7 @@ func TestHasAccess(t *testing.T) {
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+ user24 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 24})
// A public repository owned by User 2
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
assert.False(t, repo1.IsPrivate)
@@ -92,6 +108,19 @@ func TestHasAccess(t *testing.T) {
_, err = access_model.HasAccess(db.DefaultContext, user2.ID, repo2)
require.NoError(t, err)
+
+ // test enforced two-factor authentication
+ setting.EnforceTwoFactorAuth = true
+ {
+ has, err = access_model.HasAccess(db.DefaultContext, user1.ID, repo1)
+ require.NoError(t, err)
+ assert.False(t, has)
+
+ has, err = access_model.HasAccess(db.DefaultContext, user24.ID, repo1)
+ require.NoError(t, err)
+ assert.True(t, has)
+ }
+ setting.EnforceTwoFactorAuth = false
}
func TestRepository_RecalculateAccesses(t *testing.T) {
diff --git a/models/perm/access/repo_permission.go b/models/perm/access/repo_permission.go
index ce9963b83a..e9746236e7 100644
--- a/models/perm/access/repo_permission.go
+++ b/models/perm/access/repo_permission.go
@@ -7,6 +7,7 @@ import (
"context"
"fmt"
+ "forgejo.org/models/auth"
"forgejo.org/models/db"
"forgejo.org/models/organization"
perm_model "forgejo.org/models/perm"
@@ -14,6 +15,7 @@ import (
"forgejo.org/models/unit"
user_model "forgejo.org/models/user"
"forgejo.org/modules/log"
+ "forgejo.org/modules/setting"
)
// Permission contains all the permissions related variables to a repository for a user
@@ -161,6 +163,13 @@ func GetUserRepoPermission(ctx context.Context, repo *repo_model.Repository, use
return perm, nil
}
+ if user != nil && setting.EnforceTwoFactorAuth {
+ if twoFactor, _ := auth.GetTwoFactorByUID(ctx, user.ID); twoFactor == nil {
+ perm.AccessMode = perm_model.AccessModeNone
+ return perm, nil
+ }
+ }
+
var isCollaborator bool
var err error
if user != nil {
diff --git a/modules/setting/security.go b/modules/setting/security.go
index f3480d1056..cf73de3c33 100644
--- a/modules/setting/security.go
+++ b/modules/setting/security.go
@@ -36,6 +36,7 @@ var (
PasswordCheckPwn bool
SuccessfulTokensCacheSize int
DisableQueryAuthToken bool
+ EnforceTwoFactorAuth bool
CSRFCookieName = "_csrf"
CSRFCookieHTTPOnly = true
)
@@ -141,6 +142,7 @@ func loadSecurityFrom(rootCfg ConfigProvider) {
CSRFCookieHTTPOnly = sec.Key("CSRF_COOKIE_HTTP_ONLY").MustBool(true)
PasswordCheckPwn = sec.Key("PASSWORD_CHECK_PWN").MustBool(false)
SuccessfulTokensCacheSize = sec.Key("SUCCESSFUL_TOKENS_CACHE_SIZE").MustInt(20)
+ EnforceTwoFactorAuth = sec.Key("ENFORCE_TWO_FACTOR_AUTH").MustBool(false)
InternalToken = loadSecret(sec, "INTERNAL_TOKEN_URI", "INTERNAL_TOKEN")
if InstallLock && InternalToken == "" {
diff --git a/options/locale/locale_de-DE.ini b/options/locale/locale_de-DE.ini
index 0bc8fb7739..65e9bdeeee 100644
--- a/options/locale/locale_de-DE.ini
+++ b/options/locale/locale_de-DE.ini
@@ -445,6 +445,7 @@ use_scratch_code=Einmalpasswort verwenden
twofa_scratch_used=Du hast dein Einmalpasswort verwendet. Du wurdest zu den Einstellung der Zwei-Faktor-Authentifizierung umgeleitet, dort kannst du dein Gerät abmelden oder ein neues Einmalpasswort erzeugen.
twofa_passcode_incorrect=Ungültige PIN. Wenn du dein Gerät verloren hast, verwende dein Einmalpasswort.
twofa_scratch_token_incorrect=Das Einmalpasswort ist falsch.
+twofa_required = Bitte richte Zwei-Faktor-Authentifizierung ein, um auf Repositorys zugreifen zu können.
login_userpass=Anmelden
tab_openid=OpenID
oauth_signup_tab=Neues Konto registrieren
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index bfdcdb9112..5d1a151efc 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -457,6 +457,7 @@ use_onetime_code = Use a one-time code
twofa_scratch_used = You have used your scratch code. You have been redirected to the two-factor settings page so you may remove your device enrollment or generate a new scratch code.
twofa_passcode_incorrect = Your passcode is incorrect. If you misplaced your device, use your scratch code to sign in.
twofa_scratch_token_incorrect = Your scratch code is incorrect.
+twofa_required = Please configure Two-Factor Authentication in order to get access to repositories.
login_userpass = Sign in
oauth_signup_tab = Register new account
oauth_signup_title = Complete new account
diff --git a/options/locale/locale_ja-JP.ini b/options/locale/locale_ja-JP.ini
index d4d7024f5d..a13e7dc3cb 100644
--- a/options/locale/locale_ja-JP.ini
+++ b/options/locale/locale_ja-JP.ini
@@ -436,6 +436,7 @@ use_scratch_code=スクラッチコードを使う
twofa_scratch_used=あなたはスクラッチコードを使用しました。 2要素認証の設定ページにリダイレクトしましたので、デバイスの登録を解除するか、新しいスクラッチコードを生成しましょう。
twofa_passcode_incorrect=パスコードが正しくありません。デバイスを紛失した場合は、スクラッチコードを使ってサインインしてください。
twofa_scratch_token_incorrect=スクラッチコードが正しくありません。
+twofa_required=リポジトリにアクセスするには、2要素認証を設定してください。
login_userpass=サインイン
tab_openid=OpenID
oauth_signup_tab=新規アカウント登録
diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go
index 64006eeae8..1345857527 100644
--- a/routers/web/auth/auth.go
+++ b/routers/web/auth/auth.go
@@ -72,9 +72,12 @@ func autoSignIn(ctx *context.Context) (bool, error) {
isSucceed = true
+ twofa, _ := auth.GetTwoFactorByUID(ctx, u.ID)
if err := updateSession(ctx, nil, map[string]any{
// Set session IDs
"uid": u.ID,
+
+ auth_service.SessionKeyTwofaAuthed: twofa != nil,
}); err != nil {
return false, fmt.Errorf("unable to updateSession: %w", err)
}
@@ -302,6 +305,8 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe
}
}
+ isTwofaAuthed := ctx.Session.Get("twofaUid") != nil
+
if err := updateSession(ctx, []string{
// Delete the openid, 2fa and linkaccount data
"openid_verified_uri",
@@ -313,6 +318,8 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe
"linkAccount",
}, map[string]any{
"uid": u.ID,
+
+ auth_service.SessionKeyTwofaAuthed: isTwofaAuthed,
}); err != nil {
ctx.ServerError("RegenerateSession", err)
return setting.AppSubURL + "/"
diff --git a/routers/web/user/setting/security/2fa.go b/routers/web/user/setting/security/2fa.go
index f1271c8370..3b727efe43 100644
--- a/routers/web/user/setting/security/2fa.go
+++ b/routers/web/user/setting/security/2fa.go
@@ -16,6 +16,7 @@ import (
"forgejo.org/modules/log"
"forgejo.org/modules/setting"
"forgejo.org/modules/web"
+ auth_service "forgejo.org/services/auth"
"forgejo.org/services/context"
"forgejo.org/services/forms"
"forgejo.org/services/mailer"
@@ -153,12 +154,20 @@ func twofaGenerateSecretAndQr(ctx *context.Context) bool {
func EnrollTwoFactor(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings")
ctx.Data["PageIsSettingsSecurity"] = true
+ ctx.Data["ShowTwoFactorRequiredMessage"] = false
t, err := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID)
if t != nil {
// already enrolled - we should redirect back!
log.Warn("Trying to re-enroll %-v in twofa when already enrolled", ctx.Doer)
ctx.Flash.Error(ctx.Tr("settings.twofa_is_enrolled"))
+
+ if ctx.Session.Get(auth_service.SessionKeyTwofaAuthed) == nil {
+ // old session that doesn't track 2FA state got navigated here
+ _ = ctx.Session.Set(auth_service.SessionKeyTwofaAuthed, true)
+ _ = ctx.Session.Release()
+ }
+
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
return
}
@@ -179,6 +188,7 @@ func EnrollTwoFactorPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.TwoFactorAuthForm)
ctx.Data["Title"] = ctx.Tr("settings")
ctx.Data["PageIsSettingsSecurity"] = true
+ ctx.Data["ShowTwoFactorRequiredMessage"] = false
t, err := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID)
if t != nil {
@@ -236,6 +246,9 @@ func EnrollTwoFactorPost(ctx *context.Context) {
// tolerate this failure - it's more important to continue
log.Error("Unable to delete twofaUri from the session: Error: %v", err)
}
+ if err := ctx.Session.Set(auth_service.SessionKeyTwofaAuthed, true); err != nil {
+ log.Error("Unable to set %s for session: Error: %v", auth_service.SessionKeyTwofaAuthed, err)
+ }
if err := ctx.Session.Release(); err != nil {
// tolerate this failure - it's more important to continue
log.Error("Unable to save changes to the session: %v", err)
diff --git a/services/auth/session.go b/services/auth/session.go
index a15c24c940..62a38477bf 100644
--- a/services/auth/session.go
+++ b/services/auth/session.go
@@ -10,6 +10,11 @@ import (
"forgejo.org/modules/log"
)
+const (
+ SessionKeyUID = "uid"
+ SessionKeyTwofaAuthed = "twofaAuthed"
+)
+
// Ensure the struct implements the interface.
var (
_ Method = &Session{}
diff --git a/services/context/context.go b/services/context/context.go
index 91484c5ba3..6624f48aba 100644
--- a/services/context/context.go
+++ b/services/context/context.go
@@ -192,6 +192,10 @@ func Contexter() func(next http.Handler) http.Handler {
ctx.Data["SystemConfig"] = setting.Config()
+ ctx.Data["ShowTwoFactorRequiredMessage"] = setting.EnforceTwoFactorAuth &&
+ ctx.Session.Get("uid") != nil &&
+ ctx.Session.Get("twofaAuthed") != true
+
// FIXME: do we really always need these setting? There should be someway to have to avoid having to always set these
ctx.Data["DisableMigrations"] = setting.Repository.DisableMigrations
ctx.Data["DisableStars"] = setting.Repository.DisableStars
diff --git a/templates/base/alert.tmpl b/templates/base/alert.tmpl
index e2853d3dab..e8e8ee763d 100644
--- a/templates/base/alert.tmpl
+++ b/templates/base/alert.tmpl
@@ -21,3 +21,8 @@
{{if and (not .Flash.ErrorMsg) (not .Flash.SuccessMsg) (not .Flash.InfoMsg) (not .Flash.WarningMsg) (not .IsHTMX)}}
<div id="flash-message" hx-swap-oob="true"></div>
{{end}}
+{{if .ShowTwoFactorRequiredMessage}}
+ <div class="ui negative message flash-error">
+ <p><a href="{{AppSubUrl}}/user/settings/security/two_factor/enroll">{{ctx.Locale.Tr "auth.twofa_required"}} »</a></p>
+ </div>
+{{end}}
--
2.49.0