import { HttpException, HttpStatus, Injectable, UnauthorizedException, } from '@nestjs/common'; import ms from 'ms'; import crypto from 'crypto'; import { randomStringGenerator } from '@nestjs/common/utils/random-string-generator.util'; import { JwtService } from '@nestjs/jwt'; import bcrypt from 'bcryptjs'; import { AuthEmailLoginDto } from './dto/auth-email-login.dto'; import { AuthUpdateDto } from './dto/auth-update.dto'; import { RoleEnum } from 'src/roles/roles.enum'; import { StatusEnum } from 'src/statuses/statuses.enum'; import { AuthProvidersEnum } from './auth-providers.enum'; import { SocialInterface } from '../social/interfaces/social.interface'; import { AuthRegisterLoginDto } from './dto/auth-register-login.dto'; import { MailService } from 'src/mail/mail.service'; import { NullableType } from '../utils/types/nullable.type'; import { LoginResponseType } from './types/login-response.type'; import { ConfigService } from '@nestjs/config'; import { AllConfigType } from 'src/config/config.type'; import { JwtRefreshPayloadType } from './strategies/types/jwt-refresh-payload.type'; import { JwtPayloadType } from './strategies/types/jwt-payload.type'; import { User } from 'src/users/domain/user'; import { Session } from 'src/session/domain/session'; import { UsersService } from 'src/users/users.service'; import { SessionService } from 'src/session/session.service'; @Injectable() export class AuthService { constructor( private jwtService: JwtService, private usersService: UsersService, private sessionService: SessionService, private mailService: MailService, private configService: ConfigService, ) {} async validateLogin(loginDto: AuthEmailLoginDto): Promise { const user = await this.usersService.findOne({ email: loginDto.email, }); if (!user) { throw new HttpException( { status: HttpStatus.UNPROCESSABLE_ENTITY, errors: { email: 'notFound', }, }, HttpStatus.UNPROCESSABLE_ENTITY, ); } if (user.provider !== AuthProvidersEnum.email) { throw new HttpException( { status: HttpStatus.UNPROCESSABLE_ENTITY, errors: { email: `needLoginViaProvider:${user.provider}`, }, }, HttpStatus.UNPROCESSABLE_ENTITY, ); } if (!user.password) { throw new HttpException( { status: HttpStatus.UNPROCESSABLE_ENTITY, errors: { password: 'incorrectPassword', }, }, HttpStatus.UNPROCESSABLE_ENTITY, ); } const isValidPassword = await bcrypt.compare( loginDto.password, user.password, ); if (!isValidPassword) { throw new HttpException( { status: HttpStatus.UNPROCESSABLE_ENTITY, errors: { password: 'incorrectPassword', }, }, HttpStatus.UNPROCESSABLE_ENTITY, ); } const hash = crypto .createHash('sha256') .update(randomStringGenerator()) .digest('hex'); const session = await this.sessionService.create({ user, hash, }); const { token, refreshToken, tokenExpires } = await this.getTokensData({ id: user.id, role: user.role, sessionId: session.id, hash, }); return { refreshToken, token, tokenExpires, user, }; } async validateSocialLogin( authProvider: string, socialData: SocialInterface, ): Promise { let user: NullableType = null; const socialEmail = socialData.email?.toLowerCase(); let userByEmail: NullableType = null; if (socialEmail) { userByEmail = await this.usersService.findOne({ email: socialEmail, }); } if (socialData.id) { user = await this.usersService.findOne({ socialId: socialData.id, provider: authProvider, }); } if (user) { if (socialEmail && !userByEmail) { user.email = socialEmail; } await this.usersService.update(user.id, user); } else if (userByEmail) { user = userByEmail; } else if (socialData.id) { const role = { id: RoleEnum.user, }; const status = { id: StatusEnum.active, }; user = await this.usersService.create({ email: socialEmail ?? null, firstName: socialData.firstName ?? null, lastName: socialData.lastName ?? null, socialId: socialData.id, provider: authProvider, role, status, }); user = await this.usersService.findOne({ id: user?.id, }); } if (!user) { throw new HttpException( { status: HttpStatus.UNPROCESSABLE_ENTITY, errors: { user: 'userNotFound', }, }, HttpStatus.UNPROCESSABLE_ENTITY, ); } const hash = crypto .createHash('sha256') .update(randomStringGenerator()) .digest('hex'); const session = await this.sessionService.create({ user, hash, }); const { token: jwtToken, refreshToken, tokenExpires, } = await this.getTokensData({ id: user.id, role: user.role, sessionId: session.id, hash, }); return { refreshToken, token: jwtToken, tokenExpires, user, }; } async register(dto: AuthRegisterLoginDto): Promise { const user = await this.usersService.create({ ...dto, email: dto.email, role: { id: RoleEnum.user, }, status: { id: StatusEnum.inactive, }, }); const hash = await this.jwtService.signAsync( { confirmEmailUserId: user.id, }, { secret: this.configService.getOrThrow('auth.confirmEmailSecret', { infer: true, }), expiresIn: this.configService.getOrThrow('auth.confirmEmailExpires', { infer: true, }), }, ); await this.mailService.userSignUp({ to: dto.email, data: { hash, }, }); } async confirmEmail(hash: string): Promise { let userId: User['id']; try { const jwtData = await this.jwtService.verifyAsync<{ confirmEmailUserId: User['id']; }>(hash, { secret: this.configService.getOrThrow('auth.confirmEmailSecret', { infer: true, }), }); userId = jwtData.confirmEmailUserId; } catch { throw new HttpException( { status: HttpStatus.UNPROCESSABLE_ENTITY, errors: { hash: `invalidHash`, }, }, HttpStatus.UNPROCESSABLE_ENTITY, ); } const user = await this.usersService.findOne({ id: userId, }); if (!user || user?.status?.id !== StatusEnum.inactive) { throw new HttpException( { status: HttpStatus.NOT_FOUND, error: `notFound`, }, HttpStatus.NOT_FOUND, ); } user.status = { id: StatusEnum.active, }; await this.usersService.update(user.id, user); } async forgotPassword(email: string): Promise { const user = await this.usersService.findOne({ email, }); if (!user) { throw new HttpException( { status: HttpStatus.UNPROCESSABLE_ENTITY, errors: { email: 'emailNotExists', }, }, HttpStatus.UNPROCESSABLE_ENTITY, ); } const tokenExpiresIn = this.configService.getOrThrow('auth.forgotExpires', { infer: true, }); const tokenExpires = Date.now() + ms(tokenExpiresIn); const hash = await this.jwtService.signAsync( { forgotUserId: user.id, }, { secret: this.configService.getOrThrow('auth.forgotSecret', { infer: true, }), expiresIn: tokenExpiresIn, }, ); await this.mailService.forgotPassword({ to: email, data: { hash, tokenExpires, }, }); } async resetPassword(hash: string, password: string): Promise { let userId: User['id']; try { const jwtData = await this.jwtService.verifyAsync<{ forgotUserId: User['id']; }>(hash, { secret: this.configService.getOrThrow('auth.forgotSecret', { infer: true, }), }); userId = jwtData.forgotUserId; } catch { throw new HttpException( { status: HttpStatus.UNPROCESSABLE_ENTITY, errors: { hash: `invalidHash`, }, }, HttpStatus.UNPROCESSABLE_ENTITY, ); } const user = await this.usersService.findOne({ id: userId, }); if (!user) { throw new HttpException( { status: HttpStatus.UNPROCESSABLE_ENTITY, errors: { hash: `notFound`, }, }, HttpStatus.UNPROCESSABLE_ENTITY, ); } user.password = password; await this.sessionService.softDelete({ user: { id: user.id, }, }); await this.usersService.update(user.id, user); } async me(userJwtPayload: JwtPayloadType): Promise> { return this.usersService.findOne({ id: userJwtPayload.id, }); } async update( userJwtPayload: JwtPayloadType, userDto: AuthUpdateDto, ): Promise> { if (userDto.password) { if (!userDto.oldPassword) { throw new HttpException( { status: HttpStatus.UNPROCESSABLE_ENTITY, errors: { oldPassword: 'missingOldPassword', }, }, HttpStatus.UNPROCESSABLE_ENTITY, ); } const currentUser = await this.usersService.findOne({ id: userJwtPayload.id, }); if (!currentUser) { throw new HttpException( { status: HttpStatus.UNPROCESSABLE_ENTITY, errors: { user: 'userNotFound', }, }, HttpStatus.UNPROCESSABLE_ENTITY, ); } if (!currentUser.password) { throw new HttpException( { status: HttpStatus.UNPROCESSABLE_ENTITY, errors: { oldPassword: 'incorrectOldPassword', }, }, HttpStatus.UNPROCESSABLE_ENTITY, ); } const isValidOldPassword = await bcrypt.compare( userDto.oldPassword, currentUser.password, ); if (!isValidOldPassword) { throw new HttpException( { status: HttpStatus.UNPROCESSABLE_ENTITY, errors: { oldPassword: 'incorrectOldPassword', }, }, HttpStatus.UNPROCESSABLE_ENTITY, ); } else { await this.sessionService.softDelete({ user: { id: currentUser.id, }, excludeId: userJwtPayload.sessionId, }); } } await this.usersService.update(userJwtPayload.id, userDto); return this.usersService.findOne({ id: userJwtPayload.id, }); } async refreshToken( data: Pick, ): Promise> { const session = await this.sessionService.findOne({ id: data.sessionId, }); if (!session) { throw new UnauthorizedException(); } if (session.hash !== data.hash) { throw new UnauthorizedException(); } const hash = crypto .createHash('sha256') .update(randomStringGenerator()) .digest('hex'); await this.sessionService.update(session.id, { hash, }); const { token, refreshToken, tokenExpires } = await this.getTokensData({ id: session.user.id, role: session.user.role, sessionId: session.id, hash, }); return { token, refreshToken, tokenExpires, }; } async softDelete(user: User): Promise { await this.usersService.softDelete(user.id); } async logout(data: Pick) { return this.sessionService.softDelete({ id: data.sessionId, }); } private async getTokensData(data: { id: User['id']; role: User['role']; sessionId: Session['id']; hash: Session['hash']; }) { const tokenExpiresIn = this.configService.getOrThrow('auth.expires', { infer: true, }); const tokenExpires = Date.now() + ms(tokenExpiresIn); const [token, refreshToken] = await Promise.all([ await this.jwtService.signAsync( { id: data.id, role: data.role, sessionId: data.sessionId, }, { secret: this.configService.getOrThrow('auth.secret', { infer: true }), expiresIn: tokenExpiresIn, }, ), await this.jwtService.signAsync( { sessionId: data.sessionId, hash: data.hash, }, { secret: this.configService.getOrThrow('auth.refreshSecret', { infer: true, }), expiresIn: this.configService.getOrThrow('auth.refreshExpires', { infer: true, }), }, ), ]); return { token, refreshToken, tokenExpires, }; } }