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 * as jose from 'jose';
|
||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
export function hashToken(token: string): string {
|
||||||
|
return createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
export interface AccessPayload {
|
export interface AccessPayload {
|
||||||
sub: string;
|
sub: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user