v1.3.0 — 2FA-Login-Challenge
Mini-Sprint A des 2FA-Vollausbaus. Apps mit aktivem TOTP-2FA können
sich nativ einloggen. Komplett additiv.
AuthClient.Status um .twoFactorRequired(token, methods, email)
erweitert. signIn() erkennt automatisch den Server-Pfad
{twoFactorRequired: true, ...} und routet zum neuen Status.
Neue Methoden in AuthClient+Account:
- verifyTotp(code:trustDevice:) — 6-stellige Codes aus Authenticator-
App. Bei Erfolg .signedIn, bei Fehler bleibt Status im Challenge
(User kann retry mit anderem Code).
- verifyBackupCode(code:trustDevice:) — einmalige Codes als Fallback.
Wire-Format: Client schickt {code, twoFactorToken, trustDevice} an
/api/v1/auth/two-factor/verify-{totp,backup-code}. Server (mana-auth)
re-injectet den twoFactorToken als better-auth.two_factor-Cookie und
delegiert an Better Auths Plugin.
5 neue Tests, 59/59 grün.
Setzt mana-auth-Server mit den entsprechenden Custom-Endpoints
voraus — siehe gleichzeitiger Commit im mana-Repo.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
923b5d06b5
commit
7526b807da
4 changed files with 294 additions and 0 deletions
127
Tests/ManaCoreTests/AuthClientTwoFactorTests.swift
Normal file
127
Tests/ManaCoreTests/AuthClientTwoFactorTests.swift
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
@testable import ManaCore
|
||||
|
||||
@Suite("AuthClient 2FA-Login-Challenge")
|
||||
@MainActor
|
||||
struct AuthClientTwoFactorTests {
|
||||
@Test("signIn mit 2FA-Account → .twoFactorRequired statt .signedIn")
|
||||
func signInRedirectsToTwoFactor() async {
|
||||
let mocked = makeMockedAuth()
|
||||
mocked.setHandler { _ in
|
||||
(200, Data(#"""
|
||||
{"twoFactorRequired":true,"twoFactorMethods":["totp"],"twoFactorToken":"tf-abc123"}
|
||||
"""#.utf8))
|
||||
}
|
||||
|
||||
await mocked.auth.signIn(email: "u@x.de", password: "pw")
|
||||
if case let .twoFactorRequired(token, methods, email) = mocked.auth.status {
|
||||
#expect(token == "tf-abc123")
|
||||
#expect(methods == ["totp"])
|
||||
#expect(email == "u@x.de")
|
||||
} else {
|
||||
Issue.record("Expected .twoFactorRequired, got \(mocked.auth.status)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("verifyTotp erfolgreich → .signedIn")
|
||||
func verifyTotpSuccess() async throws {
|
||||
let mocked = makeMockedAuth()
|
||||
// Schritt 1: signIn liefert 2FA-Challenge.
|
||||
mocked.setHandler { _ in
|
||||
(200, Data(#"""
|
||||
{"twoFactorRequired":true,"twoFactorMethods":["totp"],"twoFactorToken":"tf-xyz"}
|
||||
"""#.utf8))
|
||||
}
|
||||
await mocked.auth.signIn(email: "u@x.de", password: "pw")
|
||||
|
||||
// Schritt 2: verifyTotp liefert Tokens.
|
||||
let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig"
|
||||
let captured = MockURLProtocol.Capture()
|
||||
mocked.setHandler { request in
|
||||
captured.store(request)
|
||||
return (200, Data(#"""
|
||||
{"success":true,"accessToken":"\#(access)","refreshToken":"r1"}
|
||||
"""#.utf8))
|
||||
}
|
||||
|
||||
try await mocked.auth.verifyTotp(code: "123456")
|
||||
#expect(mocked.auth.status == .signedIn(email: "u@x.de"))
|
||||
|
||||
let request = try #require(captured.request)
|
||||
#expect(request.url?.path == "/api/v1/auth/two-factor/verify-totp")
|
||||
// Body trägt code + twoFactorToken aus dem Challenge-Status
|
||||
let body = request.httpBody ?? request.bodyStreamData() ?? Data()
|
||||
let json = try JSONSerialization.jsonObject(with: body) as? [String: Any] ?? [:]
|
||||
#expect(json["code"] as? String == "123456")
|
||||
#expect(json["twoFactorToken"] as? String == "tf-xyz")
|
||||
}
|
||||
|
||||
@Test("verifyTotp ohne aktiven 2FA-Challenge wirft validation")
|
||||
func verifyTotpRequiresChallenge() async {
|
||||
let mocked = makeMockedAuth()
|
||||
// Status ist initial .unknown — kein 2FA-Challenge.
|
||||
do {
|
||||
try await mocked.auth.verifyTotp(code: "123456")
|
||||
Issue.record("Expected throw")
|
||||
} catch let AuthError.validation(message) {
|
||||
#expect(message?.contains("2FA-Challenge") == true)
|
||||
} catch {
|
||||
Issue.record("Unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("verifyTotp mit falschem Code → twoFactorFailed, bleibt im Challenge")
|
||||
func verifyTotpWrongCode() async {
|
||||
let mocked = makeMockedAuth()
|
||||
mocked.setHandler { _ in
|
||||
(200, Data(#"""
|
||||
{"twoFactorRequired":true,"twoFactorMethods":["totp"],"twoFactorToken":"tf-xyz"}
|
||||
"""#.utf8))
|
||||
}
|
||||
await mocked.auth.signIn(email: "u@x.de", password: "pw")
|
||||
|
||||
mocked.setHandler { _ in
|
||||
(401, Data(#"{"error":"TWO_FACTOR_FAILED","status":401}"#.utf8))
|
||||
}
|
||||
|
||||
do {
|
||||
try await mocked.auth.verifyTotp(code: "000000")
|
||||
Issue.record("Expected throw")
|
||||
} catch AuthError.twoFactorFailed {
|
||||
// Status bleibt im Challenge — User kann erneut versuchen
|
||||
if case .twoFactorRequired = mocked.auth.status {
|
||||
#expect(Bool(true))
|
||||
} else {
|
||||
Issue.record("Status should remain .twoFactorRequired, got \(mocked.auth.status)")
|
||||
}
|
||||
} catch {
|
||||
Issue.record("Unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("verifyBackupCode erfolgreich → .signedIn")
|
||||
func verifyBackupCodeSuccess() async throws {
|
||||
let mocked = makeMockedAuth()
|
||||
mocked.setHandler { _ in
|
||||
(200, Data(#"""
|
||||
{"twoFactorRequired":true,"twoFactorMethods":["totp"],"twoFactorToken":"tf-xyz"}
|
||||
"""#.utf8))
|
||||
}
|
||||
await mocked.auth.signIn(email: "u@x.de", password: "pw")
|
||||
|
||||
let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig"
|
||||
let captured = MockURLProtocol.Capture()
|
||||
mocked.setHandler { request in
|
||||
captured.store(request)
|
||||
return (200, Data(#"""
|
||||
{"success":true,"accessToken":"\#(access)","refreshToken":"r1"}
|
||||
"""#.utf8))
|
||||
}
|
||||
|
||||
try await mocked.auth.verifyBackupCode(code: "abc-def-ghi")
|
||||
#expect(mocked.auth.status == .signedIn(email: "u@x.de"))
|
||||
let request = try #require(captured.request)
|
||||
#expect(request.url?.path == "/api/v1/auth/two-factor/verify-backup-code")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue