bunker-admin 2fa50b001c Merge changemaker-control-panel into v2 monorepo
Absorbs the separate control-panel git repo as a subdirectory.
Instances and backups directories excluded via .gitignore.

Bunker Admin
2026-02-21 11:51:45 -07:00

132 lines
4.4 KiB
TypeScript

import bcrypt from 'bcryptjs';
import jwt, { SignOptions } from 'jsonwebtoken';
import crypto from 'crypto';
import { CcpRole } from '@prisma/client';
import { prisma } from '../../lib/prisma';
import { env } from '../../config/env';
import { AppError } from '../../middleware/error-handler';
interface TokenPayload {
id: string;
email: string;
role: CcpRole;
}
function signAccessToken(payload: TokenPayload): string {
return jwt.sign(payload, env.JWT_ACCESS_SECRET, {
expiresIn: env.JWT_ACCESS_EXPIRES_IN as SignOptions['expiresIn'],
});
}
function signRefreshToken(payload: TokenPayload): string {
return jwt.sign(payload, env.JWT_REFRESH_SECRET, {
expiresIn: env.JWT_REFRESH_EXPIRES_IN as SignOptions['expiresIn'],
});
}
function parseExpiry(expiresIn: string): Date {
const match = expiresIn.match(/^(\d+)([smhd])$/);
if (!match) return new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // default 7d
const [, num, unit] = match;
const multipliers: Record<string, number> = { s: 1000, m: 60000, h: 3600000, d: 86400000 };
return new Date(Date.now() + parseInt(num) * multipliers[unit]);
}
export async function login(email: string, password: string) {
const user = await prisma.ccpUser.findUnique({ where: { email } });
if (!user) {
throw new AppError(401, 'Invalid credentials', 'INVALID_CREDENTIALS');
}
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
throw new AppError(401, 'Invalid credentials', 'INVALID_CREDENTIALS');
}
const payload: TokenPayload = { id: user.id, email: user.email, role: user.role };
const accessToken = signAccessToken(payload);
const refreshToken = signRefreshToken(payload);
// Store refresh token
await prisma.ccpRefreshToken.create({
data: {
token: crypto.createHash('sha256').update(refreshToken).digest('hex'),
userId: user.id,
expiresAt: parseExpiry(env.JWT_REFRESH_EXPIRES_IN),
},
});
return {
user: { id: user.id, email: user.email, name: user.name, role: user.role },
accessToken,
refreshToken,
};
}
export async function refresh(refreshToken: string) {
let payload: TokenPayload;
try {
payload = jwt.verify(refreshToken, env.JWT_REFRESH_SECRET) as TokenPayload;
} catch {
throw new AppError(401, 'Invalid refresh token', 'INVALID_TOKEN');
}
const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
// Atomic rotation: delete old, create new
const result = await prisma.$transaction(async (tx) => {
const existing = await tx.ccpRefreshToken.findUnique({ where: { token: tokenHash } });
if (!existing || existing.expiresAt < new Date()) {
throw new AppError(401, 'Refresh token expired or revoked', 'TOKEN_EXPIRED');
}
await tx.ccpRefreshToken.delete({ where: { token: tokenHash } });
const user = await tx.ccpUser.findUnique({ where: { id: payload.id } });
if (!user) {
throw new AppError(401, 'User not found', 'USER_NOT_FOUND');
}
const newPayload: TokenPayload = { id: user.id, email: user.email, role: user.role };
const newAccessToken = signAccessToken(newPayload);
const newRefreshToken = signRefreshToken(newPayload);
await tx.ccpRefreshToken.create({
data: {
token: crypto.createHash('sha256').update(newRefreshToken).digest('hex'),
userId: user.id,
expiresAt: parseExpiry(env.JWT_REFRESH_EXPIRES_IN),
},
});
return {
user: { id: user.id, email: user.email, name: user.name, role: user.role },
accessToken: newAccessToken,
refreshToken: newRefreshToken,
};
});
return result;
}
export async function logout(refreshToken: string) {
const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
await prisma.ccpRefreshToken.deleteMany({ where: { token: tokenHash } });
}
export async function verifyPassword(userId: string, password: string): Promise<boolean> {
const user = await prisma.ccpUser.findUnique({ where: { id: userId } });
if (!user) {
throw new AppError(401, 'Authentication required', 'AUTH_REQUIRED');
}
return bcrypt.compare(password, user.password);
}
export async function getMe(userId: string) {
const user = await prisma.ccpUser.findUnique({ where: { id: userId } });
if (!user) {
throw new AppError(401, 'Authentication required', 'AUTH_REQUIRED');
}
return { id: user.id, email: user.email, name: user.name, role: user.role };
}