feat(mfa): TOTP secrets, recovery codes and factor lookup
This commit is contained in:
@@ -225,6 +225,7 @@ func serve() {
|
||||
log.Fatalf("failed to connect to Redis: %v", err)
|
||||
}
|
||||
log.Println("connected to Redis")
|
||||
services.RedisClient = auth.Redis()
|
||||
|
||||
// The bus carries agent commands and step results between replicas. It is
|
||||
// not optional even on a single-replica deployment: dispatch takes the same
|
||||
|
||||
@@ -23,12 +23,14 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect
|
||||
github.com/oklog/ulid/v2 v2.1.2 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/pquerna/otp v1.5.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.62.0 // indirect
|
||||
github.com/samber/lo v1.53.0 // indirect
|
||||
|
||||
@@ -6,6 +6,8 @@ github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986 h1:2a30
|
||||
github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986/go.mod h1:NT+jyeCzXk6vXR5MTkdn4z64TgGfE5HMLC8qfj5unl8=
|
||||
github.com/aquasecurity/trivy-db v0.0.0-20260813095258-0e0340a01b57 h1:A3Lz/9ip/qigafSxqBWcu7S8i+tJbQS7DB2V0XibOKs=
|
||||
github.com/aquasecurity/trivy-db v0.0.0-20260813095258-0e0340a01b57/go.mod h1:iIEV2oGuZScvfyX2SMIn78iVMNnepgo0QuJJh/srgVI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
@@ -100,6 +102,8 @@ github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwp
|
||||
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
|
||||
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
|
||||
@@ -22,6 +22,10 @@ type UserMFA struct {
|
||||
// vantage-shared's backup.ciphertextFields - change one, change the other.
|
||||
TOTPSecretEnc string `bson:"totp_secret_enc,omitempty" json:"-"`
|
||||
|
||||
// TOTPPendingEnc holds a secret from an unconfirmed StartTOTPSetup call.
|
||||
// Transient: never mirrored into vantage-shared's backup.ciphertextFields.
|
||||
TOTPPendingEnc string `bson:"totp_pending_enc,omitempty" json:"-"`
|
||||
|
||||
// TOTPConfirmedAt nil means setup was started but never confirmed, which
|
||||
// does not count as an enrolled factor.
|
||||
TOTPConfirmedAt *time.Time `bson:"totp_confirmed_at,omitempty" json:"totp_confirmed_at,omitempty"`
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base32"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoMFA = errors.New("no multi-factor authentication is enrolled")
|
||||
ErrBadCode = errors.New("that code is not valid")
|
||||
ErrCodeReplayed = errors.New("that code has already been used")
|
||||
)
|
||||
|
||||
// Factor names travel to the browser, which chooses which prompt to draw.
|
||||
const (
|
||||
FactorTOTP = "totp"
|
||||
FactorWebAuthn = "webauthn"
|
||||
FactorRecovery = "recovery"
|
||||
FactorPassword = "password"
|
||||
)
|
||||
|
||||
const recoveryCodeCount = 10
|
||||
|
||||
// RedisClient is set by main.go after auth.InitRedis succeeds. services must
|
||||
// not import auth: auth already imports services, and Go has no import
|
||||
// cycles.
|
||||
var RedisClient *redis.Client
|
||||
|
||||
func mfaCtx() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 5*time.Second)
|
||||
}
|
||||
|
||||
// GenerateRecoveryCodes returns the codes to show the user once, and the
|
||||
// hashed records to store. The plaintext is never persisted.
|
||||
func GenerateRecoveryCodes() ([]string, []models.RecoveryCode, error) {
|
||||
plain := make([]string, 0, recoveryCodeCount)
|
||||
stored := make([]models.RecoveryCode, 0, recoveryCodeCount)
|
||||
for i := 0; i < recoveryCodeCount; i++ {
|
||||
b := make([]byte, 5)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// Crockford-ish base32 without padding: 8 characters, no case to get
|
||||
// wrong when read off paper.
|
||||
code := strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b))
|
||||
plain = append(plain, code)
|
||||
stored = append(stored, models.RecoveryCode{Hash: HashRecoveryCode(code)})
|
||||
}
|
||||
return plain, stored, nil
|
||||
}
|
||||
|
||||
// NormaliseRecoveryCode makes a code typed off paper comparable: no case, no
|
||||
// spaces, no dashes.
|
||||
func NormaliseRecoveryCode(code string) string {
|
||||
r := strings.NewReplacer(" ", "", "-", "", "\t", "")
|
||||
return strings.ToLower(r.Replace(strings.TrimSpace(code)))
|
||||
}
|
||||
|
||||
func HashRecoveryCode(code string) string {
|
||||
sum := sha256.Sum256([]byte(NormaliseRecoveryCode(code)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// ConsumeRecoveryCode reports which unused code matches, if any. It does not
|
||||
// write: the caller marks the index used so the database update and the audit
|
||||
// event stay in one place.
|
||||
func ConsumeRecoveryCode(codes []models.RecoveryCode, input string, now time.Time) (int, bool) {
|
||||
want := HashRecoveryCode(input)
|
||||
for i, c := range codes {
|
||||
if c.UsedAt != nil {
|
||||
continue
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(c.Hash), []byte(want)) == 1 {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func GetUserMFA(instanceID, userID string) (*models.UserMFA, error) {
|
||||
ctx, cancel := mfaCtx()
|
||||
defer cancel()
|
||||
var m models.UserMFA
|
||||
err := db.Col("user_mfa").FindOne(ctx, bson.M{"instance_id": instanceID, "user_id": userID}).Decode(&m)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func CountPasskeys(instanceID, userID string) (int64, error) {
|
||||
ctx, cancel := mfaCtx()
|
||||
defer cancel()
|
||||
return db.Col("webauthn_credentials").CountDocuments(ctx, bson.M{"instance_id": instanceID, "user_id": userID})
|
||||
}
|
||||
|
||||
// hasFactor is the single definition of "this user has MFA". An unconfirmed
|
||||
// TOTP secret does not count: a half-finished setup must not lock anyone out.
|
||||
func hasFactor(m *models.UserMFA, passkeys int) bool {
|
||||
if passkeys > 0 {
|
||||
return true
|
||||
}
|
||||
return m != nil && m.TOTPConfirmedAt != nil
|
||||
}
|
||||
|
||||
// MFAMethods lists the factors a user can present, newest-friendly order. An
|
||||
// empty slice means they have no MFA at all.
|
||||
func MFAMethods(instanceID, userID string) ([]string, error) {
|
||||
m, err := GetUserMFA(instanceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
passkeys, err := CountPasskeys(instanceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
methods := []string{}
|
||||
if passkeys > 0 {
|
||||
methods = append(methods, FactorWebAuthn)
|
||||
}
|
||||
if m != nil && m.TOTPConfirmedAt != nil {
|
||||
methods = append(methods, FactorTOTP)
|
||||
}
|
||||
if len(methods) > 0 && m != nil {
|
||||
for _, c := range m.RecoveryCodes {
|
||||
if c.UsedAt == nil {
|
||||
methods = append(methods, FactorRecovery)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return methods, nil
|
||||
}
|
||||
|
||||
func HasMFA(instanceID, userID string) (bool, error) {
|
||||
m, err := GetUserMFA(instanceID, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
passkeys, err := CountPasskeys(instanceID, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return hasFactor(m, int(passkeys)), nil
|
||||
}
|
||||
|
||||
// WebAuthnHandle returns the user's stable random handle, creating the user_mfa
|
||||
// document on first use.
|
||||
func WebAuthnHandle(instanceID, userID string) ([]byte, error) {
|
||||
m, err := GetUserMFA(instanceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m != nil && len(m.WebAuthnHandle) > 0 {
|
||||
return m.WebAuthnHandle, nil
|
||||
}
|
||||
handle := make([]byte, 64)
|
||||
if _, err := rand.Read(handle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := mfaCtx()
|
||||
defer cancel()
|
||||
_, err = db.Col("user_mfa").UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "user_id": userID},
|
||||
bson.M{
|
||||
"$set": bson.M{"updated_at": time.Now()},
|
||||
"$setOnInsert": bson.M{"webauthn_handle": handle},
|
||||
},
|
||||
options.UpdateOne().SetUpsert(true))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Re-read: a concurrent caller may have won the upsert.
|
||||
m, err = GetUserMFA(instanceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.WebAuthnHandle, nil
|
||||
}
|
||||
|
||||
// StartTOTPSetup writes an unconfirmed secret and returns it with its otpauth
|
||||
// URI. An existing unconfirmed secret is replaced; a confirmed one is not
|
||||
// touched until ConfirmTOTP succeeds against the new secret.
|
||||
func StartTOTPSetup(instanceID, userID, issuer, account string) (string, string, error) {
|
||||
key, err := totp.Generate(totp.GenerateOpts{Issuer: issuer, AccountName: account})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
enc, err := encryptString(key.Secret())
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
ctx, cancel := mfaCtx()
|
||||
defer cancel()
|
||||
_, err = db.Col("user_mfa").UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "user_id": userID},
|
||||
bson.M{"$set": bson.M{"totp_pending_enc": enc, "updated_at": time.Now()}},
|
||||
options.UpdateOne().SetUpsert(true))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return key.Secret(), key.URL(), nil
|
||||
}
|
||||
|
||||
// ConfirmTOTP promotes the pending secret to the live one.
|
||||
func ConfirmTOTP(instanceID, userID, code string) error {
|
||||
ctx, cancel := mfaCtx()
|
||||
defer cancel()
|
||||
var raw struct {
|
||||
Pending string `bson:"totp_pending_enc"`
|
||||
}
|
||||
if err := db.Col("user_mfa").FindOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "user_id": userID}).Decode(&raw); err != nil {
|
||||
return ErrNoMFA
|
||||
}
|
||||
if raw.Pending == "" {
|
||||
return ErrNoMFA
|
||||
}
|
||||
secret, err := decryptString(raw.Pending)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !totp.Validate(code, secret) {
|
||||
return ErrBadCode
|
||||
}
|
||||
now := time.Now()
|
||||
_, err = db.Col("user_mfa").UpdateOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "user_id": userID},
|
||||
bson.M{
|
||||
"$set": bson.M{"totp_secret_enc": raw.Pending, "totp_confirmed_at": now, "updated_at": now},
|
||||
"$unset": bson.M{"totp_pending_enc": ""},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// VerifyTOTPCode checks a code against the confirmed secret and burns it, so
|
||||
// the same code cannot be replayed inside its 30-second window by an attacker
|
||||
// who shoulder-surfed it.
|
||||
func VerifyTOTPCode(instanceID, userID, code string) error {
|
||||
m, err := GetUserMFA(instanceID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if m == nil || m.TOTPConfirmedAt == nil || m.TOTPSecretEnc == "" {
|
||||
return ErrNoMFA
|
||||
}
|
||||
secret, err := decryptString(m.TOTPSecretEnc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
valid, err := totp.ValidateCustom(code, secret, time.Now(), totp.ValidateOpts{
|
||||
Period: 30, Skew: 1, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1,
|
||||
})
|
||||
if err != nil || !valid {
|
||||
return ErrBadCode
|
||||
}
|
||||
return burnTOTPCode(userID, code)
|
||||
}
|
||||
|
||||
// ClearMFA removes every factor. Used by an owner or admin reset.
|
||||
func ClearMFA(instanceID, userID string) error {
|
||||
ctx, cancel := mfaCtx()
|
||||
defer cancel()
|
||||
if _, err := db.Col("user_mfa").DeleteOne(ctx,
|
||||
bson.M{"instance_id": instanceID, "user_id": userID}); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := db.Col("webauthn_credentials").DeleteMany(ctx,
|
||||
bson.M{"instance_id": instanceID, "user_id": userID})
|
||||
return err
|
||||
}
|
||||
|
||||
// burnTOTPCode makes a code single-use for 90 seconds - longer than the +-1
|
||||
// step window it could still validate in. Redis is already required for
|
||||
// sessions.
|
||||
func burnTOTPCode(userID, code string) error {
|
||||
if RedisClient == nil {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := mfaCtx()
|
||||
defer cancel()
|
||||
key := "km:totp:" + userID + ":" + code
|
||||
ok, err := RedisClient.SetNX(ctx, key, 1, 90*time.Second).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrCodeReplayed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
)
|
||||
|
||||
func TestGenerateRecoveryCodesReturnsTenUniqueHashedCodes(t *testing.T) {
|
||||
plain, stored, err := GenerateRecoveryCodes()
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
if len(plain) != 10 || len(stored) != 10 {
|
||||
t.Fatalf("want 10 codes, got %d plain and %d stored", len(plain), len(stored))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i, p := range plain {
|
||||
if seen[p] {
|
||||
t.Fatalf("duplicate code %q", p)
|
||||
}
|
||||
seen[p] = true
|
||||
if stored[i].Hash == p {
|
||||
t.Fatal("code stored in plaintext")
|
||||
}
|
||||
if stored[i].Hash != HashRecoveryCode(p) {
|
||||
t.Fatalf("stored hash does not match HashRecoveryCode for code %d", i)
|
||||
}
|
||||
if stored[i].UsedAt != nil {
|
||||
t.Fatal("fresh code marked used")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRecoveryCodeIsSingleUseAndFormatTolerant(t *testing.T) {
|
||||
plain, stored, _ := GenerateRecoveryCodes()
|
||||
now := time.Now()
|
||||
|
||||
idx, ok := ConsumeRecoveryCode(stored, plain[3], now)
|
||||
if !ok || idx != 3 {
|
||||
t.Fatalf("want index 3 consumed, got idx=%d ok=%v", idx, ok)
|
||||
}
|
||||
used := now
|
||||
stored[3].UsedAt = &used
|
||||
|
||||
if _, ok := ConsumeRecoveryCode(stored, plain[3], now); ok {
|
||||
t.Fatal("a used recovery code was accepted a second time")
|
||||
}
|
||||
|
||||
// Users retype codes with different case and stray dashes or spaces.
|
||||
messy := " " + strings.ToUpper(plain[4]) + " "
|
||||
if _, ok := ConsumeRecoveryCode(stored, messy, now); !ok {
|
||||
t.Fatal("normalisation rejected a valid code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRecoveryCodeRejectsUnknownCode(t *testing.T) {
|
||||
_, stored, _ := GenerateRecoveryCodes()
|
||||
if _, ok := ConsumeRecoveryCode(stored, "not-a-real-code", time.Now()); ok {
|
||||
t.Fatal("unknown code accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasFactorRequiresConfirmedTOTPOrAPasskey(t *testing.T) {
|
||||
now := time.Now()
|
||||
cases := []struct {
|
||||
name string
|
||||
mfa *models.UserMFA
|
||||
passkeys int
|
||||
want bool
|
||||
}{
|
||||
{"nothing", nil, 0, false},
|
||||
{"unconfirmed totp only", &models.UserMFA{TOTPSecretEnc: "ab"}, 0, false},
|
||||
{"confirmed totp", &models.UserMFA{TOTPSecretEnc: "ab", TOTPConfirmedAt: &now}, 0, true},
|
||||
{"passkey only", &models.UserMFA{}, 1, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := hasFactor(tc.mfa, tc.passkeys); got != tc.want {
|
||||
t.Fatalf("hasFactor = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user