109 lines
3.5 KiB
TypeScript
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;
|
|
}
|
|
}
|