refactor: move auth persistence into repository (WealthLedger/backend#171) #180

Closed
codex-bot wants to merge 1 commits from refactor/171a-auth-repository into develop
4 changed files with 105 additions and 67 deletions
@@ -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();
+19 -65
View File
@@ -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)
);
}
+2 -1
View File
@@ -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)
);
}