Skip to content

Commit

Permalink
hash passwords and tokens, create new user with login (#9)
Browse files Browse the repository at this point in the history
  • Loading branch information
vmozharov authored Oct 27, 2023
1 parent d079ca2 commit 946c74c
Show file tree
Hide file tree
Showing 20 changed files with 496 additions and 76 deletions.
362 changes: 339 additions & 23 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"@nestjs/mapped-types": "*",
"@nestjs/passport": "^10.0.2",
"@nestjs/platform-express": "^10.0.0",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"joi": "^17.11.0",
Expand All @@ -49,6 +50,7 @@
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/bcrypt": "^5.0.1",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
Expand Down
3 changes: 1 addition & 2 deletions src/app.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ describe('AppController', () => {
it('should return "Hello World!"', async () => {
expect(await appController.getHello()).toBe(`Hello World!
Port: ${serverConfigService.port}
Is development: ${environmentConfigService.isDevelopment}
Test: ${environmentConfigService.test}`)
Is development: ${environmentConfigService.isDevelopment}`)
})
})
})
3 changes: 1 addition & 2 deletions src/app.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ export class AppService {
async getHello(): Promise<string> {
return `Hello World!
Port: ${this.serverConfigService.port}
Is development: ${this.environmentConfigService.isDevelopment}
Test: ${this.environmentConfigService.test}`
Is development: ${this.environmentConfigService.isDevelopment}`
}
}
40 changes: 34 additions & 6 deletions src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ describe('AuthService', () => {
email: '[email protected]',
password: '123',
}))
jest.spyOn(authService, 'hashText').mockImplementation(async () => 'test-hash')
const [tokens, access_token, refresh_token] = await Promise.all([
authService.login(1),
jwtService.signAsync({sub: 1}),
Expand All @@ -47,7 +48,7 @@ describe('AuthService', () => {
),
])
expect(tokens).toStrictEqual({access_token, refresh_token})
expect(usersService.update).toHaveBeenCalledWith(1, {refreshToken: refresh_token})
expect(usersService.update).toHaveBeenCalledWith(1, {refreshToken: 'test-hash'})
})
})

Expand All @@ -58,12 +59,13 @@ describe('AuthService', () => {
password: '123',
}

beforeEach(() => {
beforeEach(async () => {
returnedUser.password = await authService.hashText(returnedUser.password)
jest.spyOn(usersService, 'findOneByEmail').mockImplementation(async () => returnedUser)
})

it('should return user id', async () => {
const user = await authService.validateUser(returnedUser.email, returnedUser.password)
const user = await authService.validateUser(returnedUser.email, '123')
expect(user).toEqual({id: returnedUser.id})
})

Expand All @@ -72,10 +74,13 @@ describe('AuthService', () => {
expect(user).toBeNull()
})

it('should return null if the email is wrong', async () => {
it('should return new user id if the email is new', async () => {
jest.restoreAllMocks()
jest
.spyOn(usersService, 'create')
.mockImplementation(async () => ({id: 2, password: '123', email: 'tt.tt.tt'}))
const user = await authService.validateUser('tt.tt.tt', returnedUser.password)
expect(user).toBeNull()
expect(user).toStrictEqual({id: 2})
})
})

Expand All @@ -98,6 +103,7 @@ describe('AuthService', () => {
email: '[email protected]',
password: '123',
}))
jest.spyOn(authService, 'hashText').mockImplementation(async () => 'test-hash')

const [tokens, access_token, refresh_token] = await Promise.all([
authService.refreshTokens(1),
Expand All @@ -112,7 +118,29 @@ describe('AuthService', () => {
])

expect(tokens).toStrictEqual({access_token, refresh_token})
expect(usersService.update).toHaveBeenCalledWith(1, {refreshToken: refresh_token})
expect(usersService.update).toHaveBeenCalledWith(1, {refreshToken: 'test-hash'})
})
})

describe('hashText', () => {
it('should return a hashed text', async () => {
jest.spyOn(authService, 'hashText').mockImplementation(async () => 'test-hash')
const hashedText = await authService.hashText('test')
expect(hashedText).toBe('test-hash')
})
})

describe('compareHash', () => {
it('should return true if the text and hash are the same', async () => {
jest.spyOn(authService, 'compareHash').mockImplementation(async () => true)
const isMatch = await authService.compareHash('test', 'test-hash')
expect(isMatch).toBe(true)
})

it('should return false if the text and hash are not the same', async () => {
jest.spyOn(authService, 'compareHash').mockImplementation(async () => false)
const isMatch = await authService.compareHash('test', 'test-hash')
expect(isMatch).toBe(false)
})
})
})
34 changes: 27 additions & 7 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import {Injectable} from '@nestjs/common'
import {Injectable, Logger} from '@nestjs/common'
import {UsersService} from '../users/users.service'
import {JwtService} from '@nestjs/jwt'
import {User} from '../users/entities/user.entity'
import {Payload} from './entities/payload.entity'
import {AuthConfigService} from '../config/auth/auth.config.service'
import * as bcrypt from 'bcrypt'

