@@ -11,7 +11,7 @@ import {
11
11
strongPasswordSchema
12
12
} from '@u22n/utils' ;
13
13
import { TRPCError } from '@trpc/server' ;
14
- import { getCookie , setCookie } from 'hono/cookie' ;
14
+ import { deleteCookie , getCookie , setCookie } from 'hono/cookie' ;
15
15
import type {
16
16
AuthenticationResponseJSON ,
17
17
RegistrationResponseJSON
@@ -23,6 +23,7 @@ import { TOTPController, createTOTPKeyURI } from 'oslo/otp';
23
23
import { lucia } from '../../../utils/auth' ;
24
24
import { storage } from '../../../storage' ;
25
25
import { env } from '../../../env' ;
26
+ import { datePlus } from 'itty-time' ;
26
27
27
28
const authStorage = storage . auth ;
28
29
@@ -366,6 +367,9 @@ export const securityRouter = router({
366
367
return { success : true } ;
367
368
} ) ,
368
369
370
+ /**
371
+ * @deprecated use `generateTwoFactorResetChallenge` as that one doesn't reset the 2FA secret, unless the user completes the reset
372
+ */
369
373
resetTwoFactorSecret : accountProcedure
370
374
. input (
371
375
z . object ( {
@@ -422,6 +426,9 @@ export const securityRouter = router({
422
426
return { uri } ;
423
427
} ) ,
424
428
429
+ /**
430
+ * @deprecated use `verifyTwoFactorResetChallenge` with the `generateTwoFactorResetChallenge` to reset the 2FA secret
431
+ */
425
432
completeTwoFactorReset : accountProcedure
426
433
. input (
427
434
z . object ( {
@@ -492,6 +499,162 @@ export const securityRouter = router({
492
499
return { success : true } ;
493
500
} ) ,
494
501
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
+
495
658
disableRecoveryCode : accountProcedure
496
659
. input (
497
660
z . object ( {
0 commit comments