Files
archivmail/internal/auth/auth_test.go
T
sysops 2de340573b fix(security): behebe F-01/F-02/W-03/W-04 aus Security-Audit + PROJ-24 TOTP 2FA
F-01: err.Error() wird nicht mehr an HTTP-Clients gesendet.
      Stattdessen generische Fehlermeldungen + Server-Log.
      Betrifft: handleCreateUser, handleUpdateUser, handleDeleteUser,
                handleSyncNow, handleSecurityConfig, handleUpload.

F-02: Login-Audit-Log enthält keinen rohen err.Error() mehr.
      Neue classifyLoginError() Funktion: invalid_password / ldap_error /
      account_disabled / unknown — schützt vor LDAP-Info-Leak via Audit-API.

W-03: remoteIP() trimmt jetzt Leerzeichen aus X-Forwarded-For.
      Vollständige Lösung erfordert Proxy-Konfiguration (W-03 bleibt WARN).

W-04: Attachment-Dateiname wird durch sanitizeFilename() bereinigt.
      Nur [a-zA-Z0-9._- ] erlaubt — verhindert Header-Injection.

PROJ-24: TOTP 2FA vollständig implementiert:
      - internal/auth/totp.go: GenerateSecret, ValidateTOTP, QRCodeSVG
      - internal/api/totp_handlers.go: Setup, Login-Step2, Admin-Reset
      - internal/userstore: SetTOTPSecret, EnableTOTP, DisableTOTP, ResetTOTP
      - Login-Flow: totp_pending JWT → /api/auth/totp → vollwertiger JWT
      - AES-256-GCM verschlüsseltes Secret in users.totp_secret

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:54:00 +01:00

165 lines
4.1 KiB
Go

package auth_test
import (
"path/filepath"
"testing"
"github.com/archivmail/internal/auth"
"github.com/archivmail/internal/userstore"
)
func newTestAuth(t *testing.T) (*auth.Manager, *userstore.Store) {
t.Helper()
store, err := userstore.New(filepath.Join(t.TempDir(), "users.db"))
if err != nil {
t.Fatalf("userstore.New: %v", err)
}
t.Cleanup(func() { store.Close() })
// Seed a test user
store.Create(userstore.CreateUserRequest{
Username: "testadmin",
Email: "admin@example.com",
Password: "adminpass",
Role: userstore.RoleAdmin,
})
store.Create(userstore.CreateUserRequest{
Username: "regularuser",
Email: "user@example.com",
Password: "userpass",
Role: userstore.RoleUser,
})
mgr := auth.New(store, nil, "test-jwt-secret-32chars-long-enough", "0000000000000000000000000000000000000000000000000000000000000000")
return mgr, store
}
func TestLoginSuccess(t *testing.T) {
mgr, _ := newTestAuth(t)
token, user, totpRequired, err := mgr.Login("testadmin", "adminpass")
if err != nil {
t.Fatalf("Login: %v", err)
}
if totpRequired {
t.Error("expected totpRequired=false for user without TOTP")
}
if token == "" {
t.Error("expected non-empty token")
}
if user.Username != "testadmin" {
t.Errorf("Username = %q", user.Username)
}
if user.Role != userstore.RoleAdmin {
t.Errorf("Role = %q", user.Role)
}
}
func TestLoginWrongPassword(t *testing.T) {
mgr, _ := newTestAuth(t)
if _, _, _, err := mgr.Login("testadmin", "wrongpass"); err == nil {
t.Error("expected error for wrong password")
}
}
func TestLoginUnknownUser(t *testing.T) {
mgr, _ := newTestAuth(t)
if _, _, _, err := mgr.Login("nobody", "pw"); err == nil {
t.Error("expected error for unknown user")
}
}
func TestTokenValidation(t *testing.T) {
mgr, _ := newTestAuth(t)
token, _, _, _ := mgr.Login("testadmin", "adminpass")
sess, err := mgr.ValidateToken(token)
if err != nil {
t.Fatalf("ValidateToken: %v", err)
}
if sess.Username != "testadmin" {
t.Errorf("Session Username = %q", sess.Username)
}
if sess.Role != userstore.RoleAdmin {
t.Errorf("Session Role = %q", sess.Role)
}
if sess.JTI == "" {
t.Error("Session JTI should not be empty")
}
}
func TestTokenTampering(t *testing.T) {
mgr, _ := newTestAuth(t)
token, _, _, _ := mgr.Login("testadmin", "adminpass")
tampered := token + "x"
if _, err := mgr.ValidateToken(tampered); err == nil {
t.Error("tampered token should fail validation")
}
}
func TestLogout(t *testing.T) {
mgr, _ := newTestAuth(t)
token, _, _, _ := mgr.Login("testadmin", "adminpass")
// Token valid before logout
if _, err := mgr.ValidateToken(token); err != nil {
t.Fatalf("token should be valid before logout: %v", err)
}
if err := mgr.Logout(token); err != nil {
t.Fatalf("Logout: %v", err)
}
// Token invalid after logout
if _, err := mgr.ValidateToken(token); err == nil {
t.Error("token should be invalid after logout")
}
}
func TestHasRole(t *testing.T) {
tests := []struct {
userRole string
required string
want bool
}{
{userstore.RoleAdmin, userstore.RoleAdmin, true},
{userstore.RoleAdmin, userstore.RoleAuditor, true},
{userstore.RoleAdmin, userstore.RoleUser, true},
{userstore.RoleAuditor, userstore.RoleAdmin, false},
{userstore.RoleAuditor, userstore.RoleAuditor, true},
{userstore.RoleAuditor, userstore.RoleUser, true},
{userstore.RoleUser, userstore.RoleAdmin, false},
{userstore.RoleUser, userstore.RoleAuditor, false},
{userstore.RoleUser, userstore.RoleUser, true},
}
for _, tt := range tests {
got := auth.HasRole(tt.userRole, tt.required)
if got != tt.want {
t.Errorf("HasRole(%q, %q) = %v, want %v", tt.userRole, tt.required, got, tt.want)
}
}
}
func TestMultipleSessionsIndependent(t *testing.T) {
mgr, _ := newTestAuth(t)
token1, _, _, _ := mgr.Login("testadmin", "adminpass")
token2, _, _, _ := mgr.Login("testadmin", "adminpass")
if token1 == token2 {
t.Error("two logins should produce different tokens (different JTIs)")
}
// Logout session 1, session 2 should still work
mgr.Logout(token1)
if _, err := mgr.ValidateToken(token2); err != nil {
t.Errorf("session 2 should still be valid after session 1 logout: %v", err)
}
}