Skip to content

Enforce two-factor auth (2FA: TOTP or WebAuthn) #34187

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Apr 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 8 additions & 9 deletions cmd/admin_auth_ldap.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"

"code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/auth/source/ldap"

"github.com/urfave/cli/v2"
Expand Down Expand Up @@ -210,8 +211,8 @@ func newAuthService() *authService {
}
}

// parseAuthSource assigns values on authSource according to command line flags.
func parseAuthSource(c *cli.Context, authSource *auth.Source) {
// parseAuthSourceLdap assigns values on authSource according to command line flags.
func parseAuthSourceLdap(c *cli.Context, authSource *auth.Source) {
if c.IsSet("name") {
authSource.Name = c.String("name")
}
Expand All @@ -227,6 +228,7 @@ func parseAuthSource(c *cli.Context, authSource *auth.Source) {
if c.IsSet("disable-synchronize-users") {
authSource.IsSyncEnabled = !c.Bool("disable-synchronize-users")
}
authSource.TwoFactorPolicy = util.Iif(c.Bool("skip-local-2fa"), "skip", "")
}

// parseLdapConfig assigns values on config according to command line flags.
Expand Down Expand Up @@ -298,9 +300,6 @@ func parseLdapConfig(c *cli.Context, config *ldap.Source) error {
if c.IsSet("allow-deactivate-all") {
config.AllowDeactivateAll = c.Bool("allow-deactivate-all")
}
if c.IsSet("skip-local-2fa") {
config.SkipLocalTwoFA = c.Bool("skip-local-2fa")
}
if c.IsSet("enable-groups") {
config.GroupsEnabled = c.Bool("enable-groups")
}
Expand Down Expand Up @@ -376,7 +375,7 @@ func (a *authService) addLdapBindDn(c *cli.Context) error {
},
}

parseAuthSource(c, authSource)
parseAuthSourceLdap(c, authSource)
if err := parseLdapConfig(c, authSource.Cfg.(*ldap.Source)); err != nil {
return err
}
Expand All @@ -398,7 +397,7 @@ func (a *authService) updateLdapBindDn(c *cli.Context) error {
return err
}

parseAuthSource(c, authSource)
parseAuthSourceLdap(c, authSource)
if err := parseLdapConfig(c, authSource.Cfg.(*ldap.Source)); err != nil {
return err
}
Expand Down Expand Up @@ -427,7 +426,7 @@ func (a *authService) addLdapSimpleAuth(c *cli.Context) error {
},
}

parseAuthSource(c, authSource)
parseAuthSourceLdap(c, authSource)
if err := parseLdapConfig(c, authSource.Cfg.(*ldap.Source)); err != nil {
return err
}
Expand All @@ -449,7 +448,7 @@ func (a *authService) updateLdapSimpleAuth(c *cli.Context) error {
return err
}

parseAuthSource(c, authSource)
parseAuthSourceLdap(c, authSource)
if err := parseLdapConfig(c, authSource.Cfg.(*ldap.Source)); err != nil {
return err
}
Expand Down
13 changes: 7 additions & 6 deletions cmd/admin_auth_oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net/url"

auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/auth/source/oauth2"

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

return auth_model.CreateSource(ctx, &auth_model.Source{
Type: auth_model.OAuth2,
Name: c.String("name"),
IsActive: true,
Cfg: config,
Type: auth_model.OAuth2,
Name: c.String("name"),
IsActive: true,
Cfg: config,
TwoFactorPolicy: util.Iif(c.Bool("skip-local-2fa"), "skip", ""),
})
}

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

oAuth2Config.CustomURLMapping = customURLMapping
source.Cfg = oAuth2Config

source.TwoFactorPolicy = util.Iif(c.Bool("skip-local-2fa"), "skip", "")
return auth_model.UpdateSource(ctx, source)
}
14 changes: 6 additions & 8 deletions cmd/admin_auth_stmp.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,6 @@ func parseSMTPConfig(c *cli.Context, conf *smtp.Source) error {
if c.IsSet("disable-helo") {
conf.DisableHelo = c.Bool("disable-helo")
}
if c.IsSet("skip-local-2fa") {
conf.SkipLocalTwoFA = c.Bool("skip-local-2fa")
}
return nil
}

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

