Skip to content

Commit 52b1495

Browse files
committed
Enforce 2FA
1 parent 532c223 commit 52b1495

File tree

13 files changed

+106
-2
lines changed

13 files changed

+106
-2
lines changed

custom/conf/app.example.ini

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,10 @@ INTERNAL_TOKEN=
446446
;; 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.
447447
;; This cache will store the successfully hashed tokens in a LRU cache as a balance between performance and security.
448448
;SUCCESSFUL_TOKENS_CACHE_SIZE = 20
449+
;;
450+
;; Force users to enroll into Two-Factor Authentication. Users without 2FA have no access to any repositories.
451+
;ENFORCE_TWO_FACTOR_AUTH = false
452+
449453

450454
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
451455
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

docs/content/doc/advanced/config-cheat-sheet.en-us.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,7 @@ Certain queues have defaults that override the defaults set in `[queue]` (this o
531531
- off - do not check password complexity
532532
- `PASSWORD_CHECK_PWN`: **false**: Check [HaveIBeenPwned](https://haveibeenpwned.com/Passwords) to see if a password has been exposed.
533533
- `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.
534+
- `ENFORCE_TWO_FACTOR_AUTH`: **false**: Force users to enroll into Two-Factor Authentication. Users without 2FA have no access to any repositories.
534535

535536
## Camo (`camo`)
536537

models/perm/access/access.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ import (
99
"context"
1010
"fmt"
1111

12+
"code.gitea.io/gitea/models/auth"
1213
"code.gitea.io/gitea/models/db"
1314
"code.gitea.io/gitea/models/organization"
1415
"code.gitea.io/gitea/models/perm"
1516
repo_model "code.gitea.io/gitea/models/repo"
1617
user_model "code.gitea.io/gitea/models/user"
18+
"code.gitea.io/gitea/modules/setting"
1719
)
1820

1921
// 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
3638
restricted := false
3739

3840
if user != nil {
41+
if setting.EnforceTwoFactorAuth {
42+
if twoFactor, _ := auth.GetTwoFactorByUID(user.ID); twoFactor == nil {
43+
return perm.AccessModeNone, nil
44+
}
45+
}
3946
userID = user.ID
4047
restricted = user.IsRestricted
4148
}

models/perm/access/access_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
repo_model "code.gitea.io/gitea/models/repo"
1414
"code.gitea.io/gitea/models/unittest"
1515
user_model "code.gitea.io/gitea/models/user"
16+
"code.gitea.io/gitea/modules/setting"
1617

1718
"github.com/stretchr/testify/assert"
1819
)
@@ -22,6 +23,7 @@ func TestAccessLevel(t *testing.T) {
2223

2324
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
2425
user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
26+
user24 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 24})
2527
user29 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29})
2628
// A public repository owned by User 2
2729
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
@@ -66,13 +68,27 @@ func TestAccessLevel(t *testing.T) {
6668
level, err = access_model.AccessLevel(user29, repo24)
6769
assert.NoError(t, err)
6870
assert.Equal(t, perm_model.AccessModeRead, level)
71+
72+
// test enforced two-factor authentication
73+
setting.EnforceTwoFactorAuth = true
74+
{
75+
level, err = access_model.AccessLevel(user2, repo1)
76+
assert.NoError(t, err)
77+
assert.Equal(t, perm_model.AccessModeNone, level)
78+
79+
level, err = access_model.AccessLevel(user24, repo1)
80+
assert.NoError(t, err)
81+
assert.Equal(t, perm_model.AccessModeRead, level)
82+
}
83+
setting.EnforceTwoFactorAuth = false
6984
}
7085

7186
func TestHasAccess(t *testing.T) {
7287
assert.NoError(t, unittest.PrepareTestDatabase())
7388

7489
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
7590
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
91+
user24 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 24})
7692
// A public repository owned by User 2
7793
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
7894
assert.False(t, repo1.IsPrivate)
@@ -92,6 +108,19 @@ func TestHasAccess(t *testing.T) {
92108

93109
_, err = access_model.HasAccess(db.DefaultContext, user2.ID, repo2)
94110
assert.NoError(t, err)
111+
112+
// test enforced two-factor authentication
113+
setting.EnforceTwoFactorAuth = true
114+
{
115+
has, err = access_model.HasAccess(db.DefaultContext, user1.ID, repo1)
116+
assert.NoError(t, err)
117+
assert.False(t, has)
118+
119+
has, err = access_model.HasAccess(db.DefaultContext, user24.ID, repo1)
120+
assert.NoError(t, err)
121+
assert.True(t, has)
122+
}
123+
setting.EnforceTwoFactorAuth = false
95124
}
96125

97126
func TestRepository_RecalculateAccesses(t *testing.T) {

models/perm/access/repo_permission.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@ import (
88
"context"
99
"fmt"
1010

11+
"code.gitea.io/gitea/models/auth"
1112
"code.gitea.io/gitea/models/db"
1213
"code.gitea.io/gitea/models/organization"
1314
perm_model "code.gitea.io/gitea/models/perm"
1415
repo_model "code.gitea.io/gitea/models/repo"
1516
"code.gitea.io/gitea/models/unit"
1617
user_model "code.gitea.io/gitea/models/user"
1718
"code.gitea.io/gitea/modules/log"
19+
"code.gitea.io/gitea/modules/setting"
1820
)
1921

2022
// 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
168170
return
169171
}
170172

173+
if user != nil && setting.EnforceTwoFactorAuth {
174+
if twoFactor, _ := auth.GetTwoFactorByUID(user.ID); twoFactor == nil {
175+
perm.AccessMode = perm_model.AccessModeNone
176+
return
177+
}
178+
}
179+
171180
var is bool
172181
if user != nil {
173182
is, err = repo_model.IsCollaborator(ctx, repo.ID, user.ID)

modules/context/context.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,10 @@ func Contexter() func(next http.Handler) http.Handler {
768768
ctx.Data["ShowFooterBranding"] = setting.ShowFooterBranding
769769
ctx.Data["ShowFooterVersion"] = setting.ShowFooterVersion
770770

771+
ctx.Data["ShowTwoFactorRequiredMessage"] = setting.EnforceTwoFactorAuth &&
772+
ctx.Session.Get(auth.SessionKeyUID) != nil &&
773+
ctx.Session.Get(auth.SessionKeyTwofaAuthed) == nil
774+
771775
ctx.Data["EnableSwagger"] = setting.API.EnableSwagger
772776
ctx.Data["EnableOpenIDSignIn"] = setting.Service.EnableOpenIDSignIn
773777
ctx.Data["DisableMigrations"] = setting.Repository.DisableMigrations

modules/setting/setting.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ var (
207207
PasswordHashAlgo string
208208
PasswordCheckPwn bool
209209
SuccessfulTokensCacheSize int
210+
EnforceTwoFactorAuth bool
210211

211212
Camo = struct {
212213
Enabled bool
@@ -945,6 +946,7 @@ func loadFromConf(allowEmpty bool, extraConfig string) {
945946
CSRFCookieHTTPOnly = sec.Key("CSRF_COOKIE_HTTP_ONLY").MustBool(true)
946947
PasswordCheckPwn = sec.Key("PASSWORD_CHECK_PWN").MustBool(false)
947948
SuccessfulTokensCacheSize = sec.Key("SUCCESSFUL_TOKENS_CACHE_SIZE").MustInt(20)
949+
EnforceTwoFactorAuth = sec.Key("ENFORCE_TWO_FACTOR_AUTH").MustBool(false)
948950

949951
InternalToken = loadInternalToken(sec)
950952
if InstallLock && InternalToken == "" {

options/locale/locale_en-US.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,7 @@ use_scratch_code = Use a scratch code
318318
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.
319319
twofa_passcode_incorrect = Your passcode is incorrect. If you misplaced your device, use your scratch code to sign in.
320320
twofa_scratch_token_incorrect = Your scratch code is incorrect.
321+
twofa_required = You must setup Two-Factor Authentication to get access to repositories
321322
login_userpass = Sign In
322323
login_openid = OpenID
323324
oauth_signup_tab = Register New Account

routers/web/auth/auth.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ func AutoSignIn(ctx *context.Context) (bool, error) {
9191
if err := ctx.Session.Set("uname", u.Name); err != nil {
9292
return false, err
9393
}
94+
if twofa, _ := auth.GetTwoFactorByUID(u.ID); twofa != nil {
95+
if err := ctx.Session.Set(auth_service.SessionKeyTwofaAuthed, true); err != nil {
96+
return false, err
97+
}
98+
}
9499
if err := ctx.Session.Release(); err != nil {
95100
return false, err
96101
}
@@ -311,6 +316,8 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe
311316
return setting.AppSubURL + "/"
312317
}
313318

319+
isTwofaAuthed := ctx.Session.Get("twofaUid") != nil
320+
314321
// Delete the openid, 2fa and linkaccount data
315322
_ = ctx.Session.Delete("openid_verified_uri")
316323
_ = ctx.Session.Delete("openid_signin_remember")
@@ -325,6 +332,11 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe
325332
if err := ctx.Session.Set("uname", u.Name); err != nil {
326333
log.Error("Error setting uname %s session: %v", u.Name, err)
327334
}
335+
if isTwofaAuthed {
336+
if err := ctx.Session.Set(auth_service.SessionKeyTwofaAuthed, true); err != nil {
337+
log.Error("Error setting %s session: %v", auth_service.SessionKeyTwofaAuthed, err)
338+
}
339+
}
328340
if err := ctx.Session.Release(); err != nil {
329341
log.Error("Unable to store session: %v", err)
330342
}

routers/web/user/setting/security/2fa.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"code.gitea.io/gitea/modules/log"
1919
"code.gitea.io/gitea/modules/setting"
2020
"code.gitea.io/gitea/modules/web"
21+
auth_service "code.gitea.io/gitea/services/auth"
2122
"code.gitea.io/gitea/services/forms"
2223

2324
"github.com/pquerna/otp"
@@ -145,12 +146,21 @@ func twofaGenerateSecretAndQr(ctx *context.Context) bool {
145146
func EnrollTwoFactor(ctx *context.Context) {
146147
ctx.Data["Title"] = ctx.Tr("settings")
147148
ctx.Data["PageIsSettingsSecurity"] = true
149+
ctx.Data["ShowTwoFactorRequiredMessage"] = false
148150

149151
t, err := auth.GetTwoFactorByUID(ctx.Doer.ID)
150152
if t != nil {
151153
// already enrolled - we should redirect back!
152154
log.Warn("Trying to re-enroll %-v in twofa when already enrolled", ctx.Doer)
153155
ctx.Flash.Error(ctx.Tr("settings.twofa_is_enrolled"))
156+
157+
if ctx.Session.Get(auth_service.SessionKeyTwofaAuthed) == nil {
158+
// in case a 2FA user is using an old session (the session doesn't know 2FA authed),
159+
// he will be navigated to this page, we should update the session status
160+
_ = ctx.Session.Set(auth_service.SessionKeyTwofaAuthed, true)
161+
_ = ctx.Session.Release()
162+
}
163+
154164
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
155165
return
156166
}
@@ -171,6 +181,7 @@ func EnrollTwoFactorPost(ctx *context.Context) {
171181
form := web.GetForm(ctx).(*forms.TwoFactorAuthForm)
172182
ctx.Data["Title"] = ctx.Tr("settings")
173183
ctx.Data["PageIsSettingsSecurity"] = true
184+
ctx.Data["ShowTwoFactorRequiredMessage"] = false
174185

175186
t, err := auth.GetTwoFactorByUID(ctx.Doer.ID)
176187
if t != nil {
@@ -233,6 +244,9 @@ func EnrollTwoFactorPost(ctx *context.Context) {
233244
// tolerate this failure - it's more important to continue
234245
log.Error("Unable to delete twofaUri from the session: Error: %v", err)
235246
}
247+
if err := ctx.Session.Set(auth_service.SessionKeyTwofaAuthed, true); err != nil {
248+
log.Error("Unable to set %s to session: Error: %v", auth_service.SessionKeyTwofaAuthed, err)
249+
}
236250
if err := ctx.Session.Release(); err != nil {
237251
// tolerate this failure - it's more important to continue
238252
log.Error("Unable to save changes to the session: %v", err)

services/auth/session.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ import (
1111
"code.gitea.io/gitea/modules/log"
1212
)
1313

14+
// The session keys used by different packages (in the future ...)
15+
const (
16+
SessionKeyUID = "uid"
17+
SessionKeyUname = "uname"
18+
SessionKeyTwofaAuthed = "twofaAuthed"
19+
)
20+
1421
// Ensure the struct implements the interface.
1522
var (
1623
_ Method = &Session{}

templates/base/alert.tmpl

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,8 @@
1313
<p>{{.Flash.InfoMsg | Str2html}}</p>
1414
</div>
1515
{{end}}
16+
{{if .ShowTwoFactorRequiredMessage}}
17+
<div class="ui negative message flash-error">
18+
<p><a href="{{AppSubUrl}}/user/settings/security/two_factor/enroll">{{.locale.Tr "auth.twofa_required"}} &raquo;</a></p>
19+
</div>
20+
{{end}}

templates/status/404.tmpl

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,19 @@
22
<div class="page-content ui container center full-screen-width {{if .IsRepo}}repository{{end}}">
33
{{if .IsRepo}}{{template "repo/header" .}}{{end}}
44
<div class="ui container center">
5-
<p style="margin-top: 100px"><img class="ui centered image" src="{{AssetUrlPrefix}}/img/404.png" alt="404"/></p>
5+
{{if .ShowTwoFactorRequiredMessage}}
6+
<div class="ui negative message flash-error">
7+
<p><a href="{{AppSubUrl}}/user/settings/security/two_factor/enroll">{{.locale.Tr "auth.twofa_required"}} &raquo;</a></p>
8+
</div>
9+
{{end}}
10+
<p style="margin-top: 100px;"><img class="ui centered image" src="{{AssetUrlPrefix}}/img/404.png" alt="404"/></p>
611
<div class="ui divider"></div>
712
<br>
8-
<p>{{.locale.Tr "error404" | Safe}}
13+
<p>
14+
{{.locale.Tr "error404" | Safe}}
15+
{{/* 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 */}}
16+
{{if not .IsSigned}}<a href="{{AppSubUrl}}/user/forget_password">{{.locale.Tr "sign_in"}} &raquo;</a>{{end}}
17+
</p>
918
{{if .ShowFooterVersion}}<p>{{.locale.Tr "admin.config.app_ver"}}: {{AppVer}}</p>{{end}}
1019
</div>
1120
</div>

0 commit comments

Comments
 (0)