Skip to content

Commit 65d5be2

Browse files
committed
feat(password): reset user password with reset token
1 parent 14f20a4 commit 65d5be2

19 files changed

+526
-48
lines changed

README.md

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,30 @@ Don't forget to re-login after verifying the email verification code.
163163
Registers a new user in the database if they don’t already exist. It handles OAuth authentication by registering the OAuth account, creating a session, and linking the user’s details.
164164
- **Returns**: A tuple containing the user ID and the created session details.
165165

166+
##### `getSession(sessionId: string)`
167+
168+
Fetches a session by its session ID.
169+
170+
##### `deleteSession(sessionId: string)`
171+
172+
Deletes a session by its session ID.
173+
174+
##### `deleteExpiredSessions(timestamp: number)`
175+
176+
Deletes sessions that have expired before the provided timestamp.
177+
178+
#### `askPasswordReset(userId: string)`
179+
180+
creates a reset password token for a specified user
181+
182+
#### `askForgotPasswordReset(email: string)`
183+
184+
Same as `askPasswordReset` but with email instead of userId.
185+
186+
#### resetPasswordWithResetToken
187+
188+
Resets the password using the reset token.
189+
166190
##### `setCreateRandomUserId(fn: () => string)`
167191

168192
Sets a custom method for generating random user IDs.
@@ -179,17 +203,9 @@ Sets a custom method for generating random email verification codes.
179203

180204
Sets custom methods for hashing and verifying passwords.
181205

182-
##### `getSession(sessionId: string)`
183-
184-
Fetches a session by its session ID.
185-
186-
##### `deleteSession(sessionId: string)`
187-
188-
Deletes a session by its session ID.
206+
##### `setCreateResetPasswordTokenHashMethod(fn: (tokenId: string) => Promise<string>)`
189207

190-
##### `deleteExpiredSessions(timestamp: number)`
191-
192-
Deletes sessions that have expired before the provided timestamp.
208+
Sets custom method for reset password token hashing.
193209

194210

195211
## Hooks
@@ -204,6 +220,8 @@ The hooks property allows you to listen for and respond to events during the aut
204220
| **"sessions:create"** | Triggered when a new session is created. | (session: SlipAuthSession) => void |
205221
| **"sessions:delete"** | Triggered when a session is deleted. | (session: SlipAuthSession) => void |
206222
| **"emailVerificationCode:delete"** | Triggered when a user email is validated. | (code: SlipAuthEmailVerificationCode) => void |
223+
| **"resetPasswordToken:create"** | Triggered when a user passsword reset is asked. | (token: SlipAuthPasswordResetToken) => void |
224+
| **"resetPasswordToken:delete"** | Triggered when a user passsword reset is validated or expired. | (token: SlipAuthPasswordResetToken) => void |
207225

208226
---
209227

@@ -217,19 +235,20 @@ The hooks property allows you to listen for and respond to events during the aut
217235
- [x] Sqlite support
218236
- [x] Bun-sqlite support
219237
- [x] LibSQL support
238+
- [ ] PGlite support
220239
- [ ] Postgres support
221240
- [x] Email + Password
222-
- [ ] forgot password
223-
- [ ] reset password
241+
- [x] forgot password
242+
- [x] reset password
224243
- [ ] rate-limit login
225244
- [ ] rate-limit email verification
226245
- [ ] rate-limit forgot password
227246
- [ ] rate-limit reset password
228247
- [ ] rate limit register
229248
- [ ] error message strategy (email already taken, etc)
230249
- [ ] oauth accounts linking
231-
- [ ] Ihavebeenpwnd plugin
232-
- [ ] handle sub-adressing
250+
- [ ] ~~Ihavebeenpwnd plugin~~
251+
- [ ] handle sub-adressing (register spam)
233252
- [ ] MFA plugin
234253
- [ ] CSRF plugin
235254
- [ ] organization plugin

