Initial
This commit is contained in:
39
listify-api/src/auth/auth.controller.ts
Normal file
39
listify-api/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
register(@Body() registerDto: RegisterDto) {
|
||||
return this.authService.register(registerDto);
|
||||
}
|
||||
|
||||
@Get('verify-email')
|
||||
verifyEmail(@Query('token') token?: string) {
|
||||
return this.authService.verifyEmail(token);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
login(@Body() loginDto: LoginDto) {
|
||||
return this.authService.login(loginDto);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
refresh(@Body() refreshTokenDto: RefreshTokenDto) {
|
||||
return this.authService.refresh(refreshTokenDto);
|
||||
}
|
||||
}
|
||||
16
listify-api/src/auth/auth.module.ts
Normal file
16
listify-api/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { RefreshTokenEntity } from './refresh-token.entity';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { UserEntity } from './user.entity';
|
||||
|
||||
@Module({
|
||||
imports: [JwtModule.register({}), TypeOrmModule.forFeature([UserEntity, RefreshTokenEntity])],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtAuthGuard],
|
||||
exports: [AuthService, JwtAuthGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
194
listify-api/src/auth/auth.service.spec.ts
Normal file
194
listify-api/src/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { JwtModule, JwtService } from '@nestjs/jwt';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { JwtTokenPayload } from './auth.types';
|
||||
import { AuthService } from './auth.service';
|
||||
import { MailModule } from '../mail/mail.module';
|
||||
import { MailService } from '../mail/mail.service';
|
||||
import { RefreshTokenEntity } from './refresh-token.entity';
|
||||
import { UserEntity } from './user.entity';
|
||||
import { InMemoryRepository } from '../testing/in-memory-repository';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let module: TestingModule;
|
||||
let authService: AuthService;
|
||||
let mailService: MailService;
|
||||
let jwtService: JwtService;
|
||||
|
||||
beforeEach(async () => {
|
||||
module = await Test.createTestingModule({
|
||||
imports: [EventEmitterModule.forRoot(), JwtModule.register({}), MailModule],
|
||||
providers: [
|
||||
AuthService,
|
||||
{
|
||||
provide: getRepositoryToken(UserEntity),
|
||||
useValue: new InMemoryRepository<UserEntity>(),
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(RefreshTokenEntity),
|
||||
useValue: new InMemoryRepository<RefreshTokenEntity>(),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
await module.init();
|
||||
|
||||
authService = module.get<AuthService>(AuthService);
|
||||
mailService = module.get<MailService>(MailService);
|
||||
jwtService = module.get<JwtService>(JwtService);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
it('registers a user and sends a verification email', async () => {
|
||||
const response = await authService.register({
|
||||
email: 'User@Example.com',
|
||||
name: 'Test User',
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
const sentEmails = mailService.getSentEmails();
|
||||
|
||||
expect(response.user.email).toBe('user@example.com');
|
||||
expect(response.user.verified).toBe(false);
|
||||
expect(sentEmails).toHaveLength(1);
|
||||
expect(sentEmails[0].to).toBe('user@example.com');
|
||||
expect(sentEmails[0].verificationUrl).toContain('/verify-email?token=');
|
||||
});
|
||||
|
||||
it('rejects login before email verification', async () => {
|
||||
await authService.register({
|
||||
email: 'user@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
await expect(
|
||||
authService.login({
|
||||
email: 'user@example.com',
|
||||
password: 'password123',
|
||||
}),
|
||||
).rejects.toThrow('Please verify your email before login.');
|
||||
});
|
||||
|
||||
it('verifies email and allows login afterwards', async () => {
|
||||
await authService.register({
|
||||
email: 'user@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
const verificationUrl = mailService.getSentEmails()[0].verificationUrl;
|
||||
const token = new URL(verificationUrl).searchParams.get('token');
|
||||
|
||||
const verifyResponse = await authService.verifyEmail(token ?? undefined);
|
||||
const loginResponse = await authService.login({
|
||||
email: 'user@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
const accessPayload = jwtService.verify<JwtTokenPayload>(
|
||||
loginResponse.accessToken,
|
||||
{
|
||||
secret: process.env.JWT_ACCESS_SECRET ?? 'dev-access-secret',
|
||||
},
|
||||
);
|
||||
const refreshPayload = jwtService.verify<JwtTokenPayload>(
|
||||
loginResponse.refreshToken,
|
||||
{
|
||||
secret: process.env.JWT_REFRESH_SECRET ?? 'dev-refresh-secret',
|
||||
},
|
||||
);
|
||||
|
||||
expect(verifyResponse.user.verified).toBe(true);
|
||||
expect(loginResponse.accessToken).toBeDefined();
|
||||
expect(loginResponse.refreshToken).toBeDefined();
|
||||
expect(loginResponse.user.email).toBe('user@example.com');
|
||||
expect(accessPayload.type).toBe('access');
|
||||
expect(accessPayload.sub).toBe(loginResponse.user.id);
|
||||
expect(refreshPayload.type).toBe('refresh');
|
||||
expect(refreshPayload.jti).toBeDefined();
|
||||
});
|
||||
|
||||
it('rotates refresh tokens and rejects reuse', async () => {
|
||||
await authService.register({
|
||||
email: 'user@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
const verificationUrl = mailService.getSentEmails()[0].verificationUrl;
|
||||
const token = new URL(verificationUrl).searchParams.get('token');
|
||||
await authService.verifyEmail(token ?? undefined);
|
||||
|
||||
const loginResponse = await authService.login({
|
||||
email: 'user@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
const refreshResponse = await authService.refresh({
|
||||
refreshToken: loginResponse.refreshToken,
|
||||
});
|
||||
|
||||
expect(refreshResponse.accessToken).toBeDefined();
|
||||
expect(refreshResponse.refreshToken).toBeDefined();
|
||||
expect(refreshResponse.refreshToken).not.toBe(loginResponse.refreshToken);
|
||||
await expect(
|
||||
authService.refresh({ refreshToken: loginResponse.refreshToken }),
|
||||
).rejects.toThrow('Refresh token is invalid.');
|
||||
});
|
||||
|
||||
it('rejects access tokens on the refresh endpoint', async () => {
|
||||
await authService.register({
|
||||
email: 'user@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
const verificationUrl = mailService.getSentEmails()[0].verificationUrl;
|
||||
const token = new URL(verificationUrl).searchParams.get('token');
|
||||
await authService.verifyEmail(token ?? undefined);
|
||||
|
||||
const loginResponse = await authService.login({
|
||||
email: 'user@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
await expect(
|
||||
authService.refresh({ refreshToken: loginResponse.accessToken }),
|
||||
).rejects.toThrow('Refresh token is invalid.');
|
||||
});
|
||||
|
||||
it('validates access tokens', async () => {
|
||||
await authService.register({
|
||||
email: 'user@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
const verificationUrl = mailService.getSentEmails()[0].verificationUrl;
|
||||
const token = new URL(verificationUrl).searchParams.get('token');
|
||||
await authService.verifyEmail(token ?? undefined);
|
||||
|
||||
const loginResponse = await authService.login({
|
||||
email: 'user@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
const payload = await authService.verifyAccessToken(loginResponse.accessToken);
|
||||
|
||||
expect(payload.type).toBe('access');
|
||||
expect(payload.email).toBe('user@example.com');
|
||||
await expect(
|
||||
authService.verifyAccessToken(loginResponse.refreshToken),
|
||||
).rejects.toThrow('Access token is invalid.');
|
||||
});
|
||||
|
||||
it('rejects duplicate registrations', async () => {
|
||||
await authService.register({
|
||||
email: 'user@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
await expect(
|
||||
authService.register({
|
||||
email: 'user@example.com',
|
||||
password: 'password123',
|
||||
}),
|
||||
).rejects.toThrow('Email is already registered.');
|
||||
});
|
||||
});
|
||||
333
listify-api/src/auth/auth.service.ts
Normal file
333
listify-api/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'crypto';
|
||||
import { Repository } from 'typeorm';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import {
|
||||
AuthTokenResponse,
|
||||
AuthTokens,
|
||||
JwtTokenPayload,
|
||||
PublicUser,
|
||||
} from './auth.types';
|
||||
import { AppEvents } from '../events/app-events';
|
||||
import { RefreshTokenEntity } from './refresh-token.entity';
|
||||
import { UserEntity } from './user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly accessTokenExpiresIn = '15m';
|
||||
private readonly refreshTokenExpiresIn = '7d';
|
||||
private readonly accessTokenSecret =
|
||||
process.env.JWT_ACCESS_SECRET ?? 'dev-access-secret';
|
||||
private readonly refreshTokenSecret =
|
||||
process.env.JWT_REFRESH_SECRET ?? 'dev-refresh-secret';
|
||||
|
||||
constructor(
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly jwtService: JwtService,
|
||||
@InjectRepository(UserEntity)
|
||||
private readonly usersRepository: Repository<UserEntity>,
|
||||
@InjectRepository(RefreshTokenEntity)
|
||||
private readonly refreshTokensRepository: Repository<RefreshTokenEntity>,
|
||||
) {}
|
||||
|
||||
async register(
|
||||
registerDto: RegisterDto,
|
||||
): Promise<{ message: string; user: PublicUser }> {
|
||||
const email = this.normalizeEmail(registerDto.email);
|
||||
const password = this.requirePassword(registerDto.password);
|
||||
const name = this.normalizeName(registerDto.name);
|
||||
|
||||
const existingUser = await this.usersRepository.findOne({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
throw new ConflictException('Email is already registered.');
|
||||
}
|
||||
|
||||
const verificationToken = this.createToken();
|
||||
const user = this.usersRepository.create({
|
||||
id: randomUUID(),
|
||||
email,
|
||||
name,
|
||||
passwordHash: this.hashPassword(password),
|
||||
verificationToken,
|
||||
verified: false,
|
||||
});
|
||||
const savedUser = await this.usersRepository.save(user);
|
||||
|
||||
this.eventEmitter.emit(AppEvents.UserRegistered, {
|
||||
email,
|
||||
verificationUrl: this.createVerificationUrl(verificationToken),
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Registration successful. Please verify your email address.',
|
||||
user: this.toPublicUser(savedUser),
|
||||
};
|
||||
}
|
||||
|
||||
async verifyEmail(
|
||||
token?: string,
|
||||
): Promise<{ message: string; user: PublicUser }> {
|
||||
if (!token) {
|
||||
throw new BadRequestException('Verification token is required.');
|
||||
}
|
||||
|
||||
const user = await this.usersRepository.findOne({
|
||||
where: { verificationToken: token },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('Verification token is invalid.');
|
||||
}
|
||||
|
||||
user.verified = true;
|
||||
user.verificationToken = null;
|
||||
try {
|
||||
const savedUser = await this.usersRepository.save(user);
|
||||
|
||||
return {
|
||||
message: 'Email verified successfully.',
|
||||
user: this.toPublicUser(savedUser),
|
||||
};
|
||||
} catch {
|
||||
throw new BadRequestException('user not saved.')
|
||||
}
|
||||
}
|
||||
|
||||
async login(loginDto: LoginDto): Promise<AuthTokenResponse> {
|
||||
const email = this.normalizeEmail(loginDto.email);
|
||||
const password = this.requirePassword(loginDto.password);
|
||||
const user = await this.usersRepository.findOne({ where: { email } });
|
||||
|
||||
if (!user || !this.passwordMatches(password, user.passwordHash)) {
|
||||
throw new UnauthorizedException('Invalid email or password.');
|
||||
}
|
||||
|
||||
if (!user.verified) {
|
||||
throw new UnauthorizedException('Please verify your email before login.');
|
||||
}
|
||||
|
||||
return {
|
||||
...(await this.createAuthTokens(user)),
|
||||
user: this.toPublicUser(user),
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(refreshTokenDto: RefreshTokenDto = {}): Promise<AuthTokenResponse> {
|
||||
const refreshToken = this.requireRefreshToken(refreshTokenDto.refreshToken);
|
||||
const payload = this.verifyRefreshToken(refreshToken);
|
||||
const tokenRecord = await this.refreshTokensRepository.findOne({
|
||||
where: { jti: payload.jti },
|
||||
});
|
||||
|
||||
if (
|
||||
!tokenRecord ||
|
||||
tokenRecord.userId !== payload.sub ||
|
||||
tokenRecord.expiresAt.getTime() <= Date.now() ||
|
||||
!this.tokenMatches(refreshToken, tokenRecord.tokenHash)
|
||||
) {
|
||||
throw new UnauthorizedException('Refresh token is invalid.');
|
||||
}
|
||||
|
||||
const user = await this.usersRepository.findOne({
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
|
||||
if (!user || !user.verified) {
|
||||
throw new UnauthorizedException('Refresh token is invalid.');
|
||||
}
|
||||
|
||||
await this.refreshTokensRepository.delete({ jti: payload.jti });
|
||||
|
||||
return {
|
||||
...(await this.createAuthTokens(user)),
|
||||
user: this.toPublicUser(user),
|
||||
};
|
||||
}
|
||||
|
||||
async verifyAccessToken(accessToken: string): Promise<JwtTokenPayload> {
|
||||
try {
|
||||
const payload = this.jwtService.verify<JwtTokenPayload>(accessToken, {
|
||||
secret: this.accessTokenSecret,
|
||||
});
|
||||
|
||||
if (payload.type !== 'access') {
|
||||
throw new UnauthorizedException('Access token is invalid.');
|
||||
}
|
||||
|
||||
const user = await this.usersRepository.findOne({
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
|
||||
if (!user || !user.verified) {
|
||||
throw new UnauthorizedException('Access token is invalid.');
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch {
|
||||
throw new UnauthorizedException('Access token is invalid.');
|
||||
}
|
||||
}
|
||||
|
||||
async getUserDisplayName(userId: string): Promise<string> {
|
||||
const user = await this.usersRepository.findOne({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Authenticated user is required.');
|
||||
}
|
||||
|
||||
return user.name || user.email;
|
||||
}
|
||||
|
||||
private normalizeEmail(email?: string): string {
|
||||
const normalizedEmail = email?.trim().toLowerCase();
|
||||
|
||||
if (
|
||||
!normalizedEmail ||
|
||||
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)
|
||||
) {
|
||||
throw new BadRequestException('A valid email is required.');
|
||||
}
|
||||
|
||||
return normalizedEmail;
|
||||
}
|
||||
|
||||
private normalizeName(name?: string): string | undefined {
|
||||
const normalizedName = name?.trim();
|
||||
return normalizedName || undefined;
|
||||
}
|
||||
|
||||
private requirePassword(password?: string): string {
|
||||
if (!password || password.length < 8) {
|
||||
throw new BadRequestException(
|
||||
'Password must contain at least 8 characters.',
|
||||
);
|
||||
}
|
||||
|
||||
return password;
|
||||
}
|
||||
|
||||
private hashPassword(password: string): string {
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
const hash = scryptSync(password, salt, 64).toString('hex');
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
private passwordMatches(password: string, passwordHash: string): boolean {
|
||||
const [salt, storedHash] = passwordHash.split(':');
|
||||
|
||||
if (!salt || !storedHash) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const attemptedHash = scryptSync(password, salt, 64);
|
||||
const storedHashBuffer = Buffer.from(storedHash, 'hex');
|
||||
|
||||
return (
|
||||
storedHashBuffer.length === attemptedHash.length &&
|
||||
timingSafeEqual(storedHashBuffer, attemptedHash)
|
||||
);
|
||||
}
|
||||
|
||||
private async createAuthTokens(user: UserEntity): Promise<AuthTokens> {
|
||||
const refreshTokenJti = randomUUID();
|
||||
const accessTokenPayload: JwtTokenPayload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
type: 'access',
|
||||
};
|
||||
const refreshTokenPayload: JwtTokenPayload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
type: 'refresh',
|
||||
jti: refreshTokenJti,
|
||||
};
|
||||
const accessToken = this.jwtService.sign(accessTokenPayload, {
|
||||
expiresIn: this.accessTokenExpiresIn,
|
||||
secret: this.accessTokenSecret,
|
||||
});
|
||||
const refreshToken = this.jwtService.sign(refreshTokenPayload, {
|
||||
expiresIn: this.refreshTokenExpiresIn,
|
||||
secret: this.refreshTokenSecret,
|
||||
});
|
||||
|
||||
await this.refreshTokensRepository.save(
|
||||
this.refreshTokensRepository.create({
|
||||
jti: refreshTokenJti,
|
||||
userId: user.id,
|
||||
tokenHash: this.hashToken(refreshToken),
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
}),
|
||||
);
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
private verifyRefreshToken(refreshToken: string): JwtTokenPayload & {
|
||||
jti: string;
|
||||
} {
|
||||
try {
|
||||
const payload = this.jwtService.verify<JwtTokenPayload>(refreshToken, {
|
||||
secret: this.refreshTokenSecret,
|
||||
});
|
||||
|
||||
if (payload.type !== 'refresh' || !payload.jti) {
|
||||
throw new UnauthorizedException('Refresh token is invalid.');
|
||||
}
|
||||
|
||||
return { ...payload, jti: payload.jti };
|
||||
} catch {
|
||||
throw new UnauthorizedException('Refresh token is invalid.');
|
||||
}
|
||||
}
|
||||
|
||||
private requireRefreshToken(refreshToken?: string): string {
|
||||
if (!refreshToken) {
|
||||
throw new BadRequestException('Refresh token is required.');
|
||||
}
|
||||
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
private hashToken(token: string): string {
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
const hash = scryptSync(token, salt, 64).toString('hex');
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
private tokenMatches(token: string, tokenHash: string): boolean {
|
||||
return this.passwordMatches(token, tokenHash);
|
||||
}
|
||||
|
||||
private createToken(): string {
|
||||
return randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
private createVerificationUrl(token: string): string {
|
||||
const clientUrl = process.env.CLIENT_URL ?? 'http://localhost:4200';
|
||||
return `${clientUrl}/verify-email?token=${token}`;
|
||||
}
|
||||
|
||||
private toPublicUser(user: UserEntity): PublicUser {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name ?? undefined,
|
||||
verified: user.verified,
|
||||
};
|
||||
}
|
||||
}
|
||||
37
listify-api/src/auth/auth.types.ts
Normal file
37
listify-api/src/auth/auth.types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Request } from 'express';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
passwordHash: string;
|
||||
verificationToken?: string;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface AuthTokenResponse extends AuthTokens {
|
||||
user: PublicUser;
|
||||
}
|
||||
|
||||
export interface JwtTokenPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
type: 'access' | 'refresh';
|
||||
jti?: string;
|
||||
}
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: JwtTokenPayload;
|
||||
}
|
||||
|
||||
export interface PublicUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
verified: boolean;
|
||||
}
|
||||
4
listify-api/src/auth/dto/login.dto.ts
Normal file
4
listify-api/src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export class LoginDto {
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
3
listify-api/src/auth/dto/refresh-token.dto.ts
Normal file
3
listify-api/src/auth/dto/refresh-token.dto.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export class RefreshTokenDto {
|
||||
refreshToken?: string;
|
||||
}
|
||||
5
listify-api/src/auth/dto/register.dto.ts
Normal file
5
listify-api/src/auth/dto/register.dto.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export class RegisterDto {
|
||||
email?: string;
|
||||
name?: string;
|
||||
password?: string;
|
||||
}
|
||||
113
listify-api/src/auth/jwt-auth.guard.spec.ts
Normal file
113
listify-api/src/auth/jwt-auth.guard.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { ExecutionContext } from '@nestjs/common';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthenticatedRequest } from './auth.types';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { MailModule } from '../mail/mail.module';
|
||||
import { MailService } from '../mail/mail.service';
|
||||
import { RefreshTokenEntity } from './refresh-token.entity';
|
||||
import { UserEntity } from './user.entity';
|
||||
import { InMemoryRepository } from '../testing/in-memory-repository';
|
||||
|
||||
describe('JwtAuthGuard', () => {
|
||||
let module: TestingModule;
|
||||
let authService: AuthService;
|
||||
let guard: JwtAuthGuard;
|
||||
let mailService: MailService;
|
||||
|
||||
beforeEach(async () => {
|
||||
module = await Test.createTestingModule({
|
||||
imports: [EventEmitterModule.forRoot(), JwtModule.register({}), MailModule],
|
||||
providers: [
|
||||
AuthService,
|
||||
JwtAuthGuard,
|
||||
{
|
||||
provide: getRepositoryToken(UserEntity),
|
||||
useValue: new InMemoryRepository<UserEntity>(),
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(RefreshTokenEntity),
|
||||
useValue: new InMemoryRepository<RefreshTokenEntity>(),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
await module.init();
|
||||
|
||||
authService = module.get<AuthService>(AuthService);
|
||||
guard = module.get<JwtAuthGuard>(JwtAuthGuard);
|
||||
mailService = module.get<MailService>(MailService);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
it('allows requests with a valid access token and attaches the payload', async () => {
|
||||
const { accessToken } = await registerVerifiedUserAndLogin();
|
||||
const request = createRequest(`Bearer ${accessToken}`);
|
||||
const context = createExecutionContext(request);
|
||||
|
||||
await expect(guard.canActivate(context)).resolves.toBe(true);
|
||||
expect(request.user?.type).toBe('access');
|
||||
expect(request.user?.email).toBe('user@example.com');
|
||||
});
|
||||
|
||||
it('rejects missing authorization headers', async () => {
|
||||
const request = createRequest();
|
||||
const context = createExecutionContext(request);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
'Authorization bearer token is required.',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects refresh tokens', async () => {
|
||||
const { refreshToken } = await registerVerifiedUserAndLogin();
|
||||
const request = createRequest(`Bearer ${refreshToken}`);
|
||||
const context = createExecutionContext(request);
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toThrow(
|
||||
'Access token is invalid.',
|
||||
);
|
||||
});
|
||||
|
||||
async function registerVerifiedUserAndLogin(): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}> {
|
||||
await authService.register({
|
||||
email: 'user@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
const verificationUrl = mailService.getSentEmails()[0].verificationUrl;
|
||||
const token = new URL(verificationUrl).searchParams.get('token');
|
||||
await authService.verifyEmail(token ?? undefined);
|
||||
|
||||
return authService.login({
|
||||
email: 'user@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
}
|
||||
|
||||
function createRequest(
|
||||
authorization?: string,
|
||||
): Partial<AuthenticatedRequest> {
|
||||
return {
|
||||
headers: authorization ? { authorization } : {},
|
||||
};
|
||||
}
|
||||
|
||||
function createExecutionContext(
|
||||
request: Partial<AuthenticatedRequest>,
|
||||
): ExecutionContext {
|
||||
return {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => request,
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
});
|
||||
42
listify-api/src/auth/jwt-auth.guard.ts
Normal file
42
listify-api/src/auth/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthenticatedRequest } from './auth.types';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const accessToken = this.extractBearerToken(request);
|
||||
|
||||
request.user = await this.authService.verifyAccessToken(accessToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private extractBearerToken(request: AuthenticatedRequest): string {
|
||||
const authorizationHeader = request.headers.authorization;
|
||||
|
||||
if (!authorizationHeader) {
|
||||
throw new UnauthorizedException(
|
||||
'Authorization bearer token is required.',
|
||||
);
|
||||
}
|
||||
|
||||
const [scheme, token] = authorizationHeader.split(' ');
|
||||
|
||||
if (scheme.toLowerCase() !== 'bearer' || !token) {
|
||||
throw new UnauthorizedException(
|
||||
'Authorization bearer token is required.',
|
||||
);
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
}
|
||||
48
listify-api/src/auth/refresh-token.entity.ts
Normal file
48
listify-api/src/auth/refresh-token.entity.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { UserEntity } from './user.entity';
|
||||
|
||||
@Entity('refresh_tokens')
|
||||
export class RefreshTokenEntity {
|
||||
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||
jti!: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'varchar', length: 36 })
|
||||
userId!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
tokenHash!: string;
|
||||
|
||||
@Column({ type: 'datetime', precision: 3 })
|
||||
expiresAt!: Date;
|
||||
|
||||
@CreateDateColumn({
|
||||
type: 'datetime',
|
||||
precision: 3,
|
||||
default: () => 'CURRENT_TIMESTAMP(3)',
|
||||
})
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({
|
||||
type: 'datetime',
|
||||
precision: 3,
|
||||
default: () => 'CURRENT_TIMESTAMP(3)',
|
||||
onUpdate: 'CURRENT_TIMESTAMP(3)',
|
||||
})
|
||||
updatedAt!: Date;
|
||||
|
||||
@ManyToOne(() => UserEntity, (user) => user.refreshTokens, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user?: UserEntity;
|
||||
}
|
||||
59
listify-api/src/auth/user.entity.ts
Normal file
59
listify-api/src/auth/user.entity.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
OneToMany,
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ListTemplateEntity } from '../list-templates/list-template.entity';
|
||||
import { UserListEntity } from '../lists/user-list.entity';
|
||||
import { RefreshTokenEntity } from './refresh-token.entity';
|
||||
|
||||
@Entity('users')
|
||||
export class UserEntity {
|
||||
@PrimaryColumn({ type: 'varchar', length: 36 })
|
||||
id!: string;
|
||||
|
||||
@Index({ unique: true })
|
||||
@Column({ type: 'varchar', length: 320 })
|
||||
email!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 160, nullable: true })
|
||||
name?: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
passwordHash!: string;
|
||||
|
||||
@Index({ unique: true })
|
||||
@Column({ type: 'varchar', length: 128, nullable: true })
|
||||
verificationToken?: string | null;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
verified!: boolean;
|
||||
|
||||
@CreateDateColumn({
|
||||
type: 'datetime',
|
||||
precision: 3,
|
||||
default: () => 'CURRENT_TIMESTAMP(3)',
|
||||
})
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({
|
||||
type: 'datetime',
|
||||
precision: 3,
|
||||
default: () => 'CURRENT_TIMESTAMP(3)',
|
||||
onUpdate: 'CURRENT_TIMESTAMP(3)',
|
||||
})
|
||||
updatedAt!: Date;
|
||||
|
||||
@OneToMany(() => RefreshTokenEntity, (refreshToken) => refreshToken.user)
|
||||
refreshTokens?: RefreshTokenEntity[];
|
||||
|
||||
@OneToMany(() => ListTemplateEntity, (template) => template.owner)
|
||||
templates?: ListTemplateEntity[];
|
||||
|
||||
@OneToMany(() => UserListEntity, (list) => list.owner)
|
||||
lists?: UserListEntity[];
|
||||
}
|
||||
Reference in New Issue
Block a user