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"}} &raquo;</a></p>
+	</div>
+{{end}}
-- 
2.49.0

openSUSE Build Service is sponsored by