Compare commits
7 Commits
feat/db-sc
...
feat/auth
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5a4e26f33 | ||
|
|
682885ce5a | ||
|
|
78809a064e | ||
|
|
e2baa14814 | ||
|
|
181be58a60 | ||
|
|
8551d5f6d2 | ||
|
|
5cd13cd8ea |
@@ -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
42
src/plugins/auth.ts
Normal 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' });
|
||||
@@ -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
168
src/routes/auth.ts
Normal 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' });
|
||||
},
|
||||
);
|
||||
}
|
||||
304
src/services/auth/auth.service.ts
Normal file
304
src/services/auth/auth.service.ts
Normal 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
52
src/utils/jwt.ts
Normal 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
15
src/utils/password.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user