Skip to content

Commit 0148d03

Browse files
wxiaoguanglunny
andauthored
Enforce two-factor auth (2FA: TOTP or WebAuthn) (#34187)
Fix #880 Design: 1. A global setting `security.TWO_FACTOR_AUTH`. * To support org-level config, we need to introduce a better "owner setting" system first (in the future) 2. A user without 2FA can login and may explore, but can NOT read or write to any repositories via API/web. 3. Keep things as simple as possible. * This option only aggressively suggest users to enable their 2FA at the moment, it does NOT guarantee that users must have 2FA before all other operations, it should be good enough for real world use cases. * Some details and tests could be improved in the future since this change only adds a check and seems won't affect too much. --------- Co-authored-by: Lunny Xiao <[email protected]>
1 parent 4ed0724 commit 0148d03

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+324
-223
lines changed

cmd/admin_auth_ldap.go

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"strings"
1010

1111
"code.gitea.io/gitea/models/auth"
12+
"code.gitea.io/gitea/modules/util"
1213
"code.gitea.io/gitea/services/auth/source/ldap"
1314

1415
"github.com/urfave/cli/v2"
@@ -210,8 +211,8 @@ func newAuthService() *authService {
210211
}
211212
}
212213

213-
// parseAuthSource assigns values on authSource according to command line flags.
214-
func parseAuthSource(c *cli.Context, authSource *auth.Source) {
214+
// parseAuthSourceLdap assigns values on authSource according to command line flags.
215+
func parseAuthSourceLdap(c *cli.Context, authSource *auth.Source) {
215216
if c.IsSet("name") {
216217
authSource.Name = c.String("name")
217218
}
@@ -227,6 +228,7 @@ func parseAuthSource(c *cli.Context, authSource *auth.Source) {
227228
if c.IsSet("disable-synchronize-users") {
228229
authSource.IsSyncEnabled = !c.Bool("disable-synchronize-users")
229230
}
231+
authSource.TwoFactorPolicy = util.Iif(c.Bool("skip-local-2fa"), "skip", "")
230232
}
231233

232234
// parseLdapConfig assigns values on config according to command line flags.
@@ -298,9 +300,6 @@ func parseLdapConfig(c *cli.Context, config *ldap.Source) error {
298300
if c.IsSet("allow-deactivate-all") {
299301
config.AllowDeactivateAll = c.Bool("allow-deactivate-all")
300302
}
301-
if c.IsSet("skip-local-2fa") {
302-
config.SkipLocalTwoFA = c.Bool("skip-local-2fa")
303-
}
304303
if c.IsSet("enable-groups") {
305304
config.GroupsEnabled = c.Bool("enable-groups")
306305
}
@@ -376,7 +375,7 @@ func (a *authService) addLdapBindDn(c *cli.Context) error {
376375
},
377376
}
378377

379-
parseAuthSource(c, authSource)
378+
parseAuthSourceLdap(c, authSource)
380379
if err := parseLdapConfig(c, authSource.Cfg.(*ldap.Source)); err != nil {
381380
return err
382381
}
@@ -398,7 +397,7 @@ func (a *authService) updateLdapBindDn(c *cli.Context) error {
398397
return err
399398
}
400399

401-
parseAuthSource(c, authSource)
400+
parseAuthSourceLdap(c, authSource)
402401
if err := parseLdapConfig(c, authSource.Cfg.(*ldap.Source)); err != nil {
403402
return err
404403
}
@@ -427,7 +426,7 @@ func (a *authService) addLdapSimpleAuth(c *cli.Context) error {
427426
},
428427
}
429428

430-
parseAuthSource(c, authSource)
429+
parseAuthSourceLdap(c, authSource)
431430
if err := parseLdapConfig(c, authSource.Cfg.(*ldap.Source)); err != nil {
432431
return err
433432
}
@@ -449,7 +448,7 @@ func (a *authService) updateLdapSimpleAuth(c *cli.Context) error {
449448
return err
450449
}
451450

452-
parseAuthSource(c, authSource)
451+
parseAuthSourceLdap(c, authSource)
453452
if err := parseLdapConfig(c, authSource.Cfg.(*ldap.Source)); err != nil {
454453
return err
455454
}

cmd/admin_auth_oauth.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"net/url"
1010

1111
auth_model "code.gitea.io/gitea/models/auth"
12+
"code.gitea.io/gitea/modules/util"
1213
"code.gitea.io/gitea/services/auth/source/oauth2"
1314

1415
"github.com/urfave/cli/v2"
@@ -156,7 +157,6 @@ func parseOAuth2Config(c *cli.Context) *oauth2.Source {
156157
OpenIDConnectAutoDiscoveryURL: c.String("auto-discover-url"),
157158
CustomURLMapping: customURLMapping,
158159
IconURL: c.String("icon-url"),
159-
SkipLocalTwoFA: c.Bool("skip-local-2fa"),
160160
Scopes: c.StringSlice("scopes"),
161161
RequiredClaimName: c.String("required-claim-name"),
162162
RequiredClaimValue: c.String("required-claim-value"),
@@ -185,10 +185,11 @@ func runAddOauth(c *cli.Context) error {
185185
}
186186

187187
return auth_model.CreateSource(ctx, &auth_model.Source{
188-
Type: auth_model.OAuth2,
189-
Name: c.String("name"),
190-
IsActive: true,
191-
Cfg: config,
188+
Type: auth_model.OAuth2,
189+
Name: c.String("name"),
190+
IsActive: true,
191+
Cfg: config,
192+
TwoFactorPolicy: util.Iif(c.Bool("skip-local-2fa"), "skip", ""),
192193
})
193194
}
194195

@@ -294,6 +295,6 @@ func runUpdateOauth(c *cli.Context) error {
294295

295296
oAuth2Config.CustomURLMapping = customURLMapping
296297
source.Cfg = oAuth2Config
297-
298+
source.TwoFactorPolicy = util.Iif(c.Bool("skip-local-2fa"), "skip", "")
298299
return auth_model.UpdateSource(ctx, source)
299300
}

cmd/admin_auth_stmp.go

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,6 @@ func parseSMTPConfig(c *cli.Context, conf *smtp.Source) error {
117117
if c.IsSet("disable-helo") {
118118
conf.DisableHelo = c.Bool("disable-helo")
119119
}
120-
if c.IsSet("skip-local-2fa") {
121-
conf.SkipLocalTwoFA = c.Bool("skip-local-2fa")
122-
}
123120
return nil
124121
}
125122

@@ -156,10 +153,11 @@ func runAddSMTP(c *cli.Context) error {
156153
}
157154

158155
return auth_model.CreateSource(ctx, &auth_model.Source{
159-
Type: auth_model.SMTP,
160-
Name: c.String("name"),
161-
IsActive: active,
162-
Cfg: &smtpConfig,
156+
Type: auth_model.SMTP,
157+
Name: c.String("name"),
158+
IsActive: active,
159+
Cfg: &smtpConfig,
160+
TwoFactorPolicy: util.Iif(c.Bool("skip-local-2fa"), "skip", ""),
163161
})
164162
}
165163

