Kaynağa Gözat

2fa: initial support (#945)

Unknwon 7 yıl önce
ebeveyn
işleme
a617d52374

+ 3 - 0
Makefile

@@ -25,6 +25,9 @@ check: test
 
 dist: release
 
+web: build
+	./gogs web
+
 govet:
 	$(GOVET) gogs.go
 	$(GOVET) models pkg routers

+ 15 - 2
cmd/web.go

@@ -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)

+ 32 - 4
conf/locale/locale_en-US.ini

@@ -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

+ 1 - 1
gogs.go

@@ -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

+ 33 - 0
models/errors/two_factor.go

@@ -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)
+}

+ 2 - 2
models/models.go

@@ -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),

+ 201 - 0
models/two_factor.go

@@ -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
+}

+ 7 - 2
models/user.go

@@ -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.

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
pkg/bindata/bindata.go


+ 5 - 0
pkg/context/context.go

@@ -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 {

+ 8 - 3
pkg/tool/tool.go

@@ -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.

+ 23 - 14
public/css/gogs.css

@@ -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;
 }

+ 12 - 3
public/less/_form.less

@@ -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,

+ 15 - 10
public/less/_user.less

@@ -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 {

+ 6 - 3
routers/repo/http.go

@@ -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
 		}
 

+ 138 - 48
routers/user/auth.go

@@ -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) {

+ 154 - 11
routers/user/setting.go

@@ -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

+ 1 - 1
templates/.VERSION

@@ -1 +1 @@
-0.11.4.0405
+0.11.5.0406

+ 0 - 0
templates/user/auth/signin.tmpl → templates/user/auth/login.tmpl


+ 28 - 0
templates/user/auth/two_factor.tmpl

@@ -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" .}}

+ 28 - 0
templates/user/auth/two_factor_recovery_code.tmpl

@@ -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" .}}

+ 3 - 0
templates/user/settings/navbar.tmpl

@@ -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>

+ 0 - 1
templates/user/settings/profile.tmpl

@@ -40,7 +40,6 @@
 							<button class="ui green button">{{$.i18n.Tr "settings.update_profile"}}</button>
 						</div>
 					</form>
-
 				</div>
 			</div>
 		</div>

+ 51 - 0
templates/user/settings/security.tmpl

@@ -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" .}}

+ 28 - 0
templates/user/settings/two_factor_enable.tmpl

@@ -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" .}}

+ 36 - 0
templates/user/settings/two_factor_recovery_codes.tmpl

@@ -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" .}}

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor