feat: Security

This commit is contained in:
2026-06-05 18:34:01 +02:00
parent ee293d7daf
commit 45675192a5
134 changed files with 4128 additions and 446 deletions

View File

@@ -0,0 +1,45 @@
import {
describe,
it,
expect
} from 'vitest';
import crypto from 'crypto';
import {
hashPasswordForStorage,
isLegacySha256Hash,
verifyPassword
} from './password-auth.service';
describe('password-auth.service', () => {
it('stores bcrypt hashes for new passwords', async () => {
const hash = await hashPasswordForStorage('secret-pass');
expect(hash.startsWith('$2')).toBe(true);
expect(isLegacySha256Hash(hash)).toBe(false);
});
it('verifies bcrypt passwords', async () => {
const hash = await hashPasswordForStorage('secret-pass');
await expect(verifyPassword('secret-pass', hash)).resolves.toBe(true);
await expect(verifyPassword('wrong-pass', hash)).resolves.toBe(false);
});
it('verifies legacy sha256 passwords', async () => {
const legacyHash = crypto.createHash('sha256').update('legacy-pass')
.digest('hex');
expect(isLegacySha256Hash(legacyHash)).toBe(true);
await expect(verifyPassword('legacy-pass', legacyHash)).resolves.toBe(true);
await expect(verifyPassword('wrong-pass', legacyHash)).resolves.toBe(false);
});
it('rehashes legacy passwords to bcrypt after successful verification', async () => {
const legacyHash = crypto.createHash('sha256').update('legacy-pass')
.digest('hex');
const upgraded = await hashPasswordForStorage('legacy-pass', legacyHash);
expect(upgraded.startsWith('$2')).toBe(true);
await expect(verifyPassword('legacy-pass', upgraded)).resolves.toBe(true);
});
});

View File

@@ -0,0 +1,33 @@
import crypto from 'crypto';
import bcrypt from 'bcryptjs';
const BCRYPT_ROUNDS = 12;
export function isLegacySha256Hash(passwordHash: string): boolean {
return /^[a-f0-9]{64}$/i.test(passwordHash);
}
export async function hashPasswordForStorage(
password: string,
existingHash?: string
): Promise<string> {
if (existingHash && isLegacySha256Hash(existingHash)) {
const legacyMatches = crypto.createHash('sha256').update(password)
.digest('hex') === existingHash;
if (legacyMatches) {
return bcrypt.hash(password, BCRYPT_ROUNDS);
}
}
return bcrypt.hash(password, BCRYPT_ROUNDS);
}
export async function verifyPassword(password: string, passwordHash: string): Promise<boolean> {
if (isLegacySha256Hash(passwordHash)) {
return crypto.createHash('sha256').update(password)
.digest('hex') === passwordHash;
}
return bcrypt.compare(password, passwordHash);
}

View File

@@ -134,6 +134,34 @@ export async function countServerMemberships(serverId: string): Promise<number>
return await getMembershipRepository().count({ where: { serverId } });
}
export async function usersShareServerMembership(userA: string, userB: string): Promise<boolean> {
if (userA === userB) {
return true;
}
const repo = getMembershipRepository();
const membershipsForA = await repo.find({
where: { userId: userA },
select: ['serverId']
});
if (membershipsForA.length === 0) {
return false;
}
for (const membership of membershipsForA) {
const shared = await repo.findOne({
where: { serverId: membership.serverId, userId: userB }
});
if (shared) {
return true;
}
}
return false;
}
export async function ensureServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity> {
const repo = getMembershipRepository();
const now = Date.now();

View File

@@ -0,0 +1,110 @@
import { randomBytes } from 'crypto';
import { getDataSource } from '../db/database';
import { SessionTokenEntity } from '../entities/SessionTokenEntity';
import { getUserById } from '../cqrs';
import type { AuthUserPayload } from '../cqrs/types';
const DEFAULT_TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
export interface IssuedSessionToken {
token: string;
userId: string;
issuedAt: number;
expiresAt: number;
}
export interface AuthenticatedSession {
token: string;
user: AuthUserPayload;
issuedAt: number;
expiresAt: number;
}
function getTokenRepository() {
return getDataSource().getRepository(SessionTokenEntity);
}
export function getSessionTokenTtlMs(): number {
const configured = Number(process.env.SESSION_TOKEN_TTL_MS);
return Number.isFinite(configured) && configured > 0
? configured
: DEFAULT_TOKEN_TTL_MS;
}
export async function issueSessionToken(userId: string): Promise<IssuedSessionToken> {
const token = randomBytes(32).toString('hex');
const issuedAt = Date.now();
const expiresAt = issuedAt + getSessionTokenTtlMs();
const repo = getTokenRepository();
await repo.save(repo.create({
token,
userId,
issuedAt,
expiresAt
}));
return { token, userId, issuedAt, expiresAt };
}
export async function consumeSessionToken(token: string): Promise<AuthenticatedSession | null> {
const normalized = token.trim();
if (!normalized) {
return null;
}
const repo = getTokenRepository();
const record = await repo.findOne({ where: { token: normalized } });
if (!record || record.expiresAt < Date.now()) {
if (record) {
await repo.delete({ token: normalized });
}
return null;
}
const user = await getUserById(record.userId);
if (!user) {
await repo.delete({ token: normalized });
return null;
}
return {
token: record.token,
user,
issuedAt: record.issuedAt,
expiresAt: record.expiresAt
};
}
export async function revokeSessionToken(token: string): Promise<void> {
const normalized = token.trim();
if (!normalized) {
return;
}
await getTokenRepository().delete({ token: normalized });
}
export async function revokeAllSessionTokensForUser(userId: string): Promise<void> {
await getTokenRepository().delete({ userId });
}
export async function pruneExpiredSessionTokens(): Promise<void> {
const repo = getTokenRepository();
const now = Date.now();
const expired = await repo.createQueryBuilder('token')
.where('token.expiresAt < :now', { now })
.getMany();
if (expired.length === 0) {
return;
}
await repo.remove(expired);
}