@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name)

constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
Expand All @@ -21,17 +24,21 @@ export class AuthService {

async validateUser(email: string, password: string): Promise<{id: User['id']} | null> {
const user = await this.usersService.findOneByEmail(email)
if (user?.password === password) {

if (!user) {
this.logger.debug(`User with email ${email} not found. Creating new user...`)
const hashedPassword = await this.hashText(password)
const newUser = await this.usersService.create({email, password: hashedPassword})
return {id: newUser.id}
}

const isPasswordMatch = await this.compareHash(password, user.password)
if (isPasswordMatch) {
return {id: user.id}
}
return null
}

private async updateRefreshToken(userID: User['id'], refreshToken: string) {
// TODO: хешировать refreshToken перед добавлением в бд
await this.usersService.update(userID, {refreshToken})
}

async logout(userID: User['id']) {
await this.usersService.update(userID, {refreshToken: undefined})
}
Expand All @@ -42,6 +49,19 @@ export class AuthService {
return tokens
}

hashText(text: string) {
return bcrypt.hash(text, this.authConfigService.hashRounds)
}

compareHash(text: string, hash: string) {
return bcrypt.compare(text, hash)
}

private async updateRefreshToken(userID: User['id'], refreshToken: string) {
const hashToken = await this.hashText(refreshToken)
await this.usersService.update(userID, {refreshToken: hashToken})
}

private async getTokens(userID: User['id']) {
const payload: Omit<Omit<Payload, 'iat'>, 'exp'> = {sub: userID}
const [access_token, refresh_token] = await Promise.all([
Expand Down
8 changes: 7 additions & 1 deletion src/auth/strategies/refresh-token.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import {AuthConfigService} from '../../config/auth/auth.config.service'
import {JWT_REFRESH_STRATEGY_NAME} from './strategies.constants'
import {Payload} from '../entities/payload.entity'
import {UsersService} from '../../users/users.service'
import {AuthService} from '../auth.service'

@Injectable()
export class RefreshTokenStrategy extends PassportStrategy(Strategy, JWT_REFRESH_STRATEGY_NAME) {
constructor(
private readonly authConfigService: AuthConfigService,
private readonly usersService: UsersService,
private readonly authService: AuthService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
Expand All @@ -26,7 +28,11 @@ export class RefreshTokenStrategy extends PassportStrategy(Strategy, JWT_REFRESH

const userID = payload.sub
const user = await this.usersService.findOneByID(userID)
if (!user || !user.refreshToken || authRefreshToken !== user.refreshToken) {
const isTokenMatch = await this.authService.compareHash(
authRefreshToken,
`${user?.refreshToken}`,
)
if (!user || !isTokenMatch) {
throw new ForbiddenException('Access Denied')
}

Expand Down
6 changes: 6 additions & 0 deletions src/config/auth/auth.config.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,9 @@ export const JWT_REFRESH_TOKEN_EXPIRES_IN = {
name: 'JWT_REFRESH_TOKEN_EXPIRES_IN',
default: '60d',
}

export type HashRoundsType = number
export const HASH_ROUNDS = {
name: 'HASH_ROUNDS',
defaultValue: 10,
}
6 changes: 6 additions & 0 deletions src/config/auth/auth.config.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {Injectable} from '@nestjs/common'
import {ConfigService} from '@nestjs/config'
import {
HASH_ROUNDS,
HashRoundsType,
JWT_ACCESS_TOKEN_EXPIRES_IN,
JWT_REFRESH_SECRET_TOKEN,
JWT_REFRESH_TOKEN_EXPIRES_IN,
Expand Down Expand Up @@ -28,4 +30,8 @@ export class AuthConfigService {
get jwtRefreshTokenExpiresIn() {
return this.configService.get(JWT_REFRESH_TOKEN_EXPIRES_IN.name) as JwtAccessTokenExpiresInType
}

get hashRounds() {
return this.configService.get(HASH_ROUNDS.name) as HashRoundsType
}
}
8 changes: 8 additions & 0 deletions src/config/auth/auth.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import {HASH_ROUNDS} from './auth.config.constants'
import {NODE_ENV} from '../environment/environment.config.constants'

const isTest = process.env[NODE_ENV.name] === NODE_ENV.options.TEST

export default () => ({
[HASH_ROUNDS.name]: isTest ? 1 : HASH_ROUNDS.defaultValue,
})
5 changes: 0 additions & 5 deletions src/config/environment/environment.config.ts

This file was deleted.

5 changes: 3 additions & 2 deletions src/config/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {ServerConfigService} from './server/server.config.service'
import {EnvironmentConfigService} from './environment/environment.config.service'
import environmentConfig from './environment/environment.config'
import * as Joi from 'joi'
import {ConfigModule} from '@nestjs/config'
import serverConfigEnvValidation from './server/server.config.env-validation'
Expand All @@ -10,6 +9,8 @@ import {LoggerConfigService} from './logger/logger.config.service'
import loggerConfigEnvValidation from './logger/logger.config.env-validation'
import {AuthConfigService} from './auth/auth.config.service'
import authConfigEnvValidation from './auth/auth.config.env-validation'
import authConfig from './auth/auth.config'
import serverConfig from './server/server.config'

// Add all config services here
export const configProviders = [
Expand All @@ -20,7 +21,7 @@ export const configProviders = [
]

// Add all custom config here
const configsForLoad = [environmentConfig]
const configsForLoad = [authConfig, serverConfig]

// Add all config validation here
const validationSchema = Joi.object({
Expand Down
6 changes: 6 additions & 0 deletions src/config/server/server.config.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,9 @@ export const PORT = {
name: 'PORT',
defaultValue: 3000,
}

export type RequestTimeoutMsType = number
export const REQUEST_TIMEOUT_MS = {
name: 'REQUEST_TIMEOUT_MS',
defaultValue: 10000,
}
6 changes: 5 additions & 1 deletion src/config/server/server.config.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {ConfigService} from '@nestjs/config'
import {PORT, PortType} from './server.config.constants'
import {PORT, PortType, REQUEST_TIMEOUT_MS, RequestTimeoutMsType} from './server.config.constants'
import {Injectable} from '@nestjs/common'

@Injectable()
Expand All @@ -9,4 +9,8 @@ export class ServerConfigService {
get port(): number {
return this.configService.get(PORT.name) as PortType
}

get requestTimeoutMs(): number {
return this.configService.get(REQUEST_TIMEOUT_MS.name) as RequestTimeoutMsType
}
}
8 changes: 8 additions & 0 deletions src/config/server/server.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import {NODE_ENV} from '../environment/environment.config.constants'
import {REQUEST_TIMEOUT_MS} from './server.config.constants'

const isTest = process.env[NODE_ENV.name] === NODE_ENV.options.TEST

export default () => ({
[REQUEST_TIMEOUT_MS.name]: isTest ? 20 : REQUEST_TIMEOUT_MS.defaultValue,
})
8 changes: 5 additions & 3 deletions src/interceptors/timeout.interceptor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,21 @@ import {Test, TestingModule} from '@nestjs/testing'
import {TimeoutInterceptor} from './timeout.interceptor'
import {RequestTimeoutException} from '@nestjs/common'
import {Observable, of, throwError, TimeoutError} from 'rxjs'
import {EnvironmentConfigService} from '../config/environment/environment.config.service'
import {configModule} from '../config'
import {ServerConfigService} from '../config/server/server.config.service'

describe('TimeoutInterceptor', () => {
let interceptor: TimeoutInterceptor
let serverConfigService: ServerConfigService

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [configModule],
providers: [TimeoutInterceptor, EnvironmentConfigService],
providers: [TimeoutInterceptor, ServerConfigService],
}).compile()

interceptor = module.get(TimeoutInterceptor)
serverConfigService = module.get(ServerConfigService)
})

it('should handle the request without timing out', done => {
Expand Down Expand Up @@ -55,7 +57,7 @@ describe('TimeoutInterceptor', () => {
return new Observable(observer => {
setTimeout(() => {
observer.error(new Error('Timeout occurred'))
}, 15)
}, serverConfigService.requestTimeoutMs + 5)
})
},
}
Expand Down
7 changes: 3 additions & 4 deletions src/interceptors/timeout.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,15 @@ import {
} from '@nestjs/common'
import {Observable, throwError, TimeoutError} from 'rxjs'
import {catchError, timeout} from 'rxjs/operators'
import {EnvironmentConfigService} from '../config/environment/environment.config.service'
import {ServerConfigService} from '../config/server/server.config.service'

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
constructor(private readonly environmentConfigService: EnvironmentConfigService) {}
constructor(private readonly serverConfigService: ServerConfigService) {}

intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const timeoutMS = this.environmentConfigService.isTest ? 5 : 10000
return next.handle().pipe(
timeout(timeoutMS),
timeout(this.serverConfigService.requestTimeoutMs),
catchError(err => {
if (err instanceof TimeoutError) {
return throwError(() => new RequestTimeoutException())
Expand Down
5 changes: 2 additions & 3 deletions test/app.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,15 @@ describe('AppController (e2e)', () => {
it('/ (GET)', () => {
return request(app.getHttpServer()).get('/').expect(HttpStatus.OK).expect(`Hello World!
Port: ${serverConfigService.port}
Is development: ${environmentConfigService.isDevelopment}
Test: ${environmentConfigService.test}`)
Is development: ${environmentConfigService.isDevelopment}`)
})

it('408: Request Timeout', () => {
jest.spyOn(appService, 'getHello').mockImplementationOnce(() => {
return new Promise(resolve => {
setTimeout(() => {
resolve(`Hello World!`)
}, 15)
}, serverConfigService.requestTimeoutMs + 5)
})
})

Expand Down
Loading

0 comments on commit 946c74c

Please sign in to comment.