return auth_model.CreateSource(ctx, &auth_model.Source{
Type: auth_model.SMTP,
Name: c.String("name"),
IsActive: active,
Cfg: &smtpConfig,
Type: auth_model.SMTP,
Name: c.String("name"),
IsActive: active,
Cfg: &smtpConfig,
TwoFactorPolicy: util.Iif(c.Bool("skip-local-2fa"), "skip", ""),
})
}

Expand Down Expand Up @@ -195,6 +193,6 @@ func runUpdateSMTP(c *cli.Context) error {
}

source.Cfg = smtpConfig

source.TwoFactorPolicy = util.Iif(c.Bool("skip-local-2fa"), "skip", "")
return auth_model.UpdateSource(ctx, source)
}
4 changes: 4 additions & 0 deletions custom/conf/app.example.ini
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,10 @@ INTERNAL_TOKEN =
;;
;; On user registration, record the IP address and user agent of the user to help identify potential abuse.
;; RECORD_USER_SIGNUP_METADATA = false
;;
;; Set the two-factor auth behavior.
;; Set to "enforced", to force users to enroll into Two-Factor Authentication, users without 2FA have no access to repositories via API or web.
;TWO_FACTOR_AUTH =

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
Expand Down
43 changes: 23 additions & 20 deletions models/auth/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ var Names = map[Type]string{
// Config represents login config as far as the db is concerned
type Config interface {
convert.Conversion
SetAuthSource(*Source)
}

type ConfigBase struct {
AuthSource *Source
}

func (p *ConfigBase) SetAuthSource(s *Source) {
p.AuthSource = s
}

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

// SourceSettable configurations can have their authSource set on them
type SourceSettable interface {
SetAuthSource(*Source)
}

// Source represents an external way for authorizing users.
type Source struct {
ID int64 `xorm:"pk autoincr"`
Type Type
Name string `xorm:"UNIQUE"`
IsActive bool `xorm:"INDEX NOT NULL DEFAULT false"`
IsSyncEnabled bool `xorm:"INDEX NOT NULL DEFAULT false"`
Cfg convert.Conversion `xorm:"TEXT"`
ID int64 `xorm:"pk autoincr"`
Type Type
Name string `xorm:"UNIQUE"`
IsActive bool `xorm:"INDEX NOT NULL DEFAULT false"`
IsSyncEnabled bool `xorm:"INDEX NOT NULL DEFAULT false"`
TwoFactorPolicy string `xorm:"two_factor_policy NOT NULL DEFAULT ''"`
Cfg Config `xorm:"TEXT"`

CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
Expand All @@ -140,9 +145,7 @@ func (source *Source) BeforeSet(colName string, val xorm.Cell) {
return
}
source.Cfg = constructor()
if settable, ok := source.Cfg.(SourceSettable); ok {
settable.SetAuthSource(source)
}
source.Cfg.SetAuthSource(source)
}
}

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

func (source *Source) TwoFactorShouldSkip() bool {
return source.TwoFactorPolicy == "skip"
}

