From 52b1495d68695f91ad9468816102a05ee9388aa7 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sat, 27 Aug 2022 16:47:20 +0800 Subject: [PATCH] Enforce 2FA --- custom/conf/app.example.ini | 4 +++ .../doc/advanced/config-cheat-sheet.en-us.md | 1 + models/perm/access/access.go | 7 +++++ models/perm/access/access_test.go | 29 +++++++++++++++++++ models/perm/access/repo_permission.go | 9 ++++++ modules/context/context.go | 4 +++ modules/setting/setting.go | 2 ++ options/locale/locale_en-US.ini | 1 + routers/web/auth/auth.go | 12 ++++++++ routers/web/user/setting/security/2fa.go | 14 +++++++++ services/auth/session.go | 7 +++++ templates/base/alert.tmpl | 5 ++++ templates/status/404.tmpl | 13 +++++++-- 13 files changed, 106 insertions(+), 2 deletions(-) diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 1561a53da0f07..9c75aa81c98ab 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -446,6 +446,10 @@ INTERNAL_TOKEN= ;; Cache successful token hashes. API tokens are stored in the DB as pbkdf2 hashes however, this means that there is a potentially significant hashing load when there are multiple API operations. ;; This cache will store the successfully hashed tokens in a LRU cache as a balance between performance and security. ;SUCCESSFUL_TOKENS_CACHE_SIZE = 20 +;; +;; 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/docs/content/doc/advanced/config-cheat-sheet.en-us.md b/docs/content/doc/advanced/config-cheat-sheet.en-us.md index 05cedeb63dddf..e77cca9f062ad 100644 --- a/docs/content/doc/advanced/config-cheat-sheet.en-us.md +++ b/docs/content/doc/advanced/config-cheat-sheet.en-us.md @@ -531,6 +531,7 @@ Certain queues have defaults that override the defaults set in `[queue]` (this o - off - do not check password complexity - `PASSWORD_CHECK_PWN`: **false**: Check [HaveIBeenPwned](https://haveibeenpwned.com/Passwords) to see if a password has been exposed. - `SUCCESSFUL_TOKENS_CACHE_SIZE`: **20**: Cache successful token hashes. API tokens are stored in the DB as pbkdf2 hashes however, this means that there is a potentially significant hashing load when there are multiple API operations. This cache will store the successfully hashed tokens in a LRU cache as a balance between performance and security. +- `ENFORCE_TWO_FACTOR_AUTH`: **false**: Force users to enroll into Two-Factor Authentication. Users without 2FA have no access to any repositories. ## Camo (`camo`) diff --git a/models/perm/access/access.go b/models/perm/access/access.go index 0e5e4ff2bb5d2..bba63db70ccde 100644 --- a/models/perm/access/access.go +++ b/models/perm/access/access.go @@ -9,11 +9,13 @@ import ( "context" "fmt" + "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" ) // Access represents the highest access level of a user to the repository. The only access type @@ -36,6 +38,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(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 7f58be4f393b5..24182ac00f241 100644 --- a/models/perm/access/access_test.go +++ b/models/perm/access/access_test.go @@ -13,6 +13,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" "github.com/stretchr/testify/assert" ) @@ -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(user29, repo24) assert.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) + assert.NoError(t, err) + assert.Equal(t, perm_model.AccessModeNone, level) + + level, err = access_model.AccessLevel(user24, repo1) + assert.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) assert.NoError(t, err) + + // test enforced two-factor authentication + setting.EnforceTwoFactorAuth = true + { + has, err = access_model.HasAccess(db.DefaultContext, user1.ID, repo1) + assert.NoError(t, err) + assert.False(t, has) + + has, err = access_model.HasAccess(db.DefaultContext, user24.ID, repo1) + assert.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 93e3bdd6d8468..6559760b30ac0 100644 --- a/models/perm/access/repo_permission.go +++ b/models/perm/access/repo_permission.go @@ -8,6 +8,7 @@ import ( "context" "fmt" + "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" perm_model "code.gitea.io/gitea/models/perm" @@ -15,6 +16,7 @@ import ( "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" ) // Permission contains all the permissions related variables to a repository for a user @@ -168,6 +170,13 @@ func GetUserRepoPermission(ctx context.Context, repo *repo_model.Repository, use return } + if user != nil && setting.EnforceTwoFactorAuth { + if twoFactor, _ := auth.GetTwoFactorByUID(user.ID); twoFactor == nil { + perm.AccessMode = perm_model.AccessModeNone + return + } + } + var is bool if user != nil { is, err = repo_model.IsCollaborator(ctx, repo.ID, user.ID) diff --git a/modules/context/context.go b/modules/context/context.go index 45f1978e97cc0..da3ffad7ce536 100644 --- a/modules/context/context.go +++ b/modules/context/context.go @@ -768,6 +768,10 @@ func Contexter() func(next http.Handler) http.Handler { ctx.Data["ShowFooterBranding"] = setting.ShowFooterBranding ctx.Data["ShowFooterVersion"] = setting.ShowFooterVersion + ctx.Data["ShowTwoFactorRequiredMessage"] = setting.EnforceTwoFactorAuth && + ctx.Session.Get(auth.SessionKeyUID) != nil && + ctx.Session.Get(auth.SessionKeyTwofaAuthed) == nil + ctx.Data["EnableSwagger"] = setting.API.EnableSwagger ctx.Data["EnableOpenIDSignIn"] = setting.Service.EnableOpenIDSignIn ctx.Data["DisableMigrations"] = setting.Repository.DisableMigrations diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 09e510ffa01b4..9029fd02ecc2f 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -207,6 +207,7 @@ var ( PasswordHashAlgo string PasswordCheckPwn bool SuccessfulTokensCacheSize int + EnforceTwoFactorAuth bool Camo = struct { Enabled bool @@ -945,6 +946,7 @@ func loadFromConf(allowEmpty bool, extraConfig string) { 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 = loadInternalToken(sec) if InstallLock && InternalToken == "" { diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index e87ee34441611..8476aecc7a9d7 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -318,6 +318,7 @@ use_scratch_code = Use a scratch 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 = You must setup Two-Factor Authentication to get access to repositories login_userpass = Sign In login_openid = OpenID oauth_signup_tab = Register New Account diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index 8a4c12d57b573..77b05b04b8ca3 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -91,6 +91,11 @@ func AutoSignIn(ctx *context.Context) (bool, error) { if err := ctx.Session.Set("uname", u.Name); err != nil { return false, err } + if twofa, _ := auth.GetTwoFactorByUID(u.ID); twofa != nil { + if err := ctx.Session.Set(auth_service.SessionKeyTwofaAuthed, true); err != nil { + return false, err + } + } if err := ctx.Session.Release(); err != nil { return false, err } @@ -311,6 +316,8 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe return setting.AppSubURL + "/" } + isTwofaAuthed := ctx.Session.Get("twofaUid") != nil + // Delete the openid, 2fa and linkaccount data _ = ctx.Session.Delete("openid_verified_uri") _ = ctx.Session.Delete("openid_signin_remember") @@ -325,6 +332,11 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe if err := ctx.Session.Set("uname", u.Name); err != nil { log.Error("Error setting uname %s session: %v", u.Name, err) } + if isTwofaAuthed { + if err := ctx.Session.Set(auth_service.SessionKeyTwofaAuthed, true); err != nil { + log.Error("Error setting %s session: %v", auth_service.SessionKeyTwofaAuthed, err) + } + } if err := ctx.Session.Release(); err != nil { log.Error("Unable to store session: %v", err) } diff --git a/routers/web/user/setting/security/2fa.go b/routers/web/user/setting/security/2fa.go index 5fd81bae4181b..a1601fe769f12 100644 --- a/routers/web/user/setting/security/2fa.go +++ b/routers/web/user/setting/security/2fa.go @@ -18,6 +18,7 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" + auth_service "code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/forms" "github.com/pquerna/otp" @@ -145,12 +146,21 @@ 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.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 { + // in case a 2FA user is using an old session (the session doesn't know 2FA authed), + // he will be navigated to this page, we should update the session status + _ = ctx.Session.Set(auth_service.SessionKeyTwofaAuthed, true) + _ = ctx.Session.Release() + } + ctx.Redirect(setting.AppSubURL + "/user/settings/security") return } @@ -171,6 +181,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.Doer.ID) if t != nil { @@ -233,6 +244,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 to 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 6a23a176651ff..5708a4dac2fe4 100644 --- a/services/auth/session.go +++ b/services/auth/session.go @@ -11,6 +11,13 @@ import ( "code.gitea.io/gitea/modules/log" ) +// The session keys used by different packages (in the future ...) +const ( + SessionKeyUID = "uid" + SessionKeyUname = "uname" + SessionKeyTwofaAuthed = "twofaAuthed" +) + // Ensure the struct implements the interface. var ( _ Method = &Session{} diff --git a/templates/base/alert.tmpl b/templates/base/alert.tmpl index cf886f529cac2..c41e8f5595d92 100644 --- a/templates/base/alert.tmpl +++ b/templates/base/alert.tmpl @@ -13,3 +13,8 @@

{{.Flash.InfoMsg | Str2html}}

{{end}} +{{if .ShowTwoFactorRequiredMessage}} +
+

{{.locale.Tr "auth.twofa_required"}} »

+
+{{end}} diff --git a/templates/status/404.tmpl b/templates/status/404.tmpl index 571124016b2ab..815e32a5256ab 100644 --- a/templates/status/404.tmpl +++ b/templates/status/404.tmpl @@ -2,10 +2,19 @@
{{if .IsRepo}}{{template "repo/header" .}}{{end}}
-

404

+ {{if .ShowTwoFactorRequiredMessage}} + + {{end}} +

404


-

{{.locale.Tr "error404" | Safe}} +

+ {{.locale.Tr "error404" | Safe}} + {{/* make a clear guide to tell a anonymous user should try to sign-in to access the repository, otherwise a 404 page may confuse a user who hasn't signed-in */}} + {{if not .IsSigned}}{{.locale.Tr "sign_in"}} »{{end}} +

{{if .ShowFooterVersion}}

{{.locale.Tr "admin.config.app_ver"}}: {{AppVer}}

{{end}}