refactor(security): move all database access into pkg/security/lookup

pkg/security no longer contains SQL. Every provider calls a store interface
from lookup, implemented by a procedure backend (Postgres stored procedures,
the default there) and a direct backend (dialect-driven SQL for postgres,
sqlite, mysql and mssql with configurable table and column names).

- add sectypes, lookup, lookup/{dialect,procedure,direct,backends,ddl,conformance}
- split totp and providers sub packages out of the core package
- replace SQLNames/TableNames/QueryMode with lookup.Config (see breaking_changes.md)
- direct backend now covers column/row security and API-key login
- move txsettings SQL to lookup.ApplyTxSettings; remove password.go
- move schema scripts under lookup/, add reference DDL per dialect
- add a shared conformance suite; run it on sqlite, and on Postgres in a
  podman/docker container (RESOLVESPEC_TEST_CONTAINERS=1)
- fix procedure schema bugs found on real Postgres: duplicate p_data
  parameter, JSON null arrays, expires_at timezone casts, passkey list
  GROUP BY, missing resolvespec_passkey_login; accept zone-less timestamps
This commit is contained in:
Hein
2026-10-01 13:19:44 +02:00
parent 60bd0a6dd3
commit c9fa8c60f2
118 changed files with 11218 additions and 5565 deletions
+143
View File
@@ -0,0 +1,143 @@
package totp
import (
"context"
"fmt"
"github.com/bitechdev/ResolveSpec/pkg/security/sectypes"
"net/http"
)
// BaseAuthenticator is the subset of security.Authenticator that Authenticator wraps.
// It is declared here so totp does not import the core security package.
type BaseAuthenticator interface {
Login(ctx context.Context, req sectypes.LoginRequest) (*sectypes.LoginResponse, error)
Logout(ctx context.Context, req sectypes.LogoutRequest) error
Authenticate(r *http.Request) (*sectypes.UserContext, error)
}
// Authenticator wraps an Authenticator and adds 2FA support
type Authenticator struct {
baseAuth BaseAuthenticator
totp *Generator
provider AuthProvider
}
// NewAuthenticator creates a new 2FA-enabled authenticator
func NewAuthenticator(baseAuth BaseAuthenticator, provider AuthProvider, config *Config) *Authenticator {
if config == nil {
config = DefaultConfig()
}
return &Authenticator{
baseAuth: baseAuth,
totp: NewGenerator(config),
provider: provider,
}
}
// Login authenticates with 2FA support
func (t *Authenticator) Login(ctx context.Context, req sectypes.LoginRequest) (*sectypes.LoginResponse, error) {
// First, perform standard authentication
resp, err := t.baseAuth.Login(ctx, req)
if err != nil {
return nil, err
}
// Check if user has 2FA enabled
if resp.User == nil {
return resp, nil
}
has2FA, err := t.provider.Get2FAStatus(resp.User.UserID)
if err != nil {
return nil, fmt.Errorf("failed to check 2FA status: %w", err)
}
if !has2FA {
// User doesn't have 2FA enabled, return normal response
return resp, nil
}
// User has 2FA enabled
if req.TwoFactorCode == "" {
// No 2FA code provided, require it
resp.Requires2FA = true
resp.Token = "" // Don't return token until 2FA is verified
resp.RefreshToken = ""
return resp, nil
}
// Validate 2FA code
secret, err := t.provider.Get2FASecret(resp.User.UserID)
if err != nil {
return nil, fmt.Errorf("failed to get 2FA secret: %w", err)
}
// Try TOTP code first
valid, err := t.totp.ValidateCode(secret, req.TwoFactorCode)
if err != nil {
return nil, fmt.Errorf("failed to validate 2FA code: %w", err)
}
if !valid {
// Try backup code
valid, err = t.provider.ValidateBackupCode(resp.User.UserID, req.TwoFactorCode)
if err != nil {
return nil, fmt.Errorf("failed to validate backup code: %w", err)
}
}
if !valid {
return nil, fmt.Errorf("invalid 2FA code")
}
// 2FA verified, return full response with token
resp.User.TwoFactorEnabled = true
return resp, nil
}
// Logout delegates to base authenticator
func (t *Authenticator) Logout(ctx context.Context, req sectypes.LogoutRequest) error {
return t.baseAuth.Logout(ctx, req)
}
// Authenticate delegates to base authenticator
func (t *Authenticator) Authenticate(r *http.Request) (*sectypes.UserContext, error) {
return t.baseAuth.Authenticate(r)
}
// Setup2FA initiates 2FA setup for a user
func (t *Authenticator) Setup2FA(userID int, issuer, accountName string) (*sectypes.TwoFactorSecret, error) {
return t.provider.Generate2FASecret(userID, issuer, accountName)
}
// Enable2FA completes 2FA setup after user confirms with a valid code
func (t *Authenticator) Enable2FA(userID int, secret, verificationCode string) error {
// Verify the code before enabling
valid, err := t.totp.ValidateCode(secret, verificationCode)
if err != nil {
return fmt.Errorf("failed to validate code: %w", err)
}
if !valid {
return fmt.Errorf("invalid verification code")
}
// Generate backup codes
backupCodes, err := t.provider.GenerateBackupCodes(userID, 10)
if err != nil {
return fmt.Errorf("failed to generate backup codes: %w", err)
}
// Enable 2FA
return t.provider.Enable2FA(userID, secret, backupCodes)
}
// Disable2FA removes 2FA from a user account
func (t *Authenticator) Disable2FA(userID int) error {
return t.provider.Disable2FA(userID)
}
// RegenerateBackupCodes creates new backup codes for a user
func (t *Authenticator) RegenerateBackupCodes(userID int, count int) ([]string, error) {
return t.provider.GenerateBackupCodes(userID, count)
}
+411
View File
@@ -0,0 +1,411 @@
package totp_test
import (
"context"
"errors"
"net/http"
"testing"
"time"
"github.com/bitechdev/ResolveSpec/pkg/security/sectypes"
"github.com/bitechdev/ResolveSpec/pkg/security/totp"
)
var ErrInvalidCredentials = errors.New("invalid credentials")
// MockAuthenticator is a simple authenticator for testing 2FA
type MockAuthenticator struct {
users map[string]*sectypes.UserContext
}
func NewMockAuthenticator() *MockAuthenticator {
return &MockAuthenticator{
users: map[string]*sectypes.UserContext{
"testuser": {
UserID: 1,
UserName: "testuser",
Email: "test@example.com",
},
},
}
}
func (m *MockAuthenticator) Login(ctx context.Context, req sectypes.LoginRequest) (*sectypes.LoginResponse, error) {
user, exists := m.users[req.Username]
if !exists || req.Password != "password" {
return nil, ErrInvalidCredentials
}
return &sectypes.LoginResponse{
Token: "mock-token",
RefreshToken: "mock-refresh-token",
User: user,
ExpiresIn: 3600,
}, nil
}
func (m *MockAuthenticator) LoginWithCookie(ctx context.Context, req sectypes.LoginRequest, _ http.ResponseWriter) (*sectypes.LoginResponse, error) {
return m.Login(ctx, req)
}
func (m *MockAuthenticator) Logout(ctx context.Context, req sectypes.LogoutRequest) error {
return nil
}
func (m *MockAuthenticator) LogoutWithCookie(ctx context.Context, req sectypes.LogoutRequest, _ http.ResponseWriter) error {
return m.Logout(ctx, req)
}
func (m *MockAuthenticator) Authenticate(r *http.Request) (*sectypes.UserContext, error) {
return m.users["testuser"], nil
}
func (m *MockAuthenticator) SetAuthenticateCallback(_ func(r *http.Request) (*sectypes.UserContext, error)) {
}
func TestTwoFactorAuthenticator_Setup(t *testing.T) {
baseAuth := NewMockAuthenticator()
provider := totp.NewMemoryProvider(nil)
tfaAuth := totp.NewAuthenticator(baseAuth, provider, nil)
// Setup 2FA
secret, err := tfaAuth.Setup2FA(1, "TestApp", "test@example.com")
if err != nil {
t.Fatalf("Setup2FA() error = %v", err)
}
if secret.Secret == "" {
t.Error("Setup2FA() returned empty secret")
}
if secret.QRCodeURL == "" {
t.Error("Setup2FA() returned empty QR code URL")
}
if len(secret.BackupCodes) == 0 {
t.Error("Setup2FA() returned no backup codes")
}
if secret.Issuer != "TestApp" {
t.Errorf("Setup2FA() Issuer = %s, want TestApp", secret.Issuer)
}
if secret.AccountName != "test@example.com" {
t.Errorf("Setup2FA() AccountName = %s, want test@example.com", secret.AccountName)
}
}
func TestTwoFactorAuthenticator_Enable2FA(t *testing.T) {
baseAuth := NewMockAuthenticator()
provider := totp.NewMemoryProvider(nil)
tfaAuth := totp.NewAuthenticator(baseAuth, provider, nil)
// Setup 2FA
secret, err := tfaAuth.Setup2FA(1, "TestApp", "test@example.com")
if err != nil {
t.Fatalf("Setup2FA() error = %v", err)
}
// Generate valid code
totp := totp.NewGenerator(nil)
code, err := totp.GenerateCode(secret.Secret, time.Now())
if err != nil {
t.Fatalf("GenerateCode() error = %v", err)
}
// Enable 2FA with valid code
err = tfaAuth.Enable2FA(1, secret.Secret, code)
if err != nil {
t.Errorf("Enable2FA() error = %v", err)
}
// Verify 2FA is enabled
status, err := provider.Get2FAStatus(1)
if err != nil {
t.Fatalf("Get2FAStatus() error = %v", err)
}
if !status {
t.Error("Enable2FA() did not enable 2FA")
}
}
func TestTwoFactorAuthenticator_Enable2FA_InvalidCode(t *testing.T) {
baseAuth := NewMockAuthenticator()
provider := totp.NewMemoryProvider(nil)
tfaAuth := totp.NewAuthenticator(baseAuth, provider, nil)
// Setup 2FA
secret, err := tfaAuth.Setup2FA(1, "TestApp", "test@example.com")
if err != nil {
t.Fatalf("Setup2FA() error = %v", err)
}
// Try to enable with invalid code
err = tfaAuth.Enable2FA(1, secret.Secret, "000000")
if err == nil {
t.Error("Enable2FA() should fail with invalid code")
}
// Verify 2FA is not enabled
status, _ := provider.Get2FAStatus(1)
if status {
t.Error("Enable2FA() should not enable 2FA with invalid code")
}
}
func TestTwoFactorAuthenticator_Login_Without2FA(t *testing.T) {
baseAuth := NewMockAuthenticator()
provider := totp.NewMemoryProvider(nil)
tfaAuth := totp.NewAuthenticator(baseAuth, provider, nil)
req := sectypes.LoginRequest{
Username: "testuser",
Password: "password",
}
resp, err := tfaAuth.Login(context.Background(), req)
if err != nil {
t.Fatalf("Login() error = %v", err)
}
if resp.Requires2FA {
t.Error("Login() should not require 2FA when not enabled")
}
if resp.Token == "" {
t.Error("Login() should return token when 2FA not required")
}
}
func TestTwoFactorAuthenticator_Login_With2FA_NoCode(t *testing.T) {
baseAuth := NewMockAuthenticator()
provider := totp.NewMemoryProvider(nil)
tfaAuth := totp.NewAuthenticator(baseAuth, provider, nil)
// Setup and enable 2FA
secret, _ := tfaAuth.Setup2FA(1, "TestApp", "test@example.com")
totp := totp.NewGenerator(nil)
code, _ := totp.GenerateCode(secret.Secret, time.Now())
tfaAuth.Enable2FA(1, secret.Secret, code)
// Try to login without 2FA code
req := sectypes.LoginRequest{
Username: "testuser",
Password: "password",
}
resp, err := tfaAuth.Login(context.Background(), req)
if err != nil {
t.Fatalf("Login() error = %v", err)
}
if !resp.Requires2FA {
t.Error("Login() should require 2FA when enabled")
}
if resp.Token != "" {
t.Error("Login() should not return token when 2FA required but not provided")
}
}
func TestTwoFactorAuthenticator_Login_With2FA_ValidCode(t *testing.T) {
baseAuth := NewMockAuthenticator()
provider := totp.NewMemoryProvider(nil)
tfaAuth := totp.NewAuthenticator(baseAuth, provider, nil)
// Setup and enable 2FA
secret, _ := tfaAuth.Setup2FA(1, "TestApp", "test@example.com")
totp := totp.NewGenerator(nil)
code, _ := totp.GenerateCode(secret.Secret, time.Now())
tfaAuth.Enable2FA(1, secret.Secret, code)
// Generate new valid code for login
newCode, _ := totp.GenerateCode(secret.Secret, time.Now())
// Login with 2FA code
req := sectypes.LoginRequest{
Username: "testuser",
Password: "password",
TwoFactorCode: newCode,
}
resp, err := tfaAuth.Login(context.Background(), req)
if err != nil {
t.Fatalf("Login() error = %v", err)
}
if resp.Requires2FA {
t.Error("Login() should not require 2FA when valid code provided")
}
if resp.Token == "" {
t.Error("Login() should return token when 2FA validated")
}
if !resp.User.TwoFactorEnabled {
t.Error("Login() should set TwoFactorEnabled on user")
}
}
func TestTwoFactorAuthenticator_Login_With2FA_InvalidCode(t *testing.T) {
baseAuth := NewMockAuthenticator()
provider := totp.NewMemoryProvider(nil)
tfaAuth := totp.NewAuthenticator(baseAuth, provider, nil)
// Setup and enable 2FA
secret, _ := tfaAuth.Setup2FA(1, "TestApp", "test@example.com")
totp := totp.NewGenerator(nil)
code, _ := totp.GenerateCode(secret.Secret, time.Now())
tfaAuth.Enable2FA(1, secret.Secret, code)
// Try to login with invalid code
req := sectypes.LoginRequest{
Username: "testuser",
Password: "password",
TwoFactorCode: "000000",
}
_, err := tfaAuth.Login(context.Background(), req)
if err == nil {
t.Error("Login() should fail with invalid 2FA code")
}
}
func TestTwoFactorAuthenticator_Login_WithBackupCode(t *testing.T) {
baseAuth := NewMockAuthenticator()
provider := totp.NewMemoryProvider(nil)
tfaAuth := totp.NewAuthenticator(baseAuth, provider, nil)
// Setup and enable 2FA
secret, _ := tfaAuth.Setup2FA(1, "TestApp", "test@example.com")
totp := totp.NewGenerator(nil)
code, _ := totp.GenerateCode(secret.Secret, time.Now())
tfaAuth.Enable2FA(1, secret.Secret, code)
// Get backup codes
backupCodes, _ := tfaAuth.RegenerateBackupCodes(1, 10)
// Login with backup code
req := sectypes.LoginRequest{
Username: "testuser",
Password: "password",
TwoFactorCode: backupCodes[0],
}
resp, err := tfaAuth.Login(context.Background(), req)
if err != nil {
t.Fatalf("Login() with backup code error = %v", err)
}
if resp.Token == "" {
t.Error("Login() should return token when backup code validated")
}
// Try to use same backup code again
req2 := sectypes.LoginRequest{
Username: "testuser",
Password: "password",
TwoFactorCode: backupCodes[0],
}
_, err = tfaAuth.Login(context.Background(), req2)
if err == nil {
t.Error("Login() should fail when reusing backup code")
}
}
func TestTwoFactorAuthenticator_Disable2FA(t *testing.T) {
baseAuth := NewMockAuthenticator()
provider := totp.NewMemoryProvider(nil)
tfaAuth := totp.NewAuthenticator(baseAuth, provider, nil)
// Setup and enable 2FA
secret, _ := tfaAuth.Setup2FA(1, "TestApp", "test@example.com")
totp := totp.NewGenerator(nil)
code, _ := totp.GenerateCode(secret.Secret, time.Now())
tfaAuth.Enable2FA(1, secret.Secret, code)
// Disable 2FA
err := tfaAuth.Disable2FA(1)
if err != nil {
t.Errorf("Disable2FA() error = %v", err)
}
// Verify 2FA is disabled
status, _ := provider.Get2FAStatus(1)
if status {
t.Error("Disable2FA() did not disable 2FA")
}
// Login should not require 2FA
req := sectypes.LoginRequest{
Username: "testuser",
Password: "password",
}
resp, err := tfaAuth.Login(context.Background(), req)
if err != nil {
t.Fatalf("Login() error = %v", err)
}
if resp.Requires2FA {
t.Error("Login() should not require 2FA after disabling")
}
}
func TestTwoFactorAuthenticator_RegenerateBackupCodes(t *testing.T) {
baseAuth := NewMockAuthenticator()
provider := totp.NewMemoryProvider(nil)
tfaAuth := totp.NewAuthenticator(baseAuth, provider, nil)
// Setup and enable 2FA
secret, _ := tfaAuth.Setup2FA(1, "TestApp", "test@example.com")
totp := totp.NewGenerator(nil)
code, _ := totp.GenerateCode(secret.Secret, time.Now())
tfaAuth.Enable2FA(1, secret.Secret, code)
// Get initial backup codes
codes1, err := tfaAuth.RegenerateBackupCodes(1, 10)
if err != nil {
t.Fatalf("RegenerateBackupCodes() error = %v", err)
}
if len(codes1) != 10 {
t.Errorf("RegenerateBackupCodes() returned %d codes, want 10", len(codes1))
}
// Regenerate backup codes
codes2, err := tfaAuth.RegenerateBackupCodes(1, 10)
if err != nil {
t.Fatalf("RegenerateBackupCodes() error = %v", err)
}
// Old codes should not work
req := sectypes.LoginRequest{
Username: "testuser",
Password: "password",
TwoFactorCode: codes1[0],
}
_, err = tfaAuth.Login(context.Background(), req)
if err == nil {
t.Error("Login() should fail with old backup code after regeneration")
}
// New codes should work
req2 := sectypes.LoginRequest{
Username: "testuser",
Password: "password",
TwoFactorCode: codes2[0],
}
resp, err := tfaAuth.Login(context.Background(), req2)
if err != nil {
t.Fatalf("Login() with new backup code error = %v", err)
}
if resp.Token == "" {
t.Error("Login() should return token with new backup code")
}
}
+157
View File
@@ -0,0 +1,157 @@
package totp
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"github.com/bitechdev/ResolveSpec/pkg/security/sectypes"
"sync"
)
// MemoryProvider is an in-memory implementation of AuthProvider for testing/examples
type MemoryProvider struct {
mu sync.RWMutex
secrets map[int]string // userID -> secret
backupCodes map[int]map[string]bool // userID -> backup codes (code -> used)
totpGen *Generator
}
// NewMemoryProvider creates a new in-memory 2FA provider
func NewMemoryProvider(config *Config) *MemoryProvider {
if config == nil {
config = DefaultConfig()
}
return &MemoryProvider{
secrets: make(map[int]string),
backupCodes: make(map[int]map[string]bool),
totpGen: NewGenerator(config),
}
}
// Generate2FASecret creates a new secret for a user
func (m *MemoryProvider) Generate2FASecret(userID int, issuer, accountName string) (*sectypes.TwoFactorSecret, error) {
secret, err := m.totpGen.GenerateSecret()
if err != nil {
return nil, err
}
qrURL := m.totpGen.GenerateQRCodeURL(secret, issuer, accountName)
backupCodes, err := GenerateBackupCodes(10)
if err != nil {
return nil, err
}
return &sectypes.TwoFactorSecret{
Secret: secret,
QRCodeURL: qrURL,
BackupCodes: backupCodes,
Issuer: issuer,
AccountName: accountName,
}, nil
}
// Validate2FACode verifies a TOTP code
func (m *MemoryProvider) Validate2FACode(secret string, code string) (bool, error) {
return m.totpGen.ValidateCode(secret, code)
}
// Enable2FA activates 2FA for a user
func (m *MemoryProvider) Enable2FA(userID int, secret string, backupCodes []string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.secrets[userID] = secret
// Store backup codes
if m.backupCodes[userID] == nil {
m.backupCodes[userID] = make(map[string]bool)
}
for _, code := range backupCodes {
// Hash backup codes for security
hash := sha256.Sum256([]byte(code))
m.backupCodes[userID][hex.EncodeToString(hash[:])] = false
}
return nil
}
// Disable2FA deactivates 2FA for a user
func (m *MemoryProvider) Disable2FA(userID int) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.secrets, userID)
delete(m.backupCodes, userID)
return nil
}
// Get2FAStatus checks if user has 2FA enabled
func (m *MemoryProvider) Get2FAStatus(userID int) (bool, error) {
m.mu.RLock()
defer m.mu.RUnlock()
_, exists := m.secrets[userID]
return exists, nil
}
// Get2FASecret retrieves the user's 2FA secret
func (m *MemoryProvider) Get2FASecret(userID int) (string, error) {
m.mu.RLock()
defer m.mu.RUnlock()
secret, exists := m.secrets[userID]
if !exists {
return "", fmt.Errorf("user does not have 2FA enabled")
}
return secret, nil
}
// GenerateBackupCodes creates backup codes for 2FA
func (m *MemoryProvider) GenerateBackupCodes(userID int, count int) ([]string, error) {
codes, err := GenerateBackupCodes(count)
if err != nil {
return nil, err
}
m.mu.Lock()
defer m.mu.Unlock()
// Clear old backup codes and store new ones
m.backupCodes[userID] = make(map[string]bool)
for _, code := range codes {
hash := sha256.Sum256([]byte(code))
m.backupCodes[userID][hex.EncodeToString(hash[:])] = false
}
return codes, nil
}
// ValidateBackupCode checks and consumes a backup code
func (m *MemoryProvider) ValidateBackupCode(userID int, code string) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
userCodes, exists := m.backupCodes[userID]
if !exists {
return false, nil
}
// Hash the provided code
hash := sha256.Sum256([]byte(code))
hashStr := hex.EncodeToString(hash[:])
used, exists := userCodes[hashStr]
if !exists {
return false, nil
}
if used {
return false, fmt.Errorf("backup code already used")
}
// Mark as used
userCodes[hashStr] = true
return true, nil
}
+180
View File
@@ -0,0 +1,180 @@
package totp
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1" //nolint:gosec // G505: SHA-1 is required by RFC 6238/4226 HMAC-TOTP
"crypto/sha256"
"crypto/sha512"
"encoding/base32"
"encoding/binary"
"fmt"
"github.com/bitechdev/ResolveSpec/pkg/security/sectypes"
"hash"
"math"
"net/url"
"strings"
"time"
)
// AuthProvider defines interface for 2FA operations
type AuthProvider interface {
// Generate2FASecret creates a new secret for a user
Generate2FASecret(userID int, issuer, accountName string) (*sectypes.TwoFactorSecret, error)
// Validate2FACode verifies a TOTP code
Validate2FACode(secret string, code string) (bool, error)
// Enable2FA activates 2FA for a user (store secret in your database)
Enable2FA(userID int, secret string, backupCodes []string) error
// Disable2FA deactivates 2FA for a user
Disable2FA(userID int) error
// Get2FAStatus checks if user has 2FA enabled
Get2FAStatus(userID int) (bool, error)
// Get2FASecret retrieves the user's 2FA secret
Get2FASecret(userID int) (string, error)
// GenerateBackupCodes creates backup codes for 2FA
GenerateBackupCodes(userID int, count int) ([]string, error)
// ValidateBackupCode checks and consumes a backup code
ValidateBackupCode(userID int, code string) (bool, error)
}
// Config holds TOTP configuration
type Config struct {
Algorithm string // SHA1, SHA256, SHA512
Digits int // Number of digits in code (6 or 8)
Period int // Time step in seconds (default 30)
SkewWindow int // Number of time steps to check before/after (default 1)
}
// DefaultConfig returns standard TOTP configuration
func DefaultConfig() *Config {
return &Config{
Algorithm: "SHA1",
Digits: 6,
Period: 30,
SkewWindow: 1,
}
}
// Generator handles TOTP code generation and validation
type Generator struct {
config *Config
}
// NewGenerator creates a new TOTP generator with config
func NewGenerator(config *Config) *Generator {
if config == nil {
config = DefaultConfig()
}
return &Generator{
config: config,
}
}
// GenerateSecret creates a random base32-encoded secret
func (t *Generator) GenerateSecret() (string, error) {
secret := make([]byte, 20)
_, err := rand.Read(secret)
if err != nil {
return "", fmt.Errorf("failed to generate random secret: %w", err)
}
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(secret), nil
}
// GenerateQRCodeURL creates a URL for QR code generation
func (t *Generator) GenerateQRCodeURL(secret, issuer, accountName string) string {
params := url.Values{}
params.Set("secret", secret)
params.Set("issuer", issuer)
params.Set("algorithm", t.config.Algorithm)
params.Set("digits", fmt.Sprintf("%d", t.config.Digits))
params.Set("period", fmt.Sprintf("%d", t.config.Period))
label := url.PathEscape(fmt.Sprintf("%s:%s", issuer, accountName))
return fmt.Sprintf("otpauth://totp/%s?%s", label, params.Encode())
}
// GenerateCode creates a TOTP code for a given time
func (t *Generator) GenerateCode(secret string, timestamp time.Time) (string, error) {
// Decode secret
key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(secret))
if err != nil {
return "", fmt.Errorf("invalid secret: %w", err)
}
// Calculate counter (time steps since Unix epoch)
counter := uint64(timestamp.Unix()) / uint64(t.config.Period) //nolint:gosec // G115: value range bounded by caller/type, conversion intentional
// Generate HMAC
h := t.getHashFunc()
mac := hmac.New(h, key)
// Convert counter to 8-byte array
buf := make([]byte, 8)
binary.BigEndian.PutUint64(buf, counter)
mac.Write(buf)
sum := mac.Sum(nil)
// Dynamic truncation
offset := sum[len(sum)-1] & 0x0f
truncated := binary.BigEndian.Uint32(sum[offset:]) & 0x7fffffff
// Generate code with specified digits
code := truncated % uint32(math.Pow10(t.config.Digits))
format := fmt.Sprintf("%%0%dd", t.config.Digits)
return fmt.Sprintf(format, code), nil
}
// ValidateCode checks if a code is valid for the secret
func (t *Generator) ValidateCode(secret, code string) (bool, error) {
now := time.Now()
// Check current time and skew window
for i := -t.config.SkewWindow; i <= t.config.SkewWindow; i++ {
timestamp := now.Add(time.Duration(i*t.config.Period) * time.Second)
expected, err := t.GenerateCode(secret, timestamp)
if err != nil {
return false, err
}
if code == expected {
return true, nil
}
}
return false, nil
}
// getHashFunc returns the hash function based on algorithm
func (t *Generator) getHashFunc() func() hash.Hash {
switch strings.ToUpper(t.config.Algorithm) {
case "SHA256":
return sha256.New
case "SHA512":
return sha512.New
default:
return sha1.New
}
}
// GenerateBackupCodes creates random backup codes
func GenerateBackupCodes(count int) ([]string, error) {
codes := make([]string, count)
for i := 0; i < count; i++ {
code := make([]byte, 4)
_, err := rand.Read(code)
if err != nil {
return nil, fmt.Errorf("failed to generate backup code: %w", err)
}
codes[i] = fmt.Sprintf("%08X", binary.BigEndian.Uint32(code))
}
return codes, nil
}
+292
View File
@@ -0,0 +1,292 @@
package totp
import (
"strings"
"testing"
"time"
)
func TestTOTPGenerator_GenerateSecret(t *testing.T) {
totp := NewGenerator(nil)
secret, err := totp.GenerateSecret()
if err != nil {
t.Fatalf("GenerateSecret() error = %v", err)
}
if secret == "" {
t.Error("GenerateSecret() returned empty secret")
}
// Secret should be base32 encoded
if len(secret) < 16 {
t.Error("GenerateSecret() returned secret that is too short")
}
}
func TestTOTPGenerator_GenerateQRCodeURL(t *testing.T) {
totp := NewGenerator(nil)
secret := "JBSWY3DPEHPK3PXP"
issuer := "TestApp"
accountName := "user@example.com"
url := totp.GenerateQRCodeURL(secret, issuer, accountName)
if !strings.HasPrefix(url, "otpauth://totp/") {
t.Errorf("GenerateQRCodeURL() = %v, want otpauth://totp/ prefix", url)
}
if !strings.Contains(url, "secret="+secret) {
t.Errorf("GenerateQRCodeURL() missing secret parameter")
}
if !strings.Contains(url, "issuer="+issuer) {
t.Errorf("GenerateQRCodeURL() missing issuer parameter")
}
}
func TestTOTPGenerator_GenerateCode(t *testing.T) {
config := &Config{
Algorithm: "SHA1",
Digits: 6,
Period: 30,
SkewWindow: 1,
}
totp := NewGenerator(config)
secret := "JBSWY3DPEHPK3PXP"
// Test with known time
timestamp := time.Unix(1234567890, 0)
code, err := totp.GenerateCode(secret, timestamp)
if err != nil {
t.Fatalf("GenerateCode() error = %v", err)
}
if len(code) != 6 {
t.Errorf("GenerateCode() returned code with length %d, want 6", len(code))
}
// Code should be numeric
for _, c := range code {
if c < '0' || c > '9' {
t.Errorf("GenerateCode() returned non-numeric code: %s", code)
break
}
}
}
func TestTOTPGenerator_ValidateCode(t *testing.T) {
config := &Config{
Algorithm: "SHA1",
Digits: 6,
Period: 30,
SkewWindow: 1,
}
totp := NewGenerator(config)
secret := "JBSWY3DPEHPK3PXP"
// Generate a code for current time
now := time.Now()
code, err := totp.GenerateCode(secret, now)
if err != nil {
t.Fatalf("GenerateCode() error = %v", err)
}
// Validate the code
valid, err := totp.ValidateCode(secret, code)
if err != nil {
t.Fatalf("ValidateCode() error = %v", err)
}
if !valid {
t.Error("ValidateCode() = false, want true for current code")
}
// Test with invalid code
valid, err = totp.ValidateCode(secret, "000000")
if err != nil {
t.Fatalf("ValidateCode() error = %v", err)
}
// This might occasionally pass if 000000 is the correct code, but very unlikely
if valid && code != "000000" {
t.Error("ValidateCode() = true for invalid code")
}
}
func TestTOTPGenerator_ValidateCode_WithSkew(t *testing.T) {
config := &Config{
Algorithm: "SHA1",
Digits: 6,
Period: 30,
SkewWindow: 2, // Allow 2 periods before/after
}
totp := NewGenerator(config)
secret := "JBSWY3DPEHPK3PXP"
// Generate code for 1 period ago
past := time.Now().Add(-30 * time.Second)
code, err := totp.GenerateCode(secret, past)
if err != nil {
t.Fatalf("GenerateCode() error = %v", err)
}
// Should still validate with skew window
valid, err := totp.ValidateCode(secret, code)
if err != nil {
t.Fatalf("ValidateCode() error = %v", err)
}
if !valid {
t.Error("ValidateCode() = false, want true for code within skew window")
}
}
func TestTOTPGenerator_DifferentAlgorithms(t *testing.T) {
algorithms := []string{"SHA1", "SHA256", "SHA512"}
secret := "JBSWY3DPEHPK3PXP"
for _, algo := range algorithms {
t.Run(algo, func(t *testing.T) {
config := &Config{
Algorithm: algo,
Digits: 6,
Period: 30,
SkewWindow: 1,
}
totp := NewGenerator(config)
code, err := totp.GenerateCode(secret, time.Now())
if err != nil {
t.Fatalf("GenerateCode() with %s error = %v", algo, err)
}
valid, err := totp.ValidateCode(secret, code)
if err != nil {
t.Fatalf("ValidateCode() with %s error = %v", algo, err)
}
if !valid {
t.Errorf("ValidateCode() with %s = false, want true", algo)
}
})
}
}
func TestTOTPGenerator_8Digits(t *testing.T) {
config := &Config{
Algorithm: "SHA1",
Digits: 8,
Period: 30,
SkewWindow: 1,
}
totp := NewGenerator(config)
secret := "JBSWY3DPEHPK3PXP"
code, err := totp.GenerateCode(secret, time.Now())
if err != nil {
t.Fatalf("GenerateCode() error = %v", err)
}
if len(code) != 8 {
t.Errorf("GenerateCode() returned code with length %d, want 8", len(code))
}
valid, err := totp.ValidateCode(secret, code)
if err != nil {
t.Fatalf("ValidateCode() error = %v", err)
}
if !valid {
t.Error("ValidateCode() = false, want true for 8-digit code")
}
}
func TestGenerateBackupCodes(t *testing.T) {
count := 10
codes, err := GenerateBackupCodes(count)
if err != nil {
t.Fatalf("GenerateBackupCodes() error = %v", err)
}
if len(codes) != count {
t.Errorf("GenerateBackupCodes() returned %d codes, want %d", len(codes), count)
}
// Check uniqueness
seen := make(map[string]bool)
for _, code := range codes {
if seen[code] {
t.Errorf("GenerateBackupCodes() generated duplicate code: %s", code)
}
seen[code] = true
// Check format (8 hex characters)
if len(code) != 8 {
t.Errorf("GenerateBackupCodes() code length = %d, want 8", len(code))
}
}
}
func TestDefaultTwoFactorConfig(t *testing.T) {
config := DefaultConfig()
if config.Algorithm != "SHA1" {
t.Errorf("DefaultConfig() Algorithm = %s, want SHA1", config.Algorithm)
}
if config.Digits != 6 {
t.Errorf("DefaultConfig() Digits = %d, want 6", config.Digits)
}
if config.Period != 30 {
t.Errorf("DefaultConfig() Period = %d, want 30", config.Period)
}
if config.SkewWindow != 1 {
t.Errorf("DefaultConfig() SkewWindow = %d, want 1", config.SkewWindow)
}
}
func TestTOTPGenerator_InvalidSecret(t *testing.T) {
totp := NewGenerator(nil)
// Test with invalid base32 secret
_, err := totp.GenerateCode("INVALID!!!", time.Now())
if err == nil {
t.Error("GenerateCode() with invalid secret should return error")
}
_, err = totp.ValidateCode("INVALID!!!", "123456")
if err == nil {
t.Error("ValidateCode() with invalid secret should return error")
}
}
// Benchmark tests
func BenchmarkTOTPGenerator_GenerateCode(b *testing.B) {
totp := NewGenerator(nil)
secret := "JBSWY3DPEHPK3PXP"
now := time.Now()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = totp.GenerateCode(secret, now)
}
}
func BenchmarkTOTPGenerator_ValidateCode(b *testing.B) {
totp := NewGenerator(nil)
secret := "JBSWY3DPEHPK3PXP"
code, _ := totp.GenerateCode(secret, time.Now())
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = totp.ValidateCode(secret, code)
}
}