@@ -195,6 +193,6 @@ func runUpdateSMTP(c *cli.Context) error {
195193
}
196194

197195
source.Cfg = smtpConfig
198-
196+
source.TwoFactorPolicy = util.Iif(c.Bool("skip-local-2fa"), "skip", "")
199197
return auth_model.UpdateSource(ctx, source)
200198
}

custom/conf/app.example.ini

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,10 @@ INTERNAL_TOKEN =
524524
;;
525525
;; On user registration, record the IP address and user agent of the user to help identify potential abuse.
526526
;; RECORD_USER_SIGNUP_METADATA = false
527+
;;
528+
;; Set the two-factor auth behavior.
529+
;; Set to "enforced", to force users to enroll into Two-Factor Authentication, users without 2FA have no access to repositories via API or web.
530+
;TWO_FACTOR_AUTH =
527531

528532
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
529533
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

models/auth/source.go

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,15 @@ var Names = map[Type]string{
5858
// Config represents login config as far as the db is concerned
5959
type Config interface {
6060
convert.Conversion
61+
SetAuthSource(*Source)
62+
}
63+
64+
type ConfigBase struct {
65+
AuthSource *Source
66+
}
67+
68+
func (p *ConfigBase) SetAuthSource(s *Source) {
69+
p.AuthSource = s
6170
}
6271

6372
// SkipVerifiable configurations provide a IsSkipVerify to check if SkipVerify is set
@@ -104,19 +113,15 @@ func RegisterTypeConfig(typ Type, exemplar Config) {
104113
}
105114
}
106115

