Skip to content

Commit

Permalink
refactor token related code
Browse files Browse the repository at this point in the history
  • Loading branch information
jilv220 committed Nov 10, 2023
1 parent b5f4e1a commit 7c74dca
Show file tree
Hide file tree
Showing 8 changed files with 180 additions and 35 deletions.
12 changes: 8 additions & 4 deletions src/db/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,21 @@ import { drizzle } from 'drizzle-orm/neon-serverless';
import ws from 'ws';
import { emailVerificationToken } from '../schema/email_verification_token.ts';
import { user } from '../schema/user.ts';
import { passwordResetToken } from '../schema/password_reset_token.ts';

export const querySchema = {
user,
emailVerificationToken,
passwordResetToken,
};

neonConfig.webSocketConstructor = ws;

export const neonClient = new Pool({
connectionString: process.env.DATABASE_URL!,
});
export const db = drizzle(neonClient, {
schema: {
user,
emailVerificationToken,
},
schema: querySchema,
});

migrate(db, { migrationsFolder: 'drizzle' });
99 changes: 99 additions & 0 deletions src/models/tokenManger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { eq } from 'drizzle-orm';
import { isWithinExpiration, generateRandomString } from 'lucia/utils';
import { HOUR } from '../constant/time.ts';
import { db } from '../db/db.ts';
import { emailVerificationToken } from '../schema/email_verification_token.ts';
import { IToken } from '../types/token.ts';
import { passwordResetToken } from '../schema/password_reset_token.ts';
import {
INVALID_EMAIL_VERIFICATION_TOKEN,
EXPIRED_EMAIL_VERIFICATION_TOKEN,
} from '../constant/error.ts';

type TOKEN_TYPE = 'email_verification' | 'password_reset';

function buildTokenDAO(tokenType: TOKEN_TYPE) {
let token;
// eslint-disable-next-line default-case
switch (tokenType) {
case 'email_verification':
token = emailVerificationToken;
break;
case 'password_reset':
token = passwordResetToken;
break;
}
return token;
}

function buildTokenQuery(tokenType: TOKEN_TYPE) {
let dbQuery;

// eslint-disable-next-line default-case
switch (tokenType) {
case 'email_verification':
dbQuery = db.query.emailVerificationToken;
break;
case 'password_reset':
dbQuery = db.query.passwordResetToken;
break;
}
return dbQuery;
}

class TokenManager implements IToken {
private tokenDAO;

private accessor tokenType: TOKEN_TYPE;

private dbQuery;

private expiresIn: number;

constructor(tokenType: TOKEN_TYPE, expiresIn?: number) {
this.tokenType = tokenType;
this.expiresIn = expiresIn || 2 * HOUR;
this.tokenDAO = buildTokenDAO(tokenType);
this.dbQuery = buildTokenQuery(tokenType);
}

async generate(userId: string) {
const storedUserTokens = await this.dbQuery.findMany({
where: eq(this.tokenDAO.id, userId),
});
if (storedUserTokens.length > 0) {
const reusableStoredToken = storedUserTokens.find((token) =>
isWithinExpiration(Number(token.expires) - this.expiresIn / 2)
);
if (reusableStoredToken) return reusableStoredToken.id;
}
const token = generateRandomString(63);
await db.insert(this.tokenDAO).values({
id: token,
expires: BigInt(new Date().getTime() + this.expiresIn),
userId,
});
return token;
}

async validate(token: string) {
const storedToken = await db.transaction(async (tx) => {
const tokenInDB = await this.dbQuery.findFirst({
where: eq(this.tokenDAO.id, token),
});
if (!tokenInDB) throw new Error(INVALID_EMAIL_VERIFICATION_TOKEN);
await tx
.delete(this.tokenDAO)
.where(eq(this.tokenDAO.userId, tokenInDB.userId));
return tokenInDB;
});
const tokenExpires = Number(storedToken.expires);
if (!isWithinExpiration(tokenExpires)) {
throw new Error(EXPIRED_EMAIL_VERIFICATION_TOKEN);
}
return storedToken.userId;
}
}

export const emailVerificationTM = new TokenManager('email_verification');
export const passwordResetTM = new TokenManager('password_reset');
4 changes: 4 additions & 0 deletions src/routes/passwordReset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { Router } from 'express';

