Gogs Vulnerable to 2FA Bypass via Recovery Code
Description
Gogs is an open source self-hosted Git service. In version 0.13.3 and prior, Gogs’ 2FA recovery code validation does not scope codes by user, enabling cross-account bypass. If an attacker knows a victim’s username and password, they can use any unused recovery code (e.g., from their own account) to bypass the victim’s 2FA. This enables full account takeover and renders 2FA ineffective in all environments where it's enabled.. This issue has been patched in versions 0.13.4 and 0.14.0+dev.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
gogs.io/gogsGo | >= 0.11.19, < 0.13.4 | 0.13.4 |
Affected products
1Patches
2d568e048315dtwo_factor: verify recovery code ownership upon using (#8100)
4 files changed · +77 −19
internal/db/two_factor.go+0 −18 modified@@ -109,21 +109,3 @@ func IsTwoFactorRecoveryCodeNotFound(err error) bool { func (err ErrTwoFactorRecoveryCodeNotFound) Error() string { return fmt.Sprintf("two-factor recovery code does not found [code: %s]", err.Code) } - -// UseRecoveryCode validates recovery code of given user and marks it is used if valid. -func UseRecoveryCode(_ int64, code string) error { - recoveryCode := new(TwoFactorRecoveryCode) - has, err := x.Where("code = ?", code).And("is_used = ?", false).Get(recoveryCode) - if err != nil { - return fmt.Errorf("get unused code: %v", err) - } else if !has { - return ErrTwoFactorRecoveryCodeNotFound{Code: code} - } - - recoveryCode.IsUsed = true - if _, err = x.Id(recoveryCode.ID).Cols("is_used").Update(recoveryCode); err != nil { - return fmt.Errorf("mark code as used: %v", err) - } - - return nil -}
internal/db/two_factors.go+23 −0 modified@@ -32,6 +32,7 @@ type TwoFactorsStore interface { GetByUserID(ctx context.Context, userID int64) (*TwoFactor, error) // IsEnabled returns true if the user has enabled 2FA. IsEnabled(ctx context.Context, userID int64) bool + UseRecoveryCode(ctx context.Context, userID int64, code string) error } var TwoFactors TwoFactorsStore @@ -121,6 +122,28 @@ func (db *twoFactors) IsEnabled(ctx context.Context, userID int64) bool { return count > 0 } +// UseRecoveryCode validates a recovery code of given user and marks it as used +// if valid. It returns ErrTwoFactorRecoveryCodeNotFound if the code is invalid +// or already used. +func (db *twoFactors) UseRecoveryCode(ctx context.Context, userID int64, code string) error { + return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var recoveryCode TwoFactorRecoveryCode + err := tx.Where("user_id = ? AND code = ? AND is_used = ?", userID, code, false).First(&recoveryCode).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return ErrTwoFactorRecoveryCodeNotFound{Code: code} + } + return errors.Wrap(err, "get unused recovery code") + } + + err = tx.Model(&recoveryCode).Update("is_used", true).Error + if err != nil { + return errors.Wrap(err, "mark recovery code as used") + } + return nil + }) +} + // generateRecoveryCodes generates N number of recovery codes for 2FA. func generateRecoveryCodes(userID int64, n int) ([]*TwoFactorRecoveryCode, error) { recoveryCodes := make([]*TwoFactorRecoveryCode, n)
internal/db/two_factors_test.go+53 −0 modified@@ -79,6 +79,7 @@ func TestTwoFactors(t *testing.T) { {"Create", twoFactorsCreate}, {"GetByUserID", twoFactorsGetByUserID}, {"IsEnabled", twoFactorsIsEnabled}, + {"UseRecoveryCode", twoFactorsUseRecoveryCode}, } { t.Run(tc.name, func(t *testing.T) { t.Cleanup(func() { @@ -139,3 +140,55 @@ func twoFactorsIsEnabled(t *testing.T, db *twoFactors) { assert.True(t, db.IsEnabled(ctx, 1)) assert.False(t, db.IsEnabled(ctx, 2)) } + +func twoFactorsUseRecoveryCode(t *testing.T, ctx context.Context, s *TwoFactorsStore) { + // Create 2FA tokens for two users + err := s.Create(ctx, 1, "secure-key", "secure-secret") + require.NoError(t, err) + err = s.Create(ctx, 2, "secure-key", "secure-secret") + require.NoError(t, err) + + // Get recovery codes for both users + var user1Codes []TwoFactorRecoveryCode + err = s.db.Where("user_id = ?", 1).Find(&user1Codes).Error + require.NoError(t, err) + require.NotEmpty(t, user1Codes) + + var user2Codes []TwoFactorRecoveryCode + err = s.db.Where("user_id = ?", 2).Find(&user2Codes).Error + require.NoError(t, err) + require.NotEmpty(t, user2Codes) + + // User 1 should be able to use their own recovery code + err = s.UseRecoveryCode(ctx, 1, user1Codes[0].Code) + require.NoError(t, err) + + // Verify the code is now marked as used + var usedCode TwoFactorRecoveryCode + err = s.db.Where("id = ?", user1Codes[0].ID).First(&usedCode).Error + require.NoError(t, err) + assert.True(t, usedCode.IsUsed) + + // User 1 should NOT be able to use user 2's recovery code + // This is the key security test - recovery codes must be scoped by user + err = s.UseRecoveryCode(ctx, 1, user2Codes[0].Code) + assert.True(t, IsTwoFactorRecoveryCodeNotFound(err), "expected recovery code not found error when using another user's code") + + // User 2's code should still be unused + var user2Code TwoFactorRecoveryCode + err = s.db.Where("id = ?", user2Codes[0].ID).First(&user2Code).Error + require.NoError(t, err) + assert.False(t, user2Code.IsUsed, "user 2's recovery code should not be marked as used") + + // User 2 should be able to use their own code + err = s.UseRecoveryCode(ctx, 2, user2Codes[0].Code) + require.NoError(t, err) + + // Using an already-used code should fail + err = s.UseRecoveryCode(ctx, 1, user1Codes[0].Code) + assert.True(t, IsTwoFactorRecoveryCodeNotFound(err), "expected error when reusing a recovery code") + + // Using a non-existent code should fail + err = s.UseRecoveryCode(ctx, 1, "invalid-code") + assert.True(t, IsTwoFactorRecoveryCodeNotFound(err), "expected error for invalid recovery code") +}
internal/route/user/auth.go+1 −1 modified@@ -267,7 +267,7 @@ func LoginTwoFactorRecoveryCodePost(c *context.Context) { return } - if err := db.UseRecoveryCode(userID, c.Query("recovery_code")); err != nil { + if err := db.TwoFactors.UseRecoveryCode(c.Req.Context(), userID, c.Query("recovery_code")); err != nil { if db.IsTwoFactorRecoveryCodeNotFound(err) { c.Flash.Error(c.Tr("auth.login_two_factor_invalid_recovery_code")) c.RedirectSubpath("/user/login/two_factor_recovery_code")
26 files changed · +832 −107
cmd/web.go+15 −2 modified@@ -190,8 +190,13 @@ func runWeb(ctx *cli.Context) error { // ***** START: User ***** m.Group("/user", func() { - m.Get("/login", user.SignIn) - m.Post("/login", bindIgnErr(form.SignIn{}), user.SignInPost) + m.Group("/login", func() { + m.Combo("").Get(user.Login). + Post(bindIgnErr(form.SignIn{}), user.LoginPost) + m.Combo("/two_factor").Get(user.LoginTwoFactor).Post(user.LoginTwoFactorPost) + m.Combo("/two_factor_recovery_code").Get(user.LoginTwoFactorRecoveryCode).Post(user.LoginTwoFactorRecoveryCodePost) + }) + m.Get("/sign_up", user.SignUp) m.Post("/sign_up", bindIgnErr(form.Register{}), user.SignUpPost) m.Get("/reset_password", user.ResetPasswd) @@ -212,6 +217,14 @@ func runWeb(ctx *cli.Context) error { m.Combo("/ssh").Get(user.SettingsSSHKeys). Post(bindIgnErr(form.AddSSHKey{}), user.SettingsSSHKeysPost) m.Post("/ssh/delete", user.DeleteSSHKey) + m.Group("/security", func() { + m.Get("", user.SettingsSecurity) + m.Combo("/two_factor_enable").Get(user.SettingsTwoFactorEnable). + Post(user.SettingsTwoFactorEnablePost) + m.Combo("/two_factor_recovery_codes").Get(user.SettingsTwoFactorRecoveryCodes). + Post(user.SettingsTwoFactorRecoveryCodesPost) + m.Post("/two_factor_disable", user.SettingsTwoFactorDisable) + }) m.Group("/repositories", func() { m.Get("", user.SettingsRepos) m.Post("/leave", user.SettingsLeaveRepo)
conf/locale/locale_en-US.ini+32 −4 modified@@ -168,6 +168,14 @@ reset_password_helper = Click here to reset your password password_too_short = Password length cannot be less then 6. non_local_account = Non-local accounts cannot change passwords through Gogs. +login_two_factor = Two-factor Authentication +login_two_factor_passcode = Authentication Passcode +login_two_factor_enter_recovery_code = Enter a two-factor recovery code +login_two_factor_recovery = Two-factor Recovery +login_two_factor_recovery_code = Recovery Code +login_two_factor_enter_passcode = Enter a two-factor passcode +login_two_factor_invalid_recovery_code = Recovery code has been used or does not valid. + [mail] activate_account = Please activate your account activate_email = Verify your email address @@ -255,6 +263,7 @@ profile = Profile password = Password avatar = Avatar ssh_keys = SSH Keys +security = Security repos = Repositories orgs = Organizations applications = Applications @@ -324,10 +333,29 @@ no_activity = No recent activity key_state_desc = This key is used in last 7 days token_state_desc = This token is used in last 7 days -manage_social = Manage Associated Social Accounts -social_desc = This is a list of associated social accounts. Remove any binding that you do not recognize. -unbind = Unbind -unbind_success = Social account has been unbound. +two_factor = Two-factor Authentication +two_factor_status = Status: +two_factor_on = On +two_factor_off = Off +two_factor_enable = Enable +two_factor_disable = Disable +two_factor_view_recovery_codes = View and save <a href="%s%s">your recovery codes</a> in a safe place. You can use them as passcode if you lose access to your authentication application. +two_factor_enable_title = Enable Two-factor Authentication +two_factor_scan_qr = Please use your authentication application to scan the image: +two_factor_or_enter_secret = Or enter the secret: +two_factor_then_enter_passcode = Then enter passcode: +two_factor_verify = Verify +two_factor_invalid_passcode = The passcode you entered is not valid, please try again! +two_factor_enable_error = Enable Two-factor authentication failed: %v +two_factor_enable_success = Two-factor authentication has enabled for your account successfully! +two_factor_recovery_codes_title = Two-factor Authentication Recovery Codes +two_factor_recovery_codes_desc = Recovery codes are used when you temporarily lose access to your authentication application. Each recovery code can only be used once, <b>please keep these codes in a safe place</b>. +two_factor_regenerate_recovery_codes = Regenerate Recovery Codes +two_factor_regenerate_recovery_codes_error = Regenerate recovery codes failed: %v +two_factor_regenerate_recovery_codes_success = New recovery codes has been generated successfully! +two_factor_disable_title = Disable Two-factor Authentication +two_factor_disable_desc = Your account security level will decrease after disabled two-factor authentication. Do you want to continue? +two_factor_disable_success = Two-factor authentication has disabled successfully! manage_access_token = Manage Personal Access Tokens generate_new_token = Generate New Token
gogs.go+1 −1 modified@@ -16,7 +16,7 @@ import ( "github.com/gogits/gogs/pkg/setting" ) -const APP_VER = "0.11.4.0405" +const APP_VER = "0.11.5.0406" func init() { setting.AppVer = APP_VER
Makefile+3 −0 modified@@ -25,6 +25,9 @@ check: test dist: release +web: build + ./gogs web + govet: $(GOVET) gogs.go $(GOVET) models pkg routers
models/errors/two_factor.go+33 −0 added@@ -0,0 +1,33 @@ +// Copyright 2017 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package errors + +import "fmt" + +type TwoFactorNotFound struct { + UserID int64 +} + +func IsTwoFactorNotFound(err error) bool { + _, ok := err.(TwoFactorNotFound) + return ok +} + +func (err TwoFactorNotFound) Error() string { + return fmt.Sprintf("two-factor authentication does not found [user_id: %d]", err.UserID) +} + +type TwoFactorRecoveryCodeNotFound struct { + Code string +} + +func IsTwoFactorRecoveryCodeNotFound(err error) bool { + _, ok := err.(TwoFactorRecoveryCodeNotFound) + return ok +} + +func (err TwoFactorRecoveryCodeNotFound) Error() string { + return fmt.Sprintf("two-factor recovery code does not found [code: %s]", err.Code) +}
models/models.go+2 −2 modified@@ -27,7 +27,7 @@ import ( "github.com/gogits/gogs/pkg/setting" ) -// Engine represents a xorm engine or session. +// Engine represents a XORM engine or session. type Engine interface { Delete(interface{}) (int64, error) Exec(string, ...interface{}) (sql.Result, error) @@ -64,7 +64,7 @@ var ( func init() { tables = append(tables, - new(User), new(PublicKey), new(AccessToken), + new(User), new(PublicKey), new(AccessToken), new(TwoFactor), new(TwoFactorRecoveryCode), new(Repository), new(DeployKey), new(Collaboration), new(Access), new(Upload), new(Watch), new(Star), new(Follow), new(Action), new(Issue), new(PullRequest), new(Comment), new(Attachment), new(IssueUser),
models/two_factor.go+201 −0 added@@ -0,0 +1,201 @@ +// Copyright 2017 The Gogs Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package models + +import ( + "encoding/base64" + "fmt" + "strings" + "time" + + "github.com/Unknwon/com" + "github.com/go-xorm/xorm" + "github.com/pquerna/otp/totp" + log "gopkg.in/clog.v1" + + "github.com/gogits/gogs/models/errors" + "github.com/gogits/gogs/pkg/setting" + "github.com/gogits/gogs/pkg/tool" +) + +// TwoFactor represents a two-factor authentication token. +type TwoFactor struct { + ID int64 + UserID int64 `xorm:"UNIQUE"` + Secret string + Created time.Time `xorm:"-"` + CreatedUnix int64 +} + +func (t *TwoFactor) BeforeInsert() { + t.CreatedUnix = time.Now().Unix() +} + +func (t *TwoFactor) AfterSet(colName string, _ xorm.Cell) { + switch colName { + case "created_unix": + t.Created = time.Unix(t.CreatedUnix, 0).Local() + } +} + +// ValidateTOTP returns true if given passcode is valid for two-factor authentication token. +// It also returns possible validation error. +func (t *TwoFactor) ValidateTOTP(passcode string) (bool, error) { + secret, err := base64.StdEncoding.DecodeString(t.Secret) + if err != nil { + return false, fmt.Errorf("DecodeString: %v", err) + } + decryptSecret, err := com.AESGCMDecrypt(tool.MD5Bytes(setting.SecretKey), secret) + if err != nil { + return false, fmt.Errorf("AESGCMDecrypt: %v", err) + } + return totp.Validate(passcode, string(decryptSecret)), nil +} + +// IsUserEnabledTwoFactor returns true if user has enabled two-factor authentication. +func IsUserEnabledTwoFactor(userID int64) bool { + has, err := x.Where("user_id = ?", userID).Get(new(TwoFactor)) + if err != nil { + log.Error(2, "IsUserEnabledTwoFactor [user_id: %d]: %v", userID, err) + } + return has +} + +func generateRecoveryCodes(userID int64) ([]*TwoFactorRecoveryCode, error) { + recoveryCodes := make([]*TwoFactorRecoveryCode, 10) + for i := 0; i < 10; i++ { + code, err := tool.GetRandomString(10) + if err != nil { + return nil, fmt.Errorf("GetRandomString: %v", err) + } + recoveryCodes[i] = &TwoFactorRecoveryCode{ + UserID: userID, + Code: strings.ToLower(code[:5] + "-" + code[5:]), + } + } + return recoveryCodes, nil +} + +// NewTwoFactor creates a new two-factor authentication token and recovery codes for given user. +func NewTwoFactor(userID int64, secret string) error { + t := &TwoFactor{ + UserID: userID, + } + + // Encrypt secret + encryptSecret, err := com.AESGCMEncrypt(tool.MD5Bytes(setting.SecretKey), []byte(secret)) + if err != nil { + return fmt.Errorf("AESGCMEncrypt: %v", err) + } + t.Secret = base64.StdEncoding.EncodeToString(encryptSecret) + + recoveryCodes, err := generateRecoveryCodes(userID) + if err != nil { + return fmt.Errorf("generateRecoveryCodes: %v", err) + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if _, err = sess.Insert(t); err != nil { + return fmt.Errorf("insert two-factor: %v", err) + } else if _, err = sess.Insert(recoveryCodes); err != nil { + return fmt.Errorf("insert recovery codes: %v", err) + } + + return sess.Commit() +} + +// GetTwoFactorByUserID returns two-factor authentication token of given user. +func GetTwoFactorByUserID(userID int64) (*TwoFactor, error) { + t := new(TwoFactor) + has, err := x.Where("user_id = ?", userID).Get(t) + if err != nil { + return nil, err + } else if !has { + return nil, errors.TwoFactorNotFound{userID} + } + + return t, nil +} + +// DeleteTwoFactor removes two-factor authentication token and recovery codes of given user. +func DeleteTwoFactor(userID int64) (err error) { + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if _, err = sess.Where("user_id = ?", userID).Delete(new(TwoFactor)); err != nil { + return fmt.Errorf("delete two-factor: %v", err) + } else if err = deleteRecoveryCodesByUserID(sess, userID); err != nil { + return fmt.Errorf("deleteRecoveryCodesByUserID: %v", err) + } + + return sess.Commit() +} + +// TwoFactorRecoveryCode represents a two-factor authentication recovery code. +type TwoFactorRecoveryCode struct { + ID int64 + UserID int64 + Code string `xorm:"VARCHAR(11)"` + IsUsed bool +} + +// GetRecoveryCodesByUserID returns all recovery codes of given user. +func GetRecoveryCodesByUserID(userID int64) ([]*TwoFactorRecoveryCode, error) { + recoveryCodes := make([]*TwoFactorRecoveryCode, 0, 10) + return recoveryCodes, x.Where("user_id = ?", userID).Find(&recoveryCodes) +} + +func deleteRecoveryCodesByUserID(e Engine, userID int64) error { + _, err := e.Where("user_id = ?", userID).Delete(new(TwoFactorRecoveryCode)) + return err +} + +// RegenerateRecoveryCodes regenerates new set of recovery codes for given user. +func RegenerateRecoveryCodes(userID int64) error { + recoveryCodes, err := generateRecoveryCodes(userID) + if err != nil { + return fmt.Errorf("generateRecoveryCodes: %v", err) + } + + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + + if err = deleteRecoveryCodesByUserID(sess, userID); err != nil { + return fmt.Errorf("deleteRecoveryCodesByUserID: %v", err) + } else if _, err = sess.Insert(recoveryCodes); err != nil { + return fmt.Errorf("insert new recovery codes: %v", err) + } + + return sess.Commit() +} + +// UseRecoveryCode validates recovery code of given user and marks it is used if valid. +func UseRecoveryCode(userID int64, code string) error { + recoveryCode := new(TwoFactorRecoveryCode) + has, err := x.Where("code = ?", code).And("is_used = ?", false).Get(recoveryCode) + if err != nil { + return fmt.Errorf("get unused code: %v", err) + } else if !has { + return errors.TwoFactorRecoveryCodeNotFound{code} + } + + recoveryCode.IsUsed = true + if _, err = x.Id(recoveryCode.ID).Cols("is_used").Update(recoveryCode); err != nil { + return fmt.Errorf("mark code as used: %v", err) + } + + return nil +}
models/user.go+7 −2 modified@@ -31,8 +31,8 @@ import ( "github.com/gogits/gogs/models/errors" "github.com/gogits/gogs/pkg/avatar" - "github.com/gogits/gogs/pkg/tool" "github.com/gogits/gogs/pkg/setting" + "github.com/gogits/gogs/pkg/tool" ) type UserType int @@ -404,6 +404,11 @@ func (u *User) IsPublicMember(orgId int64) bool { return IsPublicMembership(orgId, u.ID) } +// IsEnabledTwoFactor returns true if user has enabled two-factor authentication. +func (u *User) IsEnabledTwoFactor() bool { + return IsUserEnabledTwoFactor(u.ID) +} + func (u *User) getOrganizationCount(e Engine) (int64, error) { return e.Where("uid=?", u.ID).Count(new(OrgUser)) } @@ -479,7 +484,7 @@ func IsUserExist(uid int64, name string) (bool, error) { if len(name) == 0 { return false, nil } - return x.Where("id!=?", uid).Get(&User{LowerName: strings.ToLower(name)}) + return x.Where("id != ?", uid).Get(&User{LowerName: strings.ToLower(name)}) } // GetUserSalt returns a ramdom user salt token.
pkg/bindata/bindata.go+2 −2 modifiedpkg/context/context.go+5 −0 modified@@ -89,6 +89,11 @@ func (c *Context) Success(name string) { c.HTML(http.StatusOK, name) } +// JSONSuccess responses JSON with status http.StatusOK. +func (c *Context) JSONSuccess(data interface{}) { + c.JSON(http.StatusOK, data) +} + // RenderWithErr used for page has form validation but need to prompt error to users. func (ctx *Context) RenderWithErr(msg, tpl string, f interface{}) { if f != nil {
pkg/tool/tool.go+8 −3 modified@@ -27,11 +27,16 @@ import ( "github.com/gogits/gogs/pkg/setting" ) -// EncodeMD5 encodes string to md5 hex value. -func EncodeMD5(str string) string { +// MD5Bytes encodes string to MD5 bytes. +func MD5Bytes(str string) []byte { m := md5.New() m.Write([]byte(str)) - return hex.EncodeToString(m.Sum(nil)) + return m.Sum(nil) +} + +// EncodeMD5 encodes string to MD5 hex value. +func EncodeMD5(str string) string { + return hex.EncodeToString(MD5Bytes(str)) } // Encode string to sha1 hex value.
public/css/gogs.css+23 −14 modified@@ -960,7 +960,7 @@ footer .ui.language .menu { } #create-page-form form input, #create-page-form form textarea { - width: 50%!important; + width: 50% !important; } .user.activate form, .user.forgot.password form, @@ -1017,14 +1017,14 @@ footer .ui.language .menu { .user.reset.password form textarea, .user.signin form textarea, .user.signup form textarea { - width: 50%!important; + width: 50% !important; } .user.activate form, .user.forgot.password form, .user.reset.password form, .user.signin form, .user.signup form { - width: 700px!important; + width: 700px !important; } .user.activate form .header, .user.forgot.password form .header, @@ -1040,6 +1040,12 @@ footer .ui.language .menu { .user.signup form .inline.field > label { width: 200px !important; } +.user.signin.two-factor form { + width: 300px !important; +} +.user.signin.two-factor form .header { + padding-left: inherit !important; +} .repository.new.repo form, .repository.new.migrate form, .repository.new.fork form { @@ -1079,7 +1085,7 @@ footer .ui.language .menu { .repository.new.repo form textarea, .repository.new.migrate form textarea, .repository.new.fork form textarea { - width: 50%!important; + width: 50% !important; } .repository.new.repo form .dropdown .dropdown.icon, .repository.new.migrate form .dropdown .dropdown.icon, @@ -2752,7 +2758,7 @@ footer .ui.language .menu { } .organization.new.org form input, .organization.new.org form textarea { - width: 50%!important; + width: 50% !important; } .organization.options input { min-width: 300px; @@ -2856,15 +2862,8 @@ footer .ui.language .menu { .user.settings .email.list .item:not(:first-child) .button { margin-top: -10px; } -.user.settings.organizations .orgs.non-empty { - padding: 0; -} -.user.settings.organizations .orgs .item { - padding: 10px; -} -.user.settings.organizations .orgs .item .button { - margin-top: 5px; - margin-right: 8px; +.user.settings.security .two-factor .toggle.button { + margin-top: -5px; } .user.settings.repositories .repos { padding: 0; @@ -2876,6 +2875,16 @@ footer .ui.language .menu { .user.settings.repositories .repos .item .button { margin-top: -5px; } +.user.settings.organizations .orgs.non-empty { + padding: 0; +} +.user.settings.organizations .orgs .item { + padding: 10px; +} +.user.settings.organizations .orgs .item .button { + margin-top: 5px; + margin-right: 8px; +} .user.profile .ui.card .header { word-break: break-all; }
public/less/_form.less+12 −3 modified@@ -42,7 +42,7 @@ } input, textarea { - width: 50%!important; + width: 50% !important; } } } @@ -52,10 +52,10 @@ .user.reset.password, .user.signin, .user.signup { - @input-padding: 200px!important; + @input-padding: 200px !important; #create-page-form; form { - width: 700px!important; + width: 700px !important; .header { padding-left: @input-padding+30px; } @@ -65,6 +65,15 @@ } } +.user.signin.two-factor { + form { + width: 300px !important; + .header { + padding-left: inherit !important; + } + } +} + .repository { &.new.repo, &.new.migrate,
public/less/_user.less+15 −10 modified@@ -19,16 +19,9 @@ } } } - &.organizations .orgs { - &.non-empty { - padding: 0; - } - .item { - padding: 10px; - .button { - margin-top: 5px; - margin-right: 8px; - } + &.security { + .two-factor .toggle.button { + margin-top: -5px; } } &.repositories .repos { @@ -41,6 +34,18 @@ } } } + &.organizations .orgs { + &.non-empty { + padding: 0; + } + .item { + padding: 10px; + .button { + margin-top: 5px; + margin-right: 8px; + } + } + } } &.profile {
routers/repo/http.go+6 −3 modified@@ -23,9 +23,9 @@ import ( "github.com/gogits/gogs/models" "github.com/gogits/gogs/models/errors" - "github.com/gogits/gogs/pkg/tool" "github.com/gogits/gogs/pkg/context" "github.com/gogits/gogs/pkg/setting" + "github.com/gogits/gogs/pkg/tool" ) const ( @@ -114,7 +114,6 @@ func HTTPContexter() macaron.Handler { authUser, err := models.UserSignIn(authUsername, authPassword) if err != nil && !errors.IsUserNotExist(err) { - c.Handle(http.StatusInternalServerError, "UserSignIn", err) return } @@ -139,6 +138,10 @@ func HTTPContexter() macaron.Handler { c.Handle(http.StatusInternalServerError, "GetUserByID", err) return } + } else if authUser.IsEnabledTwoFactor() { + askCredentials(c, http.StatusUnauthorized, `User with two-factor authentication enabled cannot perform HTTP/HTTPS operations via plain username and password +Please create and use personal access token on user settings page`) + return } log.Trace("HTTPGit - Authenticated user: %s", authUser.Name) @@ -152,7 +155,7 @@ func HTTPContexter() macaron.Handler { c.Handle(http.StatusInternalServerError, "HasAccess", err) return } else if !has { - askCredentials(c, http.StatusUnauthorized, "User permission denied") + askCredentials(c, http.StatusForbidden, "User permission denied") return }
routers/user/auth.go+138 −48 modified@@ -20,20 +20,22 @@ import ( ) const ( - SIGNIN = "user/auth/signin" - SIGNUP = "user/auth/signup" - ACTIVATE = "user/auth/activate" - FORGOT_PASSWORD = "user/auth/forgot_passwd" - RESET_PASSWORD = "user/auth/reset_passwd" + LOGIN = "user/auth/login" + TWO_FACTOR = "user/auth/two_factor" + TWO_FACTOR_RECOVERY_CODE = "user/auth/two_factor_recovery_code" + SIGNUP = "user/auth/signup" + ACTIVATE = "user/auth/activate" + FORGOT_PASSWORD = "user/auth/forgot_passwd" + RESET_PASSWORD = "user/auth/reset_passwd" ) -// AutoSignIn reads cookie and try to auto-login. -func AutoSignIn(ctx *context.Context) (bool, error) { +// AutoLogin reads cookie and try to auto-login. +func AutoLogin(c *context.Context) (bool, error) { if !models.HasEngine { return false, nil } - uname := ctx.GetCookie(setting.CookieUserName) + uname := c.GetCookie(setting.CookieUserName) if len(uname) == 0 { return false, nil } @@ -42,9 +44,9 @@ func AutoSignIn(ctx *context.Context) (bool, error) { defer func() { if !isSucceed { log.Trace("auto-login cookie cleared: %s", uname) - ctx.SetCookie(setting.CookieUserName, "", -1, setting.AppSubUrl) - ctx.SetCookie(setting.CookieRememberName, "", -1, setting.AppSubUrl) - ctx.SetCookie(setting.LoginStatusCookieName, "", -1, setting.AppSubUrl) + c.SetCookie(setting.CookieUserName, "", -1, setting.AppSubUrl) + c.SetCookie(setting.CookieRememberName, "", -1, setting.AppSubUrl) + c.SetCookie(setting.LoginStatusCookieName, "", -1, setting.AppSubUrl) } }() @@ -56,16 +58,16 @@ func AutoSignIn(ctx *context.Context) (bool, error) { return false, nil } - if val, ok := ctx.GetSuperSecureCookie(u.Rands+u.Passwd, setting.CookieRememberName); !ok || val != u.Name { + if val, ok := c.GetSuperSecureCookie(u.Rands+u.Passwd, setting.CookieRememberName); !ok || val != u.Name { return false, nil } isSucceed = true - ctx.Session.Set("uid", u.ID) - ctx.Session.Set("uname", u.Name) - ctx.SetCookie(setting.CSRFCookieName, "", -1, setting.AppSubUrl) + c.Session.Set("uid", u.ID) + c.Session.Set("uname", u.Name) + c.SetCookie(setting.CSRFCookieName, "", -1, setting.AppSubUrl) if setting.EnableLoginStatusCookie { - ctx.SetCookie(setting.LoginStatusCookieName, "true", 0, setting.AppSubUrl) + c.SetCookie(setting.LoginStatusCookieName, "true", 0, setting.AppSubUrl) } return true, nil } @@ -77,77 +79,165 @@ func isValidRedirect(url string) bool { return len(url) >= 2 && url[0] == '/' && url[1] != '/' } -func SignIn(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("sign_in") +func Login(c *context.Context) { + c.Data["Title"] = c.Tr("sign_in") // Check auto-login. - isSucceed, err := AutoSignIn(ctx) + isSucceed, err := AutoLogin(c) if err != nil { - ctx.Handle(500, "AutoSignIn", err) + c.Handle(500, "AutoLogin", err) return } - redirectTo := ctx.Query("redirect_to") + redirectTo := c.Query("redirect_to") if len(redirectTo) > 0 { - ctx.SetCookie("redirect_to", redirectTo, 0, setting.AppSubUrl) + c.SetCookie("redirect_to", redirectTo, 0, setting.AppSubUrl) } else { - redirectTo, _ = url.QueryUnescape(ctx.GetCookie("redirect_to")) + redirectTo, _ = url.QueryUnescape(c.GetCookie("redirect_to")) } - ctx.SetCookie("redirect_to", "", -1, setting.AppSubUrl) + c.SetCookie("redirect_to", "", -1, setting.AppSubUrl) if isSucceed { if isValidRedirect(redirectTo) { - ctx.Redirect(redirectTo) + c.Redirect(redirectTo) } else { - ctx.Redirect(setting.AppSubUrl + "/") + c.Redirect(setting.AppSubUrl + "/") } return } - ctx.HTML(200, SIGNIN) + c.HTML(200, LOGIN) } -func SignInPost(ctx *context.Context, f form.SignIn) { - ctx.Data["Title"] = ctx.Tr("sign_in") +func afterLogin(c *context.Context, u *models.User, remember bool) { + if remember { + days := 86400 * setting.LoginRememberDays + c.SetCookie(setting.CookieUserName, u.Name, days, setting.AppSubUrl, "", setting.CookieSecure, true) + c.SetSuperSecureCookie(u.Rands+u.Passwd, setting.CookieRememberName, u.Name, days, setting.AppSubUrl, "", setting.CookieSecure, true) + } - if ctx.HasError() { - ctx.HTML(200, SIGNIN) + c.Session.Set("uid", u.ID) + c.Session.Set("uname", u.Name) + c.Session.Delete("twoFactorRemember") + c.Session.Delete("twoFactorUserID") + + // Clear whatever CSRF has right now, force to generate a new one + c.SetCookie(setting.CSRFCookieName, "", -1, setting.AppSubUrl) + if setting.EnableLoginStatusCookie { + c.SetCookie(setting.LoginStatusCookieName, "true", 0, setting.AppSubUrl) + } + + redirectTo, _ := url.QueryUnescape(c.GetCookie("redirect_to")) + c.SetCookie("redirect_to", "", -1, setting.AppSubUrl) + if isValidRedirect(redirectTo) { + c.Redirect(redirectTo) + return + } + + c.Redirect(setting.AppSubUrl + "/") +} + +func LoginPost(c *context.Context, f form.SignIn) { + c.Data["Title"] = c.Tr("sign_in") + + if c.HasError() { + c.Success(LOGIN) return } u, err := models.UserSignIn(f.UserName, f.Password) if err != nil { if errors.IsUserNotExist(err) { - ctx.RenderWithErr(ctx.Tr("form.username_password_incorrect"), SIGNIN, &f) + c.RenderWithErr(c.Tr("form.username_password_incorrect"), LOGIN, &f) } else { - ctx.Handle(500, "UserSignIn", err) + c.ServerError("UserSignIn", err) } return } - if f.Remember { - days := 86400 * setting.LoginRememberDays - ctx.SetCookie(setting.CookieUserName, u.Name, days, setting.AppSubUrl, "", setting.CookieSecure, true) - ctx.SetSuperSecureCookie(u.Rands+u.Passwd, setting.CookieRememberName, u.Name, days, setting.AppSubUrl, "", setting.CookieSecure, true) + if !u.IsEnabledTwoFactor() { + afterLogin(c, u, f.Remember) + return } - ctx.Session.Set("uid", u.ID) - ctx.Session.Set("uname", u.Name) + c.Session.Set("twoFactorRemember", f.Remember) + c.Session.Set("twoFactorUserID", u.ID) + c.Redirect(setting.AppSubUrl + "/user/login/two_factor") +} - // Clear whatever CSRF has right now, force to generate a new one - ctx.SetCookie(setting.CSRFCookieName, "", -1, setting.AppSubUrl) - if setting.EnableLoginStatusCookie { - ctx.SetCookie(setting.LoginStatusCookieName, "true", 0, setting.AppSubUrl) +func LoginTwoFactor(c *context.Context) { + _, ok := c.Session.Get("twoFactorUserID").(int64) + if !ok { + c.NotFound() + return } - redirectTo, _ := url.QueryUnescape(ctx.GetCookie("redirect_to")) - ctx.SetCookie("redirect_to", "", -1, setting.AppSubUrl) - if isValidRedirect(redirectTo) { - ctx.Redirect(redirectTo) + c.Success(TWO_FACTOR) +} + +func LoginTwoFactorPost(c *context.Context) { + userID, ok := c.Session.Get("twoFactorUserID").(int64) + if !ok { + c.NotFound() return } - ctx.Redirect(setting.AppSubUrl + "/") + t, err := models.GetTwoFactorByUserID(userID) + if err != nil { + c.ServerError("GetTwoFactorByUserID", err) + return + } + valid, err := t.ValidateTOTP(c.Query("passcode")) + if err != nil { + c.ServerError("ValidateTOTP", err) + return + } else if !valid { + c.Flash.Error(c.Tr("settings.two_factor_invalid_passcode")) + c.Redirect(setting.AppSubUrl + "/user/login/two_factor") + return + } + + u, err := models.GetUserByID(userID) + if err != nil { + c.ServerError("GetUserByID", err) + return + } + afterLogin(c, u, c.Session.Get("twoFactorRemember").(bool)) +} + +func LoginTwoFactorRecoveryCode(c *context.Context) { + _, ok := c.Session.Get("twoFactorUserID").(int64) + if !ok { + c.NotFound() + return + } + + c.Success(TWO_FACTOR_RECOVERY_CODE) +} + +func LoginTwoFactorRecoveryCodePost(c *context.Context) { + userID, ok := c.Session.Get("twoFactorUserID").(int64) + if !ok { + c.NotFound() + return + } + + if err := models.UseRecoveryCode(userID, c.Query("recovery_code")); err != nil { + if errors.IsTwoFactorRecoveryCodeNotFound(err) { + c.Flash.Error(c.Tr("auth.login_two_factor_invalid_recovery_code")) + c.Redirect(setting.AppSubUrl + "/user/login/two_factor_recovery_code") + } else { + c.ServerError("UseRecoveryCode", err) + } + return + } + + u, err := models.GetUserByID(userID) + if err != nil { + c.ServerError("GetUserByID", err) + return + } + afterLogin(c, u, c.Session.Get("twoFactorRemember").(bool)) } func SignOut(ctx *context.Context) {
routers/user/setting.go+154 −11 modified@@ -5,11 +5,17 @@ package user import ( + "bytes" + "encoding/base64" "fmt" + "html/template" + "image/png" "io/ioutil" "strings" "github.com/Unknwon/com" + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" log "gopkg.in/clog.v1" "github.com/gogits/gogs/models" @@ -22,17 +28,19 @@ import ( ) const ( - SETTINGS_PROFILE = "user/settings/profile" - SETTINGS_AVATAR = "user/settings/avatar" - SETTINGS_PASSWORD = "user/settings/password" - SETTINGS_EMAILS = "user/settings/email" - SETTINGS_SSH_KEYS = "user/settings/sshkeys" - SETTINGS_SECURITY = "user/settings/security" - SETTINGS_REPOSITORIES = "user/settings/repositories" - SETTINGS_ORGANIZATIONS = "user/settings/organizations" - SETTINGS_APPLICATIONS = "user/settings/applications" - SETTINGS_DELETE = "user/settings/delete" - NOTIFICATION = "user/notification" + SETTINGS_PROFILE = "user/settings/profile" + SETTINGS_AVATAR = "user/settings/avatar" + SETTINGS_PASSWORD = "user/settings/password" + SETTINGS_EMAILS = "user/settings/email" + SETTINGS_SSH_KEYS = "user/settings/sshkeys" + SETTINGS_SECURITY = "user/settings/security" + SETTINGS_TWO_FACTOR_ENABLE = "user/settings/two_factor_enable" + SETTINGS_TWO_FACTOR_RECOVERY_CODES = "user/settings/two_factor_recovery_codes" + SETTINGS_REPOSITORIES = "user/settings/repositories" + SETTINGS_ORGANIZATIONS = "user/settings/organizations" + SETTINGS_APPLICATIONS = "user/settings/applications" + SETTINGS_DELETE = "user/settings/delete" + NOTIFICATION = "user/notification" ) func Settings(c *context.Context) { @@ -376,6 +384,141 @@ func DeleteSSHKey(ctx *context.Context) { }) } +func SettingsSecurity(c *context.Context) { + c.Data["Title"] = c.Tr("settings") + c.Data["PageIsSettingsSecurity"] = true + + t, err := models.GetTwoFactorByUserID(c.UserID()) + if err != nil && !errors.IsTwoFactorNotFound(err) { + c.ServerError("GetTwoFactorByUserID", err) + return + } + c.Data["TwoFactor"] = t + + c.Success(SETTINGS_SECURITY) +} + +func SettingsTwoFactorEnable(c *context.Context) { + if c.User.IsEnabledTwoFactor() { + c.NotFound() + return + } + + c.Data["Title"] = c.Tr("settings") + c.Data["PageIsSettingsSecurity"] = true + + var key *otp.Key + var err error + keyURL := c.Session.Get("twoFactorURL") + if keyURL != nil { + key, _ = otp.NewKeyFromURL(keyURL.(string)) + } + if key == nil { + key, err = totp.Generate(totp.GenerateOpts{ + Issuer: setting.AppName, + AccountName: c.User.Email, + }) + if err != nil { + c.ServerError("Generate", err) + return + } + } + c.Data["TwoFactorSecret"] = key.Secret() + + img, err := key.Image(240, 240) + if err != nil { + c.ServerError("Image", err) + return + } + + var buf bytes.Buffer + if err = png.Encode(&buf, img); err != nil { + c.ServerError("Encode", err) + return + } + c.Data["QRCode"] = template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())) + + c.Session.Set("twoFactorSecret", c.Data["TwoFactorSecret"]) + c.Session.Set("twoFactorURL", key.String()) + c.Success(SETTINGS_TWO_FACTOR_ENABLE) +} + +func SettingsTwoFactorEnablePost(c *context.Context) { + secret, ok := c.Session.Get("twoFactorSecret").(string) + if !ok { + c.NotFound() + return + } + + if !totp.Validate(c.Query("passcode"), secret) { + c.Flash.Error(c.Tr("settings.two_factor_invalid_passcode")) + c.Redirect(setting.AppSubUrl + "/user/settings/security/two_factor_enable") + return + } + + if err := models.NewTwoFactor(c.UserID(), secret); err != nil { + c.Flash.Error(c.Tr("settings.two_factor_enable_error", err)) + c.Redirect(setting.AppSubUrl + "/user/settings/security/two_factor_enable") + return + } + + c.Session.Delete("twoFactorSecret") + c.Session.Delete("twoFactorURL") + c.Flash.Success(c.Tr("settings.two_factor_enable_success")) + c.Redirect(setting.AppSubUrl + "/user/settings/security/two_factor_recovery_codes") +} + +func SettingsTwoFactorRecoveryCodes(c *context.Context) { + if !c.User.IsEnabledTwoFactor() { + c.NotFound() + return + } + + c.Data["Title"] = c.Tr("settings") + c.Data["PageIsSettingsSecurity"] = true + + recoveryCodes, err := models.GetRecoveryCodesByUserID(c.UserID()) + if err != nil { + c.ServerError("GetRecoveryCodesByUserID", err) + return + } + c.Data["RecoveryCodes"] = recoveryCodes + + c.Success(SETTINGS_TWO_FACTOR_RECOVERY_CODES) +} + +func SettingsTwoFactorRecoveryCodesPost(c *context.Context) { + if !c.User.IsEnabledTwoFactor() { + c.NotFound() + return + } + + if err := models.RegenerateRecoveryCodes(c.UserID()); err != nil { + c.Flash.Error(c.Tr("settings.two_factor_regenerate_recovery_codes_error", err)) + } else { + c.Flash.Success(c.Tr("settings.two_factor_regenerate_recovery_codes_success")) + } + + c.Redirect(setting.AppSubUrl + "/user/settings/security/two_factor_recovery_codes") +} + +func SettingsTwoFactorDisable(c *context.Context) { + if !c.User.IsEnabledTwoFactor() { + c.NotFound() + return + } + + if err := models.DeleteTwoFactor(c.UserID()); err != nil { + c.ServerError("DeleteTwoFactor", err) + return + } + + c.Flash.Success(c.Tr("settings.two_factor_disable_success")) + c.JSONSuccess(map[string]interface{}{ + "redirect": setting.AppSubUrl + "/user/settings/security", + }) +} + func SettingsApplications(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsSettingsApplications"] = true
templates/user/auth/login.tmpl+0 −0 renamedtemplates/user/auth/two_factor_recovery_code.tmpl+28 −0 added@@ -0,0 +1,28 @@ +{{template "base/head" .}} +<div class="user signin two-factor"> + <div class="ui middle very relaxed page grid"> + <div class="column"> + <form class="ui form" action="{{.Link}}" method="post"> + {{.CsrfTokenHtml}} + <h3 class="ui top attached center header"> + {{.i18n.Tr "auth.login_two_factor_recovery"}} + </h3> + <div class="ui attached segment"> + {{template "base/alert" .}} + <div class="required field"> + <label for="recovery_code">{{.i18n.Tr "auth.login_two_factor_recovery_code"}}</label> + <div class="ui fluid input"> + <input id="recovery_code" name="recovery_code" autofocus required> + </div> + </div> + + <button class="ui fluid green button">{{.i18n.Tr "settings.two_factor_verify"}}</button> + </div> + <p> + <a href="{{AppSubUrl}}/user/login/two_factor">{{.i18n.Tr "auth.login_two_factor_enter_passcode"}}</a> + </p> + </form> + </div> + </div> +</div> +{{template "base/footer" .}}
templates/user/auth/two_factor.tmpl+28 −0 added@@ -0,0 +1,28 @@ +{{template "base/head" .}} +<div class="user signin two-factor"> + <div class="ui middle very relaxed page grid"> + <div class="column"> + <form class="ui form" action="{{.Link}}" method="post"> + {{.CsrfTokenHtml}} + <h3 class="ui top attached center header"> + {{.i18n.Tr "auth.login_two_factor"}} + </h3> + <div class="ui attached segment"> + {{template "base/alert" .}} + <div class="required field"> + <label for="passcode">{{.i18n.Tr "auth.login_two_factor_passcode"}}</label> + <div class="ui fluid input"> + <input id="passcode" name="passcode" autofocus required> + </div> + </div> + + <button class="ui fluid green button">{{.i18n.Tr "settings.two_factor_verify"}}</button> + </div> + <p> + <a href="{{AppSubUrl}}/user/login/two_factor_recovery_code">{{.i18n.Tr "auth.login_two_factor_enter_recovery_code"}}</a> + </p> + </form> + </div> + </div> +</div> +{{template "base/footer" .}}
templates/user/settings/navbar.tmpl+3 −0 modified@@ -16,6 +16,9 @@ <a class="{{if .PageIsSettingsSSHKeys}}active{{end}} item" href="{{AppSubUrl}}/user/settings/ssh"> {{.i18n.Tr "settings.ssh_keys"}} </a> + <a class="{{if .PageIsSettingsSecurity}}active{{end}} item" href="{{AppSubUrl}}/user/settings/security"> + {{.i18n.Tr "settings.security"}} + </a> <a class="{{if .PageIsSettingsRepositories}}active{{end}} item" href="{{AppSubUrl}}/user/settings/repositories"> {{.i18n.Tr "settings.repos"}} </a>
templates/user/settings/profile.tmpl+0 −1 modified@@ -40,7 +40,6 @@ <button class="ui green button">{{$.i18n.Tr "settings.update_profile"}}</button> </div> </form> - </div> </div> </div>
templates/user/settings/security.tmpl+51 −0 added@@ -0,0 +1,51 @@ +{{template "base/head" .}} +<div class="user settings security"> + <div class="ui container"> + <div class="ui grid"> + {{template "user/settings/navbar" .}} + <div class="twelve wide column content"> + {{template "base/alert" .}} + <h4 class="ui top attached header"> + {{.i18n.Tr "settings.two_factor"}} + </h4> + <div class="ui attached segment two-factor"> + <p class="text bold"> + {{.i18n.Tr "settings.two_factor_status"}} + {{if .TwoFactor}} + <span class="text green">{{.i18n.Tr "settings.two_factor_on"}} <i class="octicon octicon-check"></i></span> + <button class="ui right mini red toggle button delete-button" data-url="{{$.Link}}/two_factor_disable">{{.i18n.Tr "settings.two_factor_disable"}}</button> + {{else}} + <span class="text red">{{.i18n.Tr "settings.two_factor_off"}} <i class="octicon octicon-x"></i></span> + <a class="ui right mini green toggle button" href="{{AppSubUrl}}/user/settings/security/two_factor_enable">{{.i18n.Tr "settings.two_factor_enable"}}</a> + {{end}} + </p> + </div> + {{if .TwoFactor}} + <br> + <p>{{.i18n.Tr "settings.two_factor_view_recovery_codes" AppSubUrl "/user/settings/security/two_factor_recovery_codes" | Safe}}</p> + {{end}} + </div> + </div> + </div> +</div> + +<div class="ui small basic delete modal"> + <div class="ui icon header"> + <i class="trash icon"></i> + {{.i18n.Tr "settings.two_factor_disable_title"}} + </div> + <div class="content"> + <p>{{.i18n.Tr "settings.two_factor_disable_desc"}}</p> + </div> + <div class="actions"> + <div class="ui red basic inverted cancel button"> + <i class="remove icon"></i> + {{.i18n.Tr "modal.no"}} + </div> + <div class="ui green basic inverted ok button"> + <i class="checkmark icon"></i> + {{.i18n.Tr "modal.yes"}} + </div> + </div> +</div> +{{template "base/footer" .}} \ No newline at end of file
templates/user/settings/two_factor_enable.tmpl+28 −0 added@@ -0,0 +1,28 @@ +{{template "base/head" .}} +<div class="user settings security two-factor"> + <div class="ui container"> + <div class="ui grid"> + {{template "user/settings/navbar" .}} + <div class="twelve wide column content"> + {{template "base/alert" .}} + <h4 class="ui top attached header"> + {{.i18n.Tr "settings.two_factor_enable_title"}} + </h4> + <div class="ui attached segment"> + <div>{{.i18n.Tr "settings.two_factor_scan_qr"}}</div> + <img src="{{.QRCode}}" alt="{{.TwoFactorSecret}}"> + <p>{{.i18n.Tr "settings.two_factor_or_enter_secret"}} <b>{{.TwoFactorSecret}}</b></p> + <form class="ui form" method="post"> + {{.CsrfTokenHtml}} + <div class="required inline field"> + <span>{{.i18n.Tr "settings.two_factor_then_enter_passcode"}}</span> + <input class="ui input" name="passcode" autocomplete="off" autofocus required> + </div> + <button class="ui green button">{{.i18n.Tr "settings.two_factor_verify"}}</button> + </form> + </div> + </div> + </div> + </div> +</div> +{{template "base/footer" .}} \ No newline at end of file
templates/user/settings/two_factor_recovery_codes.tmpl+36 −0 added@@ -0,0 +1,36 @@ +{{template "base/head" .}} +<div class="user settings security two-factor"> + <div class="ui container"> + <div class="ui grid"> + {{template "user/settings/navbar" .}} + <div class="twelve wide column content"> + {{template "base/alert" .}} + <h4 class="ui top attached header"> + {{.i18n.Tr "settings.two_factor_recovery_codes_title"}} + </h4> + <div class="ui attached segment"> + <p>{{.i18n.Tr "settings.two_factor_recovery_codes_desc" | Safe}}</p> + <ul class="ui list"> + {{range .RecoveryCodes}} + <li class="item"> + <code> + {{if .IsUsed}} + <del>{{.Code}}</del> + {{else}} + {{.Code}} + {{end}} + </code> + </li> + {{end}} + </ul> + + <form class="ui form" method="post"> + {{.CsrfTokenHtml}} + <button class="ui blue button">{{.i18n.Tr "settings.two_factor_regenerate_recovery_codes"}}</button> + </form> + </div> + </div> + </div> + </div> +</div> +{{template "base/footer" .}} \ No newline at end of file
templates/.VERSION+1 −1 modified@@ -1 +1 @@ -0.11.4.0405 \ No newline at end of file +0.11.5.0406 \ No newline at end of file
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-p6x6-9mx6-26wjghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-64175ghsaADVISORY
- github.com/gogs/gogs/commit/a617d52374e937db0edacfba2a26bdd14a05538eghsaWEB
- github.com/gogs/gogs/commit/d568e048315dc9729c8518d8085cab7dbbfac80fghsaWEB
- github.com/gogs/gogs/security/advisories/GHSA-p6x6-9mx6-26wjghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.