200 lines
5.5 KiB
TypeScript
200 lines
5.5 KiB
TypeScript
import bcrypt from 'bcrypt';
|
|
import jwt from 'jsonwebtoken';
|
|
import { ulid } from 'ulid';
|
|
import { getDb } from '../db/connection.js';
|
|
|
|
const JWT_SECRET = process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-in-production';
|
|
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'your-super-secret-refresh-key-change-in-production';
|
|
|
|
const BCRYPT_ROUNDS = 12;
|
|
const ACCESS_TOKEN_EXPIRES_IN = '15m';
|
|
const REFRESH_TOKEN_EXPIRES_IN = '7d';
|
|
|
|
export interface User {
|
|
id: string;
|
|
email: string;
|
|
display_name: string;
|
|
avatar_url?: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface AuthTokens {
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
}
|
|
|
|
export interface AuthResult {
|
|
user: User;
|
|
tokens: AuthTokens;
|
|
}
|
|
|
|
class AuthService {
|
|
private db = getDb();
|
|
|
|
async register(email: string, password: string, displayName: string): Promise<AuthResult> {
|
|
// Check if user already exists
|
|
const existingUser = this.db.prepare('SELECT id FROM users WHERE email = ?').get(email);
|
|
if (existingUser) {
|
|
throw new Error('EMAIL_EXISTS');
|
|
}
|
|
|
|
// Hash password
|
|
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
|
|
|
// Create user
|
|
const userId = ulid();
|
|
const now = new Date().toISOString();
|
|
|
|
this.db.prepare(`
|
|
INSERT INTO users (id, email, password_hash, display_name, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
`).run(userId, email, passwordHash, displayName, now, now);
|
|
|
|
// Get created user
|
|
const user = this.db.prepare(`
|
|
SELECT id, email, display_name, avatar_url, created_at, updated_at
|
|
FROM users WHERE id = ?
|
|
`).get(userId) as User;
|
|
|
|
// Generate tokens
|
|
const tokens = this.generateTokens(user);
|
|
|
|
return { user, tokens };
|
|
}
|
|
|
|
async login(email: string, password: string): Promise<AuthResult> {
|
|
// Get user by email
|
|
const userWithPassword = this.db.prepare(`
|
|
SELECT id, email, password_hash, display_name, avatar_url, created_at, updated_at
|
|
FROM users WHERE email = ?
|
|
`).get(email) as any;
|
|
|
|
if (!userWithPassword) {
|
|
throw new Error('INVALID_CREDENTIALS');
|
|
}
|
|
|
|
// Check password
|
|
const passwordValid = await bcrypt.compare(password, userWithPassword.password_hash);
|
|
if (!passwordValid) {
|
|
throw new Error('INVALID_CREDENTIALS');
|
|
}
|
|
|
|
// Remove password from user object
|
|
const { password_hash, ...user } = userWithPassword;
|
|
|
|
// Generate tokens
|
|
const tokens = this.generateTokens(user);
|
|
|
|
return { user, tokens };
|
|
}
|
|
|
|
async getProfile(userId: string): Promise<User | null> {
|
|
const user = this.db.prepare(`
|
|
SELECT id, email, display_name, avatar_url, created_at, updated_at
|
|
FROM users WHERE id = ?
|
|
`).get(userId) as User | undefined;
|
|
|
|
return user || null;
|
|
}
|
|
|
|
async updateProfile(userId: string, data: { display_name?: string; avatar_url?: string }): Promise<User> {
|
|
const updates: string[] = [];
|
|
const values: any[] = [];
|
|
|
|
if (data.display_name !== undefined) {
|
|
updates.push('display_name = ?');
|
|
values.push(data.display_name);
|
|
}
|
|
|
|
if (data.avatar_url !== undefined) {
|
|
updates.push('avatar_url = ?');
|
|
values.push(data.avatar_url);
|
|
}
|
|
|
|
if (updates.length === 0) {
|
|
throw new Error('NO_UPDATES_PROVIDED');
|
|
}
|
|
|
|
updates.push('updated_at = ?');
|
|
values.push(new Date().toISOString());
|
|
values.push(userId);
|
|
|
|
this.db.prepare(`
|
|
UPDATE users SET ${updates.join(', ')} WHERE id = ?
|
|
`).run(...values);
|
|
|
|
const user = await this.getProfile(userId);
|
|
if (!user) {
|
|
throw new Error('USER_NOT_FOUND');
|
|
}
|
|
|
|
return user;
|
|
}
|
|
|
|
async changePassword(userId: string, currentPassword: string, newPassword: string): Promise<boolean> {
|
|
// Get current password hash
|
|
const userWithPassword = this.db.prepare(`
|
|
SELECT password_hash FROM users WHERE id = ?
|
|
`).get(userId) as any;
|
|
|
|
if (!userWithPassword) {
|
|
throw new Error('USER_NOT_FOUND');
|
|
}
|
|
|
|
// Verify current password
|
|
const currentPasswordValid = await bcrypt.compare(currentPassword, userWithPassword.password_hash);
|
|
if (!currentPasswordValid) {
|
|
throw new Error('INVALID_CURRENT_PASSWORD');
|
|
}
|
|
|
|
// Hash new password
|
|
const newPasswordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
|
|
|
|
// Update password
|
|
this.db.prepare(`
|
|
UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?
|
|
`).run(newPasswordHash, new Date().toISOString(), userId);
|
|
|
|
return true;
|
|
}
|
|
|
|
async refreshTokens(refreshToken: string): Promise<AuthResult> {
|
|
try {
|
|
const decoded = jwt.verify(refreshToken, JWT_REFRESH_SECRET) as any;
|
|
const user = await this.getProfile(decoded.sub);
|
|
|
|
if (!user) {
|
|
throw new Error('USER_NOT_FOUND');
|
|
}
|
|
|
|
const tokens = this.generateTokens(user);
|
|
return { user, tokens };
|
|
} catch (error) {
|
|
throw new Error('INVALID_REFRESH_TOKEN');
|
|
}
|
|
}
|
|
|
|
verifyAccessToken(token: string): any {
|
|
try {
|
|
return jwt.verify(token, JWT_SECRET);
|
|
} catch (error) {
|
|
throw new Error('INVALID_ACCESS_TOKEN');
|
|
}
|
|
}
|
|
|
|
private generateTokens(user: User): AuthTokens {
|
|
const payload = {
|
|
sub: user.id,
|
|
email: user.email,
|
|
display_name: user.display_name,
|
|
};
|
|
|
|
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: ACCESS_TOKEN_EXPIRES_IN });
|
|
const refreshToken = jwt.sign({ sub: user.id }, JWT_REFRESH_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRES_IN });
|
|
|
|
return { accessToken, refreshToken };
|
|
}
|
|
}
|
|
|
|
export const authService = new AuthService(); |