7 Commits

Author SHA1 Message Date
Anton
c5a4e26f33 feat: register auth routes in app
Made-with: Cursor
2026-03-04 14:12:24 +03:00
Anton
682885ce5a feat: add rate limiting to auth endpoints
Made-with: Cursor
2026-03-04 14:11:29 +03:00
Anton
78809a064e feat: add email verification and password reset
Made-with: Cursor
2026-03-04 14:07:45 +03:00
Anton
e2baa14814 feat: add auth routes
Made-with: Cursor
2026-03-04 14:07:03 +03:00
Anton
181be58a60 feat: add auth plugin
Made-with: Cursor
2026-03-04 14:06:28 +03:00
Anton
8551d5f6d2 feat: add AuthService
Made-with: Cursor
2026-03-04 14:05:34 +03:00
Anton
5cd13cd8ea feat: add password hashing and JWT utils
Made-with: Cursor
2026-03-04 14:04:24 +03:00
7 changed files with 586 additions and 0 deletions

View File

@@ -4,6 +4,8 @@ import databasePlugin from './plugins/database.js';
import redisPlugin from './plugins/redis.js';
import securityPlugin from './plugins/security.js';
import rateLimitPlugin from './plugins/rateLimit.js';
import authPlugin from './plugins/auth.js';
import { authRoutes } from './routes/auth.js';
import { env } from './config/env.js';
import { randomUUID } from 'node:crypto';
@@ -69,6 +71,8 @@ export async function buildApp(): Promise<FastifyInstance> {
await app.register(databasePlugin);
await app.register(securityPlugin);
await app.register(rateLimitPlugin);
await app.register(authPlugin);
await app.register(authRoutes, { prefix: '/auth' });
app.get('/health', async () => ({ status: 'ok', timestamp: new Date().toISOString() }));

42
src/plugins/auth.ts Normal file
View File

@@ -0,0 +1,42 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import fp from 'fastify-plugin';
import { verifyToken, isAccessPayload } from '../utils/jwt.js';
import { unauthorized } from '../utils/errors.js';
declare module 'fastify' {
interface FastifyInstance {
authenticate: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
}
interface FastifyRequest {
user?: { id: string; email: string };
}
}
export async function authenticate(req: FastifyRequest, _reply: FastifyReply): Promise<void> {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
throw unauthorized('Missing or invalid authorization header');
}
const token = authHeader.slice(7);
try {
const payload = await verifyToken(token);
if (!isAccessPayload(payload)) {
throw unauthorized('Invalid token type');
}
req.user = { id: payload.sub, email: payload.email };
} catch {
throw unauthorized('Invalid or expired token');
}
}
const authPlugin = async (app: FastifyInstance) => {
app.decorateRequest('user', undefined);
app.decorate('authenticate', authenticate);
};
export default fp(authPlugin, { name: 'auth' });

View File

