chore: update gitignore for phase02
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"dev": "dotenv -e ../../.env -- tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"lint": "eslint src/"
|
||||
@@ -18,11 +18,14 @@
|
||||
"@source/database": "workspace:*",
|
||||
"@source/shared": "workspace:*",
|
||||
"argon2": "^0.41.0",
|
||||
"drizzle-orm": "^0.38.0",
|
||||
"fastify": "^5.2.0",
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"socket.io": "^4.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv-cli": "^8.0.0",
|
||||
"tsx": "^4.19.0"
|
||||
}
|
||||
}
|
||||
|
||||
+40
-3
@@ -1,26 +1,63 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import cookie from '@fastify/cookie';
|
||||
import dbPlugin from './plugins/db.js';
|
||||
import authPlugin from './plugins/auth.js';
|
||||
import authRoutes from './routes/auth/index.js';
|
||||
import { AppError } from './lib/errors.js';
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
},
|
||||
transport:
|
||||
process.env.NODE_ENV !== 'production'
|
||||
? { target: 'pino-pretty' }
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Plugins
|
||||
await app.register(cors, {
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
await app.register(cookie);
|
||||
await app.register(dbPlugin);
|
||||
await app.register(authPlugin);
|
||||
|
||||
// Error handler
|
||||
app.setErrorHandler((error: Error & { validation?: unknown; statusCode?: number; code?: string }, _request, reply) => {
|
||||
if (error instanceof AppError) {
|
||||
return reply.code(error.statusCode).send({
|
||||
error: error.name,
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
});
|
||||
}
|
||||
|
||||
// Fastify validation errors
|
||||
if (error.validation) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation Error',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
app.log.error(error);
|
||||
return reply.code(500).send({
|
||||
error: 'Internal Server Error',
|
||||
message: 'An unexpected error occurred',
|
||||
});
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.get('/api/health', async () => {
|
||||
return { status: 'ok', timestamp: new Date().toISOString() };
|
||||
});
|
||||
|
||||
await app.register(authRoutes, { prefix: '/api/auth' });
|
||||
|
||||
// Start
|
||||
const PORT = Number(process.env.PORT) || 3000;
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
public statusCode: number,
|
||||
message: string,
|
||||
public code?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AppError';
|
||||
}
|
||||
|
||||
static badRequest(message: string, code?: string) {
|
||||
return new AppError(400, message, code);
|
||||
}
|
||||
|
||||
static unauthorized(message = 'Unauthorized', code?: string) {
|
||||
return new AppError(401, message, code);
|
||||
}
|
||||
|
||||
static forbidden(message = 'Forbidden', code?: string) {
|
||||
return new AppError(403, message, code);
|
||||
}
|
||||
|
||||
static notFound(message = 'Not found', code?: string) {
|
||||
return new AppError(404, message, code);
|
||||
}
|
||||
|
||||
static conflict(message: string, code?: string) {
|
||||
return new AppError(409, message, code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
export interface AccessTokenPayload {
|
||||
sub: string; // user id
|
||||
email: string;
|
||||
isSuperAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface RefreshTokenPayload {
|
||||
sub: string; // user id
|
||||
type: 'refresh';
|
||||
}
|
||||
|
||||
const ACCESS_TOKEN_EXPIRY = '15m';
|
||||
const REFRESH_TOKEN_EXPIRY = '7d';
|
||||
|
||||
export function signAccessToken(app: FastifyInstance, payload: AccessTokenPayload): string {
|
||||
return app.jwt.sign(payload, { expiresIn: ACCESS_TOKEN_EXPIRY });
|
||||
}
|
||||
|
||||
export function signRefreshToken(app: FastifyInstance, payload: RefreshTokenPayload): string {
|
||||
return (app as any).jwtRefresh.sign(payload, { expiresIn: REFRESH_TOKEN_EXPIRY });
|
||||
}
|
||||
|
||||
export function verifyRefreshToken(app: FastifyInstance, token: string): RefreshTokenPayload {
|
||||
return (app as any).jwtRefresh.verify(token) as RefreshTokenPayload;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import argon2 from 'argon2';
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return argon2.hash(password, {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: 65536,
|
||||
timeCost: 3,
|
||||
parallelism: 4,
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyPassword(hash: string, password: string): Promise<boolean> {
|
||||
return argon2.verify(hash, password);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import fp from 'fastify-plugin';
|
||||
import jwt from '@fastify/jwt';
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import type { AccessTokenPayload } from '../lib/jwt.js';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||
jwtRefresh: FastifyInstance['jwt'];
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@fastify/jwt' {
|
||||
interface FastifyJWT {
|
||||
payload: AccessTokenPayload;
|
||||
user: AccessTokenPayload;
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(async (app: FastifyInstance) => {
|
||||
const jwtSecret = process.env.JWT_SECRET;
|
||||
const jwtRefreshSecret = process.env.JWT_REFRESH_SECRET;
|
||||
|
||||
if (!jwtSecret || !jwtRefreshSecret) {
|
||||
throw new Error('JWT_SECRET and JWT_REFRESH_SECRET environment variables are required');
|
||||
}
|
||||
|
||||
// Access token JWT
|
||||
await app.register(jwt, {
|
||||
secret: jwtSecret,
|
||||
namespace: 'jwt',
|
||||
});
|
||||
|
||||
// Refresh token JWT (separate namespace)
|
||||
await app.register(jwt, {
|
||||
secret: jwtRefreshSecret,
|
||||
namespace: 'jwtRefresh',
|
||||
});
|
||||
|
||||
// Auth decorator
|
||||
app.decorate('authenticate', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch {
|
||||
reply.code(401).send({ error: 'Unauthorized', message: 'Invalid or expired token' });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import fp from 'fastify-plugin';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { createDb, type Database } from '@source/database';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
db: Database;
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(async (app: FastifyInstance) => {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is required');
|
||||
}
|
||||
|
||||
const db = createDb(databaseUrl);
|
||||
app.decorate('db', db);
|
||||
|
||||
app.log.info('Database connected');
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { users } from '@source/database';
|
||||
import { hashPassword, verifyPassword } from '../../lib/password.js';
|
||||
import { signAccessToken, signRefreshToken, verifyRefreshToken } from '../../lib/jwt.js';
|
||||
import type { AccessTokenPayload, RefreshTokenPayload } from '../../lib/jwt.js';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
import { RegisterSchema, LoginSchema } from './schemas.js';
|
||||
|
||||
const REFRESH_COOKIE_NAME = 'refresh_token';
|
||||
const REFRESH_COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/api/auth',
|
||||
maxAge: 7 * 24 * 60 * 60, // 7 days in seconds
|
||||
};
|
||||
|
||||
export default async function authRoutes(app: FastifyInstance) {
|
||||
// POST /api/auth/register
|
||||
app.post('/register', { schema: RegisterSchema }, async (request, reply) => {
|
||||
const { email, username, password } = request.body as {
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
// Check if email already exists
|
||||
const existingEmail = await app.db.query.users.findFirst({
|
||||
where: eq(users.email, email),
|
||||
});
|
||||
if (existingEmail) {
|
||||
throw AppError.conflict('Email already in use', 'EMAIL_TAKEN');
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
const existingUsername = await app.db.query.users.findFirst({
|
||||
where: eq(users.username, username),
|
||||
});
|
||||
if (existingUsername) {
|
||||
throw AppError.conflict('Username already in use', 'USERNAME_TAKEN');
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
const [user] = await app.db
|
||||
.insert(users)
|
||||
.values({
|
||||
email,
|
||||
username,
|
||||
passwordHash,
|
||||
})
|
||||
.returning({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
username: users.username,
|
||||
isSuperAdmin: users.isSuperAdmin,
|
||||
});
|
||||
|
||||
// Generate tokens
|
||||
const accessToken = signAccessToken(app, {
|
||||
sub: user!.id,
|
||||
email: user!.email,
|
||||
isSuperAdmin: user!.isSuperAdmin,
|
||||
});
|
||||
|
||||
const refreshToken = signRefreshToken(app, {
|
||||
sub: user!.id,
|
||||
type: 'refresh',
|
||||
});
|
||||
|
||||
reply.setCookie(REFRESH_COOKIE_NAME, refreshToken, REFRESH_COOKIE_OPTIONS);
|
||||
|
||||
return reply.code(201).send({
|
||||
user: {
|
||||
id: user!.id,
|
||||
email: user!.email,
|
||||
username: user!.username,
|
||||
isSuperAdmin: user!.isSuperAdmin,
|
||||
},
|
||||
accessToken,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/auth/login
|
||||
app.post('/login', { schema: LoginSchema }, async (request, reply) => {
|
||||
const { email, password } = request.body as { email: string; password: string };
|
||||
|
||||
const user = await app.db.query.users.findFirst({
|
||||
where: eq(users.email, email),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw AppError.unauthorized('Invalid email or password', 'INVALID_CREDENTIALS');
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword(user.passwordHash, password);
|
||||
if (!isValid) {
|
||||
throw AppError.unauthorized('Invalid email or password', 'INVALID_CREDENTIALS');
|
||||
}
|
||||
|
||||
const accessToken = signAccessToken(app, {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
isSuperAdmin: user.isSuperAdmin,
|
||||
});
|
||||
|
||||
const refreshToken = signRefreshToken(app, {
|
||||
sub: user.id,
|
||||
type: 'refresh',
|
||||
});
|
||||
|
||||
reply.setCookie(REFRESH_COOKIE_NAME, refreshToken, REFRESH_COOKIE_OPTIONS);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
isSuperAdmin: user.isSuperAdmin,
|
||||
avatarUrl: user.avatarUrl,
|
||||
},
|
||||
accessToken,
|
||||
};
|
||||
});
|
||||
|
||||
// POST /api/auth/refresh
|
||||
app.post('/refresh', async (request, reply) => {
|
||||
const token = request.cookies[REFRESH_COOKIE_NAME];
|
||||
if (!token) {
|
||||
throw AppError.unauthorized('No refresh token', 'NO_REFRESH_TOKEN');
|
||||
}
|
||||
|
||||
let payload: RefreshTokenPayload;
|
||||
try {
|
||||
payload = verifyRefreshToken(app, token);
|
||||
} catch {
|
||||
reply.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' });
|
||||
throw AppError.unauthorized('Invalid refresh token', 'INVALID_REFRESH_TOKEN');
|
||||
}
|
||||
|
||||
const user = await app.db.query.users.findFirst({
|
||||
where: eq(users.id, payload.sub),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
reply.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' });
|
||||
throw AppError.unauthorized('User not found', 'USER_NOT_FOUND');
|
||||
}
|
||||
|
||||
// Token rotation: issue new tokens
|
||||
const accessToken = signAccessToken(app, {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
isSuperAdmin: user.isSuperAdmin,
|
||||
});
|
||||
|
||||
const newRefreshToken = signRefreshToken(app, {
|
||||
sub: user.id,
|
||||
type: 'refresh',
|
||||
});
|
||||
|
||||
reply.setCookie(REFRESH_COOKIE_NAME, newRefreshToken, REFRESH_COOKIE_OPTIONS);
|
||||
|
||||
return { accessToken };
|
||||
});
|
||||
|
||||
// POST /api/auth/logout
|
||||
app.post('/logout', async (_request, reply) => {
|
||||
reply.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' });
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// GET /api/auth/me
|
||||
app.get('/me', { onRequest: [app.authenticate] }, async (request) => {
|
||||
const payload = request.user;
|
||||
|
||||
const user = await app.db.query.users.findFirst({
|
||||
where: eq(users.id, payload.sub),
|
||||
columns: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
isSuperAdmin: true,
|
||||
avatarUrl: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw AppError.notFound('User not found');
|
||||
}
|
||||
|
||||
return { user };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
|
||||
export const RegisterSchema = {
|
||||
body: Type.Object({
|
||||
email: Type.String({ format: 'email' }),
|
||||
username: Type.String({ minLength: 3, maxLength: 100 }),
|
||||
password: Type.String({ minLength: 8, maxLength: 128 }),
|
||||
}),
|
||||
};
|
||||
|
||||
export const LoginSchema = {
|
||||
body: Type.Object({
|
||||
email: Type.String({ format: 'email' }),
|
||||
password: Type.String(),
|
||||
}),
|
||||
};
|
||||
Reference in New Issue
Block a user