368 lines
11 KiB
Go
368 lines
11 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha1"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/base32"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/jmoiron/sqlx"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"vdi-cloud/platform/internal/store"
|
|
)
|
|
|
|
type mfaRecord struct {
|
|
AdminID string `db:"admin_id"`
|
|
Secret string `db:"encrypted_secret"`
|
|
LastCounter int64 `db:"last_counter"`
|
|
Pending string `db:"pending_secret"`
|
|
PendingSession string `db:"pending_session"`
|
|
PendingExpires *time.Time `db:"pending_expires"`
|
|
EnabledAt *time.Time `db:"enabled_at"`
|
|
}
|
|
|
|
func (s *Server) cryptMFA(adminID, value string, seal bool) (string, error) {
|
|
key := sha256.Sum256([]byte("vdi-admin-mfa-v1:" + s.Config.JWTSecret))
|
|
block, _ := aes.NewCipher(key[:])
|
|
gcm, _ := cipher.NewGCM(block)
|
|
aad := []byte("admin_mfa:v1:" + adminID)
|
|
if seal {
|
|
nonce := make([]byte, gcm.NonceSize())
|
|
if _, err := rand.Read(nonce); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.StdEncoding.EncodeToString(gcm.Seal(nonce, nonce, []byte(value), aad)), nil
|
|
}
|
|
data, err := base64.StdEncoding.DecodeString(value)
|
|
if err != nil || len(data) < gcm.NonceSize() {
|
|
return "", errors.New("invalid encrypted MFA secret")
|
|
}
|
|
plain, err := gcm.Open(nil, data[:gcm.NonceSize()], data[gcm.NonceSize():], aad)
|
|
return string(plain), err
|
|
}
|
|
|
|
// RFC 6238 / RFC 4226, SHA-1 with dynamic truncation. Production uses 6 digits and 30 seconds.
|
|
func totpCode(secret string, counter int64, digits int) (string, error) {
|
|
key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
|
|
if err != nil || len(key) < 20 || counter < 0 || (digits != 6 && digits != 8) {
|
|
return "", errors.New("invalid TOTP parameters")
|
|
}
|
|
var input [8]byte
|
|
binary.BigEndian.PutUint64(input[:], uint64(counter))
|
|
mac := hmac.New(sha1.New, key)
|
|
mac.Write(input[:])
|
|
digest := mac.Sum(nil)
|
|
offset := int(digest[len(digest)-1] & 15)
|
|
value := binary.BigEndian.Uint32(digest[offset:offset+4]) & 0x7fffffff
|
|
mod := uint32(1000000)
|
|
if digits == 8 {
|
|
mod = 100000000
|
|
}
|
|
return fmt.Sprintf("%0*d", digits, value%mod), nil
|
|
}
|
|
|
|
func matchTOTPCounter(secret, code string, now time.Time, last int64) (int64, bool) {
|
|
if len(code) != 6 {
|
|
return 0, false
|
|
}
|
|
for _, r := range code {
|
|
if r < '0' || r > '9' {
|
|
return 0, false
|
|
}
|
|
}
|
|
for delta := int64(1); delta >= -1; delta-- {
|
|
counter := now.Unix()/30 + delta
|
|
if counter <= last {
|
|
continue
|
|
}
|
|
value, err := totpCode(secret, counter, 6)
|
|
if err == nil && hmac.Equal([]byte(value), []byte(code)) {
|
|
return counter, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func recoveryHash(code string) string {
|
|
code = strings.ToLower(strings.NewReplacer("-", "", " ", "").Replace(strings.TrimSpace(code)))
|
|
if len(code) != 32 {
|
|
return ""
|
|
}
|
|
if _, err := hex.DecodeString(code); err != nil {
|
|
return ""
|
|
}
|
|
return store.Hash("vdi-mfa-recovery-v1:" + code)
|
|
}
|
|
|
|
func newRecoveryCodes(ctx context.Context, tx *sqlx.Tx, id string) ([]string, error) {
|
|
if _, err := tx.ExecContext(ctx, "DELETE FROM admin_mfa_recovery WHERE admin_id=$1", id); err != nil {
|
|
return nil, err
|
|
}
|
|
codes := make([]string, 0, 8)
|
|
for i := 0; i < 8; i++ {
|
|
var raw [16]byte
|
|
if _, err := rand.Read(raw[:]); err != nil {
|
|
return nil, err
|
|
}
|
|
value := strings.ToUpper(hex.EncodeToString(raw[:]))
|
|
code := value[:8] + "-" + value[8:16] + "-" + value[16:24] + "-" + value[24:]
|
|
if _, err := tx.ExecContext(ctx, "INSERT INTO admin_mfa_recovery(admin_id,code_hash) VALUES($1,$2)", id, recoveryHash(code)); err != nil {
|
|
return nil, err
|
|
}
|
|
codes = append(codes, code)
|
|
}
|
|
return codes, nil
|
|
}
|
|
|
|
// Caller locks the administrator first, serializing login, account changes and MFA writes.
|
|
func (s *Server) consumeMFA(ctx context.Context, tx *sqlx.Tx, row mfaRecord, code string) (bool, error) {
|
|
if hash := recoveryHash(code); hash != "" {
|
|
result, err := tx.ExecContext(ctx, "DELETE FROM admin_mfa_recovery WHERE admin_id=$1 AND code_hash=$2", row.AdminID, hash)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
count, err := result.RowsAffected()
|
|
return count == 1, err
|
|
}
|
|
secret, err := s.cryptMFA(row.AdminID, row.Secret, false)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
counter, ok := matchTOTPCounter(secret, code, time.Now(), row.LastCounter)
|
|
if !ok {
|
|
return false, nil
|
|
}
|
|
_, err = tx.ExecContext(ctx, "UPDATE admin_mfa SET last_counter=$1 WHERE admin_id=$2", counter, row.AdminID)
|
|
return err == nil, err
|
|
}
|
|
|
|
func (s *Server) mfaStatus(c *gin.Context) {
|
|
var status struct {
|
|
Enabled bool `json:"enabled" db:"enabled"`
|
|
RecoveryRemaining int `json:"recoveryRemaining" db:"recovery_remaining"`
|
|
}
|
|
err := s.DB.GetContext(c.Request.Context(), &status, `SELECT EXISTS(SELECT 1 FROM admin_mfa WHERE admin_id=$1 AND encrypted_secret!='') enabled,
|
|
(SELECT count(*) FROM admin_mfa_recovery WHERE admin_id=$1) recovery_remaining`, s.admin(c).ID)
|
|
if err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
c.JSON(200, status)
|
|
}
|
|
|
|
func (s *Server) beginMFA(c *gin.Context) {
|
|
var in struct {
|
|
Password string `json:"password" binding:"required,max=200"`
|
|
}
|
|
if !s.decodeConfiguration(c, &in) || !s.allowAuthAttempt(c, s.admin(c).Username, "mfa") {
|
|
return
|
|
}
|
|
tx, actor := s.accountTransaction(c, false)
|
|
if tx == nil {
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
if bcrypt.CompareHashAndPassword([]byte(actor.PasswordHash), []byte(in.Password)) != nil {
|
|
s.fail(c, 400, "current_password_invalid", "当前密码不正确")
|
|
return
|
|
}
|
|
var enabled bool
|
|
if err := tx.GetContext(c.Request.Context(), &enabled, "SELECT EXISTS(SELECT 1 FROM admin_mfa WHERE admin_id=$1 AND encrypted_secret!='')", actor.ID); err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
if enabled {
|
|
s.fail(c, 409, "mfa_enabled", "已开启双重验证,请先关闭后重新绑定")
|
|
return
|
|
}
|
|
var raw [20]byte
|
|
if _, err := rand.Read(raw[:]); err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
secret := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw[:])
|
|
encrypted, err := s.cryptMFA(actor.ID, secret, true)
|
|
expires := time.Now().Add(10 * time.Minute)
|
|
if err == nil {
|
|
_, err = tx.ExecContext(c.Request.Context(), `INSERT INTO admin_mfa(admin_id,pending_secret,pending_session,pending_expires) VALUES($1,$2,$3,$4)
|
|
ON CONFLICT(admin_id) DO UPDATE SET pending_secret=$2,pending_session=$3,pending_expires=$4`, actor.ID, encrypted, c.GetString("sessionId"), expires)
|
|
}
|
|
if err == nil {
|
|
err = tx.Commit()
|
|
}
|
|
if err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
uri := url.URL{Scheme: "otpauth", Host: "totp", Path: "/VDI Cloud:" + actor.Username}
|
|
uri.RawQuery = url.Values{"secret": {secret}, "issuer": {"VDI Cloud"}, "algorithm": {"SHA1"}, "digits": {"6"}, "period": {"30"}}.Encode()
|
|
c.JSON(200, gin.H{"secret": secret, "provisioningUri": uri.String(), "expiresAt": expires})
|
|
}
|
|
|
|
func (s *Server) confirmMFA(c *gin.Context) {
|
|
var in struct {
|
|
Code string `json:"code" binding:"required,len=6"`
|
|
}
|
|
if !s.decodeConfiguration(c, &in) || !s.allowAuthAttempt(c, s.admin(c).Username, "mfa") {
|
|
return
|
|
}
|
|
tx, actor := s.accountTransaction(c, false)
|
|
if tx == nil {
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
ctx := c.Request.Context()
|
|
var row mfaRecord
|
|
err := tx.GetContext(ctx, &row, "SELECT * FROM admin_mfa WHERE admin_id=$1", actor.ID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
s.fail(c, 409, "mfa_setup_expired", "请重新开始绑定")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
if row.Secret != "" {
|
|
s.fail(c, 409, "mfa_enabled", "已开启双重验证")
|
|
return
|
|
}
|
|
if row.Pending == "" || row.PendingExpires == nil || !row.PendingExpires.After(time.Now()) || row.PendingSession != c.GetString("sessionId") {
|
|
s.fail(c, 409, "mfa_setup_expired", "绑定已过期或来自其他登录,请重新开始")
|
|
return
|
|
}
|
|
secret, err := s.cryptMFA(actor.ID, row.Pending, false)
|
|
if err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
counter, ok := matchTOTPCounter(secret, in.Code, time.Now(), -1)
|
|
if !ok {
|
|
s.fail(c, 400, "mfa_invalid", "验证码错误或已过期,请核对设备时间")
|
|
return
|
|
}
|
|
_, err = tx.ExecContext(ctx, "UPDATE admin_mfa SET encrypted_secret=pending_secret,last_counter=$1,enabled_at=now(),pending_secret='',pending_session='',pending_expires=NULL WHERE admin_id=$2", counter, actor.ID)
|
|
var codes []string
|
|
if err == nil {
|
|
codes, err = newRecoveryCodes(ctx, tx, actor.ID)
|
|
}
|
|
if err == nil {
|
|
_, err = tx.ExecContext(ctx, "DELETE FROM admin_sessions WHERE admin_id=$1 AND id!=$2", actor.ID, c.GetString("sessionId"))
|
|
}
|
|
if err == nil {
|
|
err = accountAudit(ctx, tx, actor.Username, "enable_mfa", actor.Username, c.GetString("requestId"))
|
|
}
|
|
if err == nil {
|
|
err = tx.Commit()
|
|
}
|
|
if err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
c.JSON(200, gin.H{"recoveryCodes": codes})
|
|
}
|
|
|
|
func (s *Server) changeMFA(c *gin.Context, disable bool) {
|
|
var in struct {
|
|
Password string `json:"password" binding:"required,max=200"`
|
|
Code string `json:"code" binding:"required,max=80"`
|
|
}
|
|
if !s.decodeConfiguration(c, &in) || !s.allowAuthAttempt(c, s.admin(c).Username, "mfa") {
|
|
return
|
|
}
|
|
tx, actor := s.accountTransaction(c, false)
|
|
if tx == nil {
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
ctx := c.Request.Context()
|
|
if bcrypt.CompareHashAndPassword([]byte(actor.PasswordHash), []byte(in.Password)) != nil {
|
|
s.fail(c, 400, "current_password_invalid", "当前密码不正确")
|
|
return
|
|
}
|
|
var row mfaRecord
|
|
err := tx.GetContext(ctx, &row, "SELECT * FROM admin_mfa WHERE admin_id=$1 AND encrypted_secret!=''", actor.ID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
s.fail(c, 409, "mfa_disabled", "尚未开启双重验证")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
ok, err := s.consumeMFA(ctx, tx, row, in.Code)
|
|
if err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
if !ok {
|
|
s.fail(c, 400, "mfa_invalid", "验证码错误、已使用或已过期")
|
|
return
|
|
}
|
|
action := "rotate_mfa_recovery"
|
|
var codes []string
|
|
if disable {
|
|
action = "disable_mfa"
|
|
_, err = tx.ExecContext(ctx, "DELETE FROM admin_mfa WHERE admin_id=$1", actor.ID)
|
|
} else {
|
|
codes, err = newRecoveryCodes(ctx, tx, actor.ID)
|
|
}
|
|
if err == nil {
|
|
_, err = tx.ExecContext(ctx, "DELETE FROM admin_sessions WHERE admin_id=$1 AND id!=$2", actor.ID, c.GetString("sessionId"))
|
|
}
|
|
if err == nil {
|
|
err = accountAudit(ctx, tx, actor.Username, action, actor.Username, c.GetString("requestId"))
|
|
}
|
|
if err == nil {
|
|
err = tx.Commit()
|
|
}
|
|
if err != nil {
|
|
s.dbError(c, err)
|
|
return
|
|
}
|
|
if disable {
|
|
c.Status(204)
|
|
} else {
|
|
c.JSON(200, gin.H{"recoveryCodes": codes})
|
|
}
|
|
}
|
|
|
|
func (s *Server) loginMFA(c *gin.Context, tx *sqlx.Tx, adminID, code string) bool {
|
|
var row mfaRecord
|
|
err := tx.GetContext(c.Request.Context(), &row, "SELECT * FROM admin_mfa WHERE admin_id=$1 AND encrypted_secret!=''", adminID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return true
|
|
}
|
|
if err != nil {
|
|
s.dbError(c, err)
|
|
return false
|
|
}
|
|
if code == "" {
|
|
s.fail(c, 401, "mfa_required", "请输入身份验证器动态码或一次性恢复码")
|
|
return false
|
|
}
|
|
ok, err := s.consumeMFA(c.Request.Context(), tx, row, code)
|
|
if err != nil {
|
|
s.dbError(c, err)
|
|
return false
|
|
}
|
|
if !ok {
|
|
s.fail(c, 401, "mfa_invalid", "验证码错误、已使用或已过期")
|
|
return false
|
|
}
|
|
return true
|
|
}
|