feat: Security
This commit is contained in:
45
server/src/services/password-auth.service.spec.ts
Normal file
45
server/src/services/password-auth.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
33
server/src/services/password-auth.service.ts
Normal file
33
server/src/services/password-auth.service.ts
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
110
server/src/services/session-auth.service.ts
Normal file
110
server/src/services/session-auth.service.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user