api/auth: email a reset link instead of a typed code
The reset token came back in the response body and was six space-joined numbers, so the flow only worked because the app handed the secret to itself. It is now opaque, travels only in the email, and the message carries an HTML deep link instead of a code to retype
Pedro Lucas Porcellis porcellis@eletrotupi.com · 11 days ago
e1b36f7e39e375953e4e199680dd3f36adca4939 Browse filesParents
d056c17Changed files
6
Additions
+88
Deletions
-20
api/src/lib/jwt.ts
1 1
import jwt from 'jsonwebtoken';
2 2
import crypto from 'crypto';
3 3
4 -
const JWT_SECRET = process.env.JWT_SECRET || 'development-secret-change-in-production';
4 +
const JWT_SECRET = process.env.JWT_SECRET;
5 +
6 +
if (!JWT_SECRET) {
7 +
throw new Error('JWT_SECRET is not set');
8 +
}
9 +
5 10
const JWT_EXPIRES_IN = '7d';
6 -
const PASSWORD_RESET_EXPIRES_IN = '1h';
7 11
8 12
interface TokenPayload {
9 13
userId: number;
10 14
email: string;
11 15
}
14 18
const payload: TokenPayload = { userId, email };
15 19
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
16 20
};
17 21
18 22
export const verifyToken = (token: string): TokenPayload => {
19 -
return jwt.verify(token, JWT_SECRET) as TokenPayload;
23 +
return jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
20 24
};
21 25
22 26
export const generatePasswordResetToken = (): string => {
23 -
return [...Array(6)]
24 -
.map(() => crypto.randomInt(100))
25 -
.join(" ");
27 +
return crypto.randomBytes(32).toString('hex');
26 28
};
27 29
28 30
export const getPasswordResetExpiration = (): Date => {
29 31
const expirationTime = new Date();
30 32
expirationTime.setHours(expirationTime.getHours() + 1); // 1 hour from now
32 34
};
33 35
34 36
export const isTokenExpired = (expirationDate: Date): boolean => {
35 37
return new Date() > expirationDate;
36 38
};
api/src/services/auth.service.ts
1 1
import { prisma } from '@app/lib/prisma';
2 2
import { Prisma, User } from '@prisma/client';
3 3
import bcrypt from "bcryptjs";
4 4
import crypto from "crypto";
5 5
import { addMinutes } from "date-fns";
68 68
const job = await mailQueue.add(MailJobName.PasswordReset, {
69 69
userId: user.id,
70 70
token: resetToken
71 71
});
72 72
73 -
return {
74 -
success: true,
75 -
token: resetToken
76 -
};
73 +
return { success: true };
77 74
};
78 75
79 76
export const resetPassword = async ({ token, newPassword }: PasswordResetInput) => {
80 77
const user = await prisma.user.findFirst({
81 78
where: {
151 148
}
152 149
}
153 150
154 151
throw new Error("Could not issue a unique activation code");
155 152
}
api/src/services/mail/sendPasswordReset.ts
1 1
import { findUserById } from '@app/services/user.service';
2 2
import { sendEmail } from '@app/lib/mail';
3 3
import { logger } from '@app/lib/logger';
4 4
5 -
const email = (firstName: string, token: string) => {
6 -
return (`
5 +
const APP_SCHEME = 'orbit';
6 +
const RESET_PATH = 'auth/reset-password';
7 +
8 +
const resetLink = (token: string) => `${APP_SCHEME}://${RESET_PATH}?token=${token}`;
9 +
10 +
const escapeHtml = (value: string) =>
11 +
value.replace(
12 +
/[&<>"']/g,
13 +
(char) =>
14 +
({
15 +
'&': '&',
16 +
'<': '<',
17 +
'>': '>',
18 +
'"': '"',
19 +
"'": ''',
20 +
})[char]!,
21 +
);
22 +
23 +
const text = (firstName: string, token: string) => `
7 24
Oi, ${firstName}.
8 25
9 -
Ouvimos por aí que você precisa trocar a senha?
26 +
Ouvimos por aí que você precisa trocar a senha.
10 27
11 -
Segue o código para você digitar no app: ${token}
28 +
Abra este link no celular para escolher uma nova senha:
29 +
${resetLink(token)}
30 +
31 +
O link expira em uma hora.
12 32
13 33
Qualquer coisa prende o grito!
14 -
`)
15 -
}
34 +
`;
35 +
36 +
const html = (firstName: string, token: string) => `
37 +
<div style="font-family: sans-serif; font-size: 16px; line-height: 1.6; color: #111;">
38 +
<p>Oi, ${escapeHtml(firstName)}.</p>
39 +
<p>Ouvimos por aí que você precisa trocar a senha.</p>
40 +
<p>
41 +
<a href="${resetLink(token)}"
42 +
style="display: inline-block; padding: 12px 20px; border-radius: 8px; background-color: #13ec5b; color: #111; font-weight: bold; text-decoration: none;">
43 +
Escolher uma nova senha
44 +
</a>
45 +
</p>
46 +
<p style="font-size: 13px; color: #666;">
47 +
Se o botão não abrir o app, copie e cole este endereço no celular:<br />
48 +
${resetLink(token)}
49 +
</p>
50 +
<p>O link expira em uma hora.</p>
51 +
<p>Qualquer coisa prende o grito!</p>
52 +
</div>
53 +
`;
16 54
17 55
export async function sendResetPasswordEmail(userId: number, token: string): Promise<void> {
18 56
const user = await findUserById(userId)!;
19 57
20 58
const { data, error } = await sendEmail({
21 59
to: user!.email,
22 60
subject: "Esqueceu a senha do nexo?",
23 -
text: email(user!.firstName, token)
61 +
text: text(user!.firstName, token),
62 +
html: html(user!.firstName, token),
24 63
});
25 64
26 65
if (error) {
27 66
logger.error({ err: error }, "Error sending email");
28 67
}
29 68
30 69
logger.info({ emailId: data?.id }, "Email sent successfully");
31 70
}
api/tests/auth/forgot-password.test.ts
1 1
import { describe, it, expect, afterAll, beforeEach } from 'vitest'
2 2
import request from 'supertest'
3 3
import { createApp } from '@app/createApp'
4 4
import { buildPrisma, cleanupTestDb, createTestUser } from '../test-helper'
5 5
import { recordedJobs, resetQueueMocks } from '../mocks/queue'
22 22
.post('/auth/forgot-password')
23 23
.send({ email: 'forgot@example.com' })
24 24
25 25
expect(res.status).toBe(200)
26 26
expect(res.body.success).toBe(true)
27 -
expect(res.body.token).toBeTruthy()
27 +
expect(res.body.token).toBeUndefined()
28 28
29 29
const dbUser = await db.user.findUnique({ where: { id: user.id } })
30 30
31 -
expect(dbUser!.passwordResetToken).toBe(res.body.token)
31 +
expect(dbUser!.passwordResetToken).toBeTruthy()
32 32
expect(dbUser!.passwordResetExpires).not.toBeNull()
33 33
34 34
expect(recordedJobs).toContainEqual(
35 35
expect.objectContaining({
36 36
queue: 'mail',
37 37
name: 'mail:password-reset',
38 -
data: { userId: user.id, token: res.body.token },
38 +
data: { userId: user.id, token: dbUser!.passwordResetToken },
39 39
})
40 40
)
41 41
})
42 42
43 43
it('answers 200 without a token for an unknown email (no enumeration)', async () => {
59 59
expect(res.status).toBe(400)
60 60
expect(res.body.error).toBeTruthy()
61 61
expect(res.body.fields).toBeTruthy()
62 62
})
63 63
})
/dev/null → api/tests/auth/password-reset-token.test.ts
1 +
import { describe, it, expect } from 'vitest'
2 +
import { generatePasswordResetToken } from '@app/lib/jwt'
3 +
4 +
describe('generatePasswordResetToken', () => {
5 +
it('produces an opaque 64-character hex token', () => {
6 +
expect(generatePasswordResetToken()).toMatch(/^[0-9a-f]{64}$/)
7 +
})
8 +
9 +
it('does not repeat across draws', () => {
10 +
const tokens = new Set(
11 +
Array.from({ length: 100 }, () => generatePasswordResetToken()),
12 +
)
13 +
14 +
expect(tokens.size).toBe(100)
15 +
})
16 +
})
api/tests/jobs/mail.test.ts
1 1
import { describe, it, expect, afterAll, beforeEach, vi } from 'vitest'
2 2
import { sendWelcomeEmail } from '@app/services/mail/welcome'
3 3
import { sendActivateAccountEmail } from '@app/services/mail/sendActivateAccountEmail'
4 4
import { sendResetPasswordEmail } from '@app/services/mail/sendPasswordReset'
5 5
import { mailProcessor } from '@app/lib/queue/processors/mail'
88 88
subject: expect.stringContaining('nexo'),
89 89
text: expect.stringContaining('token-abc-xyz'),
90 90
})
91 91
)
92 92
})
93 +
94 +
it('renders a clickable deep link in the html body', async () => {
95 +
const user = await createTestUser(db, {
96 +
email: 'reset-html@example.com',
97 +
firstName: 'Ana'
98 +
})
99 +
100 +
await sendResetPasswordEmail(user.id, 'token-abc-xyz')
101 +
102 +
const [call] = mockedSendEmail.mock.calls
103 +
expect(call[0].html).toContain(
104 +
'href="orbit://auth/reset-password?token=token-abc-xyz"'
105 +
)
106 +
})
93 107
})
94 108
95 109
describe('mailProcessor', () => {
96 110
it('dispatches a password reset email and awaits it', async () => {
97 111
const user = await createTestUser(db, {
114 128
await expect(
115 129
mailProcessor({ name: 'mail:unknown', data: {} } as any)
116 130
).rejects.toThrow('Unknown mail job')
117 131
})
118 132
})