Auth v2: Register/Login/Profile, Households, per-user Favorites/Notes/Shopping, Frontend Auth Pages
This commit is contained in:
200
backend/src/services/auth.service.ts
Normal file
200
backend/src/services/auth.service.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user