Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add auth, user feature #38

Merged
merged 1 commit into from
Nov 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/pull-request-template.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
-->

# Pull Request types

<!-- Choose only something to come under. -->

- 🐛 Bug Fix
Expand All @@ -21,9 +22,11 @@
- 👾 Others

# Description

<!-- Write detailed explanations, URL of explanatory materials, Issue ticket, etc -->

# Checklist

<!-- Write review checklist for dev and reviewer -->

- [ ] 1. No more unconfirmed specs in the PR
Expand All @@ -40,4 +43,5 @@
(Screenshot or Video)

# Discussion

<!-- Write what you want to check, what you don't have to check, etc. -->
4 changes: 4 additions & 0 deletions apps/admin-api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,7 @@ DATABASE_REJECT_UNAUTHORIZED=false
DATABASE_CA=
DATABASE_KEY=
DATABASE_CERT=

##== Authentication
AUTH_JWT_SECRET=secret
AUTH_JWT_TOKEN_EXPIRES_IN=1d
5 changes: 5 additions & 0 deletions apps/admin-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,17 @@
"@nestjs/common": "^10.4.6",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.6",
"@nestjs/jwt": "^10.2.0",
"@nestjs/platform-fastify": "^10.4.6",
"@nestjs/swagger": "^8.0.1",
"@nestjs/typeorm": "^10.0.2",
"@repo/api": "workspace:*",
"@repo/database-typeorm": "workspace:*",
"@repo/utils": "workspace:*",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"fastify": "^4.28.1",
"ms": "^2.1.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.20"
Expand All @@ -42,6 +46,7 @@
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/jest": "^29.5.14",
"@types/ms": "^0.7.34",
"@types/node": "^20.17.2",
"@types/supertest": "^6.0.2",
"jest": "^29.7.0",
Expand Down
3 changes: 2 additions & 1 deletion apps/admin-api/src/api/api.module.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Module } from '@nestjs/common';
import { AuthModule } from './auth/auth.module';
import { UserModule } from './user/user.module';

@Module({
imports: [UserModule],
imports: [UserModule, AuthModule],
})
export class ApiModule {}
26 changes: 26 additions & 0 deletions apps/admin-api/src/api/auth/auth.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';

describe('AuthController', () => {
let controller: AuthController;
let authServiceValue: Partial<Record<keyof AuthService, jest.Mock>>;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
providers: [
{
provide: AuthService,
useValue: authServiceValue,
},
],
}).compile();

controller = module.get<AuthController>(AuthController);
});

it('should be defined', () => {
expect(controller).toBeDefined();
});
});
17 changes: 17 additions & 0 deletions apps/admin-api/src/api/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Body, Controller, Post, SerializeOptions } from '@nestjs/common';
import { Public } from '@repo/api/decorators/public.decorator';
import { UserResDto } from '../user/dto/user.dto';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';

@Controller()
export class AuthController {
constructor(private readonly authService: AuthService) {}

@Post('users/login')
@Public()
@SerializeOptions({ type: UserResDto })
async login(@Body('user') userData: LoginDto): Promise<UserResDto> {
return this.authService.login(userData);
}
}
14 changes: 14 additions & 0 deletions apps/admin-api/src/api/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from '@repo/database-typeorm/entities/user.entity';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';

@Module({
imports: [JwtModule.register({}), TypeOrmModule.forFeature([UserEntity])],
controllers: [AuthController],
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}
55 changes: 55 additions & 0 deletions apps/admin-api/src/api/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { UserEntity } from '@repo/database-typeorm/entities/user.entity';
import { Repository } from 'typeorm';
import { AuthService } from './auth.service';

describe('AuthService', () => {
let service: AuthService;
let jwtServiceValue: Partial<Record<keyof JwtService, jest.Mock>>;
let configServiceValue: Partial<Record<keyof ConfigService, jest.Mock>>;
let userRepositoryValue: Partial<
Record<keyof Repository<UserEntity>, jest.Mock>
>;

beforeAll(async () => {
configServiceValue = {
get: jest.fn(),
};

jwtServiceValue = {
sign: jest.fn(),
verify: jest.fn(),
};

userRepositoryValue = {
findOne: jest.fn(),
};

const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{
provide: ConfigService,
useValue: configServiceValue,
},
{
provide: JwtService,
useValue: jwtServiceValue,
},
{
provide: getRepositoryToken(UserEntity),
useValue: userRepositoryValue,
},
],
}).compile();

service = module.get<AuthService>(AuthService);
});

it('should be defined', () => {
expect(service).toBeDefined();
});
});
76 changes: 76 additions & 0 deletions apps/admin-api/src/api/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { AllConfigType } from '@/config/config.type';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { UserEntity } from '@repo/database-typeorm/entities/user.entity';
import { verifyPassword } from '@repo/utils/password.util';
import { Repository } from 'typeorm';
import { UserResDto } from '../user/dto/user.dto';
import { LoginDto } from './dto/login.dto';
import { JwtPayloadType } from './types/jwt-payload.type';

