diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index b59ceee4f1db9..350777ab3f28a 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -451,6 +451,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 df1911934c885..af171c212baac 100644 --- a/docs/content/doc/advanced/config-cheat-sheet.en-us.md +++ b/docs/content/doc/advanced/config-cheat-sheet.en-us.md @@ -533,6 +533,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 7344e114a64e2..7fbb6297a83f3 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 4b6a21b217c3b..508e48d175982 100644 --- a/modules/context/context.go +++ b/modules/context/context.go @@ -768,6 +768,10 @@ func Contexter(ctx context.Context) 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 043acb733d523..99e65ffc3a5d0 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -208,6 +208,7 @@ var ( PasswordHashAlgo string PasswordCheckPwn bool SuccessfulTokensCacheSize int + EnforceTwoFactorAuth bool Camo = struct { Enabled bool @@ -962,6 +963,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 = loadSecret(sec, "INTERNAL_TOKEN_URI", "INTERNAL_TOKEN") if InstallLock && InternalToken == "" { diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 1566dfc97d422..8fd41e454b1b7 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -325,6 +325,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 25d70d7c47818..e24f593689f24 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -93,6 +93,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 } @@ -313,6 +318,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") @@ -327,6 +334,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 1ec94aa0af718..8f784a7b91828 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 b8a04b89a9585..00bb7335cd836 100644 --- a/templates/base/alert.tmpl +++ b/templates/base/alert.tmpl @@ -18,3 +18,8 @@

{{.Flash.WarningMsg | Str2html}}

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

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

+
+{{end}} diff --git a/templates/status/404.tmpl b/templates/status/404.tmpl index 8dd4cfb8ae38d..9309336c29ca7 100644 --- a/templates/status/404.tmpl +++ b/templates/status/404.tmpl @@ -2,10 +2,19 @@
{{if .IsRepo}}{{template "repo/header" .}}{{end}}
+ {{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}}