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

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
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
51 changes: 51 additions & 0 deletions models/migrations/v1_24/v320.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// 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.Sync(new(LoginSource))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use SyncWithOptions to avoid index sync.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand the details. Feel free to edit directly and document it

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":
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enforced sounds like user cannot visit anything except verify 2FA but now the user can visit basic pages. Maybe user another name to keep consistent? And in the future, it may supports more modes like read-only, only-important-operation and etc.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the enforced name is good enough. The major purpose of Gitea users is to access repositories.

read-only and only-important-operation are too wordy and more unclear.

This config option is "string enum" based, so there are always chances to choose better names in the future without breaking.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

@lunny lunny Apr 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this configuration could be extend in the future so that the name should match it's behaviour. The current behaviour will display dashboard, explores but all repositories related operations will require two factor verify. So that the name enforced is unclear. Maybe some name like repository-operation is better. The enforced sounds like even login will require two factor veirfy.

only-important-operation means a default status like what Github did, the two factor verify will be prompted only when change password or change some important informations.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You decide, feel free to edit this PR directly

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