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}}
+
+{{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}}

-
{{.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}}