Some checks are pending
Security Checks / security (push) Waiting to run
统一 frontend、backend、test、test-log 和 out 目录,更新构建、部署及测试入口;纳入配置管理兼容与首次安全引导修复;排除本机配置、密钥和测试产物。
342 lines
8.9 KiB
Go
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)
|
|
}
|