src/module.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export default defineNuxtModule<ModuleOptions>({
2121
users: "slip_auth_users",
2222
oauthAccounts: "slip_auth_oauth_accounts",
2323
emailVerificationCodes: "slip_auth_email_verification_codes",
24+
resetPasswordTokens: "slip_auth_reset_password_tokens",
2425
},
2526
},
2627
async setup(options, nuxt) {

src/runtime/core/core.ts

Lines changed: 89 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
import { createChecker, type supportedConnectors } from "drizzle-schema-checker";
2-
import { getOAuthAccountsTableSchema, getSessionsTableSchema, getUsersTableSchema, getEmailVerificationCodesTableSchema } from "../database/lib/sqlite/schema.sqlite";
2+
import { getOAuthAccountsTableSchema, getSessionsTableSchema, getUsersTableSchema, getEmailVerificationCodesTableSchema, getPasswordResetTokensTableSchema } from "../database/lib/sqlite/schema.sqlite";
33
import { drizzle as drizzleIntegration } from "db0/integrations/drizzle/index";
44
import type { ICreateOrLoginParams, ICreateUserParams, ILoginUserParams, IPasswordHashingMethods, ISlipAuthCoreOptions, SchemasMockValue, SlipAuthUser, tableNames } from "./types";
55
import { createSlipHooks } from "./hooks";
66
import { UsersRepository } from "./repositories/UsersRepository";
77
import { SessionsRepository } from "./repositories/SessionsRepository";
88
import { OAuthAccountsRepository } from "./repositories/OAuthAccountsRepository";
99
import { EmailVerificationCodesRepository } from "./repositories/EmailVerificationCodesRepository";
10+
import { ResetPasswordTokensRepository } from "./repositories/ResetPasswordTokensRepository";
1011
import type { SlipAuthPublicSession } from "../types";
11-
import { defaultIdGenerationMethod, isValidEmail, defaultEmailVerificationCodeGenerationMethod, defaultHashPasswordMethod, defaultVerifyPasswordMethod } from "./email-and-password-utils";
12-
import { InvalidEmailOrPasswordError, UnhandledError } from "./errors/SlipAuthError.js";
12+
import { defaultIdGenerationMethod, isValidEmail, defaultEmailVerificationCodeGenerationMethod, defaultHashPasswordMethod, defaultVerifyPasswordMethod, defaultResetPasswordTokenIdMethod, defaultResetPasswordTokenHashMethod } from "./email-and-password-utils";
13+
import { InvalidEmailOrPasswordError, InvalidEmailToResetPasswordError, InvalidPasswordToResetError, InvalidUserIdToResetPasswordError, ResetPasswordTokenExpiredError, UnhandledError } from "./errors/SlipAuthError.js";
1314
import type { Database } from "db0";
14-
import { isWithinExpirationDate } from "oslo";
15+
import { createDate, isWithinExpirationDate, TimeSpan } from "oslo";
1516

1617
export class SlipAuthCore {
1718
readonly #db: Database;
@@ -23,11 +24,13 @@ export class SlipAuthCore {
2324
sessions: SessionsRepository
2425
oAuthAccounts: OAuthAccountsRepository
2526
emailVerificationCodes: EmailVerificationCodesRepository
27+
resetPasswordTokens: ResetPasswordTokensRepository
2628
};
2729

2830
#createRandomUserId: () => string;
2931
#createRandomSessionId: () => string;
3032
#createRandomEmailVerificationCode: () => string;
33+
#createResetPasswordTokenHashMethod: (tokenId: string) => Promise<string>;
3134

3235
#passwordHashingMethods: IPasswordHashingMethods;
3336

@@ -50,19 +53,22 @@ export class SlipAuthCore {
5053
sessions: getSessionsTableSchema(tableNames),
5154
oauthAccounts: getOAuthAccountsTableSchema(tableNames),
5255
emailVerificationCodes: getEmailVerificationCodesTableSchema(tableNames),
56+
resetPasswordTokens: getPasswordResetTokensTableSchema(tableNames),
5357
};
5458

5559
this.#repos = {
5660
users: new UsersRepository(this.#orm, this.schemas, this.hooks, "users"),
5761
sessions: new SessionsRepository(this.#orm, this.schemas, this.hooks, "sessions"),
5862
oAuthAccounts: new OAuthAccountsRepository(this.#orm, this.schemas, this.hooks, "oauthAccounts"),
5963
emailVerificationCodes: new EmailVerificationCodesRepository(this.#orm, this.schemas, this.hooks, "emailVerificationCodes"),
64+
resetPasswordTokens: new ResetPasswordTokensRepository(this.#orm, this.schemas, this.hooks, "resetPasswordTokens"),
6065
};
6166

6267
this.#createRandomSessionId = defaultIdGenerationMethod;
6368
this.#createRandomUserId = defaultIdGenerationMethod;
6469

6570
this.#createRandomEmailVerificationCode = defaultEmailVerificationCodeGenerationMethod;
71+
this.#createResetPasswordTokenHashMethod = defaultResetPasswordTokenHashMethod;
6672

6773
this.#passwordHashingMethods = {
6874
hash: defaultHashPasswordMethod,
@@ -87,7 +93,7 @@ export class SlipAuthCore {
8793
throw new InvalidEmailOrPasswordError("invalid email");
8894
}
8995
const password = values.password;
90-
if (!password || typeof password !== "string" || password.length < 6) {
96+
if (!password || typeof password !== "string") {
9197
throw new InvalidEmailOrPasswordError("invalid password");
9298
}
9399

@@ -132,7 +138,7 @@ export class SlipAuthCore {
132138
throw new InvalidEmailOrPasswordError("invalid email");
133139
}
134140
const password = values.password;
135-
if (!password || typeof password !== "string" || password.length < 6) {
141+
if (!password || typeof password !== "string") {
136142
throw new InvalidEmailOrPasswordError("invalid password");
137143
}
138144

@@ -154,7 +160,7 @@ export class SlipAuthCore {
154160
}
155161
catch (error) {
156162
if (error instanceof Error) {
157-
if (error.stack?.startsWith(`SqliteError: UNIQUE constraint failed: ${this.#tableNames.users}.email`)) {
163+
if (error.stack?.includes(`UNIQUE constraint failed: ${this.#tableNames.users}.email`)) {
158164
throw new InvalidEmailOrPasswordError(`email already taken: ${values.email}`);
159165
}
160166
throw new UnhandledError();
@@ -248,6 +254,78 @@ export class SlipAuthCore {
248254
return true;
249255
}
250256

257+
/**
258+
* The token should be valid for at most few hours. The token should be hashed before storage as it essentially is a password.
259+
* SHA-256 can be used here since the token is long and random, unlike user passwords.
260+
*/
261+
public async askPasswordReset(userId: string) {
262+
// optionally invalidate all existing tokens
263+
// this.#repos.resetPasswordTokens.deleteAllByUserId(userId);
264+
const tokenId = defaultResetPasswordTokenIdMethod();
265+
const tokenHash = await this.#createResetPasswordTokenHashMethod(tokenId);
266+
try {
267+
await this.#repos.resetPasswordTokens.insert({
268+
token_hash: tokenHash,
269+
user_id: userId,
270+
expires_at: createDate(new TimeSpan(2, "h")),
271+
});
272+
273+
return tokenId;
274+
}
275+
catch (error) {
276+
if (error instanceof Error && error.message.includes("FOREIGN KEY constraint failed")) {
277+
throw new InvalidUserIdToResetPasswordError();
278+
}
279+
280+
throw new UnhandledError();
281+
}
282+
}
283+
284+
// TODO: rate limit
285+
public async askForgotPasswordReset(emailAddress: string): Promise<string> {
286+
const user = await this.#repos.users.findByEmail(emailAddress);
287+
if (!user) {
288+
// If you want to avoid disclosing valid emails,
289+
// this can be a normal 200 response.
290+
throw new InvalidEmailToResetPasswordError();
291+
}
292+
return this.askPasswordReset(user.id);
293+
}
294+
295+
/**
296+
* Make sure to set the Referrer-Policy header of the password reset page to strict-origin to protect the token from referrer leakage.
297+
* WARNING: WILL UN-LOG THE USER
298+
*/
299+
public async resetPasswordWithResetToken(verificationToken: string, newPassword: string): Promise<true> {
300+
if (typeof newPassword !== "string") {
301+
throw new InvalidPasswordToResetError();
302+
}
303+
304+
const tokenHash = await this.#createResetPasswordTokenHashMethod(verificationToken);
305+
const token = await this.#repos.resetPasswordTokens.findByTokenHash(tokenHash);
306+
307+
if (!token) {
308+
throw new ResetPasswordTokenExpiredError();
309+
}
310+
311+
if (token) {
312+
await this.#repos.resetPasswordTokens.deleteByTokenHash(tokenHash);
313+
}
314+
315+
const expirationDate = token.expires_at instanceof Date ? token.expires_at : new Date(token.expires_at);
316+
const offset = expirationDate.getTimezoneOffset() * 60000; // Get local time zone offset in milliseconds
317+
const localExpirationDate = new Date(expirationDate.getTime() - offset); // Adjust for local time zone
318+
if (!isWithinExpirationDate(localExpirationDate)) {
319+
throw new ResetPasswordTokenExpiredError();
320+
}
321+
322+
await this.#repos.sessions.deleteAllByUserId(token.user_id);
323+
const passwordHash = await this.#passwordHashingMethods.hash(newPassword);
324+
await this.#repos.users.updatePasswordByUserId(token.user_id, passwordHash);
325+
326+
return true;
327+
}
328+
251329
public setCreateRandomUserId(fn: () => string) {
252330
this.#createRandomUserId = fn;
253331
}
@@ -260,6 +338,10 @@ export class SlipAuthCore {
260338
this.#createRandomEmailVerificationCode = fn;
261339
}
262340

341+
public setCreateResetPasswordTokenHashMethod(fn: (tokenId: string) => Promise<string>) {
342+
this.#createResetPasswordTokenHashMethod = fn;
343+
}
344+
263345
public setPasswordHashingMethods(fn: () => IPasswordHashingMethods) {
264346
const methods = fn();
265347
this.#passwordHashingMethods = methods;

src/runtime/core/email-and-password-utils.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { generateRandomString, alphabet } from "oslo/crypto";
1+
import { generateRandomString, alphabet, sha256 } from "oslo/crypto";
22
import type { Options as ArgonOptions } from "@node-rs/argon2";
33
import { hash, verify } from "@node-rs/argon2";
4+
import { encodeHex } from "oslo/encoding";
45

56
/**
67
https://thecopenhagenbook.com/email-verification#input-validation
@@ -22,6 +23,8 @@ export function isValidEmail(email: string): boolean {
2223
export const defaultIdGenerationMethod = () => generateRandomString(15, alphabet("a-z", "A-Z", "0-9"));
2324

2425
export const defaultEmailVerificationCodeGenerationMethod = () => generateRandomString(6, alphabet("0-9", "A-Z"));
26+
export const defaultResetPasswordTokenIdMethod = () => generateRandomString(40, alphabet("0-9", "A-Z"));
27+
export const defaultResetPasswordTokenHashMethod = async (tokenId: string) => encodeHex(await sha256(new TextEncoder().encode(tokenId)));
2528

2629
const hashOptions: ArgonOptions = {
2730
// recommended minimum parameters
@@ -31,7 +34,6 @@ const hashOptions: ArgonOptions = {
3134
parallelism: 1,
3235
};
3336

34-
// https://thecopenhagenbook.com/password-authentication#argon2id
3537
export async function defaultHashPasswordMethod(rawPassword: string): Promise<string> {
3638
return await hash(rawPassword, hashOptions);
3739
};

src/runtime/core/errors/SlipAuthError.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,26 @@ export class InvalidEmailOrPasswordError extends SlipAuthError {
2222
this.#debugReason = reason;
2323
}
2424
}
25+
26+
export class InvalidEmailToResetPasswordError extends SlipAuthError {
27+
override name = "InvalidEmailToResetPasswordError";
28+
override message = "InvalidEmailToResetPasswordError";
29+
override slipError = SlipAuthErrorsCode.InvalidEmailToResetPassword;
30+
}
31+
32+
export class InvalidUserIdToResetPasswordError extends SlipAuthError {
33+
override name = "InvalidUserIdToResetPasswordError";
34+
override message = "InvalidUserIdToResetPasswordError";
35+
override slipError = SlipAuthErrorsCode.InvalidUserIdToResetPassword;
36+
}
37+
38+
export class InvalidPasswordToResetError extends SlipAuthError {
39+
override name = "InvalidPasswordToResetError";
40+
override message = "InvalidPasswordToResetError";
41+
override slipError = SlipAuthErrorsCode.InvalidPasswordToReset;
42+
}
43+
export class ResetPasswordTokenExpiredError extends SlipAuthError {
44+
override name = "ResetPasswordTokenExpiredError";
45+
override message = "ResetPasswordTokenExpiredError";
46+
override slipError = SlipAuthErrorsCode.ResetPasswordTokenExpired;
47+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
export enum SlipAuthErrorsCode {
22
Unhandled = "Unhandled",
33
InvalidEmailOrPassword = "InvalidEmailOrPassword",
4+
InvalidEmailToResetPassword = "InvalidEmailToResetPassword",
5+
InvalidUserIdToResetPassword = "InvalidUserIdToResetPassword",
6+
InvalidPasswordToReset = "InvalidPasswordToReset",
7+
ResetPasswordTokenExpired = "ResetPasswordTokenExpired",
48
}

src/runtime/core/hooks.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createHooks, type Hookable, type HookKeys } from "hookable";
2-
import type { SlipAuthSession, SlipAuthUser, SlipAuthOAuthAccount, SlipAuthEmailVerificationCode, EmailVerificationCodeTableInsert } from "./types";
2+
import type { SlipAuthSession, SlipAuthUser, SlipAuthOAuthAccount, SlipAuthEmailVerificationCode, EmailVerificationCodeTableInsert, SlipAuthPasswordResetToken } from "./types";
33

44
interface ISlipAuthHooksMap {
55
// users
@@ -12,6 +12,9 @@ interface ISlipAuthHooksMap {
1212
// emailVerificationCode
1313
"emailVerificationCode:create": (emailVerificationCodeValues: EmailVerificationCodeTableInsert) => void
1414
"emailVerificationCode:delete": (emailVerificationCode: SlipAuthEmailVerificationCode) => void
15+
// resetPasswordToken
16+
"resetPasswordToken:create": (resetPasswordToken: SlipAuthPasswordResetToken) => void
17+
"resetPasswordToken:delete": (resetPasswordToken: SlipAuthPasswordResetToken) => void
1518
}
1619

1720
export type ISlipAuthHooks = Hookable<ISlipAuthHooksMap, HookKeys<ISlipAuthHooksMap>>;

0 commit comments

Comments
 (0)