From d6838462b8a018f01c9208cbe3855b36c0c69c4e Mon Sep 17 00:00:00 2001 From: zokki Date: Fri, 15 Aug 2025 10:56:45 +0200 Subject: [PATCH] feat: Global 2FA enforcement (#8753) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolves #8549 This PR add a config to enforce 2FA for the whole Forgejo instance. It can be configured to `none`, `admin` or `all`. A user who is required to enable 2FA is like a disabled user. He can only see the `/user/settings/security`-Page to enable 2FA, this should be similar to a user which needs to change his password. Also api and git-commands are not allowed. ## Checklist The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org). ### Tests - I added test coverage for Go changes... - [x] in their respective `*_test.go` for unit tests. - [x] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I added test coverage for JavaScript changes... - [ ] in `web_src/js/*.test.js` if it can be unit tested. - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)). ### Documentation - [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - [ ] I did not document these changes and I do not expect someone else to do it. I will do it, if the general idea of this PR is a good feature. ### Release notes - [ ] I do not want this change to show in the release notes. - [x] I want the title to show in the release notes with a link to this pull request. - [ ] I want the content of the `release-notes/.md` to be be used for the release notes instead of the title. ## Release notes - Security features - [PR](https://codeberg.org/forgejo/forgejo/pulls/8753): Global 2FA enforcement Co-authored-by: 0ko <0ko@noreply.codeberg.org> Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8753 Reviewed-by: 0ko <0ko@noreply.codeberg.org> Reviewed-by: Ellen Εμιλία Άννα Zscheile Reviewed-by: Gusted Co-authored-by: zokki Co-committed-by: zokki --- custom/conf/app.example.ini | 3 + models/user/user.go | 27 ++++ models/user/user_test.go | 139 +++++++++++++++++ modules/setting/security.go | 39 +++++ options/locale_next/locale_en-US.json | 10 ++ routers/api/shared/middleware.go | 21 +++ routers/private/serv.go | 33 ++++ routers/web/admin/config.go | 1 + routers/web/auth/password.go | 2 +- routers/web/home.go | 28 +++- routers/web/user/setting/security/2fa.go | 69 ++++++++- routers/web/web.go | 33 +++- services/context/context_response.go | 18 +++ services/context/package.go | 2 +- templates/admin/config.tmpl | 10 ++ templates/base/head_navbar.tmpl | 4 +- templates/user/settings/navbar.tmpl | 2 + .../user/settings/security/security.tmpl | 13 +- templates/user/settings/security/twofa.tmpl | 18 ++- .../user/settings/security/twofa_enroll.tmpl | 8 +- tests/integration/api_packages_test.go | 96 ++++++++++++ tests/integration/api_private_serv_test.go | 94 ++++++++++++ tests/integration/api_twofa_test.go | 129 ++++++++++++++++ tests/integration/devtest_error_test.go | 12 +- tests/integration/html_helper.go | 21 ++- tests/integration/integration_test.go | 60 ++++++++ tests/integration/setting_test.go | 73 +++++++++ tests/integration/signin_test.go | 135 +++++++++++++++++ tests/integration/user_test.go | 141 ++++++++++++++---- 29 files changed, 1179 insertions(+), 62 deletions(-) diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index ec65b3382e..267696872d 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -449,6 +449,9 @@ INTERNAL_TOKEN = ;; How long to remember that a user is logged in before requiring relogin (in days) ;LOGIN_REMEMBER_DAYS = 31 ;; +;; Require 2FA globally for none|all|admin. +;GLOBAL_TWO_FACTOR_REQUIREMENT = none +;; ;; Name of cookie used to store authentication information. ;COOKIE_REMEMBER_NAME = gitea_incredible ;; diff --git a/models/user/user.go b/models/user/user.go index e3d725677f..5f413ba136 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -234,6 +234,33 @@ func GetAllAdmins(ctx context.Context) ([]*User, error) { return users, db.GetEngine(ctx).OrderBy("id").Where("type = ?", UserTypeIndividual).And("is_admin = ?", true).Find(&users) } +// MustHaveTwoFactor returns true if the user is a individual and requires 2fa +func (u *User) MustHaveTwoFactor() bool { + if !u.IsIndividual() || setting.GlobalTwoFactorRequirement.IsNone() { + return false + } + + return setting.GlobalTwoFactorRequirement.IsAll() || (u.IsAdmin && setting.GlobalTwoFactorRequirement.IsAdmin()) +} + +// IsAccessAllowed determines whether the user is permitted to log in based on +// their activation status, login prohibition, 2FA requirement and 2FA enrollment status. +func (u *User) IsAccessAllowed(ctx context.Context) bool { + if !u.IsActive || u.ProhibitLogin { + return false + } + if !u.MustHaveTwoFactor() { + return true + } + + hasTwoFactor, err := auth.HasTwoFactorByUID(ctx, u.ID) + if err != nil { + log.Error("Error getting 2fa: %s", err) + return false + } + return hasTwoFactor +} + // IsLocal returns true if user login type is LoginPlain. func (u *User) IsLocal() bool { return u.LoginType <= auth.Plain diff --git a/models/user/user_test.go b/models/user/user_test.go index 71190751da..5b0c9676de 100644 --- a/models/user/user_test.go +++ b/models/user/user_test.go @@ -637,6 +637,145 @@ func TestGetAllAdmins(t *testing.T) { assert.Equal(t, int64(1), admins[0].ID) } +func TestMustHaveTwoFactor(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + normalUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 17}) + restrictedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29}) + ghostUser := user_model.NewGhostUser() + + t.Run("NoneTwoFactorRequirement", func(t *testing.T) { + // this should be the default, so don't have to set the variable + assert.False(t, adminUser.MustHaveTwoFactor()) + assert.False(t, normalUser.MustHaveTwoFactor()) + assert.False(t, restrictedUser.MustHaveTwoFactor()) + assert.False(t, org.MustHaveTwoFactor()) + assert.False(t, ghostUser.MustHaveTwoFactor()) + }) + + t.Run("AllTwoFactorRequirement", func(t *testing.T) { + defer test.MockVariableValue(&setting.GlobalTwoFactorRequirement, setting.AllTwoFactorRequirement)() + + assert.True(t, adminUser.MustHaveTwoFactor()) + assert.True(t, normalUser.MustHaveTwoFactor()) + assert.True(t, restrictedUser.MustHaveTwoFactor()) + assert.False(t, org.MustHaveTwoFactor()) + assert.True(t, ghostUser.MustHaveTwoFactor()) + }) + + t.Run("AdminTwoFactorRequirement", func(t *testing.T) { + defer test.MockVariableValue(&setting.GlobalTwoFactorRequirement, setting.AdminTwoFactorRequirement)() + + assert.True(t, adminUser.MustHaveTwoFactor()) + assert.False(t, normalUser.MustHaveTwoFactor()) + assert.False(t, restrictedUser.MustHaveTwoFactor()) + assert.False(t, org.MustHaveTwoFactor()) + assert.False(t, ghostUser.MustHaveTwoFactor()) + }) +} + +func TestIsAccessAllowed(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + runTest := func(t *testing.T, user *user_model.User, useTOTP, accessAllowed bool) { + t.Helper() + if useTOTP { + unittest.AssertSuccessfulInsert(t, &auth.TwoFactor{UID: user.ID}) + defer unittest.AssertSuccessfulDelete(t, &auth.TwoFactor{UID: user.ID}) + } + + assert.Equal(t, accessAllowed, user.IsAccessAllowed(t.Context())) + } + + adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + normalUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + inactiveUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 9}) + org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 17}) + restrictedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29}) + prohibitLoginUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 37}) + ghostUser := user_model.NewGhostUser() + + // users with enabled WebAuthn + normalWebAuthnUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 32}) + + t.Run("NoneTwoFactorRequirement", func(t *testing.T) { + // this should be the default, so don't have to set the variable + + t.Run("no 2fa", func(t *testing.T) { + runTest(t, adminUser, false, true) + runTest(t, normalUser, false, true) + runTest(t, inactiveUser, false, false) + runTest(t, org, false, true) + runTest(t, restrictedUser, false, true) + runTest(t, prohibitLoginUser, false, false) + runTest(t, ghostUser, false, false) + }) + + t.Run("enabled 2fa", func(t *testing.T) { + runTest(t, normalWebAuthnUser, false, true) + + runTest(t, adminUser, true, true) + runTest(t, normalUser, true, true) + runTest(t, inactiveUser, true, false) + runTest(t, org, true, true) + runTest(t, restrictedUser, true, true) + runTest(t, prohibitLoginUser, true, false) + }) + }) + + t.Run("AllTwoFactorRequirement", func(t *testing.T) { + defer test.MockVariableValue(&setting.GlobalTwoFactorRequirement, setting.AllTwoFactorRequirement)() + + t.Run("no 2fa", func(t *testing.T) { + runTest(t, adminUser, false, false) + runTest(t, normalUser, false, false) + runTest(t, inactiveUser, false, false) + runTest(t, org, false, true) + runTest(t, restrictedUser, false, false) + runTest(t, prohibitLoginUser, false, false) + runTest(t, ghostUser, false, false) + }) + + t.Run("enabled 2fa", func(t *testing.T) { + runTest(t, normalWebAuthnUser, false, true) + + runTest(t, adminUser, true, true) + runTest(t, normalUser, true, true) + runTest(t, inactiveUser, true, false) + runTest(t, org, true, true) + runTest(t, restrictedUser, true, true) + runTest(t, prohibitLoginUser, true, false) + }) + }) + + t.Run("AdminTwoFactorRequirement", func(t *testing.T) { + defer test.MockVariableValue(&setting.GlobalTwoFactorRequirement, setting.AdminTwoFactorRequirement)() + + t.Run("no 2fa", func(t *testing.T) { + runTest(t, adminUser, false, false) + runTest(t, normalUser, false, true) + runTest(t, inactiveUser, false, false) + runTest(t, org, false, true) + runTest(t, restrictedUser, false, true) + runTest(t, prohibitLoginUser, false, false) + runTest(t, ghostUser, false, false) + }) + + t.Run("enabled 2fa", func(t *testing.T) { + runTest(t, normalWebAuthnUser, false, true) + + runTest(t, adminUser, true, true) + runTest(t, normalUser, true, true) + runTest(t, inactiveUser, true, false) + runTest(t, org, true, true) + runTest(t, restrictedUser, true, true) + runTest(t, prohibitLoginUser, true, false) + }) + }) +} + func Test_ValidateUser(t *testing.T) { defer test.MockVariableValue(&setting.Service.AllowedUserVisibilityModesSlice, []bool{true, false, true})() diff --git a/modules/setting/security.go b/modules/setting/security.go index 1f38857af6..c591a7c90a 100644 --- a/modules/setting/security.go +++ b/modules/setting/security.go @@ -20,6 +20,7 @@ var ( SecretKey string InternalToken string // internal access token LogInRememberDays int + GlobalTwoFactorRequirement TwoFactorRequirementType CookieRememberName string ReverseProxyAuthUser string ReverseProxyAuthEmail string @@ -113,6 +114,8 @@ func loadSecurityFrom(rootCfg ConfigProvider) { } keying.Init([]byte(SecretKey)) + GlobalTwoFactorRequirement = NewTwoFactorRequirementType(sec.Key("GLOBAL_TWO_FACTOR_REQUIREMENT").String()) + CookieRememberName = sec.Key("COOKIE_REMEMBER_NAME").MustString("gitea_incredible") ReverseProxyAuthUser = sec.Key("REVERSE_PROXY_AUTHENTICATION_USER").MustString("X-WEBAUTH-USER") @@ -171,3 +174,39 @@ func loadSecurityFrom(rootCfg ConfigProvider) { log.Warn("Enabling Query API Auth tokens is not recommended. DISABLE_QUERY_AUTH_TOKEN will be removed in Forgejo v13.0.0.") } } + +type TwoFactorRequirementType string + +// llu:TrKeysSuffix admin.config.global_2fa_requirement. +const ( + NoneTwoFactorRequirement TwoFactorRequirementType = "none" + AllTwoFactorRequirement TwoFactorRequirementType = "all" + AdminTwoFactorRequirement TwoFactorRequirementType = "admin" +) + +func NewTwoFactorRequirementType(twoFactorRequirement string) TwoFactorRequirementType { + switch twoFactorRequirement { + case AllTwoFactorRequirement.String(): + return AllTwoFactorRequirement + case AdminTwoFactorRequirement.String(): + return AdminTwoFactorRequirement + default: + return NoneTwoFactorRequirement + } +} + +func (r TwoFactorRequirementType) String() string { + return string(r) +} + +func (r TwoFactorRequirementType) IsNone() bool { + return r == NoneTwoFactorRequirement +} + +func (r TwoFactorRequirementType) IsAll() bool { + return r == AllTwoFactorRequirement +} + +func (r TwoFactorRequirementType) IsAdmin() bool { + return r == AdminTwoFactorRequirement +} diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json index 6cbfb34a60..24c0835256 100644 --- a/options/locale_next/locale_en-US.json +++ b/options/locale_next/locale_en-US.json @@ -112,7 +112,17 @@ "admin.auths.allow_username_change.description": "Allow users to change their username in the profile settings", "admin.dashboard.cleanup_offline_runners": "Cleanup offline runners", "admin.dashboard.remove_resolved_reports": "Remove resolved reports", + "admin.config.security": "Security configuration", + "admin.config.global_2fa_requirement.title": "Global two-factor requirement", + "admin.config.global_2fa_requirement.none": "No", + "admin.config.global_2fa_requirement.all": "All users", + "admin.config.global_2fa_requirement.admin": "Administrators", "settings.visibility.description": "Profile visibility affects others' ability to access your non-private repositories. Learn more.", + "settings.twofa_unroll_unavailable": "Two-factor authentication is required for your account and cannot be disabled.", + "settings.twofa_reenroll": "Re-enroll two-factor authentication", + "settings.twofa_reenroll.description": "Re-enroll your two-factor authentication", + "settings.must_enable_2fa": "This Forgejo instance requires users to enable two-factor authentication before they can access their accounts.", + "error.must_enable_2fa": "This Forgejo instance requires users to enable two-factor authentication before they can access their accounts. Enable it at: %s", "avatar.constraints_hint": "Custom avatar may not exceed %[1]s in size or be larger than %[2]dx%[3]d pixels", "og.repo.summary_card.alt_description": "Summary card of repository %[1]s, described as: %[2]s", "repo.commit.load_tags_failed": "Load tags failed because of internal error", diff --git a/routers/api/shared/middleware.go b/routers/api/shared/middleware.go index b57fabac0e..59d9f28d60 100644 --- a/routers/api/shared/middleware.go +++ b/routers/api/shared/middleware.go @@ -4,8 +4,10 @@ package shared import ( + "fmt" "net/http" + auth_model "forgejo.org/models/auth" "forgejo.org/modules/log" "forgejo.org/modules/setting" "forgejo.org/routers/common" @@ -92,6 +94,25 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.APIC }) return } + + if ctx.Doer.MustHaveTwoFactor() { + hasTwoFactor, err := auth_model.HasTwoFactorByUID(ctx, ctx.Doer.ID) + if err != nil { + ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") + log.Error("Error getting 2fa: %s", err) + ctx.JSON(http.StatusInternalServerError, map[string]string{ + "message": fmt.Sprintf("Error getting 2fa: %s", err), + }) + return + } + if !hasTwoFactor { + ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": ctx.Locale.TrString("error.must_enable_2fa", fmt.Sprintf("%suser/settings/security", setting.AppURL)), + }) + return + } + } } // Redirect to dashboard if user tries to visit any non-login page. diff --git a/routers/private/serv.go b/routers/private/serv.go index a4029e354c..7f08d4ca34 100644 --- a/routers/private/serv.go +++ b/routers/private/serv.go @@ -9,6 +9,7 @@ import ( "strings" asymkey_model "forgejo.org/models/asymkey" + "forgejo.org/models/auth" "forgejo.org/models/perm" access_model "forgejo.org/models/perm/access" repo_model "forgejo.org/models/repo" @@ -22,6 +23,27 @@ import ( wiki_service "forgejo.org/services/wiki" ) +func checkTwoFactor(ctx *context.PrivateContext, user *user_model.User) { + if !user.MustHaveTwoFactor() { + return + } + + hasTwoFactor, err := auth.HasTwoFactorByUID(ctx, user.ID) + if err != nil { + log.Error("Error getting 2fa: %s", err) + ctx.JSON(http.StatusInternalServerError, private.Response{ + Err: fmt.Sprintf("Error getting 2fa: %s", err), + }) + return + } + if !hasTwoFactor { + ctx.JSON(http.StatusForbidden, private.Response{ + UserMsg: ctx.Locale.TrString("error.must_enable_2fa", fmt.Sprintf("%suser/settings/security", setting.AppURL)), + }) + return + } +} + // ServNoCommand returns information about the provided keyid func ServNoCommand(ctx *context.PrivateContext) { keyID := ctx.ParamsInt64(":keyid") @@ -69,6 +91,12 @@ func ServNoCommand(ctx *context.PrivateContext) { }) return } + + checkTwoFactor(ctx, user) + if ctx.Written() { + return + } + results.Owner = user } ctx.JSON(http.StatusOK, &results) @@ -266,6 +294,11 @@ func ServCommand(ctx *context.PrivateContext) { return } + checkTwoFactor(ctx, user) + if ctx.Written() { + return + } + results.UserName = user.Name if !user.KeepEmailPrivate { results.UserEmail = user.Email diff --git a/routers/web/admin/config.go b/routers/web/admin/config.go index dcc99ff1a8..e1c3a5f9ee 100644 --- a/routers/web/admin/config.go +++ b/routers/web/admin/config.go @@ -128,6 +128,7 @@ func Config(ctx *context.Context) { ctx.Data["AppBuiltWith"] = setting.AppBuiltWith ctx.Data["Domain"] = setting.Domain ctx.Data["OfflineMode"] = setting.OfflineMode + ctx.Data["GlobalTwoFactorRequirement"] = setting.GlobalTwoFactorRequirement ctx.Data["RunUser"] = setting.RunUser ctx.Data["RunMode"] = util.ToTitleCase(setting.RunMode) ctx.Data["GitVersion"] = git.VersionInfo() diff --git a/routers/web/auth/password.go b/routers/web/auth/password.go index cb6b22e5b7..c645bbdede 100644 --- a/routers/web/auth/password.go +++ b/routers/web/auth/password.go @@ -264,7 +264,7 @@ func ResetPasswdPost(ctx *context.Context) { func MustChangePassword(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("auth.must_change_password") ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/settings/change_password" - ctx.Data["MustChangePassword"] = true + ctx.Data["HideNavbarLinks"] = true ctx.HTML(http.StatusOK, tplMustChangePassword) } diff --git a/routers/web/home.go b/routers/web/home.go index bd9942748a..55dfe2538e 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -9,6 +9,7 @@ import ( "net/http" "strconv" + auth_model "forgejo.org/models/auth" "forgejo.org/models/db" repo_model "forgejo.org/models/repo" user_model "forgejo.org/models/user" @@ -35,18 +36,37 @@ func Home(ctx *context.Context) { if !ctx.Doer.IsActive && setting.Service.RegisterEmailConfirm { ctx.Data["Title"] = ctx.Tr("auth.active_your_account") ctx.HTML(http.StatusOK, auth.TplActivate) - } else if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin { + return + } + if !ctx.Doer.IsActive || ctx.Doer.ProhibitLogin { log.Info("Failed authentication attempt for %s from %s", ctx.Doer.Name, ctx.RemoteAddr()) ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") ctx.HTML(http.StatusOK, "user/auth/prohibit_login") - } else if ctx.Doer.MustChangePassword { + return + } + if ctx.Doer.MustChangePassword { ctx.Data["Title"] = ctx.Tr("auth.must_change_password") ctx.Data["ChangePasscodeLink"] = setting.AppSubURL + "/user/change_password" middleware.SetRedirectToCookie(ctx.Resp, setting.AppSubURL+ctx.Req.URL.RequestURI()) ctx.Redirect(setting.AppSubURL + "/user/settings/change_password") - } else { - user.Dashboard(ctx) + return } + if ctx.Doer.MustHaveTwoFactor() { + hasTwoFactor, err := auth_model.HasTwoFactorByUID(ctx, ctx.Doer.ID) + if err != nil { + ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") + log.Error("Error getting 2fa: %s", err) + ctx.Error(http.StatusInternalServerError, "HasTwoFactorByUID", err.Error()) + return + } + if !hasTwoFactor { + ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + return + } + } + + user.Dashboard(ctx) return // Check non-logged users landing page. } else if setting.LandingPageURL != setting.LandingPageHome { diff --git a/routers/web/user/setting/security/2fa.go b/routers/web/user/setting/security/2fa.go index 8b362c4f08..d23917f8b2 100644 --- a/routers/web/user/setting/security/2fa.go +++ b/routers/web/user/setting/security/2fa.go @@ -56,6 +56,21 @@ func DisableTwoFactor(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsSettingsSecurity"] = true + if ctx.Doer.MustHaveTwoFactor() { + ctx.NotFound("DisableTwoFactor", nil) + return + } + + disableTwoFactor(ctx) + if ctx.Written() { + return + } + + ctx.Flash.Success(ctx.Tr("settings.twofa_disabled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") +} + +func disableTwoFactor(ctx *context.Context) { t, err := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID) if err != nil { if auth.IsErrTwoFactorNotEnrolled(err) { @@ -82,9 +97,6 @@ func DisableTwoFactor(ctx *context.Context) { ctx.ServerError("SendDisabledTOTP", err) return } - - ctx.Flash.Success(ctx.Tr("settings.twofa_disabled")) - ctx.Redirect(setting.AppSubURL + "/user/settings/security") } func twofaGenerateSecretAndQr(ctx *context.Context) bool { @@ -172,7 +184,6 @@ func EnrollTwoFactor(ctx *context.Context) { // EnrollTwoFactorPost handles enrolling the user into 2FA. func EnrollTwoFactorPost(ctx *context.Context) { - form := web.GetForm(ctx).(*forms.TwoFactorAuthForm) ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsSettingsSecurity"] = true @@ -188,6 +199,12 @@ func EnrollTwoFactorPost(ctx *context.Context) { return } + enrollTwoFactor(ctx) +} + +func enrollTwoFactor(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.TwoFactorAuthForm) + if ctx.HasError() { if !twofaGenerateSecretAndQr(ctx) { return @@ -213,10 +230,10 @@ func EnrollTwoFactorPost(ctx *context.Context) { return } - t = &auth.TwoFactor{ + twoFactor := &auth.TwoFactor{ UID: ctx.Doer.ID, } - token := t.GenerateScratchToken() + token := twoFactor.GenerateScratchToken() // Now we have to delete the secrets - because if we fail to insert then it's highly likely that they have already been used // If we can detect the unique constraint failure below we can move this to after the NewTwoFactor @@ -238,7 +255,7 @@ func EnrollTwoFactorPost(ctx *context.Context) { return } - if err = auth.NewTwoFactor(ctx, t, secret); err != nil { + if err := auth.NewTwoFactor(ctx, twoFactor, secret); err != nil { // FIXME: We need to handle a unique constraint fail here it's entirely possible that another request has beaten us. // If there is a unique constraint fail we should just tolerate the error ctx.ServerError("SettingsTwoFactor: Failed to save two factor", err) @@ -248,3 +265,41 @@ func EnrollTwoFactorPost(ctx *context.Context) { ctx.Flash.Success(ctx.Tr("settings.twofa_enrolled", token)) ctx.Redirect(setting.AppSubURL + "/user/settings/security") } + +// ReenrollTwoFactor shows the page where the user can reenroll 2FA. +func ReenrollTwoFactor(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + ctx.Data["ReenrollTwofa"] = true + + _, err := auth.GetTwoFactorByUID(ctx, ctx.Doer.ID) + if auth.IsErrTwoFactorNotEnrolled(err) { + ctx.Flash.Error(ctx.Tr("settings.twofa_not_enrolled")) + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + return + } + if err != nil { + ctx.ServerError("SettingsTwoFactor: GetTwoFactorByUID", err) + return + } + + if !twofaGenerateSecretAndQr(ctx) { + return + } + + ctx.HTML(http.StatusOK, tplSettingsTwofaEnroll) +} + +// ReenrollTwoFactorPost handles reenrolling the user 2FA. +func ReenrollTwoFactorPost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsSecurity"] = true + ctx.Data["ReenrollTwofa"] = true + + disableTwoFactor(ctx) + if ctx.Written() { + return + } + + enrollTwoFactor(ctx) +} diff --git a/routers/web/web.go b/routers/web/web.go index 497352cdc7..f1cbc6b7ad 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -6,9 +6,11 @@ package web import ( gocontext "context" + "fmt" "net/http" "strings" + auth_model "forgejo.org/models/auth" "forgejo.org/models/perm" quota_model "forgejo.org/models/quota" "forgejo.org/models/unit" @@ -169,6 +171,19 @@ func verifyAuthWithOptions(options *common.VerifyOptions) func(ctx *context.Cont ctx.Redirect(setting.AppSubURL + "/") return } + + if ctx.Doer.MustHaveTwoFactor() && !strings.HasPrefix(ctx.Req.URL.Path, "/user/settings/security") { + hasTwoFactor, err := auth_model.HasTwoFactorByUID(ctx, ctx.Doer.ID) + if err != nil { + log.Error("Error getting 2fa: %s", err) + ctx.Error(http.StatusInternalServerError, "HasTwoFactorByUID", err.Error()) + return + } + if !hasTwoFactor { + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + return + } + } } // Redirect to dashboard (or alternate location) if user tries to visit any non-login page. @@ -309,6 +324,20 @@ func registerRoutes(m *web.Route) { } } + requiredTwoFactor := func(ctx *context.Context) { + if !ctx.Doer.MustHaveTwoFactor() { + return + } + + hasTwoFactor, err := auth_model.HasTwoFactorByUID(ctx, ctx.Doer.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, fmt.Sprintf("Error getting 2fa: %s", err)) + return + } + ctx.Data["MustEnableTwoFactor"] = !hasTwoFactor + ctx.Data["HideNavbarLinks"] = !hasTwoFactor + } + openIDSignInEnabled := func(ctx *context.Context) { if !setting.Service.EnableOpenIDSignIn { ctx.Error(http.StatusForbidden) @@ -564,6 +593,8 @@ func registerRoutes(m *web.Route) { m.Post("/disable", security.DisableTwoFactor) m.Get("/enroll", security.EnrollTwoFactor) m.Post("/enroll", web.Bind(forms.TwoFactorAuthForm{}), security.EnrollTwoFactorPost) + m.Get("/reenroll", security.ReenrollTwoFactor) + m.Post("/reenroll", web.Bind(forms.TwoFactorAuthForm{}), security.ReenrollTwoFactorPost) }) m.Group("/webauthn", func() { m.Post("/request_register", web.Bind(forms.WebauthnRegistrationForm{}), security.WebAuthnRegister) @@ -576,7 +607,7 @@ func registerRoutes(m *web.Route) { m.Post("/toggle_visibility", security.ToggleOpenIDVisibility) }, openIDSignInEnabled) m.Post("/account_link", linkAccountEnabled, security.DeleteAccountLink) - }) + }, requiredTwoFactor) m.Group("/applications", func() { // oauth2 applications diff --git a/services/context/context_response.go b/services/context/context_response.go index e64f478420..386bdd2652 100644 --- a/services/context/context_response.go +++ b/services/context/context_response.go @@ -17,6 +17,7 @@ import ( "syscall" "time" + "forgejo.org/models/auth" user_model "forgejo.org/models/user" "forgejo.org/modules/base" "forgejo.org/modules/httplib" @@ -129,6 +130,21 @@ func (ctx *Context) RenderWithErr(msg any, tpl base.TplName, form any) { ctx.HTML(http.StatusOK, tpl) } +// validateTwoFactorRequirement sets ctx-data to hide/show ui-elements depending on the GlobalTwoFactorRequirement +func (ctx *Context) validateTwoFactorRequirement() { + if ctx.Doer == nil || !ctx.Doer.MustHaveTwoFactor() { + return + } + + hasTwoFactor, err := auth.HasTwoFactorByUID(ctx, ctx.Doer.ID) + if err != nil { + log.ErrorWithSkip(2, "Error getting 2fa: %s", err) + // fallthrough to set the variables + } + ctx.Data["MustEnableTwoFactor"] = !hasTwoFactor + ctx.Data["HideNavbarLinks"] = !hasTwoFactor +} + // NotFound displays a 404 (Not Found) page and prints the given error, if any. func (ctx *Context) NotFound(logMsg string, logErr error) { ctx.notFoundInternal(logMsg, logErr) @@ -156,6 +172,7 @@ func (ctx *Context) notFoundInternal(logMsg string, logErr error) { return } + ctx.validateTwoFactorRequirement() ctx.Data["IsRepo"] = ctx.Repo.Repository != nil ctx.Data["Title"] = ctx.Locale.TrString("error.not_found.title") ctx.HTML(http.StatusNotFound, tplStatus404) @@ -181,6 +198,7 @@ func (ctx *Context) serverErrorInternal(logMsg string, logErr error) { } } + ctx.validateTwoFactorRequirement() ctx.HTML(http.StatusInternalServerError, tplStatus500) } diff --git a/services/context/package.go b/services/context/package.go index b95e02a882..50ffa8eb7c 100644 --- a/services/context/package.go +++ b/services/context/package.go @@ -97,7 +97,7 @@ func determineAccessMode(ctx *Base, pkg *Package, doer *user_model.User) (perm.A return perm.AccessModeNone, nil } - if doer != nil && !doer.IsGhost() && (!doer.IsActive || doer.ProhibitLogin) { + if doer != nil && !doer.IsGhost() && !doer.IsAccessAllowed(ctx) { return perm.AccessModeNone, nil } diff --git a/templates/admin/config.tmpl b/templates/admin/config.tmpl index 12504b8824..36d44f21f3 100644 --- a/templates/admin/config.tmpl +++ b/templates/admin/config.tmpl @@ -51,6 +51,16 @@ +

+ {{ctx.Locale.Tr "admin.config.security"}} +

+
+
+
{{ctx.Locale.Tr "admin.config.global_2fa_requirement.title"}}
+
{{ctx.Locale.Tr (print "admin.config.global_2fa_requirement." .GlobalTwoFactorRequirement)}}
+
+
+

{{ctx.Locale.Tr "admin.config.ssh_config"}}

diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index 0c13f9e844..dc3ca33712 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -24,7 +24,7 @@ - {{if and .IsSigned .MustChangePassword}} + {{if and .IsSigned .HideNavbarLinks}} {{/* No links */}} {{else if .IsSigned}} {{if not .UnitIssuesGlobalDisabled}} @@ -54,7 +54,7 @@