Smail/backend/services/totp/service.go
smail f60d3b0880
Some checks are pending
Security Checks / security (push) Waiting to run
整理前后端与测试构建目录并完善配置和安全引导
统一 frontend、backend、test、test-log 和 out 目录,更新构建、部署及测试入口;纳入配置管理兼容与首次安全引导修复;排除本机配置、密钥和测试产物。
2026-09-05 19:19:52 +08:00

342 lines
8.9 KiB
Go

package totp
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base32"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"io"
"net/url"
"strconv"
"strings"
"sync"
"time"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/utils/credential"
"xorm.io/xorm"
)
const (
Digits = 6
PeriodSeconds = 30
EnrollmentTTL = 10 * time.Minute
maxEnrollmentEntries = 10000
secretSize = 32
)
var (
ErrUnavailable = errors.New("totp service unavailable")
ErrInvalidCode = errors.New("invalid totp code")
ErrReplay = errors.New("totp code replayed")
ErrNotEnabled = errors.New("totp is not enabled")
ErrAlreadyEnabled = errors.New("totp is already enabled")
ErrEnrollmentExpired = errors.New("totp enrollment expired")
)
type Enrollment struct {
ID string `json:"id"`
Secret string `json:"secret"`
URI string `json:"uri"`
ExpiresAt time.Time `json:"expires_at"`
}
type enrollmentItem struct {
userID int
secret []byte
expiresAt time.Time
failures int
}
type Service struct {
engine *xorm.Engine
root string
now func() time.Time
random io.Reader
mu sync.Mutex
enrollments map[string]enrollmentItem
}
func NewService(engine *xorm.Engine, vaultRoot string) (*Service, error) {
if engine == nil || strings.TrimSpace(vaultRoot) == "" {
return nil, ErrUnavailable
}
if _, err := credential.NewVault(vaultRoot); err != nil {
return nil, ErrUnavailable
}
return &Service{
engine: engine,
root: vaultRoot,
now: time.Now,
random: rand.Reader,
enrollments: make(map[string]enrollmentItem),
}, nil
}
func (s *Service) IsEnabled(userID int) (bool, error) {
if s == nil || s.engine == nil || userID <= 0 {
return false, ErrUnavailable
}
var record models.TOTPAuthenticator
has, err := s.engine.Where("user_id = ? AND enabled = ? AND revoked_at IS NULL", userID, true).Get(&record)
if err != nil {
return false, fmt.Errorf("load totp authenticator: %w", err)
}
return has, nil
}
func (s *Service) BeginEnrollment(userID int, account string) (*Enrollment, error) {
if s == nil || s.engine == nil || userID <= 0 || strings.TrimSpace(account) == "" {
return nil, ErrUnavailable
}
enabled, err := s.IsEnabled(userID)
if err != nil {
return nil, err
}
if enabled {
return nil, ErrAlreadyEnabled
}
secret := make([]byte, secretSize)
if _, err := io.ReadFull(s.random, secret); err != nil {
return nil, ErrUnavailable
}
idBytes := make([]byte, 32)
if _, err := io.ReadFull(s.random, idBytes); err != nil {
clear(secret)
return nil, ErrUnavailable
}
id := base64.RawURLEncoding.EncodeToString(idBytes)
expiresAt := s.now().UTC().Add(EnrollmentTTL)
s.mu.Lock()
s.cleanupEnrollmentsLocked(s.now().UTC())
if len(s.enrollments) >= maxEnrollmentEntries {
s.mu.Unlock()
clear(secret)
return nil, ErrUnavailable
}
for existingID, item := range s.enrollments {
if item.userID == userID {
clear(item.secret)
delete(s.enrollments, existingID)
}
}
s.enrollments[id] = enrollmentItem{userID: userID, secret: secret, expiresAt: expiresAt}
s.mu.Unlock()
encodedSecret := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(secret)
return &Enrollment{
ID: id,
Secret: encodedSecret,
URI: provisioningURI(account, encodedSecret),
ExpiresAt: expiresAt,
}, nil
}
func (s *Service) ConfirmEnrollment(userID int, enrollmentID string, code string) error {
if s == nil || userID <= 0 || strings.TrimSpace(enrollmentID) == "" {
return ErrEnrollmentExpired
}
now := s.now().UTC()
s.mu.Lock()
s.cleanupEnrollmentsLocked(now)
item, ok := s.enrollments[enrollmentID]
if !ok || item.userID != userID {
s.mu.Unlock()
return ErrEnrollmentExpired
}
step, valid := validateAt(item.secret, code, now, 1)
if !valid {
item.failures++
if item.failures >= 5 {
clear(item.secret)
delete(s.enrollments, enrollmentID)
} else {
s.enrollments[enrollmentID] = item
}
s.mu.Unlock()
return ErrInvalidCode
}
secret := append([]byte(nil), item.secret...)
clear(item.secret)
delete(s.enrollments, enrollmentID)
s.mu.Unlock()
defer clear(secret)
secretText := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(secret)
ciphertext, err := credential.Encrypt(s.root, secretText)
if err != nil {
return ErrUnavailable
}
confirmedAt := now
record := models.TOTPAuthenticator{
UserID: userID,
SecretCiphertext: ciphertext,
CredentialVersion: int(credential.CurrentVersion),
Enabled: true,
ConfirmedAt: &confirmedAt,
LastUsedStep: step,
CreatedAt: now,
UpdatedAt: now,
}
var existing models.TOTPAuthenticator
has, err := s.engine.Where("user_id = ?", userID).Get(&existing)
if err != nil {
return fmt.Errorf("load totp authenticator: %w", err)
}
if has && existing.Enabled && existing.RevokedAt == nil {
return ErrAlreadyEnabled
}
if has {
record.ID = existing.ID
record.CreatedAt = existing.CreatedAt
_, err = s.engine.ID(existing.ID).Cols("secret_ciphertext", "credential_version", "enabled", "confirmed_at", "last_used_step", "updated_at", "revoked_at").Update(&record)
} else {
_, err = s.engine.Insert(&record)
}
if err != nil {
return fmt.Errorf("save totp authenticator: %w", err)
}
return nil
}
func (s *Service) Verify(userID int, code string) error {
if s == nil || s.engine == nil || userID <= 0 {
return ErrUnavailable
}
var record models.TOTPAuthenticator
has, err := s.engine.Where("user_id = ? AND enabled = ? AND revoked_at IS NULL", userID, true).Get(&record)
if err != nil {
return fmt.Errorf("load totp authenticator: %w", err)
}
if !has {
return ErrNotEnabled
}
secretText, err := credential.Decrypt(s.root, record.SecretCiphertext)
if err != nil {
return ErrUnavailable
}
secret, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(secretText))
if err != nil || len(secret) == 0 {
return ErrUnavailable
}
defer clear(secret)
step, valid := validateAt(secret, code, s.now().UTC(), 1)
if !valid {
return ErrInvalidCode
}
if step <= record.LastUsedStep {
return ErrReplay
}
updated, err := s.engine.Table(&models.TOTPAuthenticator{}).
Where("id = ? AND enabled = ? AND revoked_at IS NULL AND last_used_step < ?", record.ID, true, step).
Cols("last_used_step", "updated_at").
Update(&models.TOTPAuthenticator{LastUsedStep: step, UpdatedAt: s.now().UTC()})
if err != nil {
return fmt.Errorf("update totp authenticator: %w", err)
}
if updated != 1 {
return ErrReplay
}
return nil
}
func (s *Service) Disable(userID int, code string) error {
if err := s.Verify(userID, code); err != nil {
return err
}
now := s.now().UTC()
updated, err := s.engine.Table(&models.TOTPAuthenticator{}).
Where("user_id = ? AND enabled = ? AND revoked_at IS NULL", userID, true).
Cols("enabled", "revoked_at", "updated_at").
Update(&models.TOTPAuthenticator{Enabled: false, RevokedAt: &now, UpdatedAt: now})
if err != nil {
return fmt.Errorf("disable totp authenticator: %w", err)
}
if updated != 1 {
return ErrNotEnabled
}
return nil
}
func (s *Service) InvalidateUser(userID int) {
if s == nil || userID <= 0 {
return
}
s.mu.Lock()
for id, item := range s.enrollments {
if item.userID == userID {
clear(item.secret)
delete(s.enrollments, id)
}
}
s.mu.Unlock()
}
func (s *Service) cleanupEnrollmentsLocked(now time.Time) {
for id, item := range s.enrollments {
if !now.Before(item.expiresAt) {
clear(item.secret)
delete(s.enrollments, id)
}
}
}
func provisioningURI(account string, secret string) string {
label := "Smail:" + strings.TrimSpace(account)
query := url.Values{}
query.Set("secret", secret)
query.Set("issuer", "Smail")
query.Set("algorithm", "SHA256")
query.Set("digits", strconv.Itoa(Digits))
query.Set("period", strconv.Itoa(PeriodSeconds))
return "otpauth://totp/" + url.PathEscape(label) + "?" + query.Encode()
}
func validateAt(secret []byte, code string, now time.Time, window int64) (int64, bool) {
if !validCodeFormat(code) || len(secret) == 0 {
return 0, false
}
current := now.Unix() / PeriodSeconds
for offset := -window; offset <= window; offset++ {
step := current + offset
if step < 0 {
continue
}
expected := generateCode(secret, uint64(step))
if hmac.Equal([]byte(expected), []byte(code)) {
return step, true
}
}
return 0, false
}
func validCodeFormat(code string) bool {
if len(code) != Digits {
return false
}
for index := range code {
if code[index] < '0' || code[index] > '9' {
return false
}
}
return true
}
func generateCode(secret []byte, step uint64) string {
counter := make([]byte, 8)
binary.BigEndian.PutUint64(counter, step)
mac := hmac.New(sha256.New, secret)
_, _ = mac.Write(counter)
digest := mac.Sum(nil)
offset := digest[len(digest)-1] & 0x0f
binaryCode := binary.BigEndian.Uint32(digest[offset:offset+4]) & 0x7fffffff
return fmt.Sprintf("%0*d", Digits, binaryCode%1000000)
}