chore: update gitignore for phase02
This commit is contained in:
@@ -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