@cosmicdrift/kumiko-bundled-features 0.147.1 → 0.147.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -7
- package/src/auth-email-password/feature.ts +6 -1
- package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
- package/src/auth-email-password/handlers/login.write.ts +66 -9
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +15 -5
- package/src/auth-email-password/web/__tests__/test-utils.tsx +12 -2
- package/src/auth-email-password/web/auth-client.ts +34 -12
- package/src/auth-email-password/web/auth-gate.tsx +32 -3
- package/src/auth-email-password/web/client-plugin.ts +11 -2
- package/src/auth-email-password/web/index.ts +4 -2
- package/src/auth-email-password/web/login-screen.tsx +31 -2
- package/src/auth-email-password/web/session.tsx +11 -6
- package/src/auth-mfa/__tests__/disable-and-regenerate.integration.test.ts +202 -0
- package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +150 -0
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +167 -0
- package/src/auth-mfa/__tests__/session-auto-revoke.integration.test.ts +168 -0
- package/src/auth-mfa/__tests__/verify.integration.test.ts +186 -0
- package/src/auth-mfa/config.ts +26 -0
- package/src/auth-mfa/constants.ts +25 -0
- package/src/auth-mfa/db/queries.ts +34 -0
- package/src/auth-mfa/errors.ts +73 -0
- package/src/auth-mfa/feature.ts +146 -0
- package/src/auth-mfa/handlers/disable.write.ts +51 -0
- package/src/auth-mfa/handlers/enable-confirm.write.ts +68 -0
- package/src/auth-mfa/handlers/enable-start.write.ts +66 -0
- package/src/auth-mfa/handlers/reencrypt.job.ts +185 -0
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +62 -0
- package/src/auth-mfa/handlers/verify.write.ts +141 -0
- package/src/auth-mfa/i18n.ts +12 -0
- package/src/auth-mfa/index.ts +15 -0
- package/src/auth-mfa/mfa-challenge-token.ts +90 -0
- package/src/auth-mfa/mfa-setup-token.ts +90 -0
- package/src/auth-mfa/mfa-status-checker.ts +75 -0
- package/src/auth-mfa/mfa-verify-attempts.ts +90 -0
- package/src/auth-mfa/recovery-codes.ts +44 -0
- package/src/auth-mfa/schema/user-mfa.ts +40 -0
- package/src/auth-mfa/totp.ts +8 -0
- package/src/auth-mfa/verify-factor.ts +30 -0
- package/src/auth-mfa/web/client-plugin.ts +37 -0
- package/src/auth-mfa/web/i18n.ts +69 -0
- package/src/auth-mfa/web/index.ts +15 -0
- package/src/auth-mfa/web/mfa-client.ts +60 -0
- package/src/auth-mfa/web/mfa-enable-screen.tsx +187 -0
- package/src/auth-mfa/web/mfa-verify-screen.tsx +106 -0
- package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +132 -0
- package/src/auth-mfa-user-data/hooks.ts +53 -0
- package/src/auth-mfa-user-data/index.ts +21 -0
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +4 -0
- package/src/legal-pages/feature.ts +19 -28
- package/src/managed-pages/__tests__/managed-pages.integration.test.ts +111 -0
- package/src/managed-pages/feature.ts +44 -44
- package/src/seo/__tests__/seo.integration.test.ts +68 -0
- package/src/seo/feature.ts +28 -41
- package/src/sessions/feature.ts +2 -1
- package/src/sessions/session-callbacks.ts +23 -0
- package/src/shared/index.ts +1 -0
- package/src/{auth-email-password → shared}/token-burn-store.ts +1 -1
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Redis-backed brute-force cap for `/auth/mfa/verify`, keyed by userId (NOT
|
|
2
|
+
// by challenge token). Modeled on auth-email-password/lockout-store.ts.
|
|
3
|
+
//
|
|
4
|
+
// Why keyed by userId, not challengeToken: a challenge token is reissued on
|
|
5
|
+
// every successful password login (see mfa-challenge-token.ts) — a fresh
|
|
6
|
+
// token would reset a token-keyed counter to zero, letting an attacker who
|
|
7
|
+
// already has the password just re-login repeatedly to get unlimited TOTP
|
|
8
|
+
// guesses. A 6-digit code with a ±1-step window has 3 valid values out of
|
|
9
|
+
// 10^6; without a cap that survives across challenge reissuance, online
|
|
10
|
+
// brute-force is practical. The counter's lifetime crosses token
|
|
11
|
+
// boundaries on purpose.
|
|
12
|
+
//
|
|
13
|
+
// This is INDEPENDENT of the AuthRoutesConfig.mfaVerifyRateLimit on the
|
|
14
|
+
// framework route (IP-scoped abuse protection for the endpoint itself) —
|
|
15
|
+
// both are needed, neither substitutes for the other.
|
|
16
|
+
|
|
17
|
+
import type Redis from "ioredis";
|
|
18
|
+
|
|
19
|
+
export type MfaVerifyLockoutState = {
|
|
20
|
+
readonly failureCount: number;
|
|
21
|
+
readonly lockedUntil: number | null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const COUNT_KEY_PREFIX = "kumiko:auth:mfa-verify:count:";
|
|
25
|
+
const UNTIL_KEY_PREFIX = "kumiko:auth:mfa-verify:until:";
|
|
26
|
+
|
|
27
|
+
function countKey(userId: string): string {
|
|
28
|
+
return `${COUNT_KEY_PREFIX}${userId}`;
|
|
29
|
+
}
|
|
30
|
+
function untilKey(userId: string): string {
|
|
31
|
+
return `${UNTIL_KEY_PREFIX}${userId}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function getMfaVerifyLockoutState(
|
|
35
|
+
redis: Redis,
|
|
36
|
+
userId: string,
|
|
37
|
+
): Promise<MfaVerifyLockoutState | null> {
|
|
38
|
+
const [countRaw, untilRaw] = await redis.mget(countKey(userId), untilKey(userId));
|
|
39
|
+
if (countRaw === null) return null;
|
|
40
|
+
const failureCount = Number(countRaw);
|
|
41
|
+
if (!Number.isFinite(failureCount)) return null;
|
|
42
|
+
const lockedUntil = untilRaw !== null ? Number(untilRaw) : null;
|
|
43
|
+
return {
|
|
44
|
+
failureCount,
|
|
45
|
+
lockedUntil: lockedUntil !== null && Number.isFinite(lockedUntil) ? lockedUntil : null,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Race-free: INCR is atomic, NX on the until-key means only the attempt
|
|
50
|
+
// that first crosses the threshold anchors the lock window — see
|
|
51
|
+
// lockout-store.ts's recordFailedAttempt for the identical reasoning.
|
|
52
|
+
export async function recordFailedMfaVerifyAttempt(
|
|
53
|
+
redis: Redis,
|
|
54
|
+
userId: string,
|
|
55
|
+
maxAttempts: number,
|
|
56
|
+
lockoutMinutes: number,
|
|
57
|
+
): Promise<MfaVerifyLockoutState> {
|
|
58
|
+
const lockDurationMs = lockoutMinutes * 60 * 1000;
|
|
59
|
+
const ttlSec = Math.max(lockoutMinutes * 60, 24 * 3600);
|
|
60
|
+
|
|
61
|
+
const count = await redis.incr(countKey(userId));
|
|
62
|
+
if (count === 1) {
|
|
63
|
+
await redis.expire(countKey(userId), ttlSec);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let lockedUntil: number | null = null;
|
|
67
|
+
if (count >= maxAttempts) {
|
|
68
|
+
const computedUntil = Date.now() + lockDurationMs;
|
|
69
|
+
const setOk = await redis.set(
|
|
70
|
+
untilKey(userId),
|
|
71
|
+
String(computedUntil),
|
|
72
|
+
"PX",
|
|
73
|
+
lockDurationMs,
|
|
74
|
+
"NX",
|
|
75
|
+
);
|
|
76
|
+
if (setOk === "OK") {
|
|
77
|
+
lockedUntil = computedUntil;
|
|
78
|
+
} else {
|
|
79
|
+
const existing = await redis.get(untilKey(userId));
|
|
80
|
+
lockedUntil = existing !== null ? Number(existing) : null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { failureCount: count, lockedUntil };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Called on a successful verify. The only path that resets the streak.
|
|
88
|
+
export async function clearMfaVerifyAttempts(redis: Redis, userId: string): Promise<void> {
|
|
89
|
+
await redis.del(countKey(userId), untilKey(userId));
|
|
90
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { randomInt } from "node:crypto";
|
|
2
|
+
import { hashPassword, verifyPassword } from "../shared";
|
|
3
|
+
|
|
4
|
+
const RECOVERY_CODE_COUNT = 8;
|
|
5
|
+
// No ambiguous chars (0/O, 1/I) — these get read aloud or copy-typed from a
|
|
6
|
+
// screen during account-recovery, a context where a misread costs a support
|
|
7
|
+
// ticket, not just a retry.
|
|
8
|
+
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
9
|
+
|
|
10
|
+
function randomCodeGroup(): string {
|
|
11
|
+
let out = "";
|
|
12
|
+
for (let i = 0; i < 4; i++) {
|
|
13
|
+
out += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function generateRecoveryCode(): string {
|
|
19
|
+
return `${randomCodeGroup()}-${randomCodeGroup()}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function generateRecoveryCodes(count: number = RECOVERY_CODE_COUNT): string[] {
|
|
23
|
+
return Array.from({ length: count }, generateRecoveryCode);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Reuses the same argon2id hashing as passwords — recovery codes are
|
|
27
|
+
// bearer-secrets of comparable sensitivity (whoever has one can log in).
|
|
28
|
+
export async function hashRecoveryCodes(codes: readonly string[]): Promise<string[]> {
|
|
29
|
+
return Promise.all(codes.map((code) => hashPassword(code)));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Sequential (not Promise.all) — recovery-code lists are ≤8 entries, and a
|
|
33
|
+
// user typing a wrong code should not pay 8x argon2 cost when the first
|
|
34
|
+
// hash already matches.
|
|
35
|
+
export async function findMatchingRecoveryCodeIndex(
|
|
36
|
+
code: string,
|
|
37
|
+
hashes: readonly string[],
|
|
38
|
+
): Promise<number> {
|
|
39
|
+
for (let i = 0; i < hashes.length; i++) {
|
|
40
|
+
const hash = hashes[i];
|
|
41
|
+
if (hash !== undefined && (await verifyPassword(hash, code))) return i;
|
|
42
|
+
}
|
|
43
|
+
return -1;
|
|
44
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
createEntity,
|
|
4
|
+
createJsonbField,
|
|
5
|
+
createTextField,
|
|
6
|
+
createTimestampField,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
|
|
9
|
+
// One row per (userId, tenantId) — presence of a row IS "MFA enabled" for
|
|
10
|
+
// that user. Event-sourced (not unmanagedTable): enable/disable/regenerate
|
|
11
|
+
// are rare, auditable state changes, not a hot-path lookup like sessions —
|
|
12
|
+
// mfa.enabled/disabled fall out of the event stream instead of being
|
|
13
|
+
// manually emitted.
|
|
14
|
+
export const userMfaEntity = createEntity({
|
|
15
|
+
table: "read_user_mfa",
|
|
16
|
+
fields: {
|
|
17
|
+
// FK to the user row, not content — PII-naming heuristic would flag
|
|
18
|
+
// "userId" as content; it's a pseudonymous reference, matching the same
|
|
19
|
+
// annotation `config`'s userId column uses.
|
|
20
|
+
userId: createTextField({ required: true, allowPlaintext: "pseudonymous-fk" }),
|
|
21
|
+
// Envelope-encrypted via the same MasterKeyProvider as secrets/config
|
|
22
|
+
// (entity-field-encryption.ts) — no manual wrapDek/unwrapDek needed.
|
|
23
|
+
// userOwned: crypto-shredding this field is exactly "revoke this
|
|
24
|
+
// user's 2FA on GDPR forget", which is the correct behavior anyway.
|
|
25
|
+
totpSecret: createTextField({
|
|
26
|
+
required: true,
|
|
27
|
+
encrypted: true,
|
|
28
|
+
userOwned: { ownerField: "userId" },
|
|
29
|
+
}),
|
|
30
|
+
// { hashes: string[] } — argon2id hashes of unredeemed recovery codes.
|
|
31
|
+
// A redeemed code's hash is removed from the array (see disable/regen
|
|
32
|
+
// handlers), so array length also IS the remaining-codes count.
|
|
33
|
+
recoveryCodes: createJsonbField({ userOwned: { ownerField: "userId" } }),
|
|
34
|
+
enabledAt: createTimestampField({ required: true }),
|
|
35
|
+
lastUsedAt: createTimestampField(),
|
|
36
|
+
},
|
|
37
|
+
indexes: [{ unique: true, columns: ["userId", "tenantId"], name: "read_user_mfa_user_unique" }],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const userMfaTable = buildEntityTable("user-mfa", userMfaEntity);
|
package/src/auth-mfa/totp.ts
CHANGED
|
@@ -27,6 +27,14 @@ function hotp(secret: Buffer, counter: number): string {
|
|
|
27
27
|
return String(truncated % 10 ** DIGITS).padStart(DIGITS, "0");
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// Exposed for callers that need to derive "the code right now" — the
|
|
31
|
+
// enable-confirm/verify UI flows show a live-updating code client-side
|
|
32
|
+
// (via their own TOTP lib or a countdown), and tests need a code to submit
|
|
33
|
+
// without hand-rolling the HMAC math again.
|
|
34
|
+
export function currentTotpCode(secret: Buffer, nowMs: number = Date.now()): string {
|
|
35
|
+
return totpAt(secret, Math.floor(nowMs / 1000));
|
|
36
|
+
}
|
|
37
|
+
|
|
30
38
|
function totpAt(secret: Buffer, epochSeconds: number): string {
|
|
31
39
|
return hotp(secret, Math.floor(epochSeconds / STEP_SECONDS));
|
|
32
40
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { base32Decode } from "./base32";
|
|
2
|
+
import type { UserMfaRow } from "./db/queries";
|
|
3
|
+
import { findMatchingRecoveryCodeIndex } from "./recovery-codes";
|
|
4
|
+
import { verifyTotp } from "./totp";
|
|
5
|
+
|
|
6
|
+
export type MfaFactorVerifyResult =
|
|
7
|
+
| { readonly ok: true; readonly method: "totp" }
|
|
8
|
+
| { readonly ok: true; readonly method: "recovery"; readonly remainingHashes: readonly string[] }
|
|
9
|
+
| { readonly ok: false };
|
|
10
|
+
|
|
11
|
+
// Shared by disable/regenerate-recovery (and later /auth/mfa/verify): tries
|
|
12
|
+
// TOTP first (cheap HMAC), falls back to recovery codes (argon2, slower —
|
|
13
|
+
// this is the rare path) only when the code isn't a valid TOTP. Callers
|
|
14
|
+
// that get `method: "recovery"` must persist `remainingHashes` as the new
|
|
15
|
+
// recoveryCodes.hashes — the matched code is single-use and already
|
|
16
|
+
// excluded from the returned array.
|
|
17
|
+
export async function verifyMfaFactor(
|
|
18
|
+
row: UserMfaRow,
|
|
19
|
+
code: string,
|
|
20
|
+
): Promise<MfaFactorVerifyResult> {
|
|
21
|
+
const secret = base32Decode(row.totpSecret);
|
|
22
|
+
if (verifyTotp(secret, code)) return { ok: true, method: "totp" };
|
|
23
|
+
|
|
24
|
+
const hashes = row.recoveryCodes.hashes;
|
|
25
|
+
const matchIndex = await findMatchingRecoveryCodeIndex(code, hashes);
|
|
26
|
+
if (matchIndex === -1) return { ok: false };
|
|
27
|
+
|
|
28
|
+
const remainingHashes = hashes.filter((_, i) => i !== matchIndex);
|
|
29
|
+
return { ok: true, method: "recovery", remainingHashes };
|
|
30
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Client-Feature-Factory für auth-mfa — mirrors emailPasswordClient()'s
|
|
3
|
+
// shape so apps register both the same way:
|
|
4
|
+
// createKumikoApp({ clientFeatures: [emailPasswordClient(), authMfaClient()] }).
|
|
5
|
+
// No gates: MfaVerifyScreen isn't a route gate, it's a state swap wired
|
|
6
|
+
// via EmailPasswordClientOptions.mfaVerifyScreen. This factory merges the
|
|
7
|
+
// default de/en translations and maps MFA_ENABLE_SCREEN_ID to
|
|
8
|
+
// MfaEnableScreen (same "components" convention as personal-access-tokens).
|
|
9
|
+
|
|
10
|
+
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
11
|
+
import type { ComponentType, ReactNode } from "react";
|
|
12
|
+
import { MFA_ENABLE_SCREEN_ID } from "../constants";
|
|
13
|
+
import { defaultTranslations, mergeTranslations } from "./i18n";
|
|
14
|
+
import { MfaEnableScreen } from "./mfa-enable-screen";
|
|
15
|
+
|
|
16
|
+
export type AuthMfaClientOptions = {
|
|
17
|
+
/** Key-Overrides pro Locale, gemerged mit den Default-Bundles (de/en). */
|
|
18
|
+
readonly translations?: TranslationsByLocale;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type AuthMfaClientFeature = {
|
|
22
|
+
readonly name: "auth-mfa";
|
|
23
|
+
readonly providers: readonly ComponentType<{ children: ReactNode }>[];
|
|
24
|
+
readonly gates: readonly ComponentType<{ children: ReactNode }>[];
|
|
25
|
+
readonly translations: TranslationsByLocale;
|
|
26
|
+
readonly components: Readonly<Record<string, ComponentType>>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function authMfaClient(options: AuthMfaClientOptions = {}): AuthMfaClientFeature {
|
|
30
|
+
return {
|
|
31
|
+
name: "auth-mfa",
|
|
32
|
+
providers: [],
|
|
33
|
+
gates: [],
|
|
34
|
+
translations: mergeTranslations(defaultTranslations, options.translations ?? {}),
|
|
35
|
+
components: { [MFA_ENABLE_SCREEN_ID]: MfaEnableScreen },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Default-Bundles für die auth-mfa Feature-UI. Merged in mit
|
|
3
|
+
// authMfaClient() analog zu emailPasswordClient() aus auth-email-password.
|
|
4
|
+
// Keys folgen `auth.mfa.<area>.<slug>`.
|
|
5
|
+
|
|
6
|
+
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
7
|
+
|
|
8
|
+
export const defaultTranslations: TranslationsByLocale = {
|
|
9
|
+
de: {
|
|
10
|
+
"auth.mfa.verify.title": "Zwei-Faktor-Bestätigung",
|
|
11
|
+
"auth.mfa.verify.subtitle": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.",
|
|
12
|
+
"auth.mfa.verify.code": "Code",
|
|
13
|
+
"auth.mfa.verify.submit": "Bestätigen",
|
|
14
|
+
"auth.mfa.verify.submitting": "…",
|
|
15
|
+
"auth.mfa.errors.invalidCode": "Ungültiger Code. Bitte erneut versuchen.",
|
|
16
|
+
"auth.mfa.errors.challengeExpired": "Die Anmeldung ist abgelaufen. Bitte erneut einloggen.",
|
|
17
|
+
"auth.mfa.errors.tooManyAttempts": "Zu viele Fehlversuche. Bitte erneut einloggen.",
|
|
18
|
+
"auth.mfa.errors.verifyFailed": "Bestätigung fehlgeschlagen.",
|
|
19
|
+
"auth.mfa.errors.mfaAlreadyEnabled": "Zwei-Faktor-Authentifizierung ist bereits aktiv.",
|
|
20
|
+
"auth.mfa.errors.mfaNotEnabled": "Zwei-Faktor-Authentifizierung ist nicht aktiv.",
|
|
21
|
+
"auth.mfa.errors.invalidSetupToken": "Die Einrichtung ist abgelaufen. Bitte erneut starten.",
|
|
22
|
+
"auth.mfa.errors.invalidRecoveryCode": "Ungültiger Recovery-Code.",
|
|
23
|
+
"auth.mfa.enable.title": "Zwei-Faktor-Authentifizierung",
|
|
24
|
+
"auth.mfa.enable.intro":
|
|
25
|
+
"Schütze dein Konto zusätzlich mit einer Authenticator-App (z.B. Google Authenticator, 1Password).",
|
|
26
|
+
"auth.mfa.enable.start": "Einrichtung starten",
|
|
27
|
+
"auth.mfa.enable.scanTitle": "QR-Code scannen",
|
|
28
|
+
"auth.mfa.enable.manualEntry": "Oder manuell eingeben:",
|
|
29
|
+
"auth.mfa.enable.recoveryTitle": "Recovery-Codes",
|
|
30
|
+
"auth.mfa.enable.recoveryHint":
|
|
31
|
+
"Speichere diese Codes an einem sicheren Ort. Sie werden nur dieses eine Mal angezeigt und erlauben dir den Zugriff, falls du dein Gerät verlierst.",
|
|
32
|
+
"auth.mfa.enable.acknowledge": "Ich habe die Recovery-Codes gespeichert.",
|
|
33
|
+
"auth.mfa.enable.code": "Code aus der Authenticator-App",
|
|
34
|
+
"auth.mfa.enable.cancel": "Abbrechen",
|
|
35
|
+
"auth.mfa.enable.confirm": "Aktivieren",
|
|
36
|
+
"auth.mfa.enable.success": "Zwei-Faktor-Authentifizierung ist jetzt aktiv.",
|
|
37
|
+
},
|
|
38
|
+
en: {
|
|
39
|
+
"auth.mfa.verify.title": "Two-factor verification",
|
|
40
|
+
"auth.mfa.verify.subtitle": "Enter the 6-digit code from your authenticator app.",
|
|
41
|
+
"auth.mfa.verify.code": "Code",
|
|
42
|
+
"auth.mfa.verify.submit": "Verify",
|
|
43
|
+
"auth.mfa.verify.submitting": "…",
|
|
44
|
+
"auth.mfa.errors.invalidCode": "Invalid code. Please try again.",
|
|
45
|
+
"auth.mfa.errors.challengeExpired": "Your sign-in has expired. Please sign in again.",
|
|
46
|
+
"auth.mfa.errors.tooManyAttempts": "Too many failed attempts. Please sign in again.",
|
|
47
|
+
"auth.mfa.errors.verifyFailed": "Verification failed.",
|
|
48
|
+
"auth.mfa.errors.mfaAlreadyEnabled": "Two-factor authentication is already enabled.",
|
|
49
|
+
"auth.mfa.errors.mfaNotEnabled": "Two-factor authentication is not enabled.",
|
|
50
|
+
"auth.mfa.errors.invalidSetupToken": "Setup expired. Please start again.",
|
|
51
|
+
"auth.mfa.errors.invalidRecoveryCode": "Invalid recovery code.",
|
|
52
|
+
"auth.mfa.enable.title": "Two-factor authentication",
|
|
53
|
+
"auth.mfa.enable.intro":
|
|
54
|
+
"Add an extra layer of protection with an authenticator app (e.g. Google Authenticator, 1Password).",
|
|
55
|
+
"auth.mfa.enable.start": "Start setup",
|
|
56
|
+
"auth.mfa.enable.scanTitle": "Scan the QR code",
|
|
57
|
+
"auth.mfa.enable.manualEntry": "Or enter manually:",
|
|
58
|
+
"auth.mfa.enable.recoveryTitle": "Recovery codes",
|
|
59
|
+
"auth.mfa.enable.recoveryHint":
|
|
60
|
+
"Save these codes somewhere safe. They're shown only this once and let you back in if you lose your device.",
|
|
61
|
+
"auth.mfa.enable.acknowledge": "I've saved my recovery codes.",
|
|
62
|
+
"auth.mfa.enable.code": "Code from your authenticator app",
|
|
63
|
+
"auth.mfa.enable.cancel": "Cancel",
|
|
64
|
+
"auth.mfa.enable.confirm": "Enable",
|
|
65
|
+
"auth.mfa.enable.success": "Two-factor authentication is now enabled.",
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export { mergeTranslations } from "@cosmicdrift/kumiko-renderer";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Public exports für die Browser-Seite des auth-mfa Features. Sub-Path-
|
|
3
|
+
// Export `@cosmicdrift/kumiko-bundled-features/auth-mfa/web` — Server-Seite
|
|
4
|
+
// (defineFeature) bleibt frei von React-/DOM-Deps, siehe auth-email-
|
|
5
|
+
// password/web/index.ts für die selbe Trennung.
|
|
6
|
+
|
|
7
|
+
export type { AuthMfaClientFeature, AuthMfaClientOptions } from "./client-plugin";
|
|
8
|
+
export { authMfaClient } from "./client-plugin";
|
|
9
|
+
export { defaultTranslations, mergeTranslations } from "./i18n";
|
|
10
|
+
export type { MfaVerifyResult } from "./mfa-client";
|
|
11
|
+
export { verifyMfaChallenge } from "./mfa-client";
|
|
12
|
+
export type { MfaEnableScreenProps } from "./mfa-enable-screen";
|
|
13
|
+
export { MfaEnableScreen } from "./mfa-enable-screen";
|
|
14
|
+
export type { MfaVerifyScreenProps } from "./mfa-verify-screen";
|
|
15
|
+
export { MfaVerifyScreen } from "./mfa-verify-screen";
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Browser-Seite von POST /api/auth/mfa/verify — completes the two-step
|
|
3
|
+
// login LoginScreen started when the server responded mfaRequired. Same
|
|
4
|
+
// fetch/CSRF pattern as auth-email-password's login() (pre-session, no
|
|
5
|
+
// csrfHeader() — there's no session yet to protect). Response shape is
|
|
6
|
+
// byte-identical to /auth/login's ({isSuccess, token, user} | {isSuccess:
|
|
7
|
+
// false, error}) since auth-routes.ts mints via the same
|
|
8
|
+
// mintSessionAndRespond() for both routes.
|
|
9
|
+
|
|
10
|
+
// kumiko-lint-ignore cross-feature-import client-only types, the feature's server barrel has no web/ re-export
|
|
11
|
+
import type { LoginFailure, LoginResponse } from "../../auth-email-password/web";
|
|
12
|
+
|
|
13
|
+
export type MfaVerifyResult =
|
|
14
|
+
| { readonly kind: "success"; readonly data: LoginResponse }
|
|
15
|
+
| { readonly kind: "failure"; readonly error: LoginFailure };
|
|
16
|
+
|
|
17
|
+
export async function verifyMfaChallenge(
|
|
18
|
+
challengeToken: string,
|
|
19
|
+
code: string,
|
|
20
|
+
): Promise<MfaVerifyResult> {
|
|
21
|
+
const res = await fetch("/api/auth/mfa/verify", {
|
|
22
|
+
method: "POST",
|
|
23
|
+
credentials: "same-origin",
|
|
24
|
+
headers: { "Content-Type": "application/json" },
|
|
25
|
+
body: JSON.stringify({ challengeToken, code }),
|
|
26
|
+
});
|
|
27
|
+
if (res.status === 429) {
|
|
28
|
+
return { kind: "failure", error: { reason: "rate_limited" } };
|
|
29
|
+
}
|
|
30
|
+
// @cast-boundary engine-payload — HTTP-API contract, server-side schema-validated
|
|
31
|
+
const body = (await res.json().catch(() => ({}))) as {
|
|
32
|
+
isSuccess?: boolean;
|
|
33
|
+
token?: string;
|
|
34
|
+
user?: LoginResponse["user"];
|
|
35
|
+
error?:
|
|
36
|
+
| {
|
|
37
|
+
code?: string;
|
|
38
|
+
message?: string;
|
|
39
|
+
details?: { reason?: string; retryAfterSeconds?: number };
|
|
40
|
+
}
|
|
41
|
+
| string;
|
|
42
|
+
};
|
|
43
|
+
if (body.isSuccess === true && body.token !== undefined && body.user !== undefined) {
|
|
44
|
+
return { kind: "success", data: { token: body.token, user: body.user } };
|
|
45
|
+
}
|
|
46
|
+
const err = body.error;
|
|
47
|
+
if (typeof err === "string") {
|
|
48
|
+
return { kind: "failure", error: { reason: err } };
|
|
49
|
+
}
|
|
50
|
+
const reason = err?.details?.reason ?? err?.code ?? "mfa_verify_failed";
|
|
51
|
+
const retry = err?.details?.retryAfterSeconds;
|
|
52
|
+
return {
|
|
53
|
+
kind: "failure",
|
|
54
|
+
error: {
|
|
55
|
+
reason,
|
|
56
|
+
...(err?.message !== undefined && { message: err.message }),
|
|
57
|
+
...(retry !== undefined && { retryAfterSeconds: retry }),
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// MfaEnableScreen — logged-in self-service TOTP-Enrollment. Kein Dialog:
|
|
3
|
+
// DefaultDialog schließt nach JEDEM onConfirm (siehe renderer-web/src/
|
|
4
|
+
// primitives/dialog.tsx — onOpenChange(false) im finally-Block), das passt
|
|
5
|
+
// nicht zu einem Mehrschritt-Flow (Secret zeigen → Recovery-Codes zeigen →
|
|
6
|
+
// Code bestätigen). Folgt stattdessen pat-tokens-screen.tsx's embedded-
|
|
7
|
+
// Screen-Konvention: Feature registriert das dormant via r.screen, App
|
|
8
|
+
// platziert es via r.nav.
|
|
9
|
+
|
|
10
|
+
import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
11
|
+
import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
|
|
12
|
+
import QRCode from "qrcode";
|
|
13
|
+
import { type ReactNode, useState } from "react";
|
|
14
|
+
// kumiko-lint-ignore cross-feature-import client-only hook, the feature's server barrel has no web/ re-export
|
|
15
|
+
import { useSession } from "../../auth-email-password/web";
|
|
16
|
+
import { AuthMfaHandlers } from "../constants";
|
|
17
|
+
|
|
18
|
+
type EnableStartResponse = {
|
|
19
|
+
readonly setupToken: string;
|
|
20
|
+
readonly otpauthUri: string;
|
|
21
|
+
readonly recoveryCodes: readonly string[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type SetupState = {
|
|
25
|
+
readonly setupToken: string;
|
|
26
|
+
readonly secretParam: string;
|
|
27
|
+
readonly recoveryCodes: readonly string[];
|
|
28
|
+
readonly qrSvg: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// otpauth:// URIs put the secret in the query string — extract it for the
|
|
32
|
+
// manual-entry fallback (authenticator apps without a camera / desktop use).
|
|
33
|
+
function extractSecret(otpauthUri: string): string {
|
|
34
|
+
const query = otpauthUri.split("?")[1] ?? "";
|
|
35
|
+
return new URLSearchParams(query).get("secret") ?? "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type MfaEnableScreenProps = { readonly embedded?: boolean };
|
|
39
|
+
|
|
40
|
+
export function MfaEnableScreen({ embedded = false }: MfaEnableScreenProps = {}): ReactNode {
|
|
41
|
+
const t = useTranslation();
|
|
42
|
+
const { Button, Banner, Field, Input, Card, Heading } = usePrimitives();
|
|
43
|
+
const dispatcher = useDispatcher();
|
|
44
|
+
const session = useSession();
|
|
45
|
+
|
|
46
|
+
const [setup, setSetup] = useState<SetupState | null>(null);
|
|
47
|
+
const [acknowledged, setAcknowledged] = useState(false);
|
|
48
|
+
const [code, setCode] = useState("");
|
|
49
|
+
const [busy, setBusy] = useState(false);
|
|
50
|
+
const [error, setError] = useState<string | null>(null);
|
|
51
|
+
const [enabled, setEnabled] = useState(false);
|
|
52
|
+
|
|
53
|
+
const startSetup = async (): Promise<void> => {
|
|
54
|
+
setBusy(true);
|
|
55
|
+
setError(null);
|
|
56
|
+
const res = await dispatcher.write<EnableStartResponse>(AuthMfaHandlers.enableStart, {
|
|
57
|
+
accountLabel: session.user?.email ?? "",
|
|
58
|
+
});
|
|
59
|
+
if (!res.isSuccess) {
|
|
60
|
+
setBusy(false);
|
|
61
|
+
setError(res.error.code);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const qrSvg = await QRCode.toString(res.data.otpauthUri, { type: "svg" });
|
|
65
|
+
setBusy(false);
|
|
66
|
+
setSetup({
|
|
67
|
+
setupToken: res.data.setupToken,
|
|
68
|
+
secretParam: extractSecret(res.data.otpauthUri),
|
|
69
|
+
recoveryCodes: res.data.recoveryCodes,
|
|
70
|
+
qrSvg,
|
|
71
|
+
});
|
|
72
|
+
setAcknowledged(false);
|
|
73
|
+
setCode("");
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const confirmSetup = async (): Promise<void> => {
|
|
77
|
+
if (!setup) return;
|
|
78
|
+
setBusy(true);
|
|
79
|
+
setError(null);
|
|
80
|
+
const res = await dispatcher.write(AuthMfaHandlers.enableConfirm, {
|
|
81
|
+
setupToken: setup.setupToken,
|
|
82
|
+
code,
|
|
83
|
+
});
|
|
84
|
+
setBusy(false);
|
|
85
|
+
if (!res.isSuccess) {
|
|
86
|
+
setError(res.error.code);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
setEnabled(true);
|
|
90
|
+
setSetup(null);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const content = (
|
|
94
|
+
<div className="flex flex-col gap-6">
|
|
95
|
+
<Heading>{t("auth.mfa.enable.title")}</Heading>
|
|
96
|
+
|
|
97
|
+
{enabled && <Banner variant="info">{t("auth.mfa.enable.success")}</Banner>}
|
|
98
|
+
{error !== null && <Banner variant="error">{t(`auth.mfa.errors.${error}`)}</Banner>}
|
|
99
|
+
|
|
100
|
+
{!setup && !enabled && (
|
|
101
|
+
<Card options={{ padded: true }} className="flex flex-col gap-3">
|
|
102
|
+
<span className="text-sm text-muted-foreground">{t("auth.mfa.enable.intro")}</span>
|
|
103
|
+
<Button
|
|
104
|
+
variant="primary"
|
|
105
|
+
onClick={() => void startSetup()}
|
|
106
|
+
loading={busy}
|
|
107
|
+
disabled={busy}
|
|
108
|
+
>
|
|
109
|
+
{t("auth.mfa.enable.start")}
|
|
110
|
+
</Button>
|
|
111
|
+
</Card>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
{setup && (
|
|
115
|
+
<Card options={{ padded: true }} className="flex flex-col gap-4">
|
|
116
|
+
<div className="flex flex-col gap-2">
|
|
117
|
+
<span className="text-sm font-semibold">{t("auth.mfa.enable.scanTitle")}</span>
|
|
118
|
+
{/* qrcode's own SVG string output, not user input — safe to inline */}
|
|
119
|
+
<div
|
|
120
|
+
className="h-40 w-40"
|
|
121
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: qrcode-generated SVG, no user input
|
|
122
|
+
dangerouslySetInnerHTML={{ __html: setup.qrSvg }}
|
|
123
|
+
/>
|
|
124
|
+
<span className="text-xs text-muted-foreground">
|
|
125
|
+
{t("auth.mfa.enable.manualEntry")}
|
|
126
|
+
</span>
|
|
127
|
+
<code className="block break-all rounded bg-muted px-3 py-2 font-mono text-sm">
|
|
128
|
+
{setup.secretParam}
|
|
129
|
+
</code>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<div className="flex flex-col gap-2">
|
|
133
|
+
<span className="text-sm font-semibold">{t("auth.mfa.enable.recoveryTitle")}</span>
|
|
134
|
+
<span className="text-xs text-muted-foreground">
|
|
135
|
+
{t("auth.mfa.enable.recoveryHint")}
|
|
136
|
+
</span>
|
|
137
|
+
<code className="block whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm">
|
|
138
|
+
{setup.recoveryCodes.join("\n")}
|
|
139
|
+
</code>
|
|
140
|
+
<Field id="mfa-enable-ack" label={t("auth.mfa.enable.acknowledge")}>
|
|
141
|
+
<Input
|
|
142
|
+
kind="boolean"
|
|
143
|
+
id="mfa-enable-ack"
|
|
144
|
+
name="mfa-enable-ack"
|
|
145
|
+
value={acknowledged}
|
|
146
|
+
onChange={setAcknowledged}
|
|
147
|
+
/>
|
|
148
|
+
</Field>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<Field id="mfa-enable-code" label={t("auth.mfa.enable.code")} required>
|
|
152
|
+
<Input
|
|
153
|
+
kind="text"
|
|
154
|
+
id="mfa-enable-code"
|
|
155
|
+
name="mfa-enable-code"
|
|
156
|
+
value={code}
|
|
157
|
+
onChange={setCode}
|
|
158
|
+
disabled={busy || !acknowledged}
|
|
159
|
+
autoComplete="one-time-code"
|
|
160
|
+
/>
|
|
161
|
+
</Field>
|
|
162
|
+
|
|
163
|
+
<div className="flex justify-end gap-2">
|
|
164
|
+
<Button variant="secondary" onClick={() => setSetup(null)} disabled={busy}>
|
|
165
|
+
{t("auth.mfa.enable.cancel")}
|
|
166
|
+
</Button>
|
|
167
|
+
<Button
|
|
168
|
+
variant="primary"
|
|
169
|
+
onClick={() => void confirmSetup()}
|
|
170
|
+
loading={busy}
|
|
171
|
+
disabled={busy || !acknowledged || code.length < 6}
|
|
172
|
+
>
|
|
173
|
+
{t("auth.mfa.enable.confirm")}
|
|
174
|
+
</Button>
|
|
175
|
+
</div>
|
|
176
|
+
</Card>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
if (embedded) return content;
|
|
182
|
+
return (
|
|
183
|
+
<FormScreenShell testId="mfa-enable-screen" maxWidth="3xl">
|
|
184
|
+
{content}
|
|
185
|
+
</FormScreenShell>
|
|
186
|
+
);
|
|
187
|
+
}
|