Browse Source

2fa: initial support (#945)

pull/4248/merge
Unknwon 8 years ago
parent
commit
a617d52374
No known key found for this signature in database
GPG Key ID: 25B575AE3213B2B3
  1. 3
      Makefile
  2. 17
      cmd/web.go
  3. 36
      conf/locale/locale_en-US.ini
  4. 2
      gogs.go
  5. 33
      models/errors/two_factor.go
  6. 4
      models/models.go
  7. 201
      models/two_factor.go
  8. 7
      models/user.go
  9. 4
      pkg/bindata/bindata.go
  10. 5
      pkg/context/context.go
  11. 11
      pkg/tool/tool.go
  12. 27
      public/css/gogs.css
  13. 9
      public/less/_form.less
  14. 25
      public/less/_user.less
  15. 9
      routers/repo/http.go
  16. 178
      routers/user/auth.go
  17. 143
      routers/user/setting.go
  18. 2
      templates/.VERSION
  19. 0
      templates/user/auth/login.tmpl
  20. 28
      templates/user/auth/two_factor.tmpl
  21. 28
      templates/user/auth/two_factor_recovery_code.tmpl
  22. 3
      templates/user/settings/navbar.tmpl
  23. 1
      templates/user/settings/profile.tmpl
  24. 51
      templates/user/settings/security.tmpl
  25. 28
      templates/user/settings/two_factor_enable.tmpl
  26. 36
      templates/user/settings/two_factor_recovery_codes.tmpl

3
Makefile

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

17
cmd/web.go

@ -190,8 +190,13 @@ func runWeb(ctx *cli.Context) error {
// ***** START: User ***** // ***** START: User *****
m.Group("/user", func() { m.Group("/user", func() {
m.Get("/login", user.SignIn) m.Group("/login", func() {
m.Post("/login", bindIgnErr(form.SignIn{}), user.SignInPost) 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.Get("/sign_up", user.SignUp)
m.Post("/sign_up", bindIgnErr(form.Register{}), user.SignUpPost) m.Post("/sign_up", bindIgnErr(form.Register{}), user.SignUpPost)
m.Get("/reset_password", user.ResetPasswd) m.Get("/reset_password", user.ResetPasswd)
@ -212,6 +217,14 @@ func runWeb(ctx *cli.Context) error {
m.Combo("/ssh").Get(user.SettingsSSHKeys). m.Combo("/ssh").Get(user.SettingsSSHKeys).
Post(bindIgnErr(form.AddSSHKey{}), user.SettingsSSHKeysPost) Post(bindIgnErr(form.AddSSHKey{}), user.SettingsSSHKeysPost)
m.Post("/ssh/delete", user.DeleteSSHKey) 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.Group("/repositories", func() {
m.Get("", user.SettingsRepos) m.Get("", user.SettingsRepos)
m.Post("/leave", user.SettingsLeaveRepo) m.Post("/leave", user.SettingsLeaveRepo)

36
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. password_too_short = Password length cannot be less then 6.
non_local_account = Non-local accounts cannot change passwords through Gogs. 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] [mail]
activate_account = Please activate your account activate_account = Please activate your account
activate_email = Verify your email address activate_email = Verify your email address
@ -255,6 +263,7 @@ profile = Profile
password = Password password = Password
avatar = Avatar avatar = Avatar
ssh_keys = SSH Keys ssh_keys = SSH Keys
security = Security
repos = Repositories repos = Repositories
orgs = Organizations orgs = Organizations
applications = Applications applications = Applications
@ -324,10 +333,29 @@ no_activity = No recent activity
key_state_desc = This key is used in last 7 days key_state_desc = This key is used in last 7 days
token_state_desc = This token is used in last 7 days token_state_desc = This token is used in last 7 days
manage_social = Manage Associated Social Accounts two_factor = Two-factor Authentication
social_desc = This is a list of associated social accounts. Remove any binding that you do not recognize. two_factor_status = Status:
unbind = Unbind two_factor_on = On
unbind_success = Social account has been unbound. 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 manage_access_token = Manage Personal Access Tokens
generate_new_token = Generate New Token generate_new_token = Generate New Token

2
gogs.go

@ -16,7 +16,7 @@ import (
"github.com/gogits/gogs/pkg/setting" "github.com/gogits/gogs/pkg/setting"
) )
const APP_VER = "0.11.4.0405" const APP_VER = "0.11.5.0406"
func init() { func init() {
setting.AppVer = APP_VER setting.AppVer = APP_VER

33
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)
}

4
models/models.go

@ -27,7 +27,7 @@ import (
"github.com/gogits/gogs/pkg/setting" "github.com/gogits/gogs/pkg/setting"
) )
// Engine represents a xorm engine or session. // Engine represents a XORM engine or session.
type Engine interface { type Engine interface {
Delete(interface{}) (int64, error) Delete(interface{}) (int64, error)
Exec(string, ...interface{}) (sql.Result, error) Exec(string, ...interface{}) (sql.Result, error)
@ -64,7 +64,7 @@ var (
func init() { func init() {
tables = append(tables, 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(Repository), new(DeployKey), new(Collaboration), new(Access), new(Upload),
new(Watch), new(Star), new(Follow), new(Action), new(Watch), new(Star), new(Follow), new(Action),
new(Issue), new(PullRequest), new(Comment), new(Attachment), new(IssueUser), new(Issue), new(PullRequest), new(Comment), new(Attachment), new(IssueUser),

201
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
models/user.go

@ -31,8 +31,8 @@ import (
"github.com/gogits/gogs/models/errors" "github.com/gogits/gogs/models/errors"
"github.com/gogits/gogs/pkg/avatar" "github.com/gogits/gogs/pkg/avatar"
"github.com/gogits/gogs/pkg/tool"
"github.com/gogits/gogs/pkg/setting" "github.com/gogits/gogs/pkg/setting"
"github.com/gogits/gogs/pkg/tool"
) )
type UserType int type UserType int
@ -404,6 +404,11 @@ func (u *User) IsPublicMember(orgId int64) bool {
return IsPublicMembership(orgId, u.ID) 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) { func (u *User) getOrganizationCount(e Engine) (int64, error) {
return e.Where("uid=?", u.ID).Count(new(OrgUser)) return e.Where("uid=?", u.ID).Count(new(OrgUser))
} }

4
pkg/bindata/bindata.go

File diff suppressed because one or more lines are too long

5
pkg/context/context.go

@ -89,6 +89,11 @@ func (c *Context) Success(name string) {
c.HTML(http.StatusOK, name) 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. // RenderWithErr used for page has form validation but need to prompt error to users.
func (ctx *Context) RenderWithErr(msg, tpl string, f interface{}) { func (ctx *Context) RenderWithErr(msg, tpl string, f interface{}) {
if f != nil { if f != nil {

11
pkg/tool/tool.go

@ -27,11 +27,16 @@ import (
"github.com/gogits/gogs/pkg/setting" "github.com/gogits/gogs/pkg/setting"
) )
// EncodeMD5 encodes string to md5 hex value. // MD5Bytes encodes string to MD5 bytes.
func EncodeMD5(str string) string { func MD5Bytes(str string) []byte {
m := md5.New() m := md5.New()
m.Write([]byte(str)) 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. // Encode string to sha1 hex value.

27
public/css/gogs.css

@ -1040,6 +1040,12 @@ footer .ui.language .menu {
.user.signup form .inline.field > label { .user.signup form .inline.field > label {
width: 200px !important; 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.repo form,
.repository.new.migrate form, .repository.new.migrate form,
.repository.new.fork form { .repository.new.fork form {
@ -2856,15 +2862,8 @@ footer .ui.language .menu {
.user.settings .email.list .item:not(:first-child) .button { .user.settings .email.list .item:not(:first-child) .button {
margin-top: -10px; margin-top: -10px;
} }
.user.settings.organizations .orgs.non-empty { .user.settings.security .two-factor .toggle.button {
padding: 0; margin-top: -5px;
}
.user.settings.organizations .orgs .item {
padding: 10px;
}
.user.settings.organizations .orgs .item .button {
margin-top: 5px;
margin-right: 8px;
} }
.user.settings.repositories .repos { .user.settings.repositories .repos {
padding: 0; padding: 0;
@ -2876,6 +2875,16 @@ footer .ui.language .menu {
.user.settings.repositories .repos .item .button { .user.settings.repositories .repos .item .button {
margin-top: -5px; 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 { .user.profile .ui.card .header {
word-break: break-all; word-break: break-all;
} }

9
public/less/_form.less

@ -65,6 +65,15 @@
} }
} }
.user.signin.two-factor {
form {
width: 300px !important;
.header {
padding-left: inherit !important;
}
}
}
.repository { .repository {
&.new.repo, &.new.repo,
&.new.migrate, &.new.migrate,

25
public/less/_user.less

@ -19,16 +19,9 @@
} }
} }
} }
&.organizations .orgs { &.security {
&.non-empty { .two-factor .toggle.button {
padding: 0; margin-top: -5px;
}
.item {
padding: 10px;
.button {
margin-top: 5px;
margin-right: 8px;
}
} }
} }
&.repositories .repos { &.repositories .repos {
@ -41,6 +34,18 @@
} }
} }
} }
&.organizations .orgs {
&.non-empty {
padding: 0;
}
.item {
padding: 10px;
.button {
margin-top: 5px;
margin-right: 8px;
}
}
}
} }
&.profile { &.profile {

9
routers/repo/http.go

@ -23,9 +23,9 @@ import (
"github.com/gogits/gogs/models" "github.com/gogits/gogs/models"
"github.com/gogits/gogs/models/errors" "github.com/gogits/gogs/models/errors"
"github.com/gogits/gogs/pkg/tool"
"github.com/gogits/gogs/pkg/context" "github.com/gogits/gogs/pkg/context"
"github.com/gogits/gogs/pkg/setting" "github.com/gogits/gogs/pkg/setting"
"github.com/gogits/gogs/pkg/tool"
) )
const ( const (
@ -114,7 +114,6 @@ func HTTPContexter() macaron.Handler {
authUser, err := models.UserSignIn(authUsername, authPassword) authUser, err := models.UserSignIn(authUsername, authPassword)
if err != nil && !errors.IsUserNotExist(err) { if err != nil && !errors.IsUserNotExist(err) {
c.Handle(http.StatusInternalServerError, "UserSignIn", err) c.Handle(http.StatusInternalServerError, "UserSignIn", err)
return return
} }
@ -139,6 +138,10 @@ func HTTPContexter() macaron.Handler {
c.Handle(http.StatusInternalServerError, "GetUserByID", err) c.Handle(http.StatusInternalServerError, "GetUserByID", err)
return 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) log.Trace("HTTPGit - Authenticated user: %s", authUser.Name)
@ -152,7 +155,7 @@ func HTTPContexter() macaron.Handler {
c.Handle(http.StatusInternalServerError, "HasAccess", err) c.Handle(http.StatusInternalServerError, "HasAccess", err)
return return
} else if !has { } else if !has {
askCredentials(c, http.StatusUnauthorized, "User permission denied") askCredentials(c, http.StatusForbidden, "User permission denied")
return return
} }

178
routers/user/auth.go

@ -20,20 +20,22 @@ import (
) )
const ( const (
SIGNIN = "user/auth/signin" LOGIN = "user/auth/login"
TWO_FACTOR = "user/auth/two_factor"
TWO_FACTOR_RECOVERY_CODE = "user/auth/two_factor_recovery_code"
SIGNUP = "user/auth/signup" SIGNUP = "user/auth/signup"
ACTIVATE = "user/auth/activate" ACTIVATE = "user/auth/activate"
FORGOT_PASSWORD = "user/auth/forgot_passwd" FORGOT_PASSWORD = "user/auth/forgot_passwd"
RESET_PASSWORD = "user/auth/reset_passwd" RESET_PASSWORD = "user/auth/reset_passwd"
) )
// AutoSignIn reads cookie and try to auto-login. // AutoLogin reads cookie and try to auto-login.
func AutoSignIn(ctx *context.Context) (bool, error) { func AutoLogin(c *context.Context) (bool, error) {
if !models.HasEngine { if !models.HasEngine {
return false, nil return false, nil
} }
uname := ctx.GetCookie(setting.CookieUserName) uname := c.GetCookie(setting.CookieUserName)
if len(uname) == 0 { if len(uname) == 0 {
return false, nil return false, nil
} }
@ -42,9 +44,9 @@ func AutoSignIn(ctx *context.Context) (bool, error) {
defer func() { defer func() {
if !isSucceed { if !isSucceed {
log.Trace("auto-login cookie cleared: %s", uname) log.Trace("auto-login cookie cleared: %s", uname)
ctx.SetCookie(setting.CookieUserName, "", -1, setting.AppSubUrl) c.SetCookie(setting.CookieUserName, "", -1, setting.AppSubUrl)
ctx.SetCookie(setting.CookieRememberName, "", -1, setting.AppSubUrl) c.SetCookie(setting.CookieRememberName, "", -1, setting.AppSubUrl)
ctx.SetCookie(setting.LoginStatusCookieName, "", -1, setting.AppSubUrl) c.SetCookie(setting.LoginStatusCookieName, "", -1, setting.AppSubUrl)
} }
}() }()
@ -56,16 +58,16 @@ func AutoSignIn(ctx *context.Context) (bool, error) {
return false, nil 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 return false, nil
} }
isSucceed = true isSucceed = true
ctx.Session.Set("uid", u.ID) c.Session.Set("uid", u.ID)
ctx.Session.Set("uname", u.Name) c.Session.Set("uname", u.Name)
ctx.SetCookie(setting.CSRFCookieName, "", -1, setting.AppSubUrl) c.SetCookie(setting.CSRFCookieName, "", -1, setting.AppSubUrl)
if setting.EnableLoginStatusCookie { if setting.EnableLoginStatusCookie {
ctx.SetCookie(setting.LoginStatusCookieName, "true", 0, setting.AppSubUrl) c.SetCookie(setting.LoginStatusCookieName, "true", 0, setting.AppSubUrl)
} }
return true, nil return true, nil
} }
@ -77,77 +79,165 @@ func isValidRedirect(url string) bool {
return len(url) >= 2 && url[0] == '/' && url[1] != '/' return len(url) >= 2 && url[0] == '/' && url[1] != '/'
} }
func SignIn(ctx *context.Context) { func Login(c *context.Context) {
ctx.Data["Title"] = ctx.Tr("sign_in") c.Data["Title"] = c.Tr("sign_in")
// Check auto-login. // Check auto-login.
isSucceed, err := AutoSignIn(ctx) isSucceed, err := AutoLogin(c)
if err != nil { if err != nil {
ctx.Handle(500, "AutoSignIn", err) c.Handle(500, "AutoLogin", err)
return return
} }
redirectTo := ctx.Query("redirect_to") redirectTo := c.Query("redirect_to")
if len(redirectTo) > 0 { if len(redirectTo) > 0 {
ctx.SetCookie("redirect_to", redirectTo, 0, setting.AppSubUrl) c.SetCookie("redirect_to", redirectTo, 0, setting.AppSubUrl)
} else { } 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 isSucceed {
if isValidRedirect(redirectTo) { if isValidRedirect(redirectTo) {
ctx.Redirect(redirectTo) c.Redirect(redirectTo)
} else { } else {
ctx.Redirect(setting.AppSubUrl + "/") c.Redirect(setting.AppSubUrl + "/")
} }
return return
} }
ctx.HTML(200, SIGNIN) c.HTML(200, LOGIN)
} }
func SignInPost(ctx *context.Context, f form.SignIn) { func afterLogin(c *context.Context, u *models.User, remember bool) {
ctx.Data["Title"] = ctx.Tr("sign_in") 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() { c.Session.Set("uid", u.ID)
ctx.HTML(200, SIGNIN) 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 return
} }
u, err := models.UserSignIn(f.UserName, f.Password) u, err := models.UserSignIn(f.UserName, f.Password)
if err != nil { if err != nil {
if errors.IsUserNotExist(err) { 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 { } else {
ctx.Handle(500, "UserSignIn", err) c.ServerError("UserSignIn", err)
} }
return return
} }
if f.Remember { if !u.IsEnabledTwoFactor() {
days := 86400 * setting.LoginRememberDays afterLogin(c, u, f.Remember)
ctx.SetCookie(setting.CookieUserName, u.Name, days, setting.AppSubUrl, "", setting.CookieSecure, true) return
ctx.SetSuperSecureCookie(u.Rands+u.Passwd, setting.CookieRememberName, u.Name, days, setting.AppSubUrl, "", setting.CookieSecure, true)
} }
ctx.Session.Set("uid", u.ID) c.Session.Set("twoFactorRemember", f.Remember)
ctx.Session.Set("uname", u.Name) 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 func LoginTwoFactor(c *context.Context) {
ctx.SetCookie(setting.CSRFCookieName, "", -1, setting.AppSubUrl) _, ok := c.Session.Get("twoFactorUserID").(int64)
if setting.EnableLoginStatusCookie { if !ok {
ctx.SetCookie(setting.LoginStatusCookieName, "true", 0, setting.AppSubUrl) c.NotFound()
return
} }
redirectTo, _ := url.QueryUnescape(ctx.GetCookie("redirect_to")) c.Success(TWO_FACTOR)
ctx.SetCookie("redirect_to", "", -1, setting.AppSubUrl) }
if isValidRedirect(redirectTo) {
ctx.Redirect(redirectTo) func LoginTwoFactorPost(c *context.Context) {
userID, ok := c.Session.Get("twoFactorUserID").(int64)
if !ok {
c.NotFound()
return 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) { func SignOut(ctx *context.Context) {

143
routers/user/setting.go

@ -5,11 +5,17 @@
package user package user
import ( import (
"bytes"
"encoding/base64"
"fmt" "fmt"
"html/template"
"image/png"
"io/ioutil" "io/ioutil"
"strings" "strings"
"github.com/Unknwon/com" "github.com/Unknwon/com"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
log "gopkg.in/clog.v1" log "gopkg.in/clog.v1"
"github.com/gogits/gogs/models" "github.com/gogits/gogs/models"
@ -28,6 +34,8 @@ const (
SETTINGS_EMAILS = "user/settings/email" SETTINGS_EMAILS = "user/settings/email"
SETTINGS_SSH_KEYS = "user/settings/sshkeys" SETTINGS_SSH_KEYS = "user/settings/sshkeys"
SETTINGS_SECURITY = "user/settings/security" 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_REPOSITORIES = "user/settings/repositories"
SETTINGS_ORGANIZATIONS = "user/settings/organizations" SETTINGS_ORGANIZATIONS = "user/settings/organizations"
SETTINGS_APPLICATIONS = "user/settings/applications" SETTINGS_APPLICATIONS = "user/settings/applications"
@ -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) { func SettingsApplications(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["Title"] = ctx.Tr("settings")
ctx.Data["PageIsSettingsApplications"] = true ctx.Data["PageIsSettingsApplications"] = true

2
templates/.VERSION

@ -1 +1 @@
0.11.4.0405 0.11.5.0406

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

28
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
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
templates/user/settings/navbar.tmpl

@ -16,6 +16,9 @@
<a class="{{if .PageIsSettingsSSHKeys}}active{{end}} item" href="{{AppSubUrl}}/user/settings/ssh"> <a class="{{if .PageIsSettingsSSHKeys}}active{{end}} item" href="{{AppSubUrl}}/user/settings/ssh">
{{.i18n.Tr "settings.ssh_keys"}} {{.i18n.Tr "settings.ssh_keys"}}
</a> </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"> <a class="{{if .PageIsSettingsRepositories}}active{{end}} item" href="{{AppSubUrl}}/user/settings/repositories">
{{.i18n.Tr "settings.repos"}} {{.i18n.Tr "settings.repos"}}
</a> </a>

1
templates/user/settings/profile.tmpl

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

51
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
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
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" .}}
Loading…
Cancel
Save