// CreateSource inserts a AuthSource in the DB if not already
// existing with the given name.
func CreateSource(ctx context.Context, source *Source) error {
Expand All @@ -223,9 +230,7 @@ func CreateSource(ctx context.Context, source *Source) error {
return nil
}

if settable, ok := source.Cfg.(SourceSettable); ok {
settable.SetAuthSource(source)
}
source.Cfg.SetAuthSource(source)

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

if settable, ok := source.Cfg.(SourceSettable); ok {
settable.SetAuthSource(source)
}
source.Cfg.SetAuthSource(source)

registerableSource, ok := source.Cfg.(RegisterableSource)
if !ok {
Expand Down
2 changes: 2 additions & 0 deletions models/auth/source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import (
)

type TestSource struct {
auth_model.ConfigBase

Provider string
ClientID string
ClientSecret string
Expand Down
10 changes: 10 additions & 0 deletions models/auth/twofactor.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,13 @@ func DeleteTwoFactorByID(ctx context.Context, id, userID int64) error {
}
return nil
}

func HasTwoFactorOrWebAuthn(ctx context.Context, id int64) (bool, error) {
has, err := HasTwoFactorByUID(ctx, id)
if err != nil {
return false, err
} else if has {
return true, nil
}
return HasWebAuthnRegistrationsByUID(ctx, id)
}
1 change: 1 addition & 0 deletions models/migrations/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@ func prepareMigrationTasks() []*migration {
newMigration(317, "Add new index for action for heatmap", v1_24.AddNewIndexForUserDashboard),
newMigration(318, "Add anonymous_access_mode for repo_unit", v1_24.AddRepoUnitAnonymousAccessMode),
newMigration(319, "Add ExclusiveOrder to Label table", v1_24.AddExclusiveOrderColumnToLabelTable),
newMigration(320, "Migrate two_factor_policy to login_source table", v1_24.MigrateSkipTwoFactor),
}
return preparedMigrations
}
Expand Down
57 changes: 57 additions & 0 deletions models/migrations/v1_24/v320.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package v1_24 //nolint

import (
"code.gitea.io/gitea/modules/json"

"xorm.io/xorm"
)

func MigrateSkipTwoFactor(x *xorm.Engine) error {
type LoginSource struct {
TwoFactorPolicy string `xorm:"two_factor_policy NOT NULL DEFAULT ''"`
}
_, err := x.SyncWithOptions(
xorm.SyncOptions{
IgnoreConstrains: true,
IgnoreIndices: true,
},
new(LoginSource),
)
if err != nil {
return err
}

type LoginSourceSimple struct {
ID int64
Cfg string
}

var loginSources []LoginSourceSimple
err = x.Table("login_source").Find(&loginSources)
if err != nil {
return err
}

for _, source := range loginSources {
if source.Cfg == "" {
continue
}

var cfg map[string]any
err = json.Unmarshal([]byte(source.Cfg), &cfg)
if err != nil {
return err
}

if cfg["SkipLocalTwoFA"] == true {
_, err = x.Exec("UPDATE login_source SET two_factor_policy = 'skip' WHERE id = ?", source.ID)
if err != nil {
return err
}
}
}
return nil
}
4 changes: 4 additions & 0 deletions models/perm/access/repo_permission.go
Original file line number Diff line number Diff line change
Expand Up @@ -522,3 +522,7 @@ func CheckRepoUnitUser(ctx context.Context, repo *repo_model.Repository, user *u

return perm.CanRead(unitType)
}

func PermissionNoAccess() Permission {
return Permission{AccessMode: perm_model.AccessModeNone}
}
11 changes: 11 additions & 0 deletions modules/session/key.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package session

const (
KeyUID = "uid"
KeyUname = "uname"

KeyUserHasTwoFactorAuth = "userHasTwoFactorAuth"
)
10 changes: 10 additions & 0 deletions modules/setting/security.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ var (
CSRFCookieName = "_csrf"
CSRFCookieHTTPOnly = true
RecordUserSignupMetadata = false
TwoFactorAuthEnforced = false
)

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

twoFactorAuth := sec.Key("TWO_FACTOR_AUTH").String()
switch twoFactorAuth {
case "":
case "enforced":
TwoFactorAuthEnforced = true
default:
log.Fatal("Invalid two-factor auth option: %s", twoFactorAuth)
}

InternalToken = loadSecret(sec, "INTERNAL_TOKEN_URI", "INTERNAL_TOKEN")
if InstallLock && InternalToken == "" {
// if Gitea has been installed but the InternalToken hasn't been generated (upgrade from an old release), we should generate
Expand Down
1 change: 1 addition & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,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, or try to login again.
login_userpass = Sign In
login_openid = OpenID
oauth_signup_tab = Register New Account
Expand Down
Loading