@Injectable()
export class AuthService {
constructor(
private readonly configService: ConfigService<AllConfigType>,
private readonly jwtService: JwtService,
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
) {}

async login(dto: LoginDto): Promise<UserResDto> {
const { email, password } = dto;

const user = await this.userRepository.findOne({
where: { email },
});

const isPasswordValid =
user && (await verifyPassword(password, user.password));

if (!isPasswordValid) {
throw new UnauthorizedException();
}

const token = await this.createToken({ id: user.id });

return {
user: {
...user,
token,
},
};
}

async verifyAccessToken(token: string): Promise<JwtPayloadType> {
let payload: JwtPayloadType;
try {
payload = this.jwtService.verify(token, {
secret: this.configService.getOrThrow('auth.secret', { infer: true }),
});
} catch {
throw new UnauthorizedException();
}

return payload;
}

async createToken(data: { id: number }): Promise<string> {
const tokenExpiresIn = this.configService.getOrThrow('auth.expires', {
infer: true,
});

const accessToken = await this.jwtService.signAsync(
{
id: data.id,
},
{
secret: this.configService.getOrThrow('auth.secret', { infer: true }),
expiresIn: tokenExpiresIn,
},
);

return accessToken;
}
}
57 changes: 57 additions & 0 deletions apps/admin-api/src/api/auth/config/auth-config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import authConfig from './auth.config';

describe('AuthConfig', () => {
const originalEnv = { ...process.env };

beforeEach(() => {
// Reset process.env to its original state before each test
process.env = { ...originalEnv };
});

beforeAll(() => {
jest.spyOn(console, 'warn').mockImplementation();
jest.spyOn(console, 'error').mockImplementation();
jest.spyOn(console, 'info').mockImplementation();
});

describe.skip('secret', () => {
it('should return the value of AUTH_JWT_SECRET', async () => {
process.env.AUTH_JWT_SECRET = 'secret';
const config = await authConfig();
expect(config.secret).toBe('secret');
});

it('should throw an error when AUTH_JWT_SECRET is an empty', async () => {
process.env.AUTH_JWT_SECRET = '';
await expect(async () => await authConfig()).rejects.toThrow(Error);
});

it('should throw an error when AUTH_JWT_SECRET is not set', async () => {
delete process.env.AUTH_JWT_SECRET;
await expect(async () => await authConfig()).rejects.toThrow(Error);
});
});

describe.skip('expires', () => {
it('should return the value of AUTH_JWT_TOKEN_EXPIRES_IN', async () => {
process.env.AUTH_JWT_TOKEN_EXPIRES_IN = '1d';
const config = await authConfig();
expect(config.expires).toBe('1d');
});

it('should throw an error when AUTH_JWT_TOKEN_EXPIRES_IN is an empty', async () => {
process.env.AUTH_JWT_TOKEN_EXPIRES_IN = '';
await expect(async () => await authConfig()).rejects.toThrow(Error);
});

it('should throw an error when AUTH_JWT_TOKEN_EXPIRES_IN is not set', async () => {
delete process.env.AUTH_JWT_TOKEN_EXPIRES_IN;
await expect(async () => await authConfig()).rejects.toThrow(Error);
});

it('should throw an error when AUTH_JWT_TOKEN_EXPIRES_IN is not a valid ms', async () => {
process.env.AUTH_JWT_TOKEN_EXPIRES_IN = 'invalid';
await expect(async () => await authConfig()).rejects.toThrow(Error);
});
});
});
4 changes: 4 additions & 0 deletions apps/admin-api/src/api/auth/config/auth-config.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type AuthConfig = {
secret: string;
expires: string;
};
26 changes: 26 additions & 0 deletions apps/admin-api/src/api/auth/config/auth.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { registerAs } from '@nestjs/config';
import { IsMs } from '@repo/api';
import { validateConfig } from '@repo/utils';
import { IsNotEmpty, IsString } from 'class-validator';
import { AuthConfig } from './auth-config.type';

class EnvironmentVariablesValidator {
@IsString()
@IsNotEmpty()
AUTH_JWT_SECRET: string;

@IsString()
@IsNotEmpty()
@IsMs()
AUTH_JWT_TOKEN_EXPIRES_IN: string;
}

export default registerAs<AuthConfig>('auth', () => {
console.info(`Register AuthConfig from environment variables`);
validateConfig(process.env, EnvironmentVariablesValidator);

return {
secret: process.env.AUTH_JWT_SECRET,
expires: process.env.AUTH_JWT_TOKEN_EXPIRES_IN,
};
});
9 changes: 9 additions & 0 deletions apps/admin-api/src/api/auth/dto/login.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { EmailField, PasswordField } from '@repo/api';

export class LoginDto {
@EmailField()
readonly email: string;

@PasswordField()
readonly password: string;
}
5 changes: 5 additions & 0 deletions apps/admin-api/src/api/auth/types/jwt-payload.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type JwtPayloadType = {
id: string;
iat: number;
exp: number;
};
12 changes: 8 additions & 4 deletions apps/admin-api/src/api/user/dto/create-user.dto.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { StringField } from '@repo/utils/decorators/field.decorators';
import { EmailField, PasswordField, StringField } from '@repo/api';
import { lowerCaseTransformer } from '@repo/utils/transformers/lower-case.transformer';
import { Transform } from 'class-transformer';

export class CreateUserDto {
@StringField()
@Transform(lowerCaseTransformer)
username: string;
email: string;
password: string;
readonly username: string;

@EmailField()
readonly email: string;

@PasswordField()
readonly password: string;
}
Loading