refactor: move auth persistence into repository (WealthLedger/backend#171)
This commit is contained in:
@@ -2,6 +2,8 @@ import { ConflictException, UnauthorizedException } from '@nestjs/common';
|
|||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
|
import { AuthRepository } from '../infrastructure/auth.repository';
|
||||||
|
|
||||||
jest.mock('bcrypt', () => ({
|
jest.mock('bcrypt', () => ({
|
||||||
hash: jest.fn(() => Promise.resolve('password-hash')),
|
hash: jest.fn(() => Promise.resolve('password-hash')),
|
||||||
compare: jest.fn(() => Promise.resolve(true)),
|
compare: jest.fn(() => Promise.resolve(true)),
|
||||||
@@ -51,7 +53,7 @@ describe('AuthService', () => {
|
|||||||
delete: jest.fn(),
|
delete: jest.fn(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const service = new AuthService(prisma as never, jwt);
|
const service = new AuthService(new AuthRepository(prisma as never), jwt);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|||||||
@@ -9,13 +9,10 @@ import { createHash, randomUUID } from 'crypto';
|
|||||||
|
|
||||||
import { Session, User } from '../../../../generated/prisma/client';
|
import { Session, User } from '../../../../generated/prisma/client';
|
||||||
import { HttpErrorCode } from '../../../common/error-code';
|
import { HttpErrorCode } from '../../../common/error-code';
|
||||||
import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code';
|
import { AuthRepository } from '../infrastructure/auth.repository';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
|
||||||
import { AuthErrorCode } from './auth-error-code';
|
import { AuthErrorCode } from './auth-error-code';
|
||||||
import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types';
|
import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types';
|
||||||
|
|
||||||
const EXPECTED_ROTATED_SESSIONS = 1;
|
|
||||||
|
|
||||||
const PASSWORD_HASH_ROUNDS = 12;
|
const PASSWORD_HASH_ROUNDS = 12;
|
||||||
export const REFRESH_TTL_MS = 604_800_000;
|
export const REFRESH_TTL_MS = 604_800_000;
|
||||||
|
|
||||||
@@ -28,7 +25,7 @@ const DUMMY_PASSWORD_HASH =
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly repository: AuthRepository,
|
||||||
private readonly jwt: JwtService,
|
private readonly jwt: JwtService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -36,27 +33,18 @@ export class AuthService {
|
|||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<AuthenticationResult> {
|
): Promise<AuthenticationResult> {
|
||||||
const existing = await this.prisma.user.findUnique({
|
const existing = await this.repository.findUserByEmail(email);
|
||||||
where: { email },
|
|
||||||
});
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
||||||
}
|
}
|
||||||
|
|
||||||
let user: User;
|
const user = await this.repository.createUser({
|
||||||
try {
|
id: randomUUID(),
|
||||||
user = await this.prisma.user.create({
|
email,
|
||||||
data: {
|
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
|
||||||
id: randomUUID(),
|
});
|
||||||
email,
|
if (!user) {
|
||||||
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
|
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (isUniqueConstraintError(error)) {
|
|
||||||
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
return this.createSession(user);
|
return this.createSession(user);
|
||||||
}
|
}
|
||||||
@@ -65,9 +53,7 @@ export class AuthService {
|
|||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<AuthenticationResult> {
|
): Promise<AuthenticationResult> {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.repository.findUserByEmail(email);
|
||||||
where: { email },
|
|
||||||
});
|
|
||||||
const valid = await bcrypt.compare(
|
const valid = await bcrypt.compare(
|
||||||
password,
|
password,
|
||||||
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
|
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
|
||||||
@@ -92,10 +78,7 @@ export class AuthService {
|
|||||||
if (payload.type !== 'refresh') {
|
if (payload.type !== 'refresh') {
|
||||||
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
}
|
}
|
||||||
const session = await this.prisma.session.findUnique({
|
const session = await this.repository.findSessionWithUser(payload.sid);
|
||||||
where: { id: payload.sid },
|
|
||||||
include: { user: true },
|
|
||||||
});
|
|
||||||
if (
|
if (
|
||||||
!session ||
|
!session ||
|
||||||
session.userId !== payload.sub ||
|
session.userId !== payload.sub ||
|
||||||
@@ -103,7 +86,7 @@ export class AuthService {
|
|||||||
session.expiresAt <= new Date()
|
session.expiresAt <= new Date()
|
||||||
) {
|
) {
|
||||||
if (session && !session.revokedAt) {
|
if (session && !session.revokedAt) {
|
||||||
await this.prisma.session.delete({ where: { id: session.id } });
|
await this.repository.deleteSession(session.id);
|
||||||
}
|
}
|
||||||
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
}
|
}
|
||||||
@@ -124,18 +107,13 @@ export class AuthService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (payload.type !== 'refresh') return;
|
if (payload.type !== 'refresh') return;
|
||||||
const session = await this.prisma.session.findUnique({
|
const session = await this.repository.findSession(payload.sid);
|
||||||
where: { id: payload.sid },
|
|
||||||
});
|
|
||||||
if (
|
if (
|
||||||
session &&
|
session &&
|
||||||
!session.revokedAt &&
|
!session.revokedAt &&
|
||||||
session.refreshTokenHash === this.hash(token)
|
session.refreshTokenHash === this.hash(token)
|
||||||
) {
|
) {
|
||||||
await this.prisma.session.update({
|
await this.repository.revokeSession(session.id);
|
||||||
where: { id: session.id },
|
|
||||||
data: { revokedAt: new Date() },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +122,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async userById(id: string): Promise<User> {
|
async userById(id: string): Promise<User> {
|
||||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
const user = await this.repository.findUserById(id);
|
||||||
if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
@@ -152,14 +130,7 @@ export class AuthService {
|
|||||||
private async createSession(
|
private async createSession(
|
||||||
user: User,
|
user: User,
|
||||||
): Promise<AuthenticationResult> {
|
): Promise<AuthenticationResult> {
|
||||||
const session = await this.prisma.session.create({
|
const session = await this.repository.createSession(user.id, randomUUID());
|
||||||
data: {
|
|
||||||
id: randomUUID(),
|
|
||||||
userId: user.id,
|
|
||||||
refreshTokenHash: '',
|
|
||||||
expiresAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return this.rotateSession({ ...session, user });
|
return this.rotateSession({ ...session, user });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,16 +151,8 @@ export class AuthService {
|
|||||||
refreshTokenHash: this.hash(refreshToken),
|
refreshTokenHash: this.hash(refreshToken),
|
||||||
expiresAt: new Date(Date.now() + REFRESH_TTL_MS),
|
expiresAt: new Date(Date.now() + REFRESH_TTL_MS),
|
||||||
};
|
};
|
||||||
if (expectedRefreshTokenHash) {
|
if (!(await this.repository.rotateSession(session.id, data, expectedRefreshTokenHash))) {
|
||||||
const updated = await this.prisma.session.updateMany({
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash },
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
if (updated.count !== EXPECTED_ROTATED_SESSIONS) {
|
|
||||||
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await this.prisma.session.update({ where: { id: session.id }, data });
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
user: this.toUser(session.user),
|
user: this.toUser(session.user),
|
||||||
@@ -205,12 +168,3 @@ export class AuthService {
|
|||||||
return createHash('sha256').update(token).digest('hex');
|
return createHash('sha256').update(token).digest('hex');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUniqueConstraintError(error: unknown): boolean {
|
|
||||||
return (
|
|
||||||
typeof error === 'object' &&
|
|
||||||
error !== null &&
|
|
||||||
'code' in error &&
|
|
||||||
isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { PassportModule } from '@nestjs/passport';
|
|||||||
|
|
||||||
import { Environment } from '../../config/env.validation';
|
import { Environment } from '../../config/env.validation';
|
||||||
import { AuthService } from './application/auth.service';
|
import { AuthService } from './application/auth.service';
|
||||||
|
import { AuthRepository } from './infrastructure/auth.repository';
|
||||||
import { JwtStrategy } from './infrastructure/jwt.strategy';
|
import { JwtStrategy } from './infrastructure/jwt.strategy';
|
||||||
import { AuthController } from './presentation/auth.controller';
|
import { AuthController } from './presentation/auth.controller';
|
||||||
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
|
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
|
||||||
@@ -22,6 +23,6 @@ import { JwtAuthGuard } from './presentation/jwt-auth.guard';
|
|||||||
],
|
],
|
||||||
exports: [JwtAuthGuard],
|
exports: [JwtAuthGuard],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, JwtStrategy, JwtAuthGuard],
|
providers: [AuthService, AuthRepository, JwtStrategy, JwtAuthGuard],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code';
|
||||||
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
|
|
||||||
|
const EXPECTED_ROTATED_SESSIONS = 1;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
findUserByEmail(email: string) {
|
||||||
|
return this.prisma.user.findUnique({ where: { email } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUser(data: { email: string; id: string; passwordHash: string }) {
|
||||||
|
try {
|
||||||
|
return await this.prisma.user.create({ data });
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueConstraintError(error)) return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findUserById(id: string) {
|
||||||
|
return this.prisma.user.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
findSessionWithUser(id: string) {
|
||||||
|
return this.prisma.session.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { user: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findSession(id: string) {
|
||||||
|
return this.prisma.session.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSession(id: string) {
|
||||||
|
return this.prisma.session.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
revokeSession(id: string) {
|
||||||
|
return this.prisma.session.update({
|
||||||
|
where: { id },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createSession(userId: string, id: string) {
|
||||||
|
return this.prisma.session.create({
|
||||||
|
data: { id, userId, refreshTokenHash: '', expiresAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async rotateSession(
|
||||||
|
id: string,
|
||||||
|
data: { expiresAt: Date; refreshTokenHash: string },
|
||||||
|
expectedRefreshTokenHash?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (expectedRefreshTokenHash) {
|
||||||
|
const updated = await this.prisma.session.updateMany({
|
||||||
|
where: { id, refreshTokenHash: expectedRefreshTokenHash },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
return updated.count === EXPECTED_ROTATED_SESSIONS;
|
||||||
|
}
|
||||||
|
await this.prisma.session.update({ where: { id }, data });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUniqueConstraintError(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint)
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user