const router = Router();
export default router;
65 changes: 36 additions & 29 deletions src/routes/resendVerification.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import { Router } from 'express';
import { auth } from '../db/lucia.ts';
import {
generateEmailVerificationToken,
validateEmailVerificationTokenSafe,
} from '../lib/token.ts';
import { sendEmailVerificationLink } from '../lib/email.ts';
import {
buildClientErrorResponse,
buildUnknownErrorResponse,
} from '../lib/utils.ts';
import { emailVerificationTM } from '../models/tokenManger.ts';
import {
EXPIRED_EMAIL_VERIFICATION_TOKEN,
INVALID_EMAIL_VERIFICATION_TOKEN,
Expand All @@ -28,7 +25,7 @@ router.post('/email-verification', async (req, res) => {
return res.status(422).end();
}
try {
const token = await generateEmailVerificationToken(session.user.userId);
const token = await emailVerificationTM.generate(session.user.userId);
sendEmailVerificationLink(session.user.email, token);
return res.end();
} catch (e) {
Expand All @@ -39,32 +36,42 @@ router.post('/email-verification', async (req, res) => {

router.get('/email-verification/:token', async (req, res) => {
const { token } = req.params;
const validateRes = await validateEmailVerificationTokenSafe(token);
if (validateRes.isErr && validateRes.error instanceof Error) {
switch (validateRes.error.message) {
case INVALID_EMAIL_VERIFICATION_TOKEN:
return buildClientErrorResponse(res, 'Invalid email verification link');
case EXPIRED_EMAIL_VERIFICATION_TOKEN:
return buildClientErrorResponse(res, 'Invalid email verification link');
default:
console.error(validateRes);
return buildUnknownErrorResponse(res);

try {
const userId = await emailVerificationTM.validate(token);
const user = await auth.getUser(userId);
await auth.invalidateAllUserSessions(user.userId);
await auth.updateUserAttributes(user.userId, {
email_verified: true,
});
const session = await auth.createSession({
userId: user.userId,
attributes: {},
});
const authRequest = auth.handleRequest(req, res);
authRequest.setSession(session);
return res.sendStatus(302);
} catch (e) {
if (e instanceof Error) {
switch (e.message) {
case INVALID_EMAIL_VERIFICATION_TOKEN:
return buildClientErrorResponse(
res,
'Invalid Email Verification Link'
);
case EXPIRED_EMAIL_VERIFICATION_TOKEN:
return buildClientErrorResponse(
res,
'Invalid Email Verification Link'
);
default:
console.error(e);
return buildUnknownErrorResponse(res);
}
}
console.error(e);
return buildUnknownErrorResponse(res);
}

const userId = validateRes.unwrap();
const user = await auth.getUser(userId);
await auth.invalidateAllUserSessions(user.userId);
await auth.updateUserAttributes(user.userId, {
email_verified: true,
});
const session = await auth.createSession({
userId: user.userId,
attributes: {},
});
const authRequest = auth.handleRequest(req, res);
authRequest.setSession(session);
return res.sendStatus(302);
});

export default router;
4 changes: 2 additions & 2 deletions src/routes/signup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import {
buildUnknownErrorResponse,
} from '../lib/utils.ts';
import { UNKNOWN_ERR } from '../constant/error.ts';
import { generateEmailVerificationToken } from '../lib/token.ts';
import { sendEmailVerificationLink } from '../lib/email.ts';
import { emailVerificationTM } from '../models/tokenManger.ts';

const router: Router = Router();

Expand Down Expand Up @@ -61,7 +61,7 @@ router.post('/signup', async (req, res) => {
attributes: {},
});

const token = await generateEmailVerificationToken(user.userId);
const token = await emailVerificationTM.generate(user.userId);
sendEmailVerificationLink(user.email, token);

const sessionCookie = auth.createSessionCookie(session);
Expand Down
25 changes: 25 additions & 0 deletions src/schema/password_reset_token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { relations } from 'drizzle-orm';
import { bigint, pgTable, text } from 'drizzle-orm/pg-core';
import { user } from './user.ts';

export const passwordResetToken = pgTable('password_reset_token', {
id: text('id').notNull(),
expires: bigint('expires', {
mode: 'bigint',
}).primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, {
onDelete: 'cascade',
}),
});

export const passwordResetTokenRelations = relations(
passwordResetToken,
({ one }) => ({
user: one(user, {
fields: [passwordResetToken.userId],
references: [user.id],
}),
})
);
2 changes: 2 additions & 0 deletions src/schema/user.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { relations } from 'drizzle-orm';
import { boolean, pgTable, text } from 'drizzle-orm/pg-core';
import { emailVerificationToken } from './email_verification_token.ts';
import { passwordResetToken } from './password_reset_token.ts';

export const user = pgTable('user', {
id: text('id').primaryKey(),
Expand All @@ -11,4 +12,5 @@ export const user = pgTable('user', {

export const userRelations = relations(user, ({ many }) => ({
emailVerificationToken: many(emailVerificationToken),
passwordRestToken: many(passwordResetToken),
}));
4 changes: 4 additions & 0 deletions src/types/token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface IToken {
generate: (userId: string) => Promise<string>;
validate: (token: string) => Promise<string>;
}

0 comments on commit 7c74dca

Please sign in to comment.