Files
backspace/packages/server/src/utils/auth.ts
T

109 lines
3.5 KiB
TypeScript

import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { eq } from 'drizzle-orm';
import { config } from '../config.js';
import { getDb, schema } from '../db/index.js';
import type { FastifyRequest, FastifyReply } from 'fastify';
const SALT_ROUNDS = 12;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
export interface JwtPayload {
userId: string;
username: string;
iat?: number;
}
export function signJwt(payload: JwtPayload): string {
const options: jwt.SignOptions = {
expiresIn: config.jwtExpiresIn as unknown as jwt.SignOptions['expiresIn'],
};
return jwt.sign(payload, config.jwtSecret, options);
}
export function verifyJwt(token: string): JwtPayload {
const decoded = jwt.verify(token, config.jwtSecret, { algorithms: ['HS256'] }) as JwtPayload;
return decoded;
}
export async function authenticate(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
reply.code(401).send({ error: 'Missing or invalid authorization header', statusCode: 401 });
return;
}
const token = authHeader.slice(7);
try {
const payload = verifyJwt(token);
// Verify user exists and is not deleted/revoked
const db = getDb();
const user = db.select({
id: schema.users.id,
isDeleted: schema.users.isDeleted,
passwordChangedAt: schema.users.passwordChangedAt,
homeInstance: schema.users.homeInstance,
}).from(schema.users).where(eq(schema.users.id, payload.userId)).get();
if (!user || user.isDeleted === 1) {
return reply.code(401).send({ error: 'This account has been deleted', statusCode: 401 });
}
// Reject tokens issued before the last password change (token revocation)
if (user.passwordChangedAt && payload.iat) {
// JWT iat is in seconds, passwordChangedAt is in milliseconds
if (payload.iat < Math.floor(user.passwordChangedAt / 1000)) {
return reply.code(401).send({ error: 'Token has been revoked — please log in again', statusCode: 401 });
}
}
(request as FastifyRequest & { userId: string; username: string }).userId = payload.userId;
(request as FastifyRequest & { userId: string; username: string }).username = payload.username;
(request as FastifyRequest & { userId: string; username: string }).homeInstance = user.homeInstance ?? null;
} catch {
return reply.code(401).send({ error: 'Invalid or expired token', statusCode: 401 });
}
}
export async function requireAdmin(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
const db = getDb();
const caller = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (!caller || caller.isAdmin !== 1) {
return reply.code(403).send({ error: 'Only instance admins can perform this action', statusCode: 403 });
}
}
export async function requireLocalUser(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
if (request.homeInstance) {
return reply.code(403).send({
error: 'Federated users must use their home instance for DM operations',
statusCode: 403,
});
}
}
declare module 'fastify' {
interface FastifyRequest {
userId: string;
username: string;
homeInstance: string | null;
}
}