feat: add AuthService
Made-with: Cursor
This commit is contained in:
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));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user