Public Access
215 lines
6.5 KiB
TypeScript
215 lines
6.5 KiB
TypeScript
import bcrypt from 'bcrypt'
|
|
import * as userRepo from '../repositories/userRepo.js'
|
|
import { signToken, generateVerificationToken, hashToken } from '../utils/tokens.js'
|
|
import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.js'
|
|
import { AppError } from '../middleware/error.js'
|
|
import * as storageRepo from '../repositories/storageRepo.js'
|
|
import type { RegisterInput, LoginInput, UpdateProfileInput, ChangePasswordInput, VerifyEmailInput, ResetPasswordInput } from '../validators/authSchemas.js'
|
|
|
|
const SALT_ROUNDS = 10
|
|
const VERIFY_TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000
|
|
const RESET_TOKEN_EXPIRY_MS = 60 * 60 * 1000
|
|
|
|
export interface AuthResult {
|
|
token: string
|
|
user: { id: number; email: string; displayName: string; role: string }
|
|
}
|
|
|
|
/** Response for a registration attempt we did not act on (email already exists).
|
|
* Deliberately indistinguishable from success so the endpoint cannot be used
|
|
* to enumerate registered email addresses. */
|
|
export interface RegistrationPending {
|
|
message: string
|
|
}
|
|
|
|
function toAuthResult(user: userRepo.DbUser): AuthResult {
|
|
return {
|
|
token: signToken({
|
|
userId: user.id,
|
|
email: user.email,
|
|
displayName: user.display_name,
|
|
role: user.role as 'user' | 'admin',
|
|
tv: user.token_version ?? 0,
|
|
}),
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
displayName: user.display_name,
|
|
role: user.role,
|
|
},
|
|
}
|
|
}
|
|
|
|
export async function register(input: RegisterInput): Promise<AuthResult | RegistrationPending> {
|
|
// Hash before the duplicate check so response timing does not reveal whether
|
|
// the email is already registered (user enumeration)
|
|
const passwordHash = await bcrypt.hash(input.password, SALT_ROUNDS)
|
|
|
|
const existing = userRepo.getUserByEmail(input.email)
|
|
if (existing) {
|
|
return { message: 'Check your inbox to finish setting up your account.' }
|
|
}
|
|
|
|
const rawVerifyToken = generateVerificationToken()
|
|
const verifyTokenHash = hashToken(rawVerifyToken)
|
|
const verifyExpires = Date.now() + VERIFY_TOKEN_EXPIRY_MS
|
|
|
|
const { id } = userRepo.createUser({
|
|
email: input.email,
|
|
passwordHash,
|
|
displayName: input.displayName,
|
|
role: 'user',
|
|
emailVerified: 0,
|
|
verifyToken: verifyTokenHash,
|
|
verifyExpires,
|
|
})
|
|
|
|
const user = userRepo.getUserById(id)!
|
|
|
|
// Don't await — fire and forget so registration isn't blocked by email failures
|
|
sendVerificationEmail(user.email, rawVerifyToken).catch(() => {})
|
|
|
|
return toAuthResult(user)
|
|
}
|
|
|
|
export async function login(input: LoginInput): Promise<AuthResult> {
|
|
const user = userRepo.getUserByEmail(input.email)
|
|
if (!user) {
|
|
throw new AppError(401, 'Invalid email or password')
|
|
}
|
|
|
|
const valid = await bcrypt.compare(input.password, user.password_hash)
|
|
if (!valid) {
|
|
throw new AppError(401, 'Invalid email or password')
|
|
}
|
|
|
|
if (!user.email_verified) {
|
|
throw new AppError(403, 'Please verify your email before logging in')
|
|
}
|
|
|
|
return toAuthResult(user)
|
|
}
|
|
|
|
export function getMe(userId: number) {
|
|
const user = userRepo.getUserById(userId)
|
|
if (!user) {
|
|
throw new AppError(404, 'User not found')
|
|
}
|
|
const usage = storageRepo.getUserStorageUsage(userId)
|
|
const usedPercent = user.storage_quota_bytes > 0
|
|
? Math.round((usage.usedBytes / user.storage_quota_bytes) * 100)
|
|
: 0
|
|
return {
|
|
id: user.id,
|
|
email: user.email,
|
|
displayName: user.display_name,
|
|
role: user.role,
|
|
emailVerified: !!user.email_verified,
|
|
storageQuotaBytes: user.storage_quota_bytes,
|
|
usedBytes: usage.usedBytes,
|
|
usedPercent,
|
|
plan: user.plan,
|
|
}
|
|
}
|
|
|
|
export async function updateProfile(userId: number, input: UpdateProfileInput) {
|
|
userRepo.updateUser(userId, { displayName: input.displayName })
|
|
return getMe(userId)
|
|
}
|
|
|
|
export async function changePassword(userId: number, input: ChangePasswordInput) {
|
|
const user = userRepo.getUserById(userId)
|
|
if (!user) {
|
|
throw new AppError(404, 'User not found')
|
|
}
|
|
|
|
const valid = await bcrypt.compare(input.currentPassword, user.password_hash)
|
|
if (!valid) {
|
|
throw new AppError(400, 'Current password is incorrect')
|
|
}
|
|
|
|
const passwordHash = await bcrypt.hash(input.newPassword, SALT_ROUNDS)
|
|
// Bump token version so tokens issued before the change stop working
|
|
userRepo.updateUser(userId, {
|
|
passwordHash,
|
|
tokenVersion: (user.token_version ?? 0) + 1,
|
|
})
|
|
}
|
|
|
|
export async function verifyEmail(input: VerifyEmailInput) {
|
|
const tokenHash = hashToken(input.token)
|
|
const user = userRepo.getUserByVerifyTokenHash(tokenHash)
|
|
if (!user || !user.verify_expires || Date.now() > user.verify_expires) {
|
|
throw new AppError(400, 'Invalid or expired verification token')
|
|
}
|
|
|
|
userRepo.updateUser(user.id, {
|
|
emailVerified: 1,
|
|
verifyToken: null,
|
|
verifyExpires: null,
|
|
})
|
|
}
|
|
|
|
export async function resendVerification(email: string) {
|
|
const user = userRepo.getUserByEmail(email)
|
|
if (!user || user.email_verified) {
|
|
return
|
|
}
|
|
|
|
const rawVerifyToken = generateVerificationToken()
|
|
const verifyTokenHash = hashToken(rawVerifyToken)
|
|
const verifyExpires = Date.now() + VERIFY_TOKEN_EXPIRY_MS
|
|
|
|
userRepo.updateUser(user.id, {
|
|
verifyToken: verifyTokenHash,
|
|
verifyExpires,
|
|
})
|
|
|
|
sendVerificationEmail(user.email, rawVerifyToken).catch(() => {})
|
|
}
|
|
|
|
export async function forgotPassword(email?: string) {
|
|
if (!email) {
|
|
throw new AppError(400, 'Email is required')
|
|
}
|
|
|
|
const user = userRepo.getUserByEmail(email)
|
|
if (!user) {
|
|
// Don't reveal whether email exists
|
|
return
|
|
}
|
|
|
|
const rawResetToken = generateVerificationToken()
|
|
const resetTokenHash = hashToken(rawResetToken)
|
|
const resetExpires = Date.now() + RESET_TOKEN_EXPIRY_MS
|
|
|
|
userRepo.updateUser(user.id, { resetToken: resetTokenHash, resetExpires })
|
|
|
|
// Fire and forget
|
|
sendPasswordResetEmail(user.email, rawResetToken).catch(() => {})
|
|
}
|
|
|
|
export async function resetPassword(input: ResetPasswordInput) {
|
|
const tokenHash = hashToken(input.token)
|
|
const user = userRepo.getUserByResetTokenHash(tokenHash)
|
|
if (!user || !user.reset_expires || Date.now() > user.reset_expires) {
|
|
throw new AppError(400, 'Invalid or expired reset token')
|
|
}
|
|
|
|
const passwordHash = await bcrypt.hash(input.newPassword, SALT_ROUNDS)
|
|
// Bump token version so tokens from before the reset stop working
|
|
userRepo.updateUser(user.id, {
|
|
passwordHash,
|
|
resetToken: null,
|
|
resetExpires: null,
|
|
tokenVersion: (user.token_version ?? 0) + 1,
|
|
})
|
|
}
|
|
|
|
export function deleteUserAccount(userId: number) {
|
|
const success = userRepo.deleteUser(userId)
|
|
if (!success) {
|
|
throw new AppError(404, 'User not found')
|
|
}
|
|
}
|