@@ -3,16 +3,25 @@ import { Argon2id } from 'oslo/password';
3
3
import { router , publicRateLimitedProcedure } from '../../trpc' ;
4
4
import { eq } from '@u22n/database/orm' ;
5
5
import { accounts } from '@u22n/database/schema' ;
6
- import { nanoIdToken , zodSchemas } from '@u22n/utils' ;
6
+ import {
7
+ nanoIdToken ,
8
+ strongPasswordSchema ,
9
+ typeIdValidator ,
10
+ zodSchemas
11
+ } from '@u22n/utils' ;
7
12
import { TRPCError } from '@trpc/server' ;
8
13
import { createLuciaSessionCookie } from '../../../utils/session' ;
9
- import { decodeHex } from 'oslo/encoding' ;
10
- import { TOTPController } from 'oslo/otp' ;
11
- import { setCookie } from 'hono/cookie' ;
14
+ import { decodeHex , encodeHex } from 'oslo/encoding' ;
15
+ import { TOTPController , createTOTPKeyURI } from 'oslo/otp' ;
16
+ import { deleteCookie , getCookie , setCookie } from 'hono/cookie' ;
12
17
import { env } from '../../../env' ;
13
18
import { storage } from '../../../storage' ;
19
+ import { ms } from 'itty-time' ;
14
20
15
21
export const recoveryRouter = router ( {
22
+ /**
23
+ * @deprecated use `getRecoveryVerificationToken` instead
24
+ */
16
25
recoverAccount : publicRateLimitedProcedure . recoverAccount
17
26
. input (
18
27
z . object ( {
@@ -163,5 +172,256 @@ export const recoveryRouter = router({
163
172
message :
164
173
'Something went wrong, you should never see this message. Please report to team immediately.'
165
174
} ) ;
175
+ } ) ,
176
+ getRecoveryVerificationToken : publicRateLimitedProcedure . recoverAccount
177
+ . input (
178
+ z
179
+ . object ( {
180
+ username : zodSchemas . username ( 2 ) ,
181
+ recoveryCode : zodSchemas . nanoIdToken ( )
182
+ } )
183
+ . and (
184
+ z . union ( [
185
+ z . object ( { password : z . string ( ) . min ( 8 ) } ) ,
186
+ z . object ( { twoFactorCode : z . string ( ) . min ( 6 ) . max ( 6 ) } )
187
+ ] )
188
+ )
189
+ )
190
+ . query ( async ( { input, ctx } ) => {
191
+ const { db } = ctx ;
192
+
193
+ const account = await db . query . accounts . findFirst ( {
194
+ where : eq ( accounts . username , input . username ) ,
195
+ columns : {
196
+ id : true ,
197
+ publicId : true ,
198
+ username : true ,
199
+ passwordHash : true ,
200
+ twoFactorSecret : true ,
201
+ twoFactorEnabled : true ,
202
+ recoveryCode : true
203
+ }
204
+ } ) ;
205
+
206
+ if (
207
+ ! account ||
208
+ ! account . recoveryCode ||
209
+ ! account . passwordHash ||
210
+ ! account . twoFactorSecret
211
+ ) {
212
+ throw new TRPCError ( {
213
+ code : 'NOT_FOUND' ,
214
+ message :
215
+ 'Either you provided a wrong username or recovery is not enabled for this account'
216
+ } ) ;
217
+ }
218
+
219
+ const isRecoveryCodeValid = await new Argon2id ( ) . verify (
220
+ account . recoveryCode ,
221
+ input . recoveryCode
222
+ ) ;
223
+
224
+ if ( ! isRecoveryCodeValid ) {
225
+ throw new TRPCError ( {
226
+ code : 'UNAUTHORIZED' ,
227
+ message : 'Invalid Credentials'
228
+ } ) ;
229
+ }
230
+
231
+ let resetting : 'password' | '2fa' | null = null ;
232
+
233
+ if ( 'password' in input ) {
234
+ const validPassword = await new Argon2id ( ) . verify (
235
+ account . passwordHash ,
236
+ input . password
237
+ ) ;
238
+
239
+ if ( ! validPassword ) {
240
+ throw new TRPCError ( {
241
+ code : 'UNAUTHORIZED' ,
242
+ message : 'Invalid Credentials'
243
+ } ) ;
244
+ }
245
+
246
+ resetting = '2fa' ;
247
+ }
248
+
249
+ if ( 'twoFactorCode' in input ) {
250
+ const secret = decodeHex ( account . twoFactorSecret ! ) ;
251
+ const otpValid = await new TOTPController ( ) . verify (
252
+ input . twoFactorCode ,
253
+ secret
254
+ ) ;
255
+
256
+ if ( ! otpValid ) {
257
+ throw new TRPCError ( {
258
+ code : 'UNAUTHORIZED' ,
259
+ message : 'Invalid Credentials'
260
+ } ) ;
261
+ }
262
+ resetting = 'password' ;
263
+ }
264
+
265
+ if ( ! resetting ) {
266
+ throw new TRPCError ( {
267
+ code : 'BAD_REQUEST' ,
268
+ message : 'Password or 2FA code required'
269
+ } ) ;
270
+ }
271
+
272
+ const resetToken = nanoIdToken ( ) ;
273
+ await storage . auth . setItem (
274
+ `reset-token:${ resetting } :${ account . publicId } ` ,
275
+ resetToken
276
+ ) ;
277
+ setCookie ( ctx . event , `reset-token_${ resetting } ` , resetToken , {
278
+ maxAge : ms ( '5 minutes' ) ,
279
+ httpOnly : true ,
280
+ domain : env . PRIMARY_DOMAIN ,
281
+ sameSite : 'Lax' ,
282
+ secure : env . NODE_ENV === 'production'
283
+ } ) ;
284
+
285
+ // If it is a 2FA reset, return the new URI too
286
+ if ( resetting === '2fa' ) {
287
+ const newSecret = crypto . getRandomValues ( new Uint8Array ( 20 ) ) ;
288
+ const uri = createTOTPKeyURI ( 'UnInbox.com' , input . username , newSecret ) ;
289
+ const hexSecret = encodeHex ( newSecret ) ;
290
+ await storage . auth . setItem (
291
+ `2fa-reset-secret:${ account . publicId } ` ,
292
+ hexSecret
293
+ ) ;
294
+ return { resetting, accountPublicId : account . publicId , uri } ;
295
+ } else {
296
+ return { resetting, accountPublicId : account . publicId } ;
297
+ }
298
+ } ) ,
299
+ resetPassword : publicRateLimitedProcedure . completeRecovery
300
+ . input (
301
+ z . object ( {
302
+ accountPublicId : typeIdValidator ( 'account' ) ,
303
+ newPassword : strongPasswordSchema
304
+ } )
305
+ )
306
+ . mutation ( async ( { input, ctx } ) => {
307
+ const { db, event } = ctx ;
308
+
309
+ const resetToken = getCookie ( event , 'reset-token_password' ) ;
310
+ const storedResetToken = await storage . auth . getItem < string > (
311
+ `reset-token:password:${ input . accountPublicId } `
312
+ ) ;
313
+
314
+ if ( ! resetToken || ! storedResetToken || resetToken !== storedResetToken ) {
315
+ throw new TRPCError ( {
316
+ code : 'BAD_REQUEST' ,
317
+ message : 'Invalid reset token'
318
+ } ) ;
319
+ }
320
+
321
+ const account = await db . query . accounts . findFirst ( {
322
+ where : eq ( accounts . publicId , input . accountPublicId ) ,
323
+ columns : {
324
+ id : true
325
+ }
326
+ } ) ;
327
+
328
+ if ( ! account ) {
329
+ throw new TRPCError ( {
330
+ code : 'NOT_FOUND' ,
331
+ message : 'Account not found'
332
+ } ) ;
333
+ }
334
+
335
+ const passwordHash = await new Argon2id ( ) . hash ( input . newPassword ) ;
336
+ await db
337
+ . update ( accounts )
338
+ . set ( {
339
+ passwordHash,
340
+ recoveryCode : null
341
+ } )
342
+ . where ( eq ( accounts . id , account . id ) ) ;
343
+
344
+ await storage . auth . removeItem (
345
+ `reset-token:password:${ input . accountPublicId } `
346
+ ) ;
347
+
348
+ deleteCookie ( event , 'reset-token_password' ) ;
349
+
350
+ return { success : true } ;
351
+ } ) ,
352
+ resetTwoFactor : publicRateLimitedProcedure . completeRecovery
353
+ . input (
354
+ z . object ( {
355
+ accountPublicId : typeIdValidator ( 'account' ) ,
356
+ twoFactorCode : z . string ( ) . min ( 6 ) . max ( 6 )
357
+ } )
358
+ )
359
+ . mutation ( async ( { input, ctx } ) => {
360
+ const { db, event } = ctx ;
361
+
362
+ const resetToken = getCookie ( event , 'reset-token_2fa' ) ;
363
+ const storedResetToken = await storage . auth . getItem < string > (
364
+ `reset-token:2fa:${ input . accountPublicId } `
365
+ ) ;
366
+
367
+ if ( ! resetToken || ! storedResetToken || resetToken !== storedResetToken ) {
368
+ throw new TRPCError ( {
369
+ code : 'BAD_REQUEST' ,
370
+ message : 'Invalid reset token'
371
+ } ) ;
372
+ }
373
+
374
+ const account = await db . query . accounts . findFirst ( {
375
+ where : eq ( accounts . publicId , input . accountPublicId ) ,
376
+ columns : {
377
+ id : true
378
+ }
379
+ } ) ;
380
+
381
+ if ( ! account ) {
382
+ throw new TRPCError ( {
383
+ code : 'NOT_FOUND' ,
384
+ message : 'Account not found'
385
+ } ) ;
386
+ }
387
+
388
+ const storedSecret = await storage . auth . getItem < string > (
389
+ `2fa-reset-secret:${ input . accountPublicId } `
390
+ ) ;
391
+ if ( ! storedSecret ) {
392
+ throw new TRPCError ( {
393
+ code : 'NOT_FOUND' ,
394
+ message : '2FA Secret not found, please try again after some time'
395
+ } ) ;
396
+ }
397
+
398
+ const secret = decodeHex ( storedSecret ) ;
399
+ const isValid = await new TOTPController ( ) . verify (
400
+ input . twoFactorCode ,
401
+ secret
402
+ ) ;
403
+ if ( ! isValid ) {
404
+ throw new TRPCError ( {
405
+ code : 'UNAUTHORIZED' ,
406
+ message : '2FA code is not valid'
407
+ } ) ;
408
+ }
409
+
410
+ await db
411
+ . update ( accounts )
412
+ . set ( {
413
+ twoFactorEnabled : true ,
414
+ twoFactorSecret : storedSecret ,
415
+ recoveryCode : null
416
+ } )
417
+ . where ( eq ( accounts . id , account . id ) ) ;
418
+
419
+ await storage . auth . removeItem (
420
+ `2fa-reset-secret:${ input . accountPublicId } `
421
+ ) ;
422
+ await storage . auth . removeItem ( `reset-token:2fa:${ input . accountPublicId } ` ) ;
423
+ deleteCookie ( event , 'reset-token_2fa' ) ;
424
+
425
+ return { success : true } ;
166
426
} )
167
427
} ) ;
0 commit comments