Skip to content

Commit 5a086fd

Browse files
committed
[WIP] feat: Add recovery page
1 parent 35ee171 commit 5a086fd

File tree

16 files changed

+965
-668
lines changed

16 files changed

+965
-668
lines changed

.eslintrc.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
module.exports = {
22
root: true,
3-
plugins: ['prettier'],
4-
extends: ['@nuxt/eslint-config', 'prettier'],
3+
plugins: ['prettier', 'drizzle'],
4+
extends: ['@nuxt/eslint-config', 'prettier', 'plugin:drizzle/all'],
55
rules: {
66
semi: [2, 'always']
77
},

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

Lines changed: 264 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,25 @@ import { Argon2id } from 'oslo/password';
33
import { router, publicRateLimitedProcedure } from '../../trpc';
44
import { eq } from '@u22n/database/orm';
55
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';
712
import { TRPCError } from '@trpc/server';
813
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';
1217
import { env } from '../../../env';
1318
import { storage } from '../../../storage';
19+
import { ms } from 'itty-time';
1420

1521
export const recoveryRouter = router({
22+
/**
23+
* @deprecated use `getRecoveryVerificationToken` instead
24+
*/
1625
recoverAccount: publicRateLimitedProcedure.recoverAccount
1726
.input(
1827
z.object({
@@ -163,5 +172,256 @@ export const recoveryRouter = router({
163172
message:
164173
'Something went wrong, you should never see this message. Please report to team immediately.'
165174
});
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 };
166426
})
167427
});

apps/platform/trpc/trpc.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ const publicRateLimits = {
5555
signUpWithPassword: [10, '1h'],
5656
signInWithPassword: [20, '1h'],
5757
recoverAccount: [10, '1h'],
58+
completeRecovery: [20, '1h'],
5859
validateInvite: [10, '1h']
5960
} satisfies Record<string, [number, Duration]>;
6061

apps/web/.eslintrc.cjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ const config = {
3434
attributes: false
3535
}
3636
}
37-
]
37+
],
38+
'react/no-children-prop': ['warn', { allowFunctions: true }]
3839
}
3940
};
4041
module.exports = config;

apps/web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@
1818
"@radix-ui/themes": "^3.0.2",
1919
"@simplewebauthn/browser": "^10.0.0",
2020
"@tailwindcss/typography": "^0.5.13",
21+
"@tanstack/react-form": "^0.19.5",
2122
"@tanstack/react-query": "^4.36.1",
2223
"@tanstack/react-table": "^8.16.0",
2324
"@tanstack/react-virtual": "^3.5.0",
25+
"@tanstack/zod-form-adapter": "^0.19.5",
2426
"@trpc/client": "10.45.2",
2527
"@trpc/react-query": "10.45.2",
2628
"@trpc/server": "10.45.2",

apps/web/src/app/(login)/_components/password-login.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export default function PasswordLoginButton() {
5959
description: 'Redirecting you to create an organization'
6060
});
6161
router.push('/join/org');
62+
return;
6263
}
6364
toast.success('Sign in successful!', {
6465
description: 'Redirecting you to your conversations'

apps/web/src/app/(login)/_components/recovery-button.tsx

Lines changed: 0 additions & 15 deletions
This file was deleted.

apps/web/src/app/(login)/page.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { Flex, Box, Heading, Separator, Badge, Button } from '@radix-ui/themes';
22
import PasskeyLoginButton from './_components/passkey-login';
33
import PasswordLoginButton from './_components/password-login';
44
import Link from 'next/link';
5-
import RecoveryButton from './_components/recovery-button';
65

76
export default async function Page() {
87
return (
@@ -52,7 +51,12 @@ export default async function Page() {
5251
</Button>
5352
</Flex>
5453
</Box>
55-
<RecoveryButton />
54+
<Button
55+
size="3"
56+
className="w-fit cursor-pointer font-semibold"
57+
variant="ghost">
58+
<Link href="/recovery">Recover your Account</Link>
59+
</Button>
5660
</Box>
5761
</Flex>
5862
);

0 commit comments

Comments
 (0)