refactor: organize backend by business responsibility
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
import { ConflictException, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
jest.mock('bcrypt', () => ({
|
||||
hash: jest.fn(() => Promise.resolve('password-hash')),
|
||||
compare: jest.fn(() => Promise.resolve(true)),
|
||||
}));
|
||||
|
||||
jest.mock('../../../prisma/prisma.service', () => ({
|
||||
PrismaService: class PrismaService {},
|
||||
}));
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
const user = {
|
||||
id: 'user',
|
||||
email: 'user@example.com',
|
||||
passwordHash: 'password-hash',
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
};
|
||||
|
||||
function hash(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
function twoCallBarrier<T>(value: T): () => Promise<T> {
|
||||
let calls = 0;
|
||||
let release: () => void;
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return async () => {
|
||||
calls += 1;
|
||||
if (calls === 2) release();
|
||||
await ready;
|
||||
return value;
|
||||
};
|
||||
}
|
||||
|
||||
describe('AuthService', () => {
|
||||
const jwt = new JwtService({ secret: 'test-secret-that-is-long-enough' });
|
||||
const prisma = {
|
||||
user: { findUnique: jest.fn(), create: jest.fn() },
|
||||
session: {
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
};
|
||||
const service = new AuthService(prisma as never, jwt);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('maps the database loser of a concurrent registration to EMAIL_ALREADY_REGISTERED', async () => {
|
||||
prisma.user.findUnique.mockImplementation(twoCallBarrier(null));
|
||||
prisma.user.create
|
||||
.mockResolvedValueOnce(user)
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('duplicate email'), { code: 'P2002' }),
|
||||
);
|
||||
prisma.session.create.mockResolvedValue({
|
||||
id: 'session',
|
||||
userId: user.id,
|
||||
refreshTokenHash: '',
|
||||
expiresAt: new Date(),
|
||||
revokedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
prisma.session.update.mockResolvedValue({});
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
service.register(user.email, 'secure password'),
|
||||
service.register(user.email, 'secure password'),
|
||||
]);
|
||||
|
||||
const rejected = results.find(
|
||||
(result): result is PromiseRejectedResult => result.status === 'rejected',
|
||||
);
|
||||
expect(
|
||||
results.filter((result) => result.status === 'fulfilled'),
|
||||
).toHaveLength(1);
|
||||
expect(rejected?.reason).toEqual(expect.any(ConflictException));
|
||||
expect((rejected?.reason as ConflictException).getResponse()).toEqual({
|
||||
code: 'EMAIL_ALREADY_REGISTERED',
|
||||
});
|
||||
});
|
||||
|
||||
it('allows one concurrent refresh winner without revoking its session', async () => {
|
||||
const oldToken = await jwt.signAsync(
|
||||
{ sub: user.id, sid: 'session', type: 'refresh', jti: 'old-token' },
|
||||
{ expiresIn: '7d', algorithm: 'HS256' },
|
||||
);
|
||||
let currentHash = hash(oldToken);
|
||||
const session = {
|
||||
id: 'session',
|
||||
userId: user.id,
|
||||
refreshTokenHash: currentHash,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
revokedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
user,
|
||||
};
|
||||
prisma.session.findUnique.mockImplementation(twoCallBarrier(session));
|
||||
prisma.session.updateMany.mockImplementation(
|
||||
({
|
||||
where,
|
||||
data,
|
||||
}: {
|
||||
where: { id?: string; refreshTokenHash?: string };
|
||||
data: { refreshTokenHash: string };
|
||||
}) => {
|
||||
if (where.id === session.id && where.refreshTokenHash === currentHash) {
|
||||
currentHash = data.refreshTokenHash;
|
||||
return Promise.resolve({ count: 1 });
|
||||
}
|
||||
return Promise.resolve({ count: 0 });
|
||||
},
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
service.refresh(oldToken),
|
||||
service.refresh(oldToken),
|
||||
]);
|
||||
|
||||
const rejected = results.find(
|
||||
(result): result is PromiseRejectedResult => result.status === 'rejected',
|
||||
);
|
||||
expect(
|
||||
results.filter((result) => result.status === 'fulfilled'),
|
||||
).toHaveLength(1);
|
||||
expect(rejected?.reason).toEqual(expect.any(UnauthorizedException));
|
||||
expect(prisma.session.updateMany).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
prisma.session.updateMany.mock.calls.map(
|
||||
([input]) =>
|
||||
(input as { where: { id?: string; refreshTokenHash?: string } })
|
||||
.where,
|
||||
),
|
||||
).toContainEqual({ id: session.id, refreshTokenHash: hash(oldToken) });
|
||||
expect(prisma.session.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import { Session, User } from '../../../../generated/prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { AuthUser, RefreshPayload } from './auth.types';
|
||||
|
||||
const ACCESS_TTL = '15m';
|
||||
const REFRESH_TTL = '7d';
|
||||
const DUMMY_PASSWORD_HASH =
|
||||
'$2b$12$Z49BwJsyiUqY0ZozFzt9IeYvjhV4kbeLw9KdxOguT5bfhA1RvPCx6';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
) {}
|
||||
|
||||
async register(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||
const normalizedEmail = this.normalizeEmail(email);
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { email: normalizedEmail },
|
||||
});
|
||||
if (existing)
|
||||
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
|
||||
|
||||
let user: User;
|
||||
try {
|
||||
user = await this.prisma.user.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
email: normalizedEmail,
|
||||
passwordHash: await bcrypt.hash(password, 12),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueConstraintError(error))
|
||||
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
|
||||
throw error;
|
||||
}
|
||||
return this.createSession(user);
|
||||
}
|
||||
|
||||
async login(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email: this.normalizeEmail(email) },
|
||||
});
|
||||
const valid = await bcrypt.compare(
|
||||
password,
|
||||
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
|
||||
);
|
||||
if (!user || !valid)
|
||||
throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' });
|
||||
return this.createSession(user);
|
||||
}
|
||||
|
||||
async refresh(
|
||||
token: string,
|
||||
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||
let payload: RefreshPayload;
|
||||
try {
|
||||
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
} catch {
|
||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
if (payload.type !== 'refresh')
|
||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
const session = await this.prisma.session.findUnique({
|
||||
where: { id: payload.sid },
|
||||
include: { user: true },
|
||||
});
|
||||
if (
|
||||
!session ||
|
||||
session.userId !== payload.sub ||
|
||||
session.revokedAt ||
|
||||
session.expiresAt <= new Date()
|
||||
) {
|
||||
if (session && !session.revokedAt)
|
||||
await this.prisma.session.delete({ where: { id: session.id } });
|
||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
if (session.refreshTokenHash !== this.hash(token))
|
||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
return this.rotateSession(session, session.refreshTokenHash);
|
||||
}
|
||||
|
||||
async logout(token?: string): Promise<void> {
|
||||
if (!token) return;
|
||||
try {
|
||||
const payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
if (payload.type !== 'refresh') return;
|
||||
const session = await this.prisma.session.findUnique({
|
||||
where: { id: payload.sid },
|
||||
});
|
||||
if (
|
||||
session &&
|
||||
!session.revokedAt &&
|
||||
session.refreshTokenHash === this.hash(token)
|
||||
) {
|
||||
await this.prisma.session.update({
|
||||
where: { id: session.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Logout is intentionally idempotent.
|
||||
}
|
||||
}
|
||||
|
||||
toUser(user: User): AuthUser {
|
||||
return { id: user.id, email: user.email };
|
||||
}
|
||||
|
||||
async userById(id: string): Promise<User> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (!user) throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
return user;
|
||||
}
|
||||
|
||||
private async createSession(
|
||||
user: User,
|
||||
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||
const session = await this.prisma.session.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
userId: user.id,
|
||||
refreshTokenHash: '',
|
||||
expiresAt: new Date(),
|
||||
},
|
||||
});
|
||||
return this.rotateSession({ ...session, user });
|
||||
}
|
||||
|
||||
private async rotateSession(
|
||||
session: Session & { user: User },
|
||||
expectedRefreshTokenHash?: string,
|
||||
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
|
||||
const refreshToken = await this.jwt.signAsync(
|
||||
{
|
||||
sub: session.userId,
|
||||
sid: session.id,
|
||||
type: 'refresh',
|
||||
jti: randomUUID(),
|
||||
},
|
||||
{ expiresIn: REFRESH_TTL, algorithm: 'HS256' },
|
||||
);
|
||||
const data = {
|
||||
refreshTokenHash: this.hash(refreshToken),
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
};
|
||||
if (expectedRefreshTokenHash) {
|
||||
const updated = await this.prisma.session.updateMany({
|
||||
where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash },
|
||||
data,
|
||||
});
|
||||
if (updated.count !== 1)
|
||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
} else {
|
||||
await this.prisma.session.update({ where: { id: session.id }, data });
|
||||
}
|
||||
return {
|
||||
user: this.toUser(session.user),
|
||||
accessToken: await this.jwt.signAsync(
|
||||
{ sub: session.userId, sid: session.id, type: 'access' },
|
||||
{ expiresIn: ACCESS_TTL, algorithm: 'HS256' },
|
||||
),
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeEmail(email: string): string {
|
||||
return email;
|
||||
}
|
||||
|
||||
private hash(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
}
|
||||
|
||||
function isUniqueConstraintError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: unknown }).code === 'P2002'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface AccessPayload {
|
||||
sub: string;
|
||||
sid: string;
|
||||
type: 'access';
|
||||
}
|
||||
|
||||
export interface RefreshPayload {
|
||||
sub: string;
|
||||
sid: string;
|
||||
type: 'refresh';
|
||||
jti: string;
|
||||
}
|
||||
Reference in New Issue
Block a user