work-in-progress atproto PDS
typescript atproto pds atcute
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: unify sudo and MFA login controllers

Mary 5edada64 6385af39

+852 -1067
+12 -10
packages/danaus/drizzle/accounts/20260117055732_omniscient_malice/migration.sql packages/danaus/drizzle/accounts/20260117063532_minor_golden_guardian/migration.sql
··· 61 61 CONSTRAINT `fk_legacy_session_next_id_legacy_session_id_fk` FOREIGN KEY (`next_id`) REFERENCES `legacy_session`(`id`) ON DELETE CASCADE 62 62 ); 63 63 --> statement-breakpoint 64 - CREATE TABLE `mfa_challenge` ( 65 - `token` text PRIMARY KEY, 66 - `did` text NOT NULL, 67 - `webauthn_challenge` text, 68 - `created_at` integer NOT NULL, 69 - `expires_at` integer NOT NULL, 70 - CONSTRAINT `fk_mfa_challenge_did_account_did_fk` FOREIGN KEY (`did`) REFERENCES `account`(`did`) ON DELETE CASCADE 71 - ); 72 - --> statement-breakpoint 73 64 CREATE TABLE `recovery_code` ( 74 65 `id` integer PRIMARY KEY AUTOINCREMENT, 75 66 `did` text NOT NULL, ··· 88 79 `last_used_counter` integer NOT NULL, 89 80 CONSTRAINT `fk_totp_credential_did_account_did_fk` FOREIGN KEY (`did`) REFERENCES `account`(`did`) ON DELETE CASCADE, 90 81 CONSTRAINT `totp_credential_did_name_unique` UNIQUE(`did`,`name`) 82 + ); 83 + --> statement-breakpoint 84 + CREATE TABLE `verify_challenge` ( 85 + `token` text PRIMARY KEY, 86 + `did` text NOT NULL, 87 + `session_id` text, 88 + `webauthn_challenge` text, 89 + `created_at` integer NOT NULL, 90 + `expires_at` integer NOT NULL, 91 + CONSTRAINT `fk_verify_challenge_did_account_did_fk` FOREIGN KEY (`did`) REFERENCES `account`(`did`) ON DELETE CASCADE, 92 + CONSTRAINT `fk_verify_challenge_session_id_web_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `web_session`(`id`) ON DELETE CASCADE 91 93 ); 92 94 --> statement-breakpoint 93 95 CREATE TABLE `web_session` ( ··· 127 129 CREATE UNIQUE INDEX `account_handle_lower_idx` ON `account` (lower("handle"));--> statement-breakpoint 128 130 CREATE UNIQUE INDEX `account_email_lower_idx` ON `account` (lower("email"));--> statement-breakpoint 129 131 CREATE INDEX `legacy_session_did_idx` ON `legacy_session` (`did`);--> statement-breakpoint 130 - CREATE INDEX `mfa_challenge_expires_idx` ON `mfa_challenge` (`expires_at`);--> statement-breakpoint 131 132 CREATE INDEX `recovery_code_did_idx` ON `recovery_code` (`did`);--> statement-breakpoint 132 133 CREATE INDEX `totp_credential_did_idx` ON `totp_credential` (`did`);--> statement-breakpoint 134 + CREATE INDEX `verify_challenge_expires_idx` ON `verify_challenge` (`expires_at`);--> statement-breakpoint 133 135 CREATE INDEX `web_session_did_idx` ON `web_session` (`did`);--> statement-breakpoint 134 136 CREATE INDEX `webauthn_challenge_expires_idx` ON `webauthn_challenge` (`expires_at`);--> statement-breakpoint 135 137 CREATE INDEX `webauthn_credential_did_idx` ON `webauthn_credential` (`did`);--> statement-breakpoint
+125 -100
packages/danaus/drizzle/accounts/20260117055732_omniscient_malice/snapshot.json packages/danaus/drizzle/accounts/20260117063532_minor_golden_guardian/snapshot.json
··· 1 1 { 2 2 "version": "7", 3 3 "dialect": "sqlite", 4 - "id": "ac52481d-ca5d-445c-b696-ae857bf5467f", 4 + "id": "932710cc-55eb-4151-8b21-3135d4bb9a08", 5 5 "prevIds": [ 6 6 "00000000-0000-0000-0000-000000000000" 7 7 ], ··· 31 31 "entityType": "tables" 32 32 }, 33 33 { 34 - "name": "mfa_challenge", 35 - "entityType": "tables" 36 - }, 37 - { 38 34 "name": "recovery_code", 39 35 "entityType": "tables" 40 36 }, 41 37 { 42 38 "name": "totp_credential", 39 + "entityType": "tables" 40 + }, 41 + { 42 + "name": "verify_challenge", 43 43 "entityType": "tables" 44 44 }, 45 45 { ··· 405 405 "table": "legacy_session" 406 406 }, 407 407 { 408 - "type": "text", 409 - "notNull": false, 410 - "autoincrement": false, 411 - "default": null, 412 - "generated": null, 413 - "name": "token", 414 - "entityType": "columns", 415 - "table": "mfa_challenge" 416 - }, 417 - { 418 - "type": "text", 419 - "notNull": true, 420 - "autoincrement": false, 421 - "default": null, 422 - "generated": null, 423 - "name": "did", 424 - "entityType": "columns", 425 - "table": "mfa_challenge" 426 - }, 427 - { 428 - "type": "text", 429 - "notNull": false, 430 - "autoincrement": false, 431 - "default": null, 432 - "generated": null, 433 - "name": "webauthn_challenge", 434 - "entityType": "columns", 435 - "table": "mfa_challenge" 436 - }, 437 - { 438 - "type": "integer", 439 - "notNull": true, 440 - "autoincrement": false, 441 - "default": null, 442 - "generated": null, 443 - "name": "created_at", 444 - "entityType": "columns", 445 - "table": "mfa_challenge" 446 - }, 447 - { 448 - "type": "integer", 449 - "notNull": true, 450 - "autoincrement": false, 451 - "default": null, 452 - "generated": null, 453 - "name": "expires_at", 454 - "entityType": "columns", 455 - "table": "mfa_challenge" 456 - }, 457 - { 458 408 "type": "integer", 459 409 "notNull": false, 460 410 "autoincrement": true, ··· 532 483 "name": "name", 533 484 "entityType": "columns", 534 485 "table": "totp_credential" 486 + }, 487 + { 488 + "type": "blob", 489 + "notNull": true, 490 + "autoincrement": false, 491 + "default": null, 492 + "generated": null, 493 + "name": "secret", 494 + "entityType": "columns", 495 + "table": "totp_credential" 496 + }, 497 + { 498 + "type": "integer", 499 + "notNull": true, 500 + "autoincrement": false, 501 + "default": null, 502 + "generated": null, 503 + "name": "created_at", 504 + "entityType": "columns", 505 + "table": "totp_credential" 506 + }, 507 + { 508 + "type": "integer", 509 + "notNull": true, 510 + "autoincrement": false, 511 + "default": null, 512 + "generated": null, 513 + "name": "last_used_counter", 514 + "entityType": "columns", 515 + "table": "totp_credential" 516 + }, 517 + { 518 + "type": "text", 519 + "notNull": false, 520 + "autoincrement": false, 521 + "default": null, 522 + "generated": null, 523 + "name": "token", 524 + "entityType": "columns", 525 + "table": "verify_challenge" 526 + }, 527 + { 528 + "type": "text", 529 + "notNull": true, 530 + "autoincrement": false, 531 + "default": null, 532 + "generated": null, 533 + "name": "did", 534 + "entityType": "columns", 535 + "table": "verify_challenge" 536 + }, 537 + { 538 + "type": "text", 539 + "notNull": false, 540 + "autoincrement": false, 541 + "default": null, 542 + "generated": null, 543 + "name": "session_id", 544 + "entityType": "columns", 545 + "table": "verify_challenge" 535 546 }, 536 547 { 537 - "type": "blob", 538 - "notNull": true, 548 + "type": "text", 549 + "notNull": false, 539 550 "autoincrement": false, 540 551 "default": null, 541 552 "generated": null, 542 - "name": "secret", 553 + "name": "webauthn_challenge", 543 554 "entityType": "columns", 544 - "table": "totp_credential" 555 + "table": "verify_challenge" 545 556 }, 546 557 { 547 558 "type": "integer", ··· 551 562 "generated": null, 552 563 "name": "created_at", 553 564 "entityType": "columns", 554 - "table": "totp_credential" 565 + "table": "verify_challenge" 555 566 }, 556 567 { 557 568 "type": "integer", ··· 559 570 "autoincrement": false, 560 571 "default": null, 561 572 "generated": null, 562 - "name": "last_used_counter", 573 + "name": "expires_at", 563 574 "entityType": "columns", 564 - "table": "totp_credential" 575 + "table": "verify_challenge" 565 576 }, 566 577 { 567 578 "type": "text", ··· 879 890 "onUpdate": "NO ACTION", 880 891 "onDelete": "CASCADE", 881 892 "nameExplicit": false, 882 - "name": "fk_mfa_challenge_did_account_did_fk", 883 - "entityType": "fks", 884 - "table": "mfa_challenge" 885 - }, 886 - { 887 - "columns": [ 888 - "did" 889 - ], 890 - "tableTo": "account", 891 - "columnsTo": [ 892 - "did" 893 - ], 894 - "onUpdate": "NO ACTION", 895 - "onDelete": "CASCADE", 896 - "nameExplicit": false, 897 893 "name": "fk_recovery_code_did_account_did_fk", 898 894 "entityType": "fks", 899 895 "table": "recovery_code" ··· 915 911 }, 916 912 { 917 913 "columns": [ 914 + "did" 915 + ], 916 + "tableTo": "account", 917 + "columnsTo": [ 918 + "did" 919 + ], 920 + "onUpdate": "NO ACTION", 921 + "onDelete": "CASCADE", 922 + "nameExplicit": false, 923 + "name": "fk_verify_challenge_did_account_did_fk", 924 + "entityType": "fks", 925 + "table": "verify_challenge" 926 + }, 927 + { 928 + "columns": [ 929 + "session_id" 930 + ], 931 + "tableTo": "web_session", 932 + "columnsTo": [ 933 + "id" 934 + ], 935 + "onUpdate": "NO ACTION", 936 + "onDelete": "CASCADE", 937 + "nameExplicit": false, 938 + "name": "fk_verify_challenge_session_id_web_session_id_fk", 939 + "entityType": "fks", 940 + "table": "verify_challenge" 941 + }, 942 + { 943 + "columns": [ 918 944 "did" 919 945 ], 920 946 "tableTo": "account", ··· 1016 1042 }, 1017 1043 { 1018 1044 "columns": [ 1019 - "token" 1020 - ], 1021 - "nameExplicit": false, 1022 - "name": "mfa_challenge_pk", 1023 - "table": "mfa_challenge", 1024 - "entityType": "pks" 1025 - }, 1026 - { 1027 - "columns": [ 1028 1045 "id" 1029 1046 ], 1030 1047 "nameExplicit": false, ··· 1038 1056 "nameExplicit": false, 1039 1057 "name": "totp_credential_pk", 1040 1058 "table": "totp_credential", 1059 + "entityType": "pks" 1060 + }, 1061 + { 1062 + "columns": [ 1063 + "token" 1064 + ], 1065 + "nameExplicit": false, 1066 + "name": "verify_challenge_pk", 1067 + "table": "verify_challenge", 1041 1068 "entityType": "pks" 1042 1069 }, 1043 1070 { ··· 1130 1157 { 1131 1158 "columns": [ 1132 1159 { 1133 - "value": "expires_at", 1134 - "isExpression": false 1135 - } 1136 - ], 1137 - "isUnique": false, 1138 - "where": null, 1139 - "origin": "manual", 1140 - "name": "mfa_challenge_expires_idx", 1141 - "entityType": "indexes", 1142 - "table": "mfa_challenge" 1143 - }, 1144 - { 1145 - "columns": [ 1146 - { 1147 1160 "value": "did", 1148 1161 "isExpression": false 1149 1162 } ··· 1168 1181 "name": "totp_credential_did_idx", 1169 1182 "entityType": "indexes", 1170 1183 "table": "totp_credential" 1184 + }, 1185 + { 1186 + "columns": [ 1187 + { 1188 + "value": "expires_at", 1189 + "isExpression": false 1190 + } 1191 + ], 1192 + "isUnique": false, 1193 + "where": null, 1194 + "origin": "manual", 1195 + "name": "verify_challenge_expires_idx", 1196 + "entityType": "indexes", 1197 + "table": "verify_challenge" 1171 1198 }, 1172 1199 { 1173 1200 "columns": [
+11 -4
packages/danaus/src/accounts/db/schema.ts
··· 224 224 (t) => [index('recovery_code_did_idx').on(t.did)], 225 225 ); 226 226 227 - /** MFA challenges during login */ 228 - export const mfaChallenge = sqliteTable( 229 - 'mfa_challenge', 227 + /** 228 + * verification challenges for MFA login and sudo elevation. 229 + * - session_id null → MFA login flow (creates new session on success) 230 + * - session_id set → sudo flow (elevates existing session on success) 231 + */ 232 + export const verifyChallenge = sqliteTable( 233 + 'verify_challenge', 230 234 { 231 235 token: text().primaryKey(), 232 236 ··· 235 239 .notNull() 236 240 .references(() => account.did, { onDelete: 'cascade' }), 237 241 242 + /** session to elevate (null = MFA login, creates new session) */ 243 + session_id: text().references(() => webSession.id, { onDelete: 'cascade' }), 244 + 238 245 /** WebAuthn challenge (base64url) for authentication */ 239 246 webauthn_challenge: text(), 240 247 241 248 created_at: integer({ mode: 'timestamp' }).notNull(), 242 249 expires_at: integer({ mode: 'timestamp' }).notNull(), 243 250 }, 244 - (t) => [index('mfa_challenge_expires_idx').on(t.expires_at)], 251 + (t) => [index('verify_challenge_expires_idx').on(t.expires_at)], 245 252 ); 246 253 247 254 // #endregion
+78 -34
packages/danaus/src/accounts/manager.ts
··· 42 42 export type InviteCodeUse = typeof t.inviteCodeUse.$inferSelect; 43 43 export type TotpCredential = typeof t.totpCredential.$inferSelect; 44 44 export type BackupCode = typeof t.recoveryCode.$inferSelect; 45 - export type MfaChallenge = typeof t.mfaChallenge.$inferSelect; 45 + export type VerifyChallenge = typeof t.verifyChallenge.$inferSelect; 46 46 export type WebauthnCredential = typeof t.webauthnCredential.$inferSelect; 47 47 export type WebauthnChallenge = typeof t.webauthnChallenge.$inferSelect; 48 48 ··· 1369 1369 1370 1370 // #endregion 1371 1371 1372 - // #region MFA challenges 1372 + // #region verification challenges 1373 1373 1374 1374 /** 1375 - * create an MFA challenge for login. 1375 + * create a verification challenge for MFA login (no session, creates one on success). 1376 1376 * @param did account did 1377 - * @returns token for the MFA page 1377 + * @returns token for the verify page 1378 1378 */ 1379 - createMfaChallenge(did: Did): string { 1379 + createVerifyChallenge(did: Did): string { 1380 1380 const token = nanoid(32); 1381 1381 const now = new Date(); 1382 1382 const expiresAt = new Date(now.getTime() + MFA_CHALLENGE_TTL_MS); 1383 1383 1384 1384 this.db 1385 - .insert(t.mfaChallenge) 1385 + .insert(t.verifyChallenge) 1386 1386 .values({ 1387 1387 token: token, 1388 1388 did: did, 1389 + session_id: null, 1389 1390 created_at: now, 1390 1391 expires_at: expiresAt, 1391 1392 }) ··· 1395 1396 } 1396 1397 1397 1398 /** 1398 - * get an MFA challenge by token. 1399 + * create or get an existing sudo challenge for session elevation. 1400 + * @param sessionId the session to elevate 1401 + * @param did account did 1402 + * @returns the verify challenge row 1403 + */ 1404 + getOrCreateSudoChallenge(sessionId: string, did: Did): VerifyChallenge { 1405 + // check for existing sudo challenge for this session 1406 + const existing = this.db 1407 + .select() 1408 + .from(t.verifyChallenge) 1409 + .where(eq(t.verifyChallenge.session_id, sessionId)) 1410 + .get(); 1411 + 1412 + const now = new Date(); 1413 + 1414 + if (existing && existing.expires_at > now) { 1415 + return existing; 1416 + } 1417 + 1418 + // delete expired challenge if it exists 1419 + if (existing) { 1420 + this.db.delete(t.verifyChallenge).where(eq(t.verifyChallenge.token, existing.token)).run(); 1421 + } 1422 + 1423 + const token = nanoid(32); 1424 + const expiresAt = new Date(now.getTime() + MFA_CHALLENGE_TTL_MS); 1425 + 1426 + const inserted = this.db 1427 + .insert(t.verifyChallenge) 1428 + .values({ 1429 + token: token, 1430 + did: did, 1431 + session_id: sessionId, 1432 + created_at: now, 1433 + expires_at: expiresAt, 1434 + }) 1435 + .returning() 1436 + .get(); 1437 + 1438 + return inserted; 1439 + } 1440 + 1441 + /** 1442 + * get a verification challenge by token. 1399 1443 * @param token the token 1400 - * @returns MFA challenge or null if expired/not found 1444 + * @returns verify challenge or null if expired/not found 1401 1445 */ 1402 - getMfaChallenge(token: string): MfaChallenge | null { 1403 - const challenge = this.db.select().from(t.mfaChallenge).where(eq(t.mfaChallenge.token, token)).get(); 1446 + getVerifyChallenge(token: string): VerifyChallenge | null { 1447 + const challenge = this.db 1448 + .select() 1449 + .from(t.verifyChallenge) 1450 + .where(eq(t.verifyChallenge.token, token)) 1451 + .get(); 1404 1452 1405 1453 if (!challenge) { 1406 1454 return null; ··· 1408 1456 1409 1457 const now = new Date(); 1410 1458 if (challenge.expires_at <= now) { 1411 - this.db.delete(t.mfaChallenge).where(eq(t.mfaChallenge.token, token)).run(); 1459 + this.db.delete(t.verifyChallenge).where(eq(t.verifyChallenge.token, token)).run(); 1412 1460 return null; 1413 1461 } 1414 1462 ··· 1416 1464 } 1417 1465 1418 1466 /** 1419 - * delete an MFA challenge. 1467 + * delete a verification challenge. 1420 1468 * @param token the token 1421 1469 */ 1422 - deleteMfaChallenge(token: string): void { 1423 - this.db.delete(t.mfaChallenge).where(eq(t.mfaChallenge.token, token)).run(); 1470 + deleteVerifyChallenge(token: string): void { 1471 + this.db.delete(t.verifyChallenge).where(eq(t.verifyChallenge.token, token)).run(); 1424 1472 } 1425 1473 1426 1474 /** 1427 - * clean up expired MFA challenges. 1475 + * clean up expired verification challenges. 1428 1476 */ 1429 - cleanupExpiredMfaChallenges(): void { 1477 + cleanupExpiredVerifyChallenges(): void { 1430 1478 const now = new Date(); 1431 - this.db.delete(t.mfaChallenge).where(lte(t.mfaChallenge.expires_at, now)).run(); 1479 + this.db.delete(t.verifyChallenge).where(lte(t.verifyChallenge.expires_at, now)).run(); 1480 + } 1481 + 1482 + /** 1483 + * set the WebAuthn challenge on an existing verification challenge. 1484 + * @param token verify challenge token 1485 + * @param webauthnChallenge base64url WebAuthn challenge 1486 + */ 1487 + setVerifyChallengeWebAuthn(token: string, webauthnChallenge: string): void { 1488 + this.db 1489 + .update(t.verifyChallenge) 1490 + .set({ webauthn_challenge: webauthnChallenge }) 1491 + .where(eq(t.verifyChallenge.token, token)) 1492 + .run(); 1432 1493 } 1433 1494 1434 1495 // #endregion ··· 1716 1777 cleanupExpiredWebAuthnChallenges(): void { 1717 1778 const now = new Date(); 1718 1779 this.db.delete(t.webauthnChallenge).where(lte(t.webauthnChallenge.expires_at, now)).run(); 1719 - } 1720 - 1721 - // #endregion 1722 - 1723 - // #region MFA challenge WebAuthn support 1724 - 1725 - /** 1726 - * set the WebAuthn challenge on an existing MFA challenge. 1727 - * @param token MFA challenge token 1728 - * @param webauthnChallenge base64url WebAuthn challenge 1729 - */ 1730 - setMfaChallengeWebAuthn(token: string, webauthnChallenge: string): void { 1731 - this.db 1732 - .update(t.mfaChallenge) 1733 - .set({ webauthn_challenge: webauthnChallenge }) 1734 - .where(eq(t.mfaChallenge.token, token)) 1735 - .run(); 1736 1780 } 1737 1781 1738 1782 // #endregion
+3 -3
packages/danaus/src/web/controllers/account/security/recovery.tsx
··· 27 27 28 28 // require sudo mode 29 29 if (!accountManager.isSessionElevated(session)) { 30 - redirect(routes.login.sudo.index.href(undefined, { redirect: url.pathname })); 30 + redirect(routes.verify.index.href(undefined, { redirect: url.pathname })); 31 31 } 32 32 33 33 // generate codes if none exist ··· 81 81 82 82 // require sudo mode 83 83 if (!accountManager.isSessionElevated(session)) { 84 - redirect(routes.login.sudo.index.href(undefined, { redirect: url.pathname })); 84 + redirect(routes.verify.index.href(undefined, { redirect: url.pathname })); 85 85 } 86 86 87 87 const { fields } = generateBackupCodesForm; ··· 139 139 140 140 // require sudo mode 141 141 if (!accountManager.isSessionElevated(session)) { 142 - redirect(routes.login.sudo.index.href(undefined, { redirect: url.pathname })); 142 + redirect(routes.verify.index.href(undefined, { redirect: url.pathname })); 143 143 } 144 144 145 145 const { fields } = deleteBackupCodesForm;
+2 -2
packages/danaus/src/web/controllers/account/security/totp.tsx
··· 36 36 37 37 // require sudo mode 38 38 if (!accountManager.isSessionElevated(session)) { 39 - redirect(routes.login.sudo.index.href(undefined, { redirect: url.pathname })); 39 + redirect(routes.verify.index.href(undefined, { redirect: url.pathname })); 40 40 } 41 41 42 42 const account = accountManager.getAccount(session.did)!; ··· 173 173 174 174 // require sudo mode 175 175 if (!accountManager.isSessionElevated(session)) { 176 - redirect(routes.login.sudo.index.href(undefined, { redirect: url.pathname })); 176 + redirect(routes.verify.index.href(undefined, { redirect: url.pathname })); 177 177 } 178 178 179 179 const { fields } = removeTotpForm;
+2 -2
packages/danaus/src/web/controllers/account/security/webauthn.tsx
··· 34 34 35 35 // require sudo mode 36 36 if (!accountManager.isSessionElevated(session)) { 37 - redirect(routes.login.sudo.index.href(undefined, { redirect: url.pathname })); 37 + redirect(routes.verify.index.href(undefined, { redirect: url.pathname })); 38 38 } 39 39 40 40 const account = accountManager.getAccount(session.did)!; ··· 167 167 168 168 // require sudo mode 169 169 if (!accountManager.isSessionElevated(session)) { 170 - redirect(routes.login.sudo.index.href(undefined, { redirect: url.pathname })); 170 + redirect(routes.verify.index.href(undefined, { redirect: url.pathname })); 171 171 } 172 172 173 173 const { fields } = removeWebAuthnForm;
+57 -11
packages/danaus/src/web/controllers/login.tsx
··· 1 - import type { Controller } from '@oomfware/fetch-router'; 1 + import type { BuildAction } from '@oomfware/fetch-router'; 2 + import { forms } from '@oomfware/forms'; 3 + import { render } from '@oomfware/jsx'; 2 4 3 - import type { routes } from '../routes.ts'; 5 + import { BaseLayout } from '#web/layouts/base.tsx'; 6 + import Button from '#web/primitives/button.tsx'; 7 + import Field from '#web/primitives/field.tsx'; 8 + import Input from '#web/primitives/input.tsx'; 9 + import { routes } from '#web/routes.ts'; 4 10 5 - import mfa from './login/mfa.tsx'; 6 - import show from './login/show.tsx'; 7 - import sudo from './login/sudo.tsx'; 11 + import { loginForm } from './login/lib/forms.ts'; 8 12 9 13 export default { 10 - middleware: [], 11 - actions: { 12 - mfa: mfa, 13 - show: show, 14 - sudo: sudo, 14 + middleware: [forms({ loginForm })], 15 + action({ url }) { 16 + const { fields } = loginForm; 17 + 18 + const redirectUrl = url.searchParams.get('redirect') ?? fields.redirect.value(); 19 + 20 + return render( 21 + <BaseLayout> 22 + <title>Sign in - Danaus</title> 23 + 24 + <div class="flex flex-1 items-center justify-center p-4"> 25 + <div class="w-full max-w-96 rounded-xl bg-neutral-background-1 p-6 shadow-16"> 26 + <form {...loginForm} class="flex flex-col gap-6"> 27 + <h1 class="text-base-500 font-semibold">Sign in to your account</h1> 28 + 29 + <input {...fields.redirect.as('hidden', redirectUrl ?? routes.account.overview.href())} /> 30 + 31 + <Field 32 + label="Handle or email" 33 + required 34 + validationMessageText={fields.identifier.issues()?.[0]!.message} 35 + > 36 + <Input 37 + {...fields.identifier.as('text')} 38 + autocomplete="username" 39 + placeholder="alice.bsky.social" 40 + required 41 + autofocus 42 + /> 43 + </Field> 44 + 45 + <Field 46 + label="Password" 47 + required 48 + validationMessageText={fields._password.issues()?.[0]!.message} 49 + > 50 + <Input {...fields._password.as('password')} autocomplete="current-password" required /> 51 + </Field> 52 + 53 + <Button type="submit" variant="primary"> 54 + Sign in 55 + </Button> 56 + </form> 57 + </div> 58 + </div> 59 + </BaseLayout>, 60 + ); 15 61 }, 16 - } satisfies Controller<typeof routes.login>; 62 + } satisfies BuildAction<'ANY', typeof routes.login>;
+73 -152
packages/danaus/src/web/controllers/login/lib/forms.ts
··· 12 12 import { setWebSessionToken } from '#app/auth/web.ts'; 13 13 14 14 import { getAppContext } from '#web/middlewares/app-context.ts'; 15 - import { getSession } from '#web/middlewares/session.ts'; 16 15 import { routes } from '#web/routes.ts'; 17 16 18 17 export type AuthFactor = 'totp' | 'recovery' | 'password' | 'webauthn'; ··· 125 124 throw err; 126 125 } 127 126 128 - // clean up any expired MFA challenges 129 - accountManager.cleanupExpiredMfaChallenges(); 127 + // clean up any expired verify challenges 128 + accountManager.cleanupExpiredVerifyChallenges(); 130 129 131 130 // check if MFA is enabled 132 131 if (accountManager.getMfaStatus(account.did) !== null) { 133 - // create MFA challenge and redirect 134 - const token = accountManager.createMfaChallenge(account.did); 132 + // create verify challenge and redirect 133 + const token = accountManager.createVerifyChallenge(account.did); 135 134 136 - redirect(routes.login.mfa.index.href(undefined, { token, redirect: data.redirect })); 135 + redirect(routes.verify.index.href(undefined, { token, redirect: data.redirect })); 137 136 } 138 137 139 138 const { session, token } = await accountManager.createWebSession({ ··· 153 152 }, 154 153 ); 155 154 156 - export const verifyMfaLoginForm = form( 155 + const VERIFY_ALLOWED_MFA_FACTORS: AuthFactor[] = ['totp', 'recovery']; 156 + const VERIFY_ALLOWED_SUDO_MFA_FACTORS: AuthFactor[] = ['totp', 'webauthn', 'recovery']; 157 + const VERIFY_ALLOWED_SUDO_OFA_FACTORS: AuthFactor[] = ['password']; 158 + 159 + export const verifyForm = form( 157 160 v.object({ 158 161 challenge: v.string(), 159 - factor: v.picklist<AuthFactor[]>(['totp', 'recovery']), 162 + factor: v.picklist<AuthFactor[]>(['totp', 'recovery', 'password']), 160 163 _code: v.string(), 161 164 remember: v.optional(v.boolean(), false), 162 165 redirect: v.string(), ··· 165 168 const { accountManager } = getAppContext(); 166 169 const { request } = getContext(); 167 170 168 - const challenge = accountManager.getMfaChallenge(data.challenge); 171 + const challenge = accountManager.getVerifyChallenge(data.challenge); 169 172 if (challenge === null) { 170 - redirect(routes.login.show.href(undefined, { redirect: data.redirect })); 173 + redirect(routes.login.href(undefined, { redirect: data.redirect })); 174 + } 175 + 176 + const isSudo = challenge.session_id !== null; 177 + 178 + // determine allowed factors based on mode and MFA status 179 + let allowedFactors: AuthFactor[]; 180 + if (isSudo) { 181 + const hasMfa = accountManager.getMfaStatus(challenge.did) !== null; 182 + allowedFactors = hasMfa ? VERIFY_ALLOWED_SUDO_MFA_FACTORS : VERIFY_ALLOWED_SUDO_OFA_FACTORS; 183 + } else { 184 + allowedFactors = VERIFY_ALLOWED_MFA_FACTORS; 171 185 } 172 186 173 187 await verifyFactor({ 174 188 did: challenge.did, 175 189 factor: data.factor, 176 190 code: data._code, 177 - allowedFactors: ['totp', 'recovery'], 191 + allowedFactors, 178 192 }); 179 193 180 - accountManager.deleteMfaChallenge(data.challenge); 194 + // delete challenge 195 + accountManager.deleteVerifyChallenge(data.challenge); 181 196 182 - const { session, token } = await accountManager.createWebSession({ 183 - did: challenge.did, 184 - remember: data.remember ?? false, 185 - userAgent: request.headers.get('user-agent') ?? undefined, 186 - }); 197 + if (isSudo) { 198 + // elevate session and redirect 199 + accountManager.elevateSession(challenge.session_id!); 200 + redirect(data.redirect); 201 + } else { 202 + // MFA login: create new session 203 + const { session, token } = await accountManager.createWebSession({ 204 + did: challenge.did, 205 + remember: data.remember ?? false, 206 + userAgent: request.headers.get('user-agent') ?? undefined, 207 + }); 187 208 188 - setWebSessionToken(request, token, { 189 - expires: session.expires_at, 190 - httpOnly: true, 191 - sameSite: 'lax', 192 - path: '/', 193 - }); 209 + setWebSessionToken(request, token, { 210 + expires: session.expires_at, 211 + httpOnly: true, 212 + sameSite: 'lax', 213 + path: '/', 214 + }); 194 215 195 - redirect(data.redirect); 216 + redirect(data.redirect); 217 + } 196 218 }, 197 219 ); 198 220 ··· 218 240 type: v.literal('public-key'), 219 241 }); 220 242 221 - export const verifyWebAuthnMfaForm = form( 243 + export const verifyWebAuthnForm = form( 222 244 v.object({ 223 245 challenge: v.string(), 224 246 response: v.pipe(v.string(), v.minLength(1), v.parseJson(), authenticationResponseSchema), ··· 229 251 const { accountManager, config } = getAppContext(); 230 252 const { request } = getContext(); 231 253 232 - const mfaChallenge = accountManager.getMfaChallenge(data.challenge); 233 - if (mfaChallenge === null) { 234 - redirect(routes.login.show.href(undefined, { redirect: data.redirect })); 254 + const challenge = accountManager.getVerifyChallenge(data.challenge); 255 + if (challenge === null) { 256 + redirect(routes.login.href(undefined, { redirect: data.redirect })); 235 257 } 236 258 237 - if (!mfaChallenge.webauthn_challenge) { 259 + if (!challenge.webauthn_challenge) { 238 260 invalid(`WebAuthn not initiated for this session`); 239 261 } 240 262 241 263 // find the credential being used 242 264 const credential = accountManager.getWebAuthnCredentialByCredentialId(data.response.id); 243 - if (credential === null || credential.did !== mfaChallenge.did) { 265 + if (credential === null || credential.did !== challenge.did) { 244 266 invalid(`Invalid security key`); 245 267 } 246 268 ··· 250 272 try { 251 273 const verification = await verifyWebAuthnAuthentication({ 252 274 response: data.response, 253 - expectedChallenge: mfaChallenge.webauthn_challenge, 275 + expectedChallenge: challenge.webauthn_challenge, 254 276 expectedOrigin: config.service.publicUrl, 255 277 expectedRpId: new URL(config.service.publicUrl).hostname, 256 278 credential, ··· 269 291 invalid(`Security key verification failed`); 270 292 } 271 293 272 - accountManager.deleteMfaChallenge(data.challenge); 294 + const isSudo = challenge.session_id !== null; 273 295 274 - const { session, token } = await accountManager.createWebSession({ 275 - did: mfaChallenge.did, 276 - remember: data.remember ?? false, 277 - userAgent: request.headers.get('user-agent') ?? undefined, 278 - }); 296 + // delete challenge 297 + accountManager.deleteVerifyChallenge(data.challenge); 279 298 280 - setWebSessionToken(request, token, { 281 - expires: session.expires_at, 282 - httpOnly: true, 283 - sameSite: 'lax', 284 - path: '/', 285 - }); 299 + if (isSudo) { 300 + // elevate session and redirect 301 + accountManager.elevateSession(challenge.session_id!); 302 + redirect(data.redirect); 303 + } else { 304 + // MFA login: create new session 305 + const { session, token } = await accountManager.createWebSession({ 306 + did: challenge.did, 307 + remember: data.remember ?? false, 308 + userAgent: request.headers.get('user-agent') ?? undefined, 309 + }); 286 310 287 - redirect(data.redirect); 288 - }, 289 - ); 290 - 291 - const SUDO_ALLOWED_MFA_FACTORS: AuthFactor[] = ['totp', 'webauthn', 'recovery']; 292 - const SUDO_ALLOWED_OFA_FACTORS: AuthFactor[] = ['password']; 293 - 294 - export const verifySudoForm = form( 295 - v.object({ 296 - factor: v.picklist<AuthFactor[]>(['totp', 'recovery', 'password']), 297 - _code: v.string(), 298 - redirect: v.pipe(v.string(), v.minLength(1)), 299 - }), 300 - async (data) => { 301 - const { accountManager } = getAppContext(); 302 - const session = getSession(); 303 - 304 - // determine allowed factors based on MFA status 305 - const hasMfa = accountManager.getMfaStatus(session.did) !== null; 306 - const allowedFactors = hasMfa ? SUDO_ALLOWED_MFA_FACTORS : SUDO_ALLOWED_OFA_FACTORS; 307 - 308 - await verifyFactor({ 309 - did: session.did, 310 - factor: data.factor, 311 - code: data._code, 312 - allowedFactors, 313 - }); 314 - 315 - // elevate session and redirect 316 - accountManager.elevateSession(session.id); 317 - redirect(data.redirect); 318 - }, 319 - ); 320 - 321 - const sudoAuthenticationResponseSchema = v.object({ 322 - id: v.string(), 323 - rawId: v.string(), 324 - response: v.object({ 325 - clientDataJSON: v.string(), 326 - authenticatorData: v.string(), 327 - signature: v.string(), 328 - userHandle: v.optional(v.string()), 329 - }), 330 - authenticatorAttachment: v.optional(v.picklist(['cross-platform', 'platform'])), 331 - clientExtensionResults: v.object({ 332 - appid: v.optional(v.boolean()), 333 - credProps: v.optional( 334 - v.object({ 335 - rk: v.optional(v.boolean()), 336 - }), 337 - ), 338 - hmacCreateSecret: v.optional(v.boolean()), 339 - }), 340 - type: v.literal('public-key'), 341 - }); 342 - 343 - export const verifyWebAuthnSudoForm = form( 344 - v.object({ 345 - challenge: v.string(), 346 - response: v.pipe(v.string(), v.minLength(1), v.parseJson(), sudoAuthenticationResponseSchema), 347 - redirect: v.pipe(v.string(), v.minLength(1)), 348 - }), 349 - async (data) => { 350 - const { accountManager, config } = getAppContext(); 351 - const session = getSession(); 352 - 353 - if (accountManager.getMfaStatus(session.did) === null) { 354 - redirect(routes.login.sudo.index.href(undefined, { redirect: data.redirect })); 355 - } 356 - 357 - const sudoChallenge = accountManager.getWebAuthnChallenge(data.challenge); 358 - if (sudoChallenge === null || sudoChallenge.did !== session.did) { 359 - invalid(`Invalid or expired challenge`); 360 - } 361 - 362 - // find the credential being used 363 - const credential = accountManager.getWebAuthnCredentialByCredentialId(data.response.id); 364 - if (credential === null || credential.did !== session.did) { 365 - invalid(`Invalid security key`); 366 - } 367 - 368 - // verify the authentication response 369 - const { verifyWebAuthnAuthentication } = await import('#app/accounts/webauthn.ts'); 370 - 371 - try { 372 - const verification = await verifyWebAuthnAuthentication({ 373 - response: data.response, 374 - expectedChallenge: sudoChallenge.challenge, 375 - expectedOrigin: config.service.publicUrl, 376 - expectedRpId: new URL(config.service.publicUrl).hostname, 377 - credential, 311 + setWebSessionToken(request, token, { 312 + expires: session.expires_at, 313 + httpOnly: true, 314 + sameSite: 'lax', 315 + path: '/', 378 316 }); 379 317 380 - if (!verification.verified) { 381 - invalid(`Security key verification failed`); 382 - } 383 - 384 - // update counter 385 - accountManager.updateWebAuthnCredentialCounter( 386 - credential.id, 387 - verification.authenticationInfo.newCounter, 388 - ); 389 - } catch { 390 - invalid(`Security key verification failed`); 318 + redirect(data.redirect); 391 319 } 392 - 393 - // clean up the challenge 394 - accountManager.deleteWebAuthnChallenge(data.challenge); 395 - 396 - // elevate session and redirect 397 - accountManager.elevateSession(session.id); 398 - redirect(data.redirect); 399 320 }, 400 321 );
-336
packages/danaus/src/web/controllers/login/mfa.tsx
··· 1 - import { redirect, type Controller } from '@oomfware/fetch-router'; 2 - import { forms } from '@oomfware/forms'; 3 - import { render, type JSXNode } from '@oomfware/jsx'; 4 - 5 - import { PreferredMfa } from '#app/accounts/db/schema.ts'; 6 - import type { MfaStatus } from '#app/accounts/manager.ts'; 7 - import { 8 - RECOVERY_CODE_LENGTH, 9 - RECOVERY_CODE_RE, 10 - TOTP_CODE_LENGTH, 11 - TOTP_CODE_RE, 12 - } from '#app/accounts/totp.ts'; 13 - import { generateWebAuthnAuthenticationOptions } from '#app/accounts/webauthn.ts'; 14 - 15 - import { BaseLayout } from '#web/layouts/base.tsx'; 16 - import { getAppContext } from '#web/middlewares/app-context.ts'; 17 - import Button from '#web/primitives/button.tsx'; 18 - import Field from '#web/primitives/field.tsx'; 19 - import Input from '#web/primitives/input.tsx'; 20 - import MenuItem from '#web/primitives/menu-item.tsx'; 21 - import MenuList from '#web/primitives/menu-list.tsx'; 22 - import MenuPopover from '#web/primitives/menu-popover.tsx'; 23 - import MenuTrigger from '#web/primitives/menu-trigger.tsx'; 24 - import Menu from '#web/primitives/menu.tsx'; 25 - import { routes } from '#web/routes.ts'; 26 - 27 - import { verifyMfaLoginForm, verifyWebAuthnMfaForm, type AuthFactor } from './lib/forms.ts'; 28 - 29 - export default { 30 - middleware: [forms({ verifyMfaLoginForm, verifyWebAuthnMfaForm })], 31 - actions: { 32 - index({ url }) { 33 - const { accountManager } = getAppContext(); 34 - 35 - const redirectUrl = url.searchParams.get('redirect'); 36 - const token = url.searchParams.get('token'); 37 - if (token === null) { 38 - redirect(routes.login.show.href(undefined, { redirect: redirectUrl })); 39 - } 40 - 41 - const mfaChallenge = accountManager.getMfaChallenge(token); 42 - if (mfaChallenge === null) { 43 - redirect(routes.login.show.href(undefined, { redirect: redirectUrl })); 44 - } 45 - 46 - const mfaStatus = accountManager.getMfaStatus(mfaChallenge.did); 47 - if (mfaStatus === null) { 48 - redirect(routes.login.show.href(undefined, { redirect: redirectUrl })); 49 - } 50 - 51 - switch (mfaStatus.preferred) { 52 - case PreferredMfa.WebAuthn: { 53 - redirect(routes.login.mfa.webauthn.href(undefined, { token: token, redirect: redirectUrl })); 54 - } 55 - case PreferredMfa.Totp: { 56 - redirect(routes.login.mfa.totp.href(undefined, { token: token, redirect: redirectUrl })); 57 - } 58 - } 59 - }, 60 - totp({ url }) { 61 - const { accountManager } = getAppContext(); 62 - 63 - const { fields } = verifyMfaLoginForm; 64 - 65 - const redirectUrl = url.searchParams.get('redirect') ?? fields.redirect.value(); 66 - const challenge = url.searchParams.get('token') ?? fields.challenge.value(); 67 - if (challenge == null) { 68 - redirect(routes.login.show.href(undefined, { redirect: redirectUrl })); 69 - } 70 - 71 - const mfaChallenge = accountManager.getMfaChallenge(challenge); 72 - if (mfaChallenge === null) { 73 - redirect(routes.login.show.href(undefined, { redirect: redirectUrl })); 74 - } 75 - 76 - const mfaStatus = accountManager.getMfaStatus(mfaChallenge.did); 77 - if (mfaStatus === null) { 78 - redirect(routes.login.show.href(undefined, { redirect: redirectUrl })); 79 - } 80 - 81 - return render( 82 - <BaseForm factor="totp" challenge={challenge} redirectUrl={redirectUrl} mfaStatus={mfaStatus}> 83 - <div class="flex flex-col gap-2"> 84 - <h1 class="text-base-500 font-semibold">Two-factor authentication</h1> 85 - <p class="text-base-300 text-neutral-foreground-3"> 86 - Enter the 6-digit code from your authenticator app. 87 - </p> 88 - </div> 89 - 90 - <Field label="Verification code" validationMessageText={fields.allIssues()?.at(0)?.message}> 91 - <Input 92 - {...fields._code.as('text')} 93 - placeholder="000000" 94 - autocomplete="one-time-code" 95 - inputmode="numeric" 96 - pattern={TOTP_CODE_RE.source} 97 - minlength={TOTP_CODE_LENGTH} 98 - maxlength={TOTP_CODE_LENGTH} 99 - required 100 - autofocus 101 - /> 102 - </Field> 103 - 104 - <Button type="submit" variant="primary"> 105 - Confirm 106 - </Button> 107 - </BaseForm>, 108 - ); 109 - }, 110 - async webauthn({ url }) { 111 - const { accountManager, config } = getAppContext(); 112 - 113 - const { fields } = verifyWebAuthnMfaForm; 114 - 115 - const redirectUrl = url.searchParams.get('redirect') ?? fields.redirect.value(); 116 - const challenge = url.searchParams.get('token') ?? fields.challenge.value(); 117 - if (challenge == null || accountManager.getMfaChallenge(challenge) === null) { 118 - redirect(routes.login.show.href(undefined, { redirect: redirectUrl })); 119 - } 120 - 121 - const mfaChallenge = accountManager.getMfaChallenge(challenge)!; 122 - 123 - // get user's WebAuthn credentials (security keys or passkeys) 124 - const webauthnCredentials = accountManager.listWebAuthnCredentials(mfaChallenge.did); 125 - if (webauthnCredentials.length === 0) { 126 - // no WebAuthn credentials, redirect to TOTP 127 - redirect(routes.login.mfa.totp.href(undefined, { token: challenge, redirect: redirectUrl })); 128 - } 129 - 130 - // generate authentication options 131 - const options = await generateWebAuthnAuthenticationOptions({ 132 - rpId: new URL(config.service.publicUrl).hostname, 133 - allowCredentials: webauthnCredentials, 134 - }); 135 - 136 - // store the challenge for verification 137 - accountManager.setMfaChallengeWebAuthn(challenge, options.challenge); 138 - 139 - return render( 140 - <BaseLayout> 141 - <title>Two-factor authentication - Danaus</title> 142 - 143 - <script src="/assets/webauthn-authenticate.js" type="module" /> 144 - 145 - <div class="flex flex-1 items-center justify-center p-4"> 146 - <div class="w-full max-w-96 rounded-xl bg-neutral-background-1 p-6 shadow-16"> 147 - <form {...verifyWebAuthnMfaForm} class="flex flex-col gap-6"> 148 - <input {...fields.challenge.as('hidden', challenge)} /> 149 - <input {...fields.redirect.as('hidden', redirectUrl ?? routes.account.overview.href())} /> 150 - 151 - <div class="flex flex-col gap-2"> 152 - <h1 class="text-base-500 font-semibold">Two-factor authentication</h1> 153 - <p class="text-base-300 text-neutral-foreground-3"> 154 - Insert your security key and touch it to verify your identity. 155 - </p> 156 - </div> 157 - 158 - <danaus-webauthn-authenticate data-options={JSON.stringify(options)}> 159 - <input {...fields.response.as('hidden', '')} data-target="webauthn-authenticate.response" /> 160 - 161 - <Button data-target="webauthn-authenticate.start" type="button" variant="primary"> 162 - Use security key 163 - </Button> 164 - 165 - <div 166 - data-target="webauthn-authenticate.status" 167 - class="text-center text-base-300 text-neutral-foreground-3" 168 - /> 169 - </danaus-webauthn-authenticate> 170 - 171 - <Menu> 172 - <MenuTrigger> 173 - <Button>Show other methods</Button> 174 - </MenuTrigger> 175 - 176 - <MenuPopover> 177 - <MenuList> 178 - <MenuItem 179 - href={routes.login.mfa.totp.href(undefined, { 180 - token: challenge, 181 - redirect: redirectUrl, 182 - })} 183 - > 184 - Use authenticator app 185 - </MenuItem> 186 - 187 - <MenuItem 188 - href={routes.login.mfa.recovery.href(undefined, { 189 - token: challenge, 190 - redirect: redirectUrl, 191 - })} 192 - > 193 - Use 2FA recovery code 194 - </MenuItem> 195 - </MenuList> 196 - </MenuPopover> 197 - </Menu> 198 - </form> 199 - </div> 200 - </div> 201 - </BaseLayout>, 202 - ); 203 - }, 204 - recovery({ url }) { 205 - const { accountManager } = getAppContext(); 206 - 207 - const { fields } = verifyMfaLoginForm; 208 - 209 - const redirectUrl = url.searchParams.get('redirect') ?? fields.redirect.value(); 210 - const challenge = url.searchParams.get('token') ?? fields.challenge.value(); 211 - if (challenge == null) { 212 - redirect(routes.login.show.href(undefined, { redirect: redirectUrl })); 213 - } 214 - 215 - const mfaChallenge = accountManager.getMfaChallenge(challenge); 216 - if (mfaChallenge === null) { 217 - redirect(routes.login.show.href(undefined, { redirect: redirectUrl })); 218 - } 219 - 220 - const mfaStatus = accountManager.getMfaStatus(mfaChallenge.did); 221 - if (mfaStatus === null) { 222 - redirect(routes.login.show.href(undefined, { redirect: redirectUrl })); 223 - } 224 - 225 - return render( 226 - <BaseForm factor="recovery" challenge={challenge} redirectUrl={redirectUrl} mfaStatus={mfaStatus}> 227 - <div class="flex flex-col gap-2"> 228 - <h1 class="text-base-500 font-semibold">Two-factor authentication</h1> 229 - <p class="text-base-300 text-neutral-foreground-3"> 230 - Enter one of your recovery codes to verify your identity. 231 - </p> 232 - </div> 233 - 234 - <Field label="Recovery code" validationMessageText={fields.allIssues()?.at(0)?.message}> 235 - <Input 236 - {...fields._code.as('text')} 237 - placeholder="XXXX-XXXX" 238 - pattern={RECOVERY_CODE_RE.source} 239 - minlength={RECOVERY_CODE_LENGTH} 240 - maxlength={RECOVERY_CODE_LENGTH + 1} 241 - required 242 - autofocus 243 - /> 244 - </Field> 245 - 246 - <Button type="submit" variant="primary"> 247 - Confirm 248 - </Button> 249 - </BaseForm>, 250 - ); 251 - }, 252 - }, 253 - } satisfies Controller<typeof routes.login.mfa>; 254 - 255 - const BaseForm = (props: { 256 - factor: AuthFactor; 257 - challenge: string; 258 - redirectUrl: string | undefined; 259 - mfaStatus: MfaStatus; 260 - children: JSXNode; 261 - }) => { 262 - const { fields } = verifyMfaLoginForm; 263 - 264 - const challenge = props.challenge; 265 - const redirectUrl = props.redirectUrl ?? routes.account.overview.href(); 266 - const { mfaStatus } = props; 267 - 268 - // count how many other methods are available 269 - const otherMethodsCount = 270 - (props.factor !== 'webauthn' && mfaStatus.hasWebAuthn ? 1 : 0) + 271 - (props.factor !== 'totp' && mfaStatus.hasTotp ? 1 : 0) + 272 - (props.factor !== 'recovery' && mfaStatus.hasRecoveryCodes ? 1 : 0); 273 - 274 - return ( 275 - <BaseLayout> 276 - <title>Two-factor authentication - Danaus</title> 277 - 278 - <div class="flex flex-1 items-center justify-center p-4"> 279 - <div class="w-full max-w-96 rounded-xl bg-neutral-background-1 p-6 shadow-16"> 280 - <form {...verifyMfaLoginForm} class="flex flex-col gap-6"> 281 - <input {...fields.challenge.as('hidden', challenge)} /> 282 - <input {...fields.redirect.as('hidden', redirectUrl)} /> 283 - <input {...fields.factor.as('hidden', props.factor)} /> 284 - 285 - {props.children} 286 - 287 - {otherMethodsCount > 0 && ( 288 - <Menu> 289 - <MenuTrigger> 290 - <Button>Show other methods</Button> 291 - </MenuTrigger> 292 - 293 - <MenuPopover> 294 - <MenuList> 295 - {props.factor !== 'webauthn' && mfaStatus.hasWebAuthn && ( 296 - <MenuItem 297 - href={routes.login.mfa.webauthn.href(undefined, { 298 - token: challenge, 299 - redirect: redirectUrl, 300 - })} 301 - > 302 - Use security key 303 - </MenuItem> 304 - )} 305 - 306 - {props.factor !== 'totp' && mfaStatus.hasTotp && ( 307 - <MenuItem 308 - href={routes.login.mfa.totp.href(undefined, { 309 - token: challenge, 310 - redirect: redirectUrl, 311 - })} 312 - > 313 - Use authenticator app 314 - </MenuItem> 315 - )} 316 - 317 - {props.factor !== 'recovery' && mfaStatus.hasRecoveryCodes && ( 318 - <MenuItem 319 - href={routes.login.mfa.recovery.href(undefined, { 320 - token: challenge, 321 - redirect: redirectUrl, 322 - })} 323 - > 324 - Use 2FA recovery code 325 - </MenuItem> 326 - )} 327 - </MenuList> 328 - </MenuPopover> 329 - </Menu> 330 - )} 331 - </form> 332 - </div> 333 - </div> 334 - </BaseLayout> 335 - ); 336 - };
-62
packages/danaus/src/web/controllers/login/show.tsx
··· 1 - import type { BuildAction } from '@oomfware/fetch-router'; 2 - import { forms } from '@oomfware/forms'; 3 - import { render } from '@oomfware/jsx'; 4 - 5 - import { BaseLayout } from '#web/layouts/base.tsx'; 6 - import Button from '#web/primitives/button.tsx'; 7 - import Field from '#web/primitives/field.tsx'; 8 - import Input from '#web/primitives/input.tsx'; 9 - import { routes } from '#web/routes.ts'; 10 - 11 - import { loginForm } from './lib/forms'; 12 - 13 - export default { 14 - middleware: [forms({ loginForm })], 15 - action({ url }) { 16 - const { fields } = loginForm; 17 - 18 - const redirectUrl = url.searchParams.get('redirect') ?? fields.redirect.value(); 19 - 20 - return render( 21 - <BaseLayout> 22 - <title>Sign in - Danaus</title> 23 - 24 - <div class="flex flex-1 items-center justify-center p-4"> 25 - <div class="w-full max-w-96 rounded-xl bg-neutral-background-1 p-6 shadow-16"> 26 - <form {...loginForm} class="flex flex-col gap-6"> 27 - <h1 class="text-base-500 font-semibold">Sign in to your account</h1> 28 - 29 - <input {...fields.redirect.as('hidden', redirectUrl ?? routes.account.overview.href())} /> 30 - 31 - <Field 32 - label="Handle or email" 33 - required 34 - validationMessageText={fields.identifier.issues()?.[0]!.message} 35 - > 36 - <Input 37 - {...fields.identifier.as('text')} 38 - autocomplete="username" 39 - placeholder="alice.bsky.social" 40 - required 41 - autofocus 42 - /> 43 - </Field> 44 - 45 - <Field 46 - label="Password" 47 - required 48 - validationMessageText={fields._password.issues()?.[0]!.message} 49 - > 50 - <Input {...fields._password.as('password')} autocomplete="current-password" required /> 51 - </Field> 52 - 53 - <Button type="submit" variant="primary"> 54 - Sign in 55 - </Button> 56 - </form> 57 - </div> 58 - </div> 59 - </BaseLayout>, 60 - ); 61 - }, 62 - } satisfies BuildAction<'ANY', typeof routes.login.show>;
-333
packages/danaus/src/web/controllers/login/sudo.tsx
··· 1 - import { redirect, type Controller } from '@oomfware/fetch-router'; 2 - import { forms } from '@oomfware/forms'; 3 - import { render, type JSXNode } from '@oomfware/jsx'; 4 - 5 - import { PreferredMfa } from '#app/accounts/db/schema.ts'; 6 - import { 7 - RECOVERY_CODE_LENGTH, 8 - RECOVERY_CODE_RE, 9 - TOTP_CODE_LENGTH, 10 - TOTP_CODE_RE, 11 - } from '#app/accounts/totp.ts'; 12 - import { generateWebAuthnAuthenticationOptions } from '#app/accounts/webauthn.ts'; 13 - 14 - import { BaseLayout } from '#web/layouts/base.tsx'; 15 - import { getAppContext } from '#web/middlewares/app-context.ts'; 16 - import { getSession, requireSession } from '#web/middlewares/session.ts'; 17 - import Button from '#web/primitives/button.tsx'; 18 - import Field from '#web/primitives/field.tsx'; 19 - import Input from '#web/primitives/input.tsx'; 20 - import MenuItem from '#web/primitives/menu-item.tsx'; 21 - import MenuList from '#web/primitives/menu-list.tsx'; 22 - import MenuPopover from '#web/primitives/menu-popover.tsx'; 23 - import MenuTrigger from '#web/primitives/menu-trigger.tsx'; 24 - import Menu from '#web/primitives/menu.tsx'; 25 - import { routes } from '#web/routes.ts'; 26 - 27 - import { verifySudoForm, verifyWebAuthnSudoForm, type AuthFactor } from './lib/forms.ts'; 28 - 29 - export default { 30 - middleware: [requireSession(), forms({ verifySudoForm, verifyWebAuthnSudoForm })], 31 - actions: { 32 - index({ url }): never { 33 - const { accountManager } = getAppContext(); 34 - const session = getSession(); 35 - 36 - const redirectUrl = url.searchParams.get('redirect'); 37 - if (!redirectUrl) { 38 - redirect(routes.account.overview.href()); 39 - } 40 - 41 - const isElevated = accountManager.isSessionElevated(session); 42 - if (isElevated) { 43 - redirect(redirectUrl); 44 - } 45 - 46 - const mfaStatus = accountManager.getMfaStatus(session.did); 47 - if (mfaStatus === null) { 48 - redirect(routes.login.sudo.password.href(undefined, { redirect: redirectUrl })); 49 - } 50 - 51 - switch (mfaStatus.preferred) { 52 - case PreferredMfa.WebAuthn: { 53 - redirect(routes.login.sudo.webauthn.href(undefined, { redirect: redirectUrl })); 54 - } 55 - case PreferredMfa.Totp: { 56 - redirect(routes.login.sudo.totp.href(undefined, { redirect: redirectUrl })); 57 - } 58 - } 59 - }, 60 - totp({ url }) { 61 - const { accountManager } = getAppContext(); 62 - const session = getSession(); 63 - 64 - const { fields } = verifySudoForm; 65 - 66 - const redirectUrl = url.searchParams.get('redirect') ?? fields.redirect.value(); 67 - if (!redirectUrl) { 68 - redirect(routes.account.overview.href()); 69 - } 70 - 71 - if (accountManager.getMfaStatus(session.did) === null) { 72 - redirect(routes.login.sudo.index.href(undefined, { redirect: redirectUrl })); 73 - } 74 - 75 - return render( 76 - <BaseForm factor="totp" redirectUrl={redirectUrl}> 77 - <div class="flex flex-col gap-2"> 78 - <h1 class="text-base-500 font-semibold">Confirm your identity</h1> 79 - <p class="text-base-300 text-neutral-foreground-3"> 80 - Enter the 6-digit code from your authenticator app to continue. 81 - </p> 82 - </div> 83 - 84 - <Field label="Verification code" validationMessageText={fields.allIssues()?.at(0)?.message}> 85 - <Input 86 - {...fields._code.as('text')} 87 - placeholder="000000" 88 - autocomplete="one-time-code" 89 - inputmode="numeric" 90 - pattern={TOTP_CODE_RE.source} 91 - minlength={TOTP_CODE_LENGTH} 92 - maxlength={TOTP_CODE_LENGTH} 93 - required 94 - autofocus 95 - /> 96 - </Field> 97 - 98 - <Button type="submit" variant="primary"> 99 - Confirm 100 - </Button> 101 - </BaseForm>, 102 - ); 103 - }, 104 - recovery({ url }) { 105 - const { accountManager } = getAppContext(); 106 - const session = getSession(); 107 - 108 - const { fields } = verifySudoForm; 109 - 110 - const redirectUrl = url.searchParams.get('redirect') ?? fields.redirect.value(); 111 - if (!redirectUrl) { 112 - redirect(routes.account.overview.href()); 113 - } 114 - 115 - if (accountManager.getMfaStatus(session.did) === null) { 116 - redirect(routes.login.sudo.index.href(undefined, { redirect: redirectUrl })); 117 - } 118 - 119 - return render( 120 - <BaseForm factor="recovery" redirectUrl={redirectUrl}> 121 - <div class="flex flex-col gap-2"> 122 - <h1 class="text-base-500 font-semibold">Confirm your identity</h1> 123 - <p class="text-base-300 text-neutral-foreground-3"> 124 - Enter one of your recovery codes to continue. 125 - </p> 126 - </div> 127 - 128 - <Field label="Recovery code" validationMessageText={fields.allIssues()?.at(0)?.message}> 129 - <Input 130 - {...fields._code.as('text')} 131 - placeholder="XXXX-XXXX" 132 - pattern={RECOVERY_CODE_RE.source} 133 - minlength={RECOVERY_CODE_LENGTH} 134 - maxlength={RECOVERY_CODE_LENGTH + 1} 135 - required 136 - autofocus 137 - /> 138 - </Field> 139 - 140 - <Button type="submit" variant="primary"> 141 - Confirm 142 - </Button> 143 - </BaseForm>, 144 - ); 145 - }, 146 - async webauthn({ url }) { 147 - const { accountManager, config } = getAppContext(); 148 - const session = getSession(); 149 - 150 - const { fields } = verifyWebAuthnSudoForm; 151 - 152 - const redirectUrl = url.searchParams.get('redirect') ?? fields.redirect.value(); 153 - if (!redirectUrl) { 154 - redirect(routes.account.overview.href()); 155 - } 156 - 157 - if (accountManager.getMfaStatus(session.did) === null) { 158 - redirect(routes.login.sudo.index.href(undefined, { redirect: redirectUrl })); 159 - } 160 - 161 - // get user's WebAuthn credentials (security keys or passkeys) 162 - const webauthnCredentials = accountManager.listWebAuthnCredentials(session.did); 163 - if (webauthnCredentials.length === 0) { 164 - // no WebAuthn credentials, redirect to TOTP 165 - redirect(routes.login.sudo.totp.href(undefined, { redirect: redirectUrl })); 166 - } 167 - 168 - // generate authentication options 169 - const options = await generateWebAuthnAuthenticationOptions({ 170 - rpId: new URL(config.service.publicUrl).hostname, 171 - allowCredentials: webauthnCredentials, 172 - }); 173 - 174 - // store the challenge for verification (reuse webauthn challenge table) 175 - const challengeToken = accountManager.createWebAuthnChallenge(session.did, options.challenge); 176 - 177 - return render( 178 - <BaseLayout> 179 - <title>Confirm your identity - Danaus</title> 180 - 181 - <script src="/assets/webauthn-authenticate.js" type="module" /> 182 - 183 - <div class="flex flex-1 items-center justify-center p-4"> 184 - <div class="w-full max-w-96 rounded-xl bg-neutral-background-1 p-6 shadow-16"> 185 - <form {...verifyWebAuthnSudoForm} class="flex flex-col gap-6"> 186 - <input {...fields.challenge.as('hidden', challengeToken)} /> 187 - <input {...fields.redirect.as('hidden', redirectUrl)} /> 188 - 189 - <div class="flex flex-col gap-2"> 190 - <h1 class="text-base-500 font-semibold">Confirm your identity</h1> 191 - <p class="text-base-300 text-neutral-foreground-3"> 192 - Insert your security key and touch it to continue. 193 - </p> 194 - </div> 195 - 196 - <danaus-webauthn-authenticate data-options={JSON.stringify(options)}> 197 - <input {...fields.response.as('hidden', '')} data-target="webauthn-authenticate.response" /> 198 - 199 - <Button data-target="webauthn-authenticate.start" type="button" variant="primary"> 200 - Use security key 201 - </Button> 202 - 203 - <div 204 - data-target="webauthn-authenticate.status" 205 - class="text-center text-base-300 text-neutral-foreground-3" 206 - /> 207 - </danaus-webauthn-authenticate> 208 - 209 - <Menu> 210 - <MenuTrigger> 211 - <Button>Show other methods</Button> 212 - </MenuTrigger> 213 - 214 - <MenuPopover> 215 - <MenuList> 216 - <MenuItem href={routes.login.sudo.totp.href(undefined, { redirect: redirectUrl })}> 217 - Use authenticator app 218 - </MenuItem> 219 - 220 - <MenuItem href={routes.login.sudo.recovery.href(undefined, { redirect: redirectUrl })}> 221 - Use 2FA recovery code 222 - </MenuItem> 223 - </MenuList> 224 - </MenuPopover> 225 - </Menu> 226 - </form> 227 - </div> 228 - </div> 229 - </BaseLayout>, 230 - ); 231 - }, 232 - password({ url }) { 233 - const { accountManager } = getAppContext(); 234 - const session = getSession(); 235 - 236 - const { fields } = verifySudoForm; 237 - 238 - const redirectUrl = url.searchParams.get('redirect') ?? fields.redirect.value(); 239 - if (!redirectUrl) { 240 - redirect(routes.account.overview.href()); 241 - } 242 - 243 - if (accountManager.getMfaStatus(session.did) !== null) { 244 - redirect(routes.login.sudo.index.href(undefined, { redirect: redirectUrl })); 245 - } 246 - 247 - return render( 248 - <BaseForm factor="password" redirectUrl={redirectUrl}> 249 - <div class="flex flex-col gap-2"> 250 - <h1 class="text-base-500 font-semibold">Confirm your identity</h1> 251 - <p class="text-base-300 text-neutral-foreground-3">Enter your password to continue.</p> 252 - </div> 253 - 254 - <Field label="Password" validationMessageText={fields.allIssues()?.at(0)?.message}> 255 - <Input {...fields._code.as('password')} autocomplete="current-password" required autofocus /> 256 - </Field> 257 - 258 - <Button type="submit" variant="primary"> 259 - Confirm 260 - </Button> 261 - </BaseForm>, 262 - ); 263 - }, 264 - }, 265 - } satisfies Controller<typeof routes.login.sudo>; 266 - 267 - const BaseForm = (props: { factor: AuthFactor; redirectUrl: string; children: JSXNode }) => { 268 - const { accountManager } = getAppContext(); 269 - const { did } = getSession(); 270 - 271 - const { fields } = verifySudoForm; 272 - 273 - const mfaStatus = accountManager.getMfaStatus(did); 274 - 275 - return ( 276 - <BaseLayout> 277 - <title>Confirm your identity - Danaus</title> 278 - 279 - <div class="flex flex-1 items-center justify-center p-4"> 280 - <div class="w-full max-w-96 rounded-xl bg-neutral-background-1 p-6 shadow-16"> 281 - <form {...verifySudoForm} class="flex flex-col gap-6"> 282 - <input {...fields.redirect.as('hidden', props.redirectUrl)} /> 283 - <input {...fields.factor.as('hidden', props.factor)} /> 284 - 285 - {props.children} 286 - 287 - {mfaStatus !== null && ( 288 - <Menu> 289 - <MenuTrigger> 290 - <Button>Show other methods</Button> 291 - </MenuTrigger> 292 - 293 - <MenuPopover> 294 - <MenuList> 295 - {props.factor !== 'webauthn' && mfaStatus.hasWebAuthn && ( 296 - <MenuItem 297 - href={routes.login.sudo.webauthn.href(undefined, { 298 - redirect: props.redirectUrl, 299 - })} 300 - > 301 - Use security key 302 - </MenuItem> 303 - )} 304 - 305 - {props.factor !== 'totp' && mfaStatus.hasTotp && ( 306 - <MenuItem 307 - href={routes.login.sudo.totp.href(undefined, { 308 - redirect: props.redirectUrl, 309 - })} 310 - > 311 - Use authenticator app 312 - </MenuItem> 313 - )} 314 - 315 - {props.factor !== 'recovery' && mfaStatus.hasRecoveryCodes && ( 316 - <MenuItem 317 - href={routes.login.sudo.recovery.href(undefined, { 318 - redirect: props.redirectUrl, 319 - })} 320 - > 321 - Use 2FA recovery code 322 - </MenuItem> 323 - )} 324 - </MenuList> 325 - </MenuPopover> 326 - </Menu> 327 - )} 328 - </form> 329 - </div> 330 - </div> 331 - </BaseLayout> 332 - ); 333 - };
+456
packages/danaus/src/web/controllers/verify.tsx
··· 1 + import { redirect, type Controller } from '@oomfware/fetch-router'; 2 + import { forms } from '@oomfware/forms'; 3 + import { render, type JSXNode } from '@oomfware/jsx'; 4 + 5 + import { PreferredMfa } from '#app/accounts/db/schema.ts'; 6 + import type { MfaStatus, VerifyChallenge } from '#app/accounts/manager.ts'; 7 + import { 8 + RECOVERY_CODE_LENGTH, 9 + RECOVERY_CODE_RE, 10 + TOTP_CODE_LENGTH, 11 + TOTP_CODE_RE, 12 + } from '#app/accounts/totp.ts'; 13 + import { generateWebAuthnAuthenticationOptions } from '#app/accounts/webauthn.ts'; 14 + 15 + import { BaseLayout } from '#web/layouts/base.tsx'; 16 + import { getAppContext } from '#web/middlewares/app-context.ts'; 17 + import { tryGetSession } from '#web/middlewares/session.ts'; 18 + import Button from '#web/primitives/button.tsx'; 19 + import Checkbox from '#web/primitives/checkbox.tsx'; 20 + import Field from '#web/primitives/field.tsx'; 21 + import Input from '#web/primitives/input.tsx'; 22 + import MenuItem from '#web/primitives/menu-item.tsx'; 23 + import MenuList from '#web/primitives/menu-list.tsx'; 24 + import MenuPopover from '#web/primitives/menu-popover.tsx'; 25 + import MenuTrigger from '#web/primitives/menu-trigger.tsx'; 26 + import Menu from '#web/primitives/menu.tsx'; 27 + import { routes } from '#web/routes.ts'; 28 + 29 + import { verifyForm, verifyWebAuthnForm, type AuthFactor } from './login/lib/forms.ts'; 30 + 31 + /** context for verify pages - resolved challenge with mode detection */ 32 + interface VerifyContext { 33 + challenge: VerifyChallenge; 34 + mfaStatus: MfaStatus | null; 35 + redirectUrl: string; 36 + /** true if session_id is set (sudo mode), false if null (MFA login) */ 37 + isSudo: boolean; 38 + } 39 + 40 + /** 41 + * resolves the verify context from the request. 42 + * 43 + * modes are mutually exclusive: 44 + * 1. if ?token present → MFA login mode (token must be valid) 45 + * 2. if no ?token but session → sudo mode (create/reuse sudo challenge) 46 + * 3. neither → redirect to login 47 + */ 48 + const resolveVerifyContext = (url: URL): VerifyContext => { 49 + const { accountManager } = getAppContext(); 50 + 51 + const tokenParam = url.searchParams.get('token'); 52 + const redirectUrl = url.searchParams.get('redirect') ?? routes.account.overview.href(); 53 + 54 + // mode 1: MFA login - ?token is present 55 + if (tokenParam !== null) { 56 + const challenge = accountManager.getVerifyChallenge(tokenParam); 57 + if (challenge === null) { 58 + // invalid or expired token → redirect to login (don't fall back to sudo) 59 + redirect(routes.login.href(undefined, { redirect: redirectUrl })); 60 + } 61 + 62 + const mfaStatus = accountManager.getMfaStatus(challenge.did); 63 + if (mfaStatus === null) { 64 + // no MFA configured (shouldn't happen, but handle it) 65 + redirect(routes.login.href(undefined, { redirect: redirectUrl })); 66 + } 67 + 68 + return { 69 + challenge, 70 + mfaStatus, 71 + redirectUrl, 72 + isSudo: false, 73 + }; 74 + } 75 + 76 + // mode 2: sudo - no token, but has session 77 + const session = tryGetSession(); 78 + if (session === null) { 79 + redirect(routes.login.href(undefined, { redirect: redirectUrl })); 80 + } 81 + 82 + // already elevated? redirect directly to target 83 + if (accountManager.isSessionElevated(session)) { 84 + redirect(redirectUrl); 85 + } 86 + 87 + // create or reuse sudo challenge 88 + const challenge = accountManager.getOrCreateSudoChallenge(session.id, session.did); 89 + const mfaStatus = accountManager.getMfaStatus(session.did); 90 + 91 + return { 92 + challenge, 93 + mfaStatus, 94 + redirectUrl, 95 + isSudo: true, 96 + }; 97 + }; 98 + 99 + export default { 100 + middleware: [forms({ verifyForm, verifyWebAuthnForm })], 101 + actions: { 102 + index({ url }) { 103 + const ctx = resolveVerifyContext(url); 104 + 105 + // for sudo mode with no MFA → go to password 106 + if (ctx.isSudo && ctx.mfaStatus === null) { 107 + redirect(routes.verify.password.href(undefined, { redirect: ctx.redirectUrl })); 108 + } 109 + 110 + // for MFA login without mfaStatus, this shouldn't happen but redirect to login 111 + if (ctx.mfaStatus === null) { 112 + redirect(routes.login.href(undefined, { redirect: ctx.redirectUrl })); 113 + } 114 + 115 + // redirect to preferred method 116 + const tokenParam = ctx.isSudo ? undefined : ctx.challenge.token; 117 + switch (ctx.mfaStatus.preferred) { 118 + case PreferredMfa.WebAuthn: { 119 + redirect( 120 + routes.verify.webauthn.href(undefined, { token: tokenParam, redirect: ctx.redirectUrl }), 121 + ); 122 + } 123 + case PreferredMfa.Totp: { 124 + redirect(routes.verify.totp.href(undefined, { token: tokenParam, redirect: ctx.redirectUrl })); 125 + } 126 + } 127 + }, 128 + totp({ url }) { 129 + const { fields } = verifyForm; 130 + 131 + const ctx = resolveVerifyContext(url); 132 + 133 + // for sudo without MFA → redirect to index 134 + if (ctx.isSudo && ctx.mfaStatus === null) { 135 + redirect(routes.verify.index.href(undefined, { redirect: ctx.redirectUrl })); 136 + } 137 + 138 + return render( 139 + <BaseForm 140 + factor="totp" 141 + challenge={ctx.challenge.token} 142 + redirectUrl={ctx.redirectUrl} 143 + mfaStatus={ctx.mfaStatus} 144 + isSudo={ctx.isSudo} 145 + > 146 + <div class="flex flex-col gap-2"> 147 + <h1 class="text-base-500 font-semibold"> 148 + {ctx.isSudo ? 'Confirm your identity' : 'Two-factor authentication'} 149 + </h1> 150 + <p class="text-base-300 text-neutral-foreground-3"> 151 + Enter the 6-digit code from your authenticator app 152 + {ctx.isSudo ? ' to continue.' : '.'} 153 + </p> 154 + </div> 155 + 156 + <Field label="Verification code" validationMessageText={fields.allIssues()?.at(0)?.message}> 157 + <Input 158 + {...fields._code.as('text')} 159 + placeholder="000000" 160 + autocomplete="one-time-code" 161 + inputmode="numeric" 162 + pattern={TOTP_CODE_RE.source} 163 + minlength={TOTP_CODE_LENGTH} 164 + maxlength={TOTP_CODE_LENGTH} 165 + required 166 + autofocus 167 + /> 168 + </Field> 169 + 170 + <Button type="submit" variant="primary"> 171 + Confirm 172 + </Button> 173 + </BaseForm>, 174 + ); 175 + }, 176 + async webauthn({ url }) { 177 + const { accountManager, config } = getAppContext(); 178 + 179 + const { fields } = verifyWebAuthnForm; 180 + 181 + const ctx = resolveVerifyContext(url); 182 + 183 + // for sudo without MFA → redirect to index 184 + if (ctx.isSudo && ctx.mfaStatus === null) { 185 + redirect(routes.verify.index.href(undefined, { redirect: ctx.redirectUrl })); 186 + } 187 + 188 + // get user's WebAuthn credentials 189 + const webauthnCredentials = accountManager.listWebAuthnCredentials(ctx.challenge.did); 190 + if (webauthnCredentials.length === 0) { 191 + // no WebAuthn credentials → redirect to TOTP 192 + const tokenParam = ctx.isSudo ? undefined : ctx.challenge.token; 193 + redirect(routes.verify.totp.href(undefined, { token: tokenParam, redirect: ctx.redirectUrl })); 194 + } 195 + 196 + // generate authentication options 197 + const options = await generateWebAuthnAuthenticationOptions({ 198 + rpId: new URL(config.service.publicUrl).hostname, 199 + allowCredentials: webauthnCredentials, 200 + }); 201 + 202 + // store the challenge for verification 203 + accountManager.setVerifyChallengeWebAuthn(ctx.challenge.token, options.challenge); 204 + 205 + return render( 206 + <BaseLayout> 207 + <title>{ctx.isSudo ? 'Confirm your identity' : 'Two-factor authentication'} - Danaus</title> 208 + 209 + <script src="/assets/webauthn-authenticate.js" type="module" /> 210 + 211 + <div class="flex flex-1 items-center justify-center p-4"> 212 + <div class="w-full max-w-96 rounded-xl bg-neutral-background-1 p-6 shadow-16"> 213 + <form {...verifyWebAuthnForm} class="flex flex-col gap-6"> 214 + <input {...fields.challenge.as('hidden', ctx.challenge.token)} /> 215 + <input {...fields.redirect.as('hidden', ctx.redirectUrl)} /> 216 + 217 + <div class="flex flex-col gap-2"> 218 + <h1 class="text-base-500 font-semibold"> 219 + {ctx.isSudo ? 'Confirm your identity' : 'Two-factor authentication'} 220 + </h1> 221 + <p class="text-base-300 text-neutral-foreground-3"> 222 + Insert your security key and touch it 223 + {ctx.isSudo ? ' to continue.' : ' to verify your identity.'} 224 + </p> 225 + </div> 226 + 227 + <danaus-webauthn-authenticate data-options={JSON.stringify(options)}> 228 + <input {...fields.response.as('hidden', '')} data-target="webauthn-authenticate.response" /> 229 + 230 + <Button data-target="webauthn-authenticate.start" type="button" variant="primary"> 231 + Use security key 232 + </Button> 233 + 234 + <div 235 + data-target="webauthn-authenticate.status" 236 + class="text-center text-base-300 text-neutral-foreground-3" 237 + /> 238 + </danaus-webauthn-authenticate> 239 + 240 + <OtherMethodsMenu 241 + factor="webauthn" 242 + challenge={ctx.challenge.token} 243 + redirectUrl={ctx.redirectUrl} 244 + mfaStatus={ctx.mfaStatus} 245 + isSudo={ctx.isSudo} 246 + /> 247 + </form> 248 + </div> 249 + </div> 250 + </BaseLayout>, 251 + ); 252 + }, 253 + recovery({ url }) { 254 + const { fields } = verifyForm; 255 + 256 + const ctx = resolveVerifyContext(url); 257 + 258 + // for sudo without MFA → redirect to index 259 + if (ctx.isSudo && ctx.mfaStatus === null) { 260 + redirect(routes.verify.index.href(undefined, { redirect: ctx.redirectUrl })); 261 + } 262 + 263 + return render( 264 + <BaseForm 265 + factor="recovery" 266 + challenge={ctx.challenge.token} 267 + redirectUrl={ctx.redirectUrl} 268 + mfaStatus={ctx.mfaStatus} 269 + isSudo={ctx.isSudo} 270 + > 271 + <div class="flex flex-col gap-2"> 272 + <h1 class="text-base-500 font-semibold"> 273 + {ctx.isSudo ? 'Confirm your identity' : 'Two-factor authentication'} 274 + </h1> 275 + <p class="text-base-300 text-neutral-foreground-3"> 276 + Enter one of your recovery codes 277 + {ctx.isSudo ? ' to continue.' : ' to verify your identity.'} 278 + </p> 279 + </div> 280 + 281 + <Field label="Recovery code" validationMessageText={fields.allIssues()?.at(0)?.message}> 282 + <Input 283 + {...fields._code.as('text')} 284 + placeholder="XXXX-XXXX" 285 + pattern={RECOVERY_CODE_RE.source} 286 + minlength={RECOVERY_CODE_LENGTH} 287 + maxlength={RECOVERY_CODE_LENGTH + 1} 288 + required 289 + autofocus 290 + /> 291 + </Field> 292 + 293 + <Button type="submit" variant="primary"> 294 + Confirm 295 + </Button> 296 + </BaseForm>, 297 + ); 298 + }, 299 + password({ url }) { 300 + const { fields } = verifyForm; 301 + 302 + const ctx = resolveVerifyContext(url); 303 + 304 + // password is only allowed in sudo mode for non-MFA users 305 + if (!ctx.isSudo) { 306 + redirect(routes.login.href(undefined, { redirect: ctx.redirectUrl })); 307 + } 308 + 309 + // MFA users must use MFA methods 310 + if (ctx.mfaStatus !== null) { 311 + redirect(routes.verify.index.href(undefined, { redirect: ctx.redirectUrl })); 312 + } 313 + 314 + return render( 315 + <BaseForm 316 + factor="password" 317 + challenge={ctx.challenge.token} 318 + redirectUrl={ctx.redirectUrl} 319 + mfaStatus={null} 320 + isSudo={true} 321 + > 322 + <div class="flex flex-col gap-2"> 323 + <h1 class="text-base-500 font-semibold">Confirm your identity</h1> 324 + <p class="text-base-300 text-neutral-foreground-3">Enter your password to continue.</p> 325 + </div> 326 + 327 + <Field label="Password" validationMessageText={fields.allIssues()?.at(0)?.message}> 328 + <Input {...fields._code.as('password')} autocomplete="current-password" required autofocus /> 329 + </Field> 330 + 331 + <Button type="submit" variant="primary"> 332 + Confirm 333 + </Button> 334 + </BaseForm>, 335 + ); 336 + }, 337 + }, 338 + } satisfies Controller<typeof routes.verify>; 339 + 340 + const BaseForm = (props: { 341 + factor: AuthFactor; 342 + challenge: string; 343 + redirectUrl: string; 344 + mfaStatus: MfaStatus | null; 345 + isSudo: boolean; 346 + children: JSXNode; 347 + }) => { 348 + const { fields } = verifyForm; 349 + 350 + const title = props.isSudo ? 'Confirm your identity' : 'Two-factor authentication'; 351 + 352 + return ( 353 + <BaseLayout> 354 + <title>{title} - Danaus</title> 355 + 356 + <div class="flex flex-1 items-center justify-center p-4"> 357 + <div class="w-full max-w-96 rounded-xl bg-neutral-background-1 p-6 shadow-16"> 358 + <form {...verifyForm} class="flex flex-col gap-6"> 359 + <input {...fields.challenge.as('hidden', props.challenge)} /> 360 + <input {...fields.redirect.as('hidden', props.redirectUrl)} /> 361 + <input {...fields.factor.as('hidden', props.factor)} /> 362 + 363 + {props.children} 364 + 365 + {/* remember checkbox - only for MFA login, not sudo */} 366 + {!props.isSudo && ( 367 + <Checkbox name="remember" value="true"> 368 + Remember this device for 1 year 369 + </Checkbox> 370 + )} 371 + 372 + <OtherMethodsMenu 373 + factor={props.factor} 374 + challenge={props.challenge} 375 + redirectUrl={props.redirectUrl} 376 + mfaStatus={props.mfaStatus} 377 + isSudo={props.isSudo} 378 + /> 379 + </form> 380 + </div> 381 + </div> 382 + </BaseLayout> 383 + ); 384 + }; 385 + 386 + const OtherMethodsMenu = (props: { 387 + factor: AuthFactor; 388 + challenge: string; 389 + redirectUrl: string; 390 + mfaStatus: MfaStatus | null; 391 + isSudo: boolean; 392 + }) => { 393 + const { mfaStatus, isSudo, challenge, redirectUrl } = props; 394 + 395 + if (mfaStatus === null) { 396 + return null; 397 + } 398 + 399 + // count how many other methods are available 400 + const otherMethodsCount = 401 + (props.factor !== 'webauthn' && mfaStatus.hasWebAuthn ? 1 : 0) + 402 + (props.factor !== 'totp' && mfaStatus.hasTotp ? 1 : 0) + 403 + (props.factor !== 'recovery' && mfaStatus.hasRecoveryCodes ? 1 : 0); 404 + 405 + if (otherMethodsCount === 0) { 406 + return null; 407 + } 408 + 409 + // for MFA login, include token param; for sudo, omit it 410 + const tokenParam = isSudo ? undefined : challenge; 411 + 412 + return ( 413 + <Menu> 414 + <MenuTrigger> 415 + <Button>Show other methods</Button> 416 + </MenuTrigger> 417 + 418 + <MenuPopover> 419 + <MenuList> 420 + {props.factor !== 'webauthn' && mfaStatus.hasWebAuthn && ( 421 + <MenuItem 422 + href={routes.verify.webauthn.href(undefined, { 423 + token: tokenParam, 424 + redirect: redirectUrl, 425 + })} 426 + > 427 + Use security key 428 + </MenuItem> 429 + )} 430 + 431 + {props.factor !== 'totp' && mfaStatus.hasTotp && ( 432 + <MenuItem 433 + href={routes.verify.totp.href(undefined, { 434 + token: tokenParam, 435 + redirect: redirectUrl, 436 + })} 437 + > 438 + Use authenticator app 439 + </MenuItem> 440 + )} 441 + 442 + {props.factor !== 'recovery' && mfaStatus.hasRecoveryCodes && ( 443 + <MenuItem 444 + href={routes.verify.recovery.href(undefined, { 445 + token: tokenParam, 446 + redirect: redirectUrl, 447 + })} 448 + > 449 + Use 2FA recovery code 450 + </MenuItem> 451 + )} 452 + </MenuList> 453 + </MenuPopover> 454 + </Menu> 455 + ); 456 + };
+24 -1
packages/danaus/src/web/middlewares/session.ts
··· 19 19 const { accountManager, config } = getAppContext(); 20 20 const path = url.pathname; 21 21 22 - const redirectUrl = routes.login.show.href(undefined, { redirect: path }); 22 + const redirectUrl = routes.login.href(undefined, { redirect: path }); 23 23 24 24 const token = readWebSessionToken(request); 25 25 if (!token) { ··· 54 54 55 55 return session; 56 56 }; 57 + 58 + /** 59 + * tries to get a web session from the current request. 60 + * does not require the requireSession middleware. 61 + * @returns the web session or null if not found/invalid 62 + */ 63 + export const tryGetSession = (): WebSession | null => { 64 + const { accountManager, config } = getAppContext(); 65 + const { request } = getContext(); 66 + 67 + const token = readWebSessionToken(request); 68 + if (!token) { 69 + return null; 70 + } 71 + 72 + const sessionId = verifyWebSessionToken(config.secrets.jwtKey, token); 73 + if (!sessionId) { 74 + return null; 75 + } 76 + 77 + const session = accountManager.getWebSession(sessionId); 78 + return session; 79 + };
+9 -17
packages/danaus/src/web/routes.ts
··· 13 13 }, 14 14 }, 15 15 16 - // login routes 17 - login: { 18 - show: '/account/login', 19 - mfa: { 20 - index: '/account/login/mfa', 21 - totp: '/account/login/mfa/totp', 22 - webauthn: '/account/login/mfa/webauthn', 23 - recovery: '/account/login/mfa/recovery', 24 - }, 16 + // login route 17 + login: '/account/login', 25 18 26 - // sudo is located here just so we can share some parts with the MFA page 27 - sudo: { 28 - index: '/account/sudo', 29 - totp: '/account/sudo/totp', 30 - webauthn: '/account/sudo/webauthn', 31 - recovery: '/account/sudo/recovery', 32 - password: '/account/sudo/password', 33 - }, 19 + // verification routes - handles both MFA login (?token) and sudo (session-based) 20 + verify: { 21 + index: '/account/verify', 22 + totp: '/account/verify/totp', 23 + webauthn: '/account/verify/webauthn', 24 + recovery: '/account/verify/recovery', 25 + password: '/account/verify/password', 34 26 }, 35 27 36 28 // account routes - all require session