@@ -29,6 +29,7 @@ const rateLimitPlugin: FastifyPluginAsync = async (app: FastifyInstance) => {
app.decorate('rateLimitOptions', options);
await app.register(rateLimit, {
global: false,
max: options.apiGuest.max,
timeWindow: options.apiGuest.timeWindow,
keyGenerator: (req) => {

168
src/routes/auth.ts Normal file
View File

@@ -0,0 +1,168 @@
import type { FastifyInstance } from 'fastify';
import { AuthService } from '../services/auth/auth.service.js';
const registerSchema = {
body: {
type: 'object',
required: ['email', 'password', 'nickname'],
properties: {
email: { type: 'string', minLength: 1 },
password: { type: 'string', minLength: 8 },
nickname: { type: 'string', minLength: 2, maxLength: 30 },
},
},
};
const loginSchema = {
body: {
type: 'object',
required: ['email', 'password'],
properties: {
email: { type: 'string', minLength: 1 },
password: { type: 'string' },
},
},
};
const refreshTokenSchema = {
body: {
type: 'object',
required: ['refreshToken'],
properties: {
refreshToken: { type: 'string' },
},
},
};
const logoutSchema = refreshTokenSchema;
const verifyEmailSchema = {
body: {
type: 'object',
required: ['userId', 'code'],
properties: {
userId: { type: 'string', minLength: 1 },
code: { type: 'string', minLength: 1, maxLength: 10 },
},
},
};
const forgotPasswordSchema = {
body: {
type: 'object',
required: ['email'],
properties: {
email: { type: 'string', minLength: 1 },
},
},
};
const resetPasswordSchema = {
body: {
type: 'object',
required: ['token', 'newPassword'],
properties: {
token: { type: 'string' },
newPassword: { type: 'string', minLength: 8 },
},
},
};
export async function authRoutes(app: FastifyInstance) {
const authService = new AuthService(app.db);
const { rateLimitOptions } = app;
app.post(
'/register',
{ schema: registerSchema, config: { rateLimit: rateLimitOptions.register } },
async (req, reply) => {
const body = req.body as { email: string; password: string; nickname: string };
const { userId, verificationCode } = await authService.register(body);
return reply.status(201).send({
userId,
message: 'Registration successful. Please verify your email.',
verificationCode,
});
},
);
app.post(
'/login',
{ schema: loginSchema, config: { rateLimit: rateLimitOptions.login } },
async (req, reply) => {
const body = req.body as { email: string; password: string };
const userAgent = req.headers['user-agent'];
const ipAddress = req.ip;
const result = await authService.login({
email: body.email,
password: body.password,
userAgent,
ipAddress,
});
return reply.send(result);
},
);
app.post(
'/logout',
{ schema: logoutSchema, config: { rateLimit: rateLimitOptions.apiGuest } },
async (req, reply) => {
const body = req.body as { refreshToken: string };
await authService.logout(body.refreshToken);
return reply.status(204).send();
},
);
app.post(
'/refresh',
{ schema: refreshTokenSchema, config: { rateLimit: rateLimitOptions.apiGuest } },
async (req, reply) => {
const body = req.body as { refreshToken: string };
const userAgent = req.headers['user-agent'];
const ipAddress = req.ip;
const result = await authService.refresh({
refreshToken: body.refreshToken,
userAgent,
ipAddress,
});
return reply.send(result);
},
);
app.post(
'/verify-email',
{ schema: verifyEmailSchema, config: { rateLimit: rateLimitOptions.verifyEmail } },
async (req, reply) => {
const body = req.body as { userId: string; code: string };
await authService.verifyEmail(body.userId, body.code);
return reply.send({ message: 'Email verified successfully' });
},
);
app.post(
'/forgot-password',
{ schema: forgotPasswordSchema, config: { rateLimit: rateLimitOptions.forgotPassword } },
async (req, reply) => {
const body = req.body as { email: string };
await authService.forgotPassword(body.email);
return reply.send({
message: 'If the email exists, a reset link has been sent.',
});
},
);
app.post(
'/reset-password',
{ schema: resetPasswordSchema, config: { rateLimit: rateLimitOptions.forgotPassword } },
async (req, reply) => {
const body = req.body as { token: string; newPassword: string };
await authService.resetPassword(body.token, body.newPassword);
return reply.send({ message: 'Password reset successfully' });
},
);
}

View File

@@ -0,0 +1,304 @@
import { eq, and, gt } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type * as schema from '../../db/schema/index.js';
import { users, sessions, emailVerificationCodes, passwordResetTokens } from '../../db/schema/index.js';
import { hashPassword, verifyPassword } from '../../utils/password.js';
import {
signAccessToken,
signRefreshToken,
verifyToken,
isRefreshPayload,
hashToken,
} from '../../utils/jwt.js';
import {
AppError,
conflict,
unauthorized,
notFound,
ERROR_CODES,
} from '../../utils/errors.js';
import { randomBytes, randomUUID } from 'node:crypto';
type Db = NodePgDatabase<typeof schema>;
export interface RegisterInput {
email: string;
password: string;
nickname: string;
}
export interface LoginInput {
email: string;
password: string;
userAgent?: string;
ipAddress?: string;
}
export interface LoginResult {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
export interface RefreshInput {
refreshToken: string;
userAgent?: string;
ipAddress?: string;
}
export interface ForgotPasswordResult {
token: string;
expiresAt: Date;
}
const REFRESH_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const VERIFICATION_CODE_LENGTH = 6;
const VERIFICATION_CODE_TTL_MS = 15 * 60 * 1000;
const RESET_TOKEN_TTL_MS = 60 * 60 * 1000;
export class AuthService {
constructor(private readonly db: Db) {}
async register(input: RegisterInput): Promise<{ userId: string; verificationCode: string }> {
const [existing] = await this.db
.select()
.from(users)
.where(eq(users.email, input.email.toLowerCase().trim()))
.limit(1);
if (existing) {
throw conflict(ERROR_CODES.EMAIL_TAKEN, 'Email already registered');
}
const [nicknameConflict] = await this.db
.select()
.from(users)
.where(eq(users.nickname, input.nickname.trim()))
.limit(1);
if (nicknameConflict) {
throw conflict(ERROR_CODES.NICKNAME_TAKEN, 'Nickname already taken');
}
const passwordHash = await hashPassword(input.password);
const [user] = await this.db
.insert(users)
.values({
email: input.email.toLowerCase().trim(),
passwordHash,
nickname: input.nickname.trim(),
})
.returning({ id: users.id });
if (!user) {
throw new AppError(ERROR_CODES.INTERNAL_ERROR, 'Failed to create user', 500);
}
const code = randomBytes(VERIFICATION_CODE_LENGTH)
.toString('hex')
.slice(0, VERIFICATION_CODE_LENGTH)
.toUpperCase();
const expiresAt = new Date(Date.now() + VERIFICATION_CODE_TTL_MS);
await this.db.insert(emailVerificationCodes).values({
userId: user.id,
code,
expiresAt,
});
return { userId: user.id, verificationCode: code };
}
async login(input: LoginInput): Promise<LoginResult> {
const [user] = await this.db
.select()
.from(users)
.where(eq(users.email, input.email.toLowerCase().trim()))
.limit(1);
if (!user || !(await verifyPassword(user.passwordHash, input.password))) {
throw unauthorized('Invalid email or password');
}
const [accessToken, refreshToken] = await Promise.all([
signAccessToken({ sub: user.id, email: user.email }),
signRefreshToken({ sub: user.id, sid: randomUUID() }),
]);
const refreshHash = hashToken(refreshToken);
const expiresAt = new Date(Date.now() + REFRESH_TTL_MS);
await this.db.insert(sessions).values({
userId: user.id,
refreshTokenHash: refreshHash,
userAgent: input.userAgent ?? null,
ipAddress: input.ipAddress ?? null,
expiresAt,
});
return {
accessToken,
refreshToken,
expiresIn: Math.floor(REFRESH_TTL_MS / 1000),
};
}
async logout(refreshToken: string): Promise<void> {
const hash = hashToken(refreshToken);
await this.db.delete(sessions).where(eq(sessions.refreshTokenHash, hash));
}
async refresh(input: RefreshInput): Promise<LoginResult> {
const payload = await verifyToken(input.refreshToken);
if (!isRefreshPayload(payload)) {
throw unauthorized('Invalid refresh token');
}
const hash = hashToken(input.refreshToken);
const [session] = await this.db
.select()
.from(sessions)
.where(and(eq(sessions.refreshTokenHash, hash), gt(sessions.expiresAt, new Date())))
.limit(1);
if (!session) {
throw new AppError(ERROR_CODES.INVALID_REFRESH_TOKEN, 'Invalid or expired refresh token', 401);
}
await this.db.delete(sessions).where(eq(sessions.id, session.id));
const [user] = await this.db.select().from(users).where(eq(users.id, session.userId)).limit(1);
if (!user) {
throw notFound('User not found');
}
const [accessToken, newRefreshToken] = await Promise.all([
signAccessToken({ sub: user.id, email: user.email }),
signRefreshToken({ sub: user.id, sid: randomUUID() }),
]);
const newHash = hashToken(newRefreshToken);
const expiresAt = new Date(Date.now() + REFRESH_TTL_MS);
await this.db.insert(sessions).values({
userId: user.id,
refreshTokenHash: newHash,
userAgent: input.userAgent ?? session.userAgent,
ipAddress: input.ipAddress ?? session.ipAddress,
expiresAt,
});
return {
accessToken,
refreshToken: newRefreshToken,
expiresIn: Math.floor(REFRESH_TTL_MS / 1000),
};
}
async verifyEmail(userId: string, verificationCode: string): Promise<void> {
const codeUpper = verificationCode.toUpperCase();
const [record] = await this.db
.select()
.from(emailVerificationCodes)
.where(
and(
eq(emailVerificationCodes.userId, userId),
eq(emailVerificationCodes.code, codeUpper),
),
)
.limit(1);
if (!record) {
throw new AppError(ERROR_CODES.INVALID_CODE, 'Invalid or expired verification code', 400);
}
if (record.expiresAt < new Date()) {
await this.db
.delete(emailVerificationCodes)
.where(eq(emailVerificationCodes.id, record.id));
throw new AppError(ERROR_CODES.INVALID_CODE, 'Verification code expired', 400);
}
const [user] = await this.db.select().from(users).where(eq(users.id, userId)).limit(1);
if (!user) {
throw notFound('User not found');
}
if (user.emailVerifiedAt) {
throw new AppError(ERROR_CODES.ALREADY_VERIFIED, 'Email already verified', 400);
}
await this.db
.update(users)
.set({ emailVerifiedAt: new Date(), updatedAt: new Date() })
.where(eq(users.id, userId));
await this.db
.delete(emailVerificationCodes)
.where(eq(emailVerificationCodes.userId, userId));
}
async forgotPassword(email: string): Promise<ForgotPasswordResult> {
const [user] = await this.db
.select()
.from(users)
.where(eq(users.email, email.toLowerCase().trim()))
.limit(1);
if (!user) {
return {
token: '',
expiresAt: new Date(Date.now() + RESET_TOKEN_TTL_MS),
};
}
const token = randomBytes(32).toString('hex');
const tokenHash = hashToken(token);
const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MS);
await this.db.insert(passwordResetTokens).values({
userId: user.id,
tokenHash,
expiresAt,
});
return { token, expiresAt };
}
async resetPassword(token: string, newPassword: string): Promise<void> {
const tokenHash = hashToken(token);
const [record] = await this.db
.select()
.from(passwordResetTokens)
.where(eq(passwordResetTokens.tokenHash, tokenHash))
.limit(1);
if (!record) {
throw new AppError(ERROR_CODES.INVALID_RESET_TOKEN, 'Invalid or expired reset token', 400);
}
if (record.expiresAt < new Date()) {
await this.db
.delete(passwordResetTokens)
.where(eq(passwordResetTokens.id, record.id));
throw new AppError(ERROR_CODES.INVALID_RESET_TOKEN, 'Reset token expired', 400);
}
const passwordHash = await hashPassword(newPassword);
await this.db
.update(users)
.set({ passwordHash, updatedAt: new Date() })
.where(eq(users.id, record.userId));
await this.db
.delete(passwordResetTokens)
.where(eq(passwordResetTokens.id, record.id));
}
}

52
src/utils/jwt.ts Normal file
View File

@@ -0,0 +1,52 @@
import { createHash } from 'node:crypto';
import * as jose from 'jose';
import { env } from '../config/env.js';
export function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
export interface AccessPayload {
sub: string;
email: string;
type: 'access';
}
export interface RefreshPayload {
sub: string;
sid: string;
type: 'refresh';
}
type JwtPayload = AccessPayload | RefreshPayload;
const secret = new TextEncoder().encode(env.JWT_SECRET);
export async function signAccessToken(payload: Omit<AccessPayload, 'type'>): Promise<string> {
return new jose.SignJWT({ ...payload, type: 'access' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(env.JWT_ACCESS_TTL)
.sign(secret);
}
export async function signRefreshToken(payload: Omit<RefreshPayload, 'type'>): Promise<string> {
return new jose.SignJWT({ ...payload, type: 'refresh' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(env.JWT_REFRESH_TTL)
.sign(secret);
}
export async function verifyToken(token: string): Promise<JwtPayload> {
const { payload } = await jose.jwtVerify(token, secret);
return payload as unknown as JwtPayload;
}
export function isAccessPayload(p: JwtPayload): p is AccessPayload {
return p.type === 'access';
}
export function isRefreshPayload(p: JwtPayload): p is RefreshPayload {
return p.type === 'refresh';
}

15
src/utils/password.ts Normal file
View File

@@ -0,0 +1,15 @@
import * as argon2 from 'argon2';
const HASH_OPTIONS: argon2.Options = {
type: argon2.argon2id,
memoryCost: 19456,
timeCost: 2,
};
export async function hashPassword(plain: string): Promise<string> {
return argon2.hash(plain, HASH_OPTIONS);
}
export async function verifyPassword(hash: string, plain: string): Promise<boolean> {
return argon2.verify(hash, plain);
}