mirror of https://github.com/gogs/gogs.git
db: add tests for two factors (#6099)
* Rename to TwoFactors.Create * Use GORM to execute queries * TwoFactor.GetByUserID * Add tests * Fix failing tests * Add MD5 tests * Add tests for RandomCharspull/6102/head
parent
659acd48b1
commit
cb439a126a
|
@ -0,0 +1,56 @@
|
||||||
|
// Copyright 2020 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 cryptoutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AESGCMEncrypt encrypts plaintext with the given key using AES in GCM mode.
|
||||||
|
func AESGCMEncrypt(key, plaintext []byte) ([]byte, error) {
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := rand.Read(nonce); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
|
||||||
|
return append(nonce, ciphertext...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AESGCMDecrypt decrypts ciphertext with the given key using AES in GCM mode.
|
||||||
|
func AESGCMDecrypt(key, ciphertext []byte) ([]byte, error) {
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
size := gcm.NonceSize()
|
||||||
|
if len(ciphertext)-size <= 0 {
|
||||||
|
return nil, errors.New("ciphertext is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := ciphertext[:size]
|
||||||
|
ciphertext = ciphertext[size:]
|
||||||
|
|
||||||
|
return gcm.Open(nil, nonce, ciphertext, nil)
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
// Copyright 2020 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 cryptoutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAESGCM(t *testing.T) {
|
||||||
|
key := make([]byte, 16) // AES-128
|
||||||
|
_, err := rand.Read(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext := []byte("this will be encrypted")
|
||||||
|
|
||||||
|
encrypted, err := AESGCMEncrypt(key, plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := AESGCMDecrypt(key, encrypted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, plaintext, decrypted)
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
// Copyright 2020 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 cryptoutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MD5 encodes string to hexadecimal of MD5 checksum.
|
||||||
|
func MD5(str string) string {
|
||||||
|
return hex.EncodeToString(MD5Bytes(str))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MD5Bytes encodes string to MD5 checksum.
|
||||||
|
func MD5Bytes(str string) []byte {
|
||||||
|
m := md5.New()
|
||||||
|
_, _ = m.Write([]byte(str))
|
||||||
|
return m.Sum(nil)
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
// Copyright 2020 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 cryptoutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMD5(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
output string
|
||||||
|
}{
|
||||||
|
{input: "", output: "d41d8cd98f00b204e9800998ecf8427e"},
|
||||||
|
{input: "The quick brown fox jumps over the lazy dog", output: "9e107d9d372bb6826bd81d3542a419d6"},
|
||||||
|
{input: "The quick brown fox jumps over the lazy dog.", output: "e4d909c290d0fb1ca068ffaddf22cbd0"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.input, func(t *testing.T) {
|
||||||
|
assert.Equal(t, test.output, MD5(test.input))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,20 +0,0 @@
|
||||||
// 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)
|
|
||||||
}
|
|
|
@ -13,7 +13,7 @@ import (
|
||||||
log "unknwon.dev/clog/v2"
|
log "unknwon.dev/clog/v2"
|
||||||
"xorm.io/xorm"
|
"xorm.io/xorm"
|
||||||
|
|
||||||
"gogs.io/gogs/internal/tool"
|
"gogs.io/gogs/internal/strutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
const _MIN_DB_VER = 10
|
const _MIN_DB_VER = 10
|
||||||
|
@ -156,10 +156,10 @@ func generateOrgRandsAndSalt(x *xorm.Engine) (err error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, org := range orgs {
|
for _, org := range orgs {
|
||||||
if org.Rands, err = tool.RandomString(10); err != nil {
|
if org.Rands, err = strutil.RandomChars(10); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if org.Salt, err = tool.RandomString(10); err != nil {
|
if org.Salt, err = strutil.RandomChars(10); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err = sess.ID(org.ID).Update(org); err != nil {
|
if _, err = sess.ID(org.ID).Update(org); err != nil {
|
||||||
|
|
|
@ -180,9 +180,19 @@ func SetMockReposStore(t *testing.T, mock ReposStore) {
|
||||||
var _ TwoFactorsStore = (*MockTwoFactorsStore)(nil)
|
var _ TwoFactorsStore = (*MockTwoFactorsStore)(nil)
|
||||||
|
|
||||||
type MockTwoFactorsStore struct {
|
type MockTwoFactorsStore struct {
|
||||||
|
MockCreate func(userID int64, key, secret string) error
|
||||||
|
MockGetByUserID func(userID int64) (*TwoFactor, error)
|
||||||
MockIsUserEnabled func(userID int64) bool
|
MockIsUserEnabled func(userID int64) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockTwoFactorsStore) Create(userID int64, key, secret string) error {
|
||||||
|
return m.MockCreate(userID, key, secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTwoFactorsStore) GetByUserID(userID int64) (*TwoFactor, error) {
|
||||||
|
return m.MockGetByUserID(userID)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MockTwoFactorsStore) IsUserEnabled(userID int64) bool {
|
func (m *MockTwoFactorsStore) IsUserEnabled(userID int64) bool {
|
||||||
return m.MockIsUserEnabled(userID)
|
return m.MockIsUserEnabled(userID)
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,6 +22,7 @@ import (
|
||||||
"github.com/gogs/git-module"
|
"github.com/gogs/git-module"
|
||||||
|
|
||||||
"gogs.io/gogs/internal/conf"
|
"gogs.io/gogs/internal/conf"
|
||||||
|
"gogs.io/gogs/internal/cryptoutil"
|
||||||
"gogs.io/gogs/internal/db/errors"
|
"gogs.io/gogs/internal/db/errors"
|
||||||
"gogs.io/gogs/internal/gitutil"
|
"gogs.io/gogs/internal/gitutil"
|
||||||
"gogs.io/gogs/internal/osutil"
|
"gogs.io/gogs/internal/osutil"
|
||||||
|
@ -56,7 +57,7 @@ func ComposeHookEnvs(opts ComposeHookEnvsOptions) []string {
|
||||||
ENV_AUTH_USER_NAME + "=" + opts.AuthUser.Name,
|
ENV_AUTH_USER_NAME + "=" + opts.AuthUser.Name,
|
||||||
ENV_AUTH_USER_EMAIL + "=" + opts.AuthUser.Email,
|
ENV_AUTH_USER_EMAIL + "=" + opts.AuthUser.Email,
|
||||||
ENV_REPO_OWNER_NAME + "=" + opts.OwnerName,
|
ENV_REPO_OWNER_NAME + "=" + opts.OwnerName,
|
||||||
ENV_REPO_OWNER_SALT_MD5 + "=" + tool.MD5(opts.OwnerSalt),
|
ENV_REPO_OWNER_SALT_MD5 + "=" + cryptoutil.MD5(opts.OwnerSalt),
|
||||||
ENV_REPO_ID + "=" + com.ToStr(opts.RepoID),
|
ENV_REPO_ID + "=" + com.ToStr(opts.RepoID),
|
||||||
ENV_REPO_NAME + "=" + opts.RepoName,
|
ENV_REPO_NAME + "=" + opts.RepoName,
|
||||||
ENV_REPO_CUSTOM_HOOKS_PATH + "=" + filepath.Join(opts.RepoPath, "custom_hooks"),
|
ENV_REPO_CUSTOM_HOOKS_PATH + "=" + filepath.Join(opts.RepoPath, "custom_hooks"),
|
||||||
|
|
|
@ -7,38 +7,24 @@ package db
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pquerna/otp/totp"
|
"github.com/pquerna/otp/totp"
|
||||||
"github.com/unknwon/com"
|
"github.com/unknwon/com"
|
||||||
"xorm.io/xorm"
|
|
||||||
|
|
||||||
"gogs.io/gogs/internal/conf"
|
"gogs.io/gogs/internal/conf"
|
||||||
"gogs.io/gogs/internal/db/errors"
|
"gogs.io/gogs/internal/cryptoutil"
|
||||||
"gogs.io/gogs/internal/tool"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TwoFactor represents a two-factor authentication token.
|
// TwoFactor is a 2FA token of a user.
|
||||||
type TwoFactor struct {
|
type TwoFactor struct {
|
||||||
ID int64
|
ID int64
|
||||||
UserID int64 `xorm:"UNIQUE"`
|
UserID int64 `xorm:"UNIQUE" gorm:"UNIQUE"`
|
||||||
Secret string
|
Secret string
|
||||||
Created time.Time `xorm:"-" json:"-"`
|
Created time.Time `xorm:"-" gorm:"-" json:"-"`
|
||||||
CreatedUnix int64
|
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.
|
// ValidateTOTP returns true if given passcode is valid for two-factor authentication token.
|
||||||
// It also returns possible validation error.
|
// It also returns possible validation error.
|
||||||
func (t *TwoFactor) ValidateTOTP(passcode string) (bool, error) {
|
func (t *TwoFactor) ValidateTOTP(passcode string) (bool, error) {
|
||||||
|
@ -46,74 +32,13 @@ func (t *TwoFactor) ValidateTOTP(passcode string) (bool, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("DecodeString: %v", err)
|
return false, fmt.Errorf("DecodeString: %v", err)
|
||||||
}
|
}
|
||||||
decryptSecret, err := com.AESGCMDecrypt(tool.MD5Bytes(conf.Security.SecretKey), secret)
|
decryptSecret, err := com.AESGCMDecrypt(cryptoutil.MD5Bytes(conf.Security.SecretKey), secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("AESGCMDecrypt: %v", err)
|
return false, fmt.Errorf("AESGCMDecrypt: %v", err)
|
||||||
}
|
}
|
||||||
return totp.Validate(passcode, string(decryptSecret)), nil
|
return totp.Validate(passcode, string(decryptSecret)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateRecoveryCodes(userID int64) ([]*TwoFactorRecoveryCode, error) {
|
|
||||||
recoveryCodes := make([]*TwoFactorRecoveryCode, 10)
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
code, err := tool.RandomString(10)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("RandomString: %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(conf.Security.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: userID}
|
|
||||||
}
|
|
||||||
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteTwoFactor removes two-factor authentication token and recovery codes of given user.
|
// DeleteTwoFactor removes two-factor authentication token and recovery codes of given user.
|
||||||
func DeleteTwoFactor(userID int64) (err error) {
|
func DeleteTwoFactor(userID int64) (err error) {
|
||||||
sess := x.NewSession()
|
sess := x.NewSession()
|
||||||
|
@ -152,7 +77,7 @@ func deleteRecoveryCodesByUserID(e Engine, userID int64) error {
|
||||||
|
|
||||||
// RegenerateRecoveryCodes regenerates new set of recovery codes for given user.
|
// RegenerateRecoveryCodes regenerates new set of recovery codes for given user.
|
||||||
func RegenerateRecoveryCodes(userID int64) error {
|
func RegenerateRecoveryCodes(userID int64) error {
|
||||||
recoveryCodes, err := generateRecoveryCodes(userID)
|
recoveryCodes, err := generateRecoveryCodes(userID, 10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("generateRecoveryCodes: %v", err)
|
return fmt.Errorf("generateRecoveryCodes: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,24 +5,118 @@
|
||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jinzhu/gorm"
|
"github.com/jinzhu/gorm"
|
||||||
|
"github.com/pkg/errors"
|
||||||
log "unknwon.dev/clog/v2"
|
log "unknwon.dev/clog/v2"
|
||||||
|
|
||||||
|
"gogs.io/gogs/internal/cryptoutil"
|
||||||
|
"gogs.io/gogs/internal/errutil"
|
||||||
|
"gogs.io/gogs/internal/strutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TwoFactorsStore is the persistent interface for 2FA.
|
// TwoFactorsStore is the persistent interface for 2FA.
|
||||||
//
|
//
|
||||||
// NOTE: All methods are sorted in alphabetical order.
|
// NOTE: All methods are sorted in alphabetical order.
|
||||||
type TwoFactorsStore interface {
|
type TwoFactorsStore interface {
|
||||||
|
// Create creates a new 2FA token and recovery codes for given user.
|
||||||
|
// The "key" is used to encrypt and later decrypt given "secret",
|
||||||
|
// which should be configured in site-level and change of the "key"
|
||||||
|
// will break all existing 2FA tokens.
|
||||||
|
Create(userID int64, key, secret string) error
|
||||||
|
// GetByUserID returns the 2FA token of given user.
|
||||||
|
// It returns ErrTwoFactorNotFound when not found.
|
||||||
|
GetByUserID(userID int64) (*TwoFactor, error)
|
||||||
// IsUserEnabled returns true if the user has enabled 2FA.
|
// IsUserEnabled returns true if the user has enabled 2FA.
|
||||||
IsUserEnabled(userID int64) bool
|
IsUserEnabled(userID int64) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var TwoFactors TwoFactorsStore
|
var TwoFactors TwoFactorsStore
|
||||||
|
|
||||||
|
// NOTE: This is a GORM create hook.
|
||||||
|
func (t *TwoFactor) BeforeCreate() {
|
||||||
|
t.CreatedUnix = gorm.NowFunc().Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: This is a GORM query hook.
|
||||||
|
func (t *TwoFactor) AfterFind() {
|
||||||
|
t.Created = time.Unix(t.CreatedUnix, 0).Local()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ TwoFactorsStore = (*twoFactors)(nil)
|
||||||
|
|
||||||
type twoFactors struct {
|
type twoFactors struct {
|
||||||
*gorm.DB
|
*gorm.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *twoFactors) Create(userID int64, key, secret string) error {
|
||||||
|
encrypted, err := cryptoutil.AESGCMEncrypt(cryptoutil.MD5Bytes(key), []byte(secret))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "encrypt secret")
|
||||||
|
}
|
||||||
|
tf := &TwoFactor{
|
||||||
|
UserID: userID,
|
||||||
|
Secret: base64.StdEncoding.EncodeToString(encrypted),
|
||||||
|
}
|
||||||
|
|
||||||
|
recoveryCodes, err := generateRecoveryCodes(userID, 10)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "generate recovery codes")
|
||||||
|
}
|
||||||
|
|
||||||
|
vals := make([]string, 0, len(recoveryCodes))
|
||||||
|
items := make([]interface{}, 0, len(recoveryCodes)*2)
|
||||||
|
for _, code := range recoveryCodes {
|
||||||
|
vals = append(vals, "(?, ?)")
|
||||||
|
items = append(items, code.UserID, code.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
err := tx.Create(tf).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sql := "INSERT INTO two_factor_recovery_code (user_id, code) VALUES " + strings.Join(vals, ", ")
|
||||||
|
return tx.Exec(sql, items...).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ errutil.NotFound = (*ErrTwoFactorNotFound)(nil)
|
||||||
|
|
||||||
|
type ErrTwoFactorNotFound struct {
|
||||||
|
args errutil.Args
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsErrTwoFactorNotFound(err error) bool {
|
||||||
|
_, ok := err.(ErrTwoFactorNotFound)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err ErrTwoFactorNotFound) Error() string {
|
||||||
|
return fmt.Sprintf("2FA does not found: %v", err.args)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ErrTwoFactorNotFound) NotFound() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *twoFactors) GetByUserID(userID int64) (*TwoFactor, error) {
|
||||||
|
tf := new(TwoFactor)
|
||||||
|
err := db.Where("user_id = ?", userID).First(tf).Error
|
||||||
|
if err != nil {
|
||||||
|
if gorm.IsRecordNotFoundError(err) {
|
||||||
|
return nil, ErrTwoFactorNotFound{args: errutil.Args{"userID": userID}}
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return tf, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (db *twoFactors) IsUserEnabled(userID int64) bool {
|
func (db *twoFactors) IsUserEnabled(userID int64) bool {
|
||||||
var count int64
|
var count int64
|
||||||
err := db.Model(new(TwoFactor)).Where("user_id = ?", userID).Count(&count).Error
|
err := db.Model(new(TwoFactor)).Where("user_id = ?", userID).Count(&count).Error
|
||||||
|
@ -31,3 +125,19 @@ func (db *twoFactors) IsUserEnabled(userID int64) bool {
|
||||||
}
|
}
|
||||||
return count > 0
|
return count > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// generateRecoveryCodes generates N number of recovery codes for 2FA.
|
||||||
|
func generateRecoveryCodes(userID int64, n int) ([]*TwoFactorRecoveryCode, error) {
|
||||||
|
recoveryCodes := make([]*TwoFactorRecoveryCode, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
code, err := strutil.RandomChars(10)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "generate random characters")
|
||||||
|
}
|
||||||
|
recoveryCodes[i] = &TwoFactorRecoveryCode{
|
||||||
|
UserID: userID,
|
||||||
|
Code: strings.ToLower(code[:5] + "-" + code[5:]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return recoveryCodes, nil
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,100 @@
|
||||||
|
// Copyright 2020 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 db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jinzhu/gorm"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"gogs.io/gogs/internal/errutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_twoFactors(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip()
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tables := []interface{}{new(TwoFactor), new(TwoFactorRecoveryCode)}
|
||||||
|
db := &twoFactors{
|
||||||
|
DB: initTestDB(t, "twoFactors", tables...),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
test func(*testing.T, *twoFactors)
|
||||||
|
}{
|
||||||
|
{"Create", test_twoFactors_Create},
|
||||||
|
{"GetByUserID", test_twoFactors_GetByUserID},
|
||||||
|
{"IsUserEnabled", test_twoFactors_IsUserEnabled},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Cleanup(func() {
|
||||||
|
err := clearTables(db.DB, tables...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
tc.test(t, db)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_twoFactors_Create(t *testing.T, db *twoFactors) {
|
||||||
|
// Create a 2FA token
|
||||||
|
err := db.Create(1, "secure-key", "secure-secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get it back and check the Created field
|
||||||
|
tf, err := db.GetByUserID(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, gorm.NowFunc().Format(time.RFC3339), tf.Created.Format(time.RFC3339))
|
||||||
|
|
||||||
|
// Verify there are 10 recover codes generated
|
||||||
|
var count int64
|
||||||
|
err = db.Model(new(TwoFactorRecoveryCode)).Count(&count).Error
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, int64(10), count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_twoFactors_GetByUserID(t *testing.T, db *twoFactors) {
|
||||||
|
// Create a 2FA token for user 1
|
||||||
|
err := db.Create(1, "secure-key", "secure-secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// We should be able to get it back
|
||||||
|
_, err = db.GetByUserID(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get a non-existent 2FA token
|
||||||
|
_, err = db.GetByUserID(2)
|
||||||
|
expErr := ErrTwoFactorNotFound{args: errutil.Args{"userID": int64(2)}}
|
||||||
|
assert.Equal(t, expErr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_twoFactors_IsUserEnabled(t *testing.T, db *twoFactors) {
|
||||||
|
// Create a 2FA token for user 1
|
||||||
|
err := db.Create(1, "secure-key", "secure-secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, db.IsUserEnabled(1))
|
||||||
|
assert.False(t, db.IsUserEnabled(2))
|
||||||
|
}
|
|
@ -32,6 +32,7 @@ import (
|
||||||
"gogs.io/gogs/internal/conf"
|
"gogs.io/gogs/internal/conf"
|
||||||
"gogs.io/gogs/internal/db/errors"
|
"gogs.io/gogs/internal/db/errors"
|
||||||
"gogs.io/gogs/internal/errutil"
|
"gogs.io/gogs/internal/errutil"
|
||||||
|
"gogs.io/gogs/internal/strutil"
|
||||||
"gogs.io/gogs/internal/tool"
|
"gogs.io/gogs/internal/tool"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -483,9 +484,9 @@ func IsUserExist(uid int64, name string) (bool, error) {
|
||||||
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.
|
// GetUserSalt returns a random user salt token.
|
||||||
func GetUserSalt() (string, error) {
|
func GetUserSalt() (string, error) {
|
||||||
return tool.RandomString(10)
|
return strutil.RandomChars(10)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewGhostUser creates and returns a fake user for someone who has deleted his/her account.
|
// NewGhostUser creates and returns a fake user for someone who has deleted his/her account.
|
||||||
|
|
|
@ -27,8 +27,8 @@ import (
|
||||||
"gogs.io/gogs/internal/markup"
|
"gogs.io/gogs/internal/markup"
|
||||||
"gogs.io/gogs/internal/osutil"
|
"gogs.io/gogs/internal/osutil"
|
||||||
"gogs.io/gogs/internal/ssh"
|
"gogs.io/gogs/internal/ssh"
|
||||||
|
"gogs.io/gogs/internal/strutil"
|
||||||
"gogs.io/gogs/internal/template/highlight"
|
"gogs.io/gogs/internal/template/highlight"
|
||||||
"gogs.io/gogs/internal/tool"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -365,7 +365,7 @@ func InstallPost(c *context.Context, f form.Install) {
|
||||||
cfg.Section("log").Key("ROOT_PATH").SetValue(f.LogRootPath)
|
cfg.Section("log").Key("ROOT_PATH").SetValue(f.LogRootPath)
|
||||||
|
|
||||||
cfg.Section("security").Key("INSTALL_LOCK").SetValue("true")
|
cfg.Section("security").Key("INSTALL_LOCK").SetValue("true")
|
||||||
secretKey, err := tool.RandomString(15)
|
secretKey, err := strutil.RandomChars(15)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.RenderWithErr(c.Tr("install.secret_key_failed", err), INSTALL, &f)
|
c.RenderWithErr(c.Tr("install.secret_key_failed", err), INSTALL, &f)
|
||||||
return
|
return
|
||||||
|
|
|
@ -10,8 +10,8 @@ import (
|
||||||
"gopkg.in/macaron.v1"
|
"gopkg.in/macaron.v1"
|
||||||
log "unknwon.dev/clog/v2"
|
log "unknwon.dev/clog/v2"
|
||||||
|
|
||||||
|
"gogs.io/gogs/internal/cryptoutil"
|
||||||
"gogs.io/gogs/internal/db"
|
"gogs.io/gogs/internal/db"
|
||||||
"gogs.io/gogs/internal/tool"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TriggerTask(c *macaron.Context) {
|
func TriggerTask(c *macaron.Context) {
|
||||||
|
@ -39,7 +39,7 @@ func TriggerTask(c *macaron.Context) {
|
||||||
|
|
||||||
// 🚨 SECURITY: No need to check existence of the repository if the client
|
// 🚨 SECURITY: No need to check existence of the repository if the client
|
||||||
// can't even get the valid secret. Mostly likely not a legitimate request.
|
// can't even get the valid secret. Mostly likely not a legitimate request.
|
||||||
if secret != tool.MD5(owner.Salt) {
|
if secret != cryptoutil.MD5(owner.Salt) {
|
||||||
c.Error(http.StatusBadRequest, "Invalid secret")
|
c.Error(http.StatusBadRequest, "Invalid secret")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -209,7 +209,7 @@ func LoginTwoFactorPost(c *context.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
t, err := db.GetTwoFactorByUserID(userID)
|
t, err := db.TwoFactors.GetByUserID(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err, "get two factor by user ID")
|
c.Error(err, "get two factor by user ID")
|
||||||
return
|
return
|
||||||
|
|
|
@ -20,6 +20,7 @@ import (
|
||||||
|
|
||||||
"gogs.io/gogs/internal/conf"
|
"gogs.io/gogs/internal/conf"
|
||||||
"gogs.io/gogs/internal/context"
|
"gogs.io/gogs/internal/context"
|
||||||
|
"gogs.io/gogs/internal/cryptoutil"
|
||||||
"gogs.io/gogs/internal/db"
|
"gogs.io/gogs/internal/db"
|
||||||
"gogs.io/gogs/internal/db/errors"
|
"gogs.io/gogs/internal/db/errors"
|
||||||
"gogs.io/gogs/internal/email"
|
"gogs.io/gogs/internal/email"
|
||||||
|
@ -118,7 +119,7 @@ func SettingsPost(c *context.Context, f form.UpdateProfile) {
|
||||||
func UpdateAvatarSetting(c *context.Context, f form.Avatar, ctxUser *db.User) error {
|
func UpdateAvatarSetting(c *context.Context, f form.Avatar, ctxUser *db.User) error {
|
||||||
ctxUser.UseCustomAvatar = f.Source == form.AVATAR_LOCAL
|
ctxUser.UseCustomAvatar = f.Source == form.AVATAR_LOCAL
|
||||||
if len(f.Gravatar) > 0 {
|
if len(f.Gravatar) > 0 {
|
||||||
ctxUser.Avatar = tool.MD5(f.Gravatar)
|
ctxUser.Avatar = cryptoutil.MD5(f.Gravatar)
|
||||||
ctxUser.AvatarEmail = f.Gravatar
|
ctxUser.AvatarEmail = f.Gravatar
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -381,8 +382,8 @@ func SettingsSecurity(c *context.Context) {
|
||||||
c.Title("settings.security")
|
c.Title("settings.security")
|
||||||
c.PageIs("SettingsSecurity")
|
c.PageIs("SettingsSecurity")
|
||||||
|
|
||||||
t, err := db.GetTwoFactorByUserID(c.UserID())
|
t, err := db.TwoFactors.GetByUserID(c.UserID())
|
||||||
if err != nil && !errors.IsTwoFactorNotFound(err) {
|
if err != nil && !db.IsErrTwoFactorNotFound(err) {
|
||||||
c.Errorf(err, "get two factor by user ID")
|
c.Errorf(err, "get two factor by user ID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -449,7 +450,7 @@ func SettingsTwoFactorEnablePost(c *context.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.NewTwoFactor(c.UserID(), secret); err != nil {
|
if err := db.TwoFactors.Create(c.UserID(), conf.Security.SecretKey, secret); err != nil {
|
||||||
c.Flash.Error(c.Tr("settings.two_factor_enable_error", err))
|
c.Flash.Error(c.Tr("settings.two_factor_enable_error", err))
|
||||||
c.RedirectSubpath("/user/settings/security/two_factor_enable")
|
c.RedirectSubpath("/user/settings/security/two_factor_enable")
|
||||||
return
|
return
|
||||||
|
|
|
@ -5,6 +5,8 @@
|
||||||
package strutil
|
package strutil
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"math/big"
|
||||||
"unicode"
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -15,3 +17,30 @@ func ToUpperFirst(s string) string {
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RandomChars returns a generated string in given number of random characters.
|
||||||
|
func RandomChars(n int) (string, error) {
|
||||||
|
const alphanum = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||||
|
|
||||||
|
randomInt := func(max *big.Int) (int, error) {
|
||||||
|
r, err := rand.Int(rand.Reader, max)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return int(r.Int64()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer := make([]byte, n)
|
||||||
|
max := big.NewInt(int64(len(alphanum)))
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
index, err := randomInt(max)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer[i] = alphanum[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(buffer), nil
|
||||||
|
}
|
||||||
|
|
|
@ -41,3 +41,17 @@ func TestToUpperFirst(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRandomChars(t *testing.T) {
|
||||||
|
cache := make(map[string]bool)
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
chars, err := RandomChars(10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cache[chars] {
|
||||||
|
t.Fatalf("Duplicated chars %q", chars)
|
||||||
|
}
|
||||||
|
cache[chars] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -6,13 +6,11 @@ package tool
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"math/big"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
@ -27,22 +25,8 @@ import (
|
||||||
"gogs.io/gogs/internal/conf"
|
"gogs.io/gogs/internal/conf"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MD5Bytes encodes string to MD5 bytes.
|
|
||||||
// TODO: Move to hashutil.MD5Bytes.
|
|
||||||
func MD5Bytes(str string) []byte {
|
|
||||||
m := md5.New()
|
|
||||||
_, _ = m.Write([]byte(str))
|
|
||||||
return m.Sum(nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MD5 encodes string to MD5 hex value.
|
|
||||||
// TODO: Move to hashutil.MD5.
|
|
||||||
func MD5(str string) string {
|
|
||||||
return hex.EncodeToString(MD5Bytes(str))
|
|
||||||
}
|
|
||||||
|
|
||||||
// SHA1 encodes string to SHA1 hex value.
|
// SHA1 encodes string to SHA1 hex value.
|
||||||
// TODO: Move to hashutil.SHA1.
|
// TODO: Move to cryptoutil.SHA1.
|
||||||
func SHA1(str string) string {
|
func SHA1(str string) string {
|
||||||
h := sha1.New()
|
h := sha1.New()
|
||||||
_, _ = h.Write([]byte(str))
|
_, _ = h.Write([]byte(str))
|
||||||
|
@ -86,40 +70,6 @@ func BasicAuthDecode(encoded string) (string, string, error) {
|
||||||
return auth[0], auth[1], nil
|
return auth[0], auth[1], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BasicAuthEncode encodes username and password in HTTP Basic Authentication format.
|
|
||||||
func BasicAuthEncode(username, password string) string {
|
|
||||||
return base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
|
|
||||||
}
|
|
||||||
|
|
||||||
const alphanum = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
||||||
|
|
||||||
// RandomString returns generated random string in given length of characters.
|
|
||||||
// It also returns possible error during generation.
|
|
||||||
func RandomString(n int) (string, error) {
|
|
||||||
buffer := make([]byte, n)
|
|
||||||
max := big.NewInt(int64(len(alphanum)))
|
|
||||||
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
index, err := randomInt(max)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer[i] = alphanum[index]
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(buffer), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func randomInt(max *big.Int) (int, error) {
|
|
||||||
rand, err := rand.Int(rand.Reader, max)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return int(rand.Int64()), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// verify time limit code
|
// verify time limit code
|
||||||
func VerifyTimeLimitCode(data string, minutes int, code string) bool {
|
func VerifyTimeLimitCode(data string, minutes int, code string) bool {
|
||||||
if len(code) <= 18 {
|
if len(code) <= 18 {
|
||||||
|
|
Loading…
Reference in New Issue