mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-05 21:06:28 +00:00
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:
@@ -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)
|
||||
}
|
||||
@@ -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 §ypes.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")
|
||||
}
|
||||
}
|
||||
@@ -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 §ypes.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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user