172 lines
6.2 KiB
Go
172 lines
6.2 KiB
Go
package totp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
)
|
|
|
|
func emailForUser(t *testing.T, users *user.TenantUserStore, userID string) string {
|
|
t.Helper()
|
|
u, err := users.Get(context.Background(), userID)
|
|
if err != nil {
|
|
t.Fatalf("benutzer laden: %v", err)
|
|
}
|
|
return u.Email
|
|
}
|
|
|
|
// authenticatedRequest baut einen Request mit echtem, gueltigem
|
|
// Session-Cookie (echter Issuer, echte Verifikation ueber auth.RequireAuth) —
|
|
// kein simulierter Context, dasselbe Muster wie internal/oidc (IAM-13).
|
|
func authenticatedRequest(t *testing.T, issuer *auth.TokenIssuer, userID string, req *http.Request) *http.Request {
|
|
t.Helper()
|
|
token, err := issuer.Issue(userID, "acme")
|
|
if err != nil {
|
|
t.Fatalf("session-token ausstellen: %v", err)
|
|
}
|
|
req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: token})
|
|
return req
|
|
}
|
|
|
|
// Akzeptanzkriterium 1 + Pruefung 1: Login ohne 2FA funktioniert normal,
|
|
// Login mit falschem Passwort liefert die generische Fehlermeldung.
|
|
func TestHandlerLogin_WithoutTOTP(t *testing.T) {
|
|
store, users, userID, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
|
|
login := auth.NewLoginService(users, auth.NewTokenIssuer("test-secret"), "acme")
|
|
h := NewHandler(users, store, login, "NEXARCH")
|
|
|
|
body, _ := json.Marshal(map[string]string{"email": emailForUser(t, users, userID), "password": "korrektes-passwort"})
|
|
req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
h.Login(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if len(rec.Result().Cookies()) == 0 {
|
|
t.Fatal("erwartet gesetztes session-cookie")
|
|
}
|
|
}
|
|
|
|
func TestHandlerLogin_WrongPassword_GivesGenericError(t *testing.T) {
|
|
store, users, userID, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
|
|
login := auth.NewLoginService(users, auth.NewTokenIssuer("test-secret"), "acme")
|
|
h := NewHandler(users, store, login, "NEXARCH")
|
|
|
|
body, _ := json.Marshal(map[string]string{"email": emailForUser(t, users, userID), "password": "falsches-passwort"})
|
|
req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
h.Login(rec, req)
|
|
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want 401", rec.Code)
|
|
}
|
|
var resp loginErrorResponse
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("response dekodieren: %v", err)
|
|
}
|
|
if resp.Error != "Anmeldedaten oder Code ungültig." {
|
|
t.Fatalf("erwartet generische Fehlermeldung, habe %q (koennte interne Details preisgeben)", resp.Error)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 1 + 2: mit aktivem 2FA verlangt Login den Code,
|
|
// Setup-Confirm liefert die Wiederherstellungscodes EINMALIG.
|
|
func TestHandlerLogin_WithTOTP_RequiresCode(t *testing.T) {
|
|
store, users, userID, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
|
|
secret, _, err := store.BeginSetup(ctx, userID, "NEXARCH", "user@example.com")
|
|
if err != nil {
|
|
t.Fatalf("begin setup: %v", err)
|
|
}
|
|
code, err := GenerateCode(secret, time.Now())
|
|
if err != nil {
|
|
t.Fatalf("code generieren: %v", err)
|
|
}
|
|
if _, err := store.ConfirmSetup(ctx, userID, code); err != nil {
|
|
t.Fatalf("confirm setup: %v", err)
|
|
}
|
|
|
|
login := auth.NewLoginService(users, auth.NewTokenIssuer("test-secret"), "acme")
|
|
h := NewHandler(users, store, login, "NEXARCH")
|
|
email := emailForUser(t, users, userID)
|
|
|
|
// Ohne Code: second_factor_required, kein Cookie.
|
|
body, _ := json.Marshal(map[string]string{"email": email, "password": "korrektes-passwort"})
|
|
req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
h.Login(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("ohne code: status = %d, want 401", rec.Code)
|
|
}
|
|
var resp loginErrorResponse
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
|
if resp.Code != "second_factor_required" {
|
|
t.Fatalf("erwartet code=second_factor_required, habe %q", resp.Code)
|
|
}
|
|
|
|
// Mit gueltigem Code: Login gelingt.
|
|
freshCode, _ := GenerateCode(secret, time.Now())
|
|
body2, _ := json.Marshal(map[string]string{"email": email, "password": "korrektes-passwort", "totp_code": freshCode})
|
|
req2 := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body2))
|
|
rec2 := httptest.NewRecorder()
|
|
h.Login(rec2, req2)
|
|
if rec2.Code != http.StatusOK {
|
|
t.Fatalf("mit code: status = %d, want 200, body: %s", rec2.Code, rec2.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandlerSetupConfirm_ReturnsRecoveryCodes(t *testing.T) {
|
|
store, users, userID, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
|
|
login := auth.NewLoginService(users, auth.NewTokenIssuer("test-secret"), "acme")
|
|
sessionIssuer := auth.NewTokenIssuer("test-session-secret")
|
|
h := NewHandler(users, store, login, "NEXARCH")
|
|
|
|
beginReq := authenticatedRequest(t, sessionIssuer, userID, httptest.NewRequest(http.MethodPost, "/auth/totp/setup/begin", nil))
|
|
beginRec := httptest.NewRecorder()
|
|
auth.RequireAuth(sessionIssuer, h.SetupBegin)(beginRec, beginReq)
|
|
if beginRec.Code != http.StatusOK {
|
|
t.Fatalf("setup begin: status = %d, body: %s", beginRec.Code, beginRec.Body.String())
|
|
}
|
|
var beginResp setupBeginResponse
|
|
if err := json.Unmarshal(beginRec.Body.Bytes(), &beginResp); err != nil {
|
|
t.Fatalf("begin-response dekodieren: %v", err)
|
|
}
|
|
|
|
code, err := GenerateCode(beginResp.Secret, time.Now())
|
|
if err != nil {
|
|
t.Fatalf("code generieren: %v", err)
|
|
}
|
|
confirmBody, _ := json.Marshal(setupConfirmRequest{Code: code})
|
|
confirmReq := authenticatedRequest(t, sessionIssuer, userID,
|
|
httptest.NewRequest(http.MethodPost, "/auth/totp/setup/confirm", bytes.NewReader(confirmBody)))
|
|
confirmRec := httptest.NewRecorder()
|
|
auth.RequireAuth(sessionIssuer, h.SetupConfirm)(confirmRec, confirmReq)
|
|
|
|
if confirmRec.Code != http.StatusOK {
|
|
t.Fatalf("setup confirm: status = %d, body: %s", confirmRec.Code, confirmRec.Body.String())
|
|
}
|
|
var confirmResp setupConfirmResponse
|
|
if err := json.Unmarshal(confirmRec.Body.Bytes(), &confirmResp); err != nil {
|
|
t.Fatalf("confirm-response dekodieren: %v", err)
|
|
}
|
|
if len(confirmResp.RecoveryCodes) != RecoveryCodeCount {
|
|
t.Fatalf("erwartet %d wiederherstellungscodes, habe %d", RecoveryCodeCount, len(confirmResp.RecoveryCodes))
|
|
}
|
|
}
|