Skip to content

Commit fccc890

Browse files
feat: Legacy security enable/disable/reset toggles (#457)
1 parent abc89c4 commit fccc890

File tree

8 files changed

+685
-666
lines changed

8 files changed

+685
-666
lines changed

.vscode/settings.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
{
22
"i18n-ally.localesPaths": ["packages/locales"],
3-
"editor.codeActionsOnSave": {
4-
"source.fixAll.eslint": "explicit"
5-
},
3+
"editor.codeActionsOnSave": {},
64
"i18n-ally.dirStructure": "auto",
75
"i18n-ally.enabledFrameworks": ["vue", "vue-sfc"],
86
"unocss.root": ["apps/web-app"],

apps/platform/trpc/routers/authRouter/passkeyRouter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ export const passkeyRouter = router({
157157
httpOnly: true,
158158
secure: env.NODE_ENV === 'production',
159159
sameSite: 'Strict',
160-
maxAge: ms('5m'),
160+
maxAge: ms('5 minutes'),
161161
domain: env.PRIMARY_DOMAIN
162162
});
163163
const passkeyOptions = await usePasskeys.generateAuthenticationOptions({

apps/platform/trpc/routers/userRouter/securityRouter.ts

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
strongPasswordSchema
1212
} from '@u22n/utils';
1313
import { TRPCError } from '@trpc/server';
14-
import { getCookie, setCookie } from 'hono/cookie';
14+
import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
1515
import type {
1616
AuthenticationResponseJSON,
1717
RegistrationResponseJSON
@@ -23,6 +23,7 @@ import { TOTPController, createTOTPKeyURI } from 'oslo/otp';
2323
import { lucia } from '../../../utils/auth';
2424
import { storage } from '../../../storage';
2525
import { env } from '../../../env';
26+
import { datePlus } from 'itty-time';
2627

2728
const authStorage = storage.auth;
2829

@@ -366,6 +367,9 @@ export const securityRouter = router({
366367
return { success: true };
367368
}),
368369

370+
/**
371+
* @deprecated use `generateTwoFactorResetChallenge` as that one doesn't reset the 2FA secret, unless the user completes the reset
372+
*/
369373
resetTwoFactorSecret: accountProcedure
370374
.input(
371375
z.object({
@@ -422,6 +426,9 @@ export const securityRouter = router({
422426
return { uri };
423427
}),
424428

429+
/**
430+
* @deprecated use `verifyTwoFactorResetChallenge` with the `generateTwoFactorResetChallenge` to reset the 2FA secret
431+
*/
425432
completeTwoFactorReset: accountProcedure
426433
.input(
427434
z.object({
@@ -492,6 +499,162 @@ export const securityRouter = router({
492499
return { success: true };
493500
}),
494501

502+
generateTwoFactorResetChallenge: accountProcedure
503+
.input(
504+
z.object({
505+
verificationToken: zodSchemas.nanoIdToken()
506+
})
507+
)
508+
.query(async ({ ctx, input }) => {
509+
const { verificationToken } = input;
510+
const { db, account } = ctx;
511+
512+
const accountData = await db.query.accounts.findFirst({
513+
where: eq(accounts.id, account.id),
514+
columns: {
515+
publicId: true,
516+
username: true
517+
}
518+
});
519+
520+
if (!accountData) {
521+
throw new TRPCError({
522+
code: 'NOT_FOUND',
523+
message: 'User not found'
524+
});
525+
}
526+
527+
const storedVerificationToken = await authStorage.getItem(
528+
`authVerificationToken: ${accountData.publicId}`
529+
);
530+
531+
if (verificationToken !== storedVerificationToken) {
532+
throw new TRPCError({
533+
code: 'FORBIDDEN',
534+
message: 'VerificationToken is invalid'
535+
});
536+
}
537+
538+
const existingChallengeId = getCookie(
539+
ctx.event,
540+
'un-2fa-reset-challenge'
541+
);
542+
if (existingChallengeId) {
543+
const encodedSecret = await authStorage.getItem<string>(
544+
`un-2fa-reset-challenge:${existingChallengeId}`
545+
);
546+
if (encodedSecret) {
547+
return {
548+
uri: createTOTPKeyURI(
549+
'UnInbox.com',
550+
accountData.username,
551+
decodeHex(encodedSecret)
552+
)
553+
};
554+
}
555+
}
556+
557+
const newSecret = crypto.getRandomValues(new Uint8Array(20));
558+
const uri = createTOTPKeyURI(
559+
'UnInbox.com',
560+
accountData.username,
561+
newSecret
562+
);
563+
564+
const un2faResetChallengeId = nanoIdToken();
565+
authStorage.setItem(
566+
`un-2fa-reset-challenge:${un2faResetChallengeId}`,
567+
encodeHex(newSecret)
568+
);
569+
570+
setCookie(ctx.event, 'un-2fa-reset-challenge', un2faResetChallengeId, {
571+
httpOnly: true,
572+
secure: env.NODE_ENV === 'production',
573+
sameSite: 'Strict',
574+
expires: datePlus('5 minutes'),
575+
domain: env.PRIMARY_DOMAIN
576+
});
577+
578+
return { uri };
579+
}),
580+
581+
verifyTwoFactorResetChallenge: accountProcedure
582+
.input(
583+
z.object({
584+
verificationToken: zodSchemas.nanoIdToken(),
585+
code: z.string().min(6).max(6)
586+
})
587+
)
588+
.mutation(async ({ ctx, input }) => {
589+
const { verificationToken, code } = input;
590+
const { db, account } = ctx;
591+
592+
const accountData = await db.query.accounts.findFirst({
593+
where: eq(accounts.id, account.id),
594+
columns: {
595+
publicId: true,
596+
username: true
597+
}
598+
});
599+
600+
if (!accountData) {
601+
throw new TRPCError({
602+
code: 'NOT_FOUND',
603+
message: 'User not found'
604+
});
605+
}
606+
607+
const storedVerificationToken = await authStorage.getItem(
608+
`authVerificationToken: ${accountData.publicId}`
609+
);
610+
611+
if (verificationToken !== storedVerificationToken) {
612+
throw new TRPCError({
613+
code: 'FORBIDDEN',
614+
message: 'VerificationToken is invalid'
615+
});
616+
}
617+
618+
const challengeId = getCookie(ctx.event, 'un-2fa-reset-challenge');
619+
if (!challengeId) {
620+
throw new TRPCError({
621+
code: 'BAD_REQUEST',
622+
message: '2FA Challenge cookie not found or expired, please try again'
623+
});
624+
}
625+
626+
const encodedSecret = await authStorage.getItem<string>(
627+
`un-2fa-reset-challenge:${challengeId}`
628+
);
629+
630+
if (!encodedSecret) {
631+
throw new TRPCError({
632+
code: 'BAD_REQUEST',
633+
message: '2FA Challenge cookie not found or expired, please try again'
634+
});
635+
}
636+
637+
const secret = decodeHex(encodedSecret);
638+
const isValid = await new TOTPController().verify(code, secret);
639+
640+
if (!isValid) {
641+
throw new TRPCError({
642+
code: 'UNAUTHORIZED',
643+
message: 'Invalid 2FA code'
644+
});
645+
}
646+
647+
await db
648+
.update(accounts)
649+
.set({ twoFactorSecret: encodedSecret, twoFactorEnabled: true })
650+
.where(eq(accounts.id, account.id));
651+
652+
deleteCookie(ctx.event, 'un-2fa-reset-challenge');
653+
authStorage.removeItem(`un-2fa-reset-challenge:${challengeId}`);
654+
655+
return { success: true };
656+
}),
657+
495658
disableRecoveryCode: accountProcedure
496659
.input(
497660
z.object({

0 commit comments

Comments
 (0)