vdi-cloud/platform/internal/server/mfa.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
}