feat(security): Add two-factor authentication support

* Implement TwoFactorAuthenticator for 2FA login.
* Create DatabaseTwoFactorProvider for PostgreSQL integration.
* Add MemoryTwoFactorProvider for in-memory testing.
* Develop TOTPGenerator for generating and validating codes.
* Include tests for all new functionalities.
* Ensure backup codes are securely hashed and validated.
This commit is contained in:
2026-01-31 22:45:28 +02:00
parent e11e6a8bf7
commit fdf9e118c5
10 changed files with 2060 additions and 21 deletions

View File

@@ -0,0 +1,399 @@
package security_test
import (
"context"
"errors"
"net/http"
"testing"
"time"
"github.com/bitechdev/ResolveSpec/pkg/security"
)
var ErrInvalidCredentials = errors.New("invalid credentials")
// MockAuthenticator is a simple authenticator for testing 2FA
type MockAuthenticator struct {
users map[string]*security.UserContext
}
func NewMockAuthenticator() *MockAuthenticator {
return &MockAuthenticator{
users: map[string]*security.UserContext{
"testuser": {
UserID: 1,
UserName: "testuser",
Email: "test@example.com",
},
},
}
}
func (m *MockAuthenticator) Login(ctx context.Context, req security.LoginRequest) (*security.LoginResponse, error) {
user, exists := m.users[req.Username]
if !exists || req.Password != "password" {
return nil, ErrInvalidCredentials
}
return &security.LoginResponse{
Token: "mock-token",
RefreshToken: "mock-refresh-token",
User: user,
ExpiresIn: 3600,
}, nil
}
func (m *MockAuthenticator) Logout(ctx context.Context, req security.LogoutRequest) error {
return nil
}
func (m *MockAuthenticator) Authenticate(r *http.Request) (*security.UserContext, error) {
return m.users["testuser"], nil
}
func TestTwoFactorAuthenticator_Setup(t *testing.T) {
baseAuth := NewMockAuthenticator()
provider := security.NewMemoryTwoFactorProvider(nil)
tfaAuth := security.NewTwoFactorAuthenticator(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 := security.NewMemoryTwoFactorProvider(nil)
tfaAuth := security.NewTwoFactorAuthenticator(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 := security.NewTOTPGenerator(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 := security.NewMemoryTwoFactorProvider(nil)
tfaAuth := security.NewTwoFactorAuthenticator(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 := security.NewMemoryTwoFactorProvider(nil)
tfaAuth := security.NewTwoFactorAuthenticator(baseAuth, provider, nil)
req := security.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 := security.NewMemoryTwoFactorProvider(nil)
tfaAuth := security.NewTwoFactorAuthenticator(baseAuth, provider, nil)
// Setup and enable 2FA
secret, _ := tfaAuth.Setup2FA(1, "TestApp", "test@example.com")
totp := security.NewTOTPGenerator(nil)
code, _ := totp.GenerateCode(secret.Secret, time.Now())
tfaAuth.Enable2FA(1, secret.Secret, code)
// Try to login without 2FA code
req := security.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 := security.NewMemoryTwoFactorProvider(nil)
tfaAuth := security.NewTwoFactorAuthenticator(baseAuth, provider, nil)
// Setup and enable 2FA
secret, _ := tfaAuth.Setup2FA(1, "TestApp", "test@example.com")
totp := security.NewTOTPGenerator(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 := security.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 := security.NewMemoryTwoFactorProvider(nil)
tfaAuth := security.NewTwoFactorAuthenticator(baseAuth, provider, nil)
// Setup and enable 2FA
secret, _ := tfaAuth.Setup2FA(1, "TestApp", "test@example.com")
totp := security.NewTOTPGenerator(nil)
code, _ := totp.GenerateCode(secret.Secret, time.Now())
tfaAuth.Enable2FA(1, secret.Secret, code)
// Try to login with invalid code
req := security.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 := security.NewMemoryTwoFactorProvider(nil)
tfaAuth := security.NewTwoFactorAuthenticator(baseAuth, provider, nil)
// Setup and enable 2FA
secret, _ := tfaAuth.Setup2FA(1, "TestApp", "test@example.com")
totp := security.NewTOTPGenerator(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 := security.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 := security.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 := security.NewMemoryTwoFactorProvider(nil)
tfaAuth := security.NewTwoFactorAuthenticator(baseAuth, provider, nil)
// Setup and enable 2FA
secret, _ := tfaAuth.Setup2FA(1, "TestApp", "test@example.com")
totp := security.NewTOTPGenerator(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 := security.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 := security.NewMemoryTwoFactorProvider(nil)
tfaAuth := security.NewTwoFactorAuthenticator(baseAuth, provider, nil)
// Setup and enable 2FA
secret, _ := tfaAuth.Setup2FA(1, "TestApp", "test@example.com")
totp := security.NewTOTPGenerator(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 := security.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 := security.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")
}
}