107-
// SourceSettable configurations can have their authSource set on them
108-
type SourceSettable interface {
109-
SetAuthSource(*Source)
110-
}
111-
112116
// Source represents an external way for authorizing users.
113117
type Source struct {
114-
ID int64 `xorm:"pk autoincr"`
115-
Type Type
116-
Name string `xorm:"UNIQUE"`
117-
IsActive bool `xorm:"INDEX NOT NULL DEFAULT false"`
118-
IsSyncEnabled bool `xorm:"INDEX NOT NULL DEFAULT false"`
119-
Cfg convert.Conversion `xorm:"TEXT"`
118+
ID int64 `xorm:"pk autoincr"`
119+
Type Type
120+
Name string `xorm:"UNIQUE"`
121+
IsActive bool `xorm:"INDEX NOT NULL DEFAULT false"`
122+
IsSyncEnabled bool `xorm:"INDEX NOT NULL DEFAULT false"`
123+
TwoFactorPolicy string `xorm:"two_factor_policy NOT NULL DEFAULT ''"`
124+
Cfg Config `xorm:"TEXT"`
120125

121126
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
122127
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
@@ -140,9 +145,7 @@ func (source *Source) BeforeSet(colName string, val xorm.Cell) {
140145
return
141146
}
142147
source.Cfg = constructor()
143-
if settable, ok := source.Cfg.(SourceSettable); ok {
144-
settable.SetAuthSource(source)
145-
}
148+
source.Cfg.SetAuthSource(source)
146149
}
147150
}
148151

@@ -200,6 +203,10 @@ func (source *Source) SkipVerify() bool {
200203
return ok && skipVerifiable.IsSkipVerify()
201204
}
202205

206+
func (source *Source) TwoFactorShouldSkip() bool {
207+
return source.TwoFactorPolicy == "skip"
208+
}
209+
203210
// CreateSource inserts a AuthSource in the DB if not already
204211
// existing with the given name.
205212
func CreateSource(ctx context.Context, source *Source) error {
@@ -223,9 +230,7 @@ func CreateSource(ctx context.Context, source *Source) error {
223230
return nil
224231
}
225232

226-
if settable, ok := source.Cfg.(SourceSettable); ok {
227-
settable.SetAuthSource(source)
228-
}
233+
source.Cfg.SetAuthSource(source)
229234

230235
registerableSource, ok := source.Cfg.(RegisterableSource)
231236
if !ok {
@@ -320,9 +325,7 @@ func UpdateSource(ctx context.Context, source *Source) error {
320325
return nil
321326
}
322327

323-
if settable, ok := source.Cfg.(SourceSettable); ok {
324-
settable.SetAuthSource(source)
325-
}
328+
source.Cfg.SetAuthSource(source)
326329

327330
registerableSource, ok := source.Cfg.(RegisterableSource)
328331
if !ok {

models/auth/source_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import (
1919
)
2020

2121
type TestSource struct {
22+
auth_model.ConfigBase
23+
2224
Provider string
2325
ClientID string
2426
ClientSecret string

models/auth/twofactor.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,13 @@ func DeleteTwoFactorByID(ctx context.Context, id, userID int64) error {
164164
}
165165
return nil
166166
}
167+
168+
func HasTwoFactorOrWebAuthn(ctx context.Context, id int64) (bool, error) {
169+
has, err := HasTwoFactorByUID(ctx, id)
170+
if err != nil {
171+
return false, err
172+
} else if has {
173+
return true, nil
174+
}
175+
return HasWebAuthnRegistrationsByUID(ctx, id)
176+
}

models/migrations/migrations.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,7 @@ func prepareMigrationTasks() []*migration {
381381
newMigration(317, "Add new index for action for heatmap", v1_24.AddNewIndexForUserDashboard),
382382
newMigration(318, "Add anonymous_access_mode for repo_unit", v1_24.AddRepoUnitAnonymousAccessMode),
383383
newMigration(319, "Add ExclusiveOrder to Label table", v1_24.AddExclusiveOrderColumnToLabelTable),
384+
newMigration(320, "Migrate two_factor_policy to login_source table", v1_24.MigrateSkipTwoFactor),
384385
}
385386
return preparedMigrations
386387
}

models/migrations/v1_24/v320.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package v1_24 //nolint
5+
6+
import (
7+
"code.gitea.io/gitea/modules/json"
8+
9+
"xorm.io/xorm"
10+
)
11+
12+
func MigrateSkipTwoFactor(x *xorm.Engine) error {
13+
type LoginSource struct {
14+
TwoFactorPolicy string `xorm:"two_factor_policy NOT NULL DEFAULT ''"`
15+
}
16+
_, err := x.SyncWithOptions(
17+
xorm.SyncOptions{
18+
IgnoreConstrains: true,
19+
IgnoreIndices: true,
20+
},
21+
new(LoginSource),
22+
)
23+
if err != nil {
24+
return err
25+
}
26+
27+
type LoginSourceSimple struct {
28+
ID int64
29+
Cfg string
30+
}
31+
32+
var loginSources []LoginSourceSimple
33+
err = x.Table("login_source").Find(&loginSources)
34+
if err != nil {
35+
return err
36+
}
37+
38+
for _, source := range loginSources {
39+
if source.Cfg == "" {
40+
continue
41+
}
42+
43+
var cfg map[string]any
44+
err = json.Unmarshal([]byte(source.Cfg), &cfg)
45+
if err != nil {
46+
return err
47+
}
48+
49+
if cfg["SkipLocalTwoFA"] == true {
50+
_, err = x.Exec("UPDATE login_source SET two_factor_policy = 'skip' WHERE id = ?", source.ID)
51+
if err != nil {
52+
return err
53+
}
54+
}
55+
}
56+
return nil
57+
}

models/perm/access/repo_permission.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,3 +522,7 @@ func CheckRepoUnitUser(ctx context.Context, repo *repo_model.Repository, user *u
522522

523523
return perm.CanRead(unitType)
524524
}
525+
526+
func PermissionNoAccess() Permission {
527+
return Permission{AccessMode: perm_model.AccessModeNone}
528+
}

modules/session/key.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package session
5+
6+
const (
7+
KeyUID = "uid"
8+
KeyUname = "uname"
9+
10+
KeyUserHasTwoFactorAuth = "userHasTwoFactorAuth"
11+
)

modules/setting/security.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ var (
3939
CSRFCookieName = "_csrf"
4040
CSRFCookieHTTPOnly = true
4141
RecordUserSignupMetadata = false
42+
TwoFactorAuthEnforced = false
4243
)
4344

4445
// loadSecret load the secret from ini by uriKey or verbatimKey, only one of them could be set
@@ -142,6 +143,15 @@ func loadSecurityFrom(rootCfg ConfigProvider) {
142143
PasswordCheckPwn = sec.Key("PASSWORD_CHECK_PWN").MustBool(false)
143144
SuccessfulTokensCacheSize = sec.Key("SUCCESSFUL_TOKENS_CACHE_SIZE").MustInt(20)
144145

146+
twoFactorAuth := sec.Key("TWO_FACTOR_AUTH").String()
147+
switch twoFactorAuth {
148+
case "":
149+
case "enforced":
150+
TwoFactorAuthEnforced = true
151+
default:
152+
log.Fatal("Invalid two-factor auth option: %s", twoFactorAuth)
153+
}
154+
145155
InternalToken = loadSecret(sec, "INTERNAL_TOKEN_URI", "INTERNAL_TOKEN")
146156
if InstallLock && InternalToken == "" {
147157
// if Gitea has been installed but the InternalToken hasn't been generated (upgrade from an old release), we should generate

options/locale/locale_en-US.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,7 @@ use_scratch_code = Use a scratch code
450450
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.
451451
twofa_passcode_incorrect = Your passcode is incorrect. If you misplaced your device, use your scratch code to sign in.
452452
twofa_scratch_token_incorrect = Your scratch code is incorrect.
453+
twofa_required = You must setup Two-Factor Authentication to get access to repositories, or try to login again.
453454
login_userpass = Sign In
454455
login_openid = OpenID
455456
oauth_signup_tab = Register New Account

0 commit comments

Comments
 (0)