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

Feature 2fa #213

Merged
merged 15 commits into from
Feb 23, 2024
1 change: 1 addition & 0 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
nodeLinker: node-modules
89 changes: 83 additions & 6 deletions apps/api/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,20 @@ import {
UseGuards,
UseInterceptors
} from '@nestjs/common'
import { instanceToPlain } from 'class-transformer'

import { AuthUser } from '../user/user.decorator'
import { UserEntity } from '../entities/user.entity'
import { AuthService } from './auth.service'
import {
ConfirmationCodeDto,
ForgotPasswordResetRequestDto,
Recovery2FADto,
ResetPasswordRequestDto,
SignInWithEmailCredentialsDto,
SignUpWithEmailCredentialsDto
SignUpWithEmailCredentialsDto,
TurnOn2FADto
} from '@isomera/dtos'
import { JWTAuthGuard } from './guards/jwt-auth.guard'
import { LocalAuthGuard } from './guards/local-auth.guard'
import { SessionAuthGuard } from './guards/session-auth.guard'
import { TokenInterceptor } from './interceptors/token.interceptor'
Expand All @@ -34,6 +36,8 @@ import {
StatusType
} from '@isomera/interfaces'
import { JwtRefreshTokenGuard } from './guards/jwt-refresh-token'
import { Jwt2faAuthGuard } from './guards/jwt-2fa-auth.guard'
import { JWTAuthGuard } from './guards/jwt-auth.guard'

@Controller('auth')
export class AuthController {
Expand Down Expand Up @@ -75,7 +79,7 @@ export class AuthController {
}

@Get('/me')
@UseGuards(SessionAuthGuard, JWTAuthGuard)
@UseGuards(SessionAuthGuard, Jwt2faAuthGuard)
me(@AuthUser() user: Pure<SignInWithEmailCredentialsDto>): UserEntity {
return user as UserEntity
}
Expand All @@ -102,9 +106,15 @@ export class AuthController {
@Post('/refresh')
@HttpCode(HttpStatus.OK)
async refreshToken(
@AuthUser() user: Pure<UserEntity>
@AuthUser() user: Pure<UserEntity> & { isTwoFactorAuthenticated: boolean }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this prop already be in UserEntity?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This field doesn't exist in the entity because it's not being stored in the db

): Promise<RefreshTokenResponseInterface> {
const { refresh_token, access_token } = this.authService.signToken(user)
const payload = {
email: user.email,
isTwoFactorAuthenticationEnabled: !!user.isTwoFAEnabled,
isTwoFactorAuthenticated: !!user.isTwoFactorAuthenticated
}

const { refresh_token, access_token } = this.authService.signToken(payload)

await this.authService.storeRefreshToken(user, refresh_token)
return {
Expand All @@ -115,7 +125,7 @@ export class AuthController {
}

@Post('/logout')
@UseGuards(SessionAuthGuard, JWTAuthGuard)
@UseGuards(SessionAuthGuard, Jwt2faAuthGuard)
@HttpCode(HttpStatus.OK)
async logout(
@AuthUser() user: Pure<UserEntity>
Expand All @@ -125,4 +135,71 @@ export class AuthController {
status: StatusType.OK
}
}

@Post('2fa/generate')
@UseGuards(SessionAuthGuard, Jwt2faAuthGuard)
@HttpCode(HttpStatus.OK)
async register2FA(@AuthUser() user: Pure<UserEntity>) {
const { otpAuthUrl } =
await this.authService.generateTwoFactorAuthenticationSecret(user)

return {
status: StatusType.OK,
image: await this.authService.generateQrCodeDataURL(otpAuthUrl)
}
}

@Post('2fa/request-recovery')
@HttpCode(HttpStatus.OK)
async requestRecovery2FA(@Body() { code }: Pure<Recovery2FADto>) {
await this.authService.requestRecovery2FA(code)
return {
status: StatusType.OK
}
}

@Post('2fa/confirm-recovery')
@HttpCode(HttpStatus.OK)
async confirmRecovery2FACode(
@Body() { code, email }: Pure<ConfirmationCodeDto>
) {
await this.authService.confirmRecovery2FACode({ code, email })
return {
status: StatusType.OK
}
}

@Post('2fa/turn-on')
@UseGuards(SessionAuthGuard, Jwt2faAuthGuard)
@HttpCode(HttpStatus.OK)
async turnOnTwoFactorAuthentication(
@AuthUser() user: Pure<UserEntity>,
@Body() { code }: Pure<TurnOn2FADto>
) {
await this.authService.turnOn2FA(user, code)
const data = await this.authService.loginWith2fa(user, code)
delete data.password

const { access_token, refresh_token } = data

return {
status: StatusType.OK,
secret: user.twoFASecret,
access_token,
refresh_token
}
}

@Post('2fa/authenticate')
@HttpCode(200)
@UseGuards(SessionAuthGuard, JWTAuthGuard)
async authenticate(
@AuthUser() user: Pure<UserEntity>,
@Body() { code }: Pure<TurnOn2FADto>
) {
const data = await this.authService.loginWith2fa(user, code)
delete data.password

return data
}
}
4 changes: 3 additions & 1 deletion apps/api/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { MailerModule } from '../mailer/mailer.module'
import { ConfirmCodeModule } from '../user/confirm-code.module'
import { OrganizationModule } from '../organization/organization.module'
import { JwtRefreshTokenStrategy } from './strategies/jwt-refresh-token.strategy'
import { Jwt2faStrategy } from './strategies/jwt-2fa.strategy'

@Module({
imports: [
Expand All @@ -37,7 +38,8 @@ import { JwtRefreshTokenStrategy } from './strategies/jwt-refresh-token.strategy
LocalStrategy,
JwtStrategy,
SessionSerializer,
JwtRefreshTokenStrategy
JwtRefreshTokenStrategy,
Jwt2faStrategy
]
})
export class AuthModule {}
151 changes: 136 additions & 15 deletions apps/api/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ import { generateRandomStringUtil } from '@isomera/utils'
import { OrganizationService } from '../organization/organization.service'
import { ConfigService } from '@nestjs/config'
import * as bcrypt from 'bcrypt'
import { pages } from '@isomera/impl'
import { HandlebarsTemplate } from '../mailer/types/mailer.types'
import { authenticator } from 'otplib'
import { toDataURL } from 'qrcode'

@Injectable()
export class AuthService {
Expand Down Expand Up @@ -88,7 +89,10 @@ export class AuthService {

console.log('xxx', user)

const { refresh_token, access_token } = this.signToken(user)
const payload = {
email: user.email
}
const { refresh_token, access_token } = this.signToken(payload)

await this.storeRefreshToken(user, refresh_token)

Expand All @@ -112,30 +116,25 @@ export class AuthService {
return user
}

signToken(user: UserEntity): { refresh_token: string; access_token: string } {
signToken(payload: object): {
PhamAnhHoang marked this conversation as resolved.
Show resolved Hide resolved
refresh_token: string
access_token: string
} {
return {
refresh_token: this.generateRefreshToken(user.email),
access_token: this.generateAccessToken(user.email)
refresh_token: this.generateRefreshToken(payload),
access_token: this.generateAccessToken(payload)
}
}

public generateAccessToken(email: string): string {
const payload = {
sub: email
}

public generateAccessToken(payload: object): string {
PhamAnhHoang marked this conversation as resolved.
Show resolved Hide resolved
return this.jwtService.sign(payload, {
expiresIn: `${this.configService.get<string>(
'JWT_ACCESS_TOKEN_EXPIRATION_TIME'
)}s`
})
}

public generateRefreshToken(email: string): string {
const payload = {
sub: email
}

public generateRefreshToken(payload: object): string {
PhamAnhHoang marked this conversation as resolved.
Show resolved Hide resolved
return this.jwtService.sign(payload, {
expiresIn: `${this.configService.get<string>(
'JWT_REFRESH_TOKEN_EXPIRATION_TIME'
Expand Down Expand Up @@ -262,4 +261,126 @@ export class AuthService {
async logout(user: UserEntity) {
return this.userService.storeRefreshToken(user, null)
}

// for 2FA
async generateTwoFactorAuthenticationSecret(user: UserEntity) {
const secret = authenticator.generateSecret()

const otpAuthUrl = authenticator.keyuri(
user.email,
this.configService.get<string>('AUTH_APP_NAME'),
secret
)

await this.userService.setTwoFactorAuthenticationSecret(user, secret)

return {
secret,
otpAuthUrl
}
}

// for 2FA
isTwoFactorAuthenticationCodeValid(user: UserEntity, code: string): boolean {
return authenticator.verify({
token: code,
secret: user.twoFASecret
})
}

/**
* Generate QR code
* @param otpAuthUrl
* @returns
*/
async generateQrCodeDataURL(otpAuthUrl: string) {
return toDataURL(otpAuthUrl)
}

/**
* Turn on 2FA
* @param user
* @param code
*/
async turnOn2FA(user: UserEntity, code: string) {
const isCodeValid = this.isTwoFactorAuthenticationCodeValid(user, code)

if (!isCodeValid) {
throw new UnauthorizedException('Code is incorrect.')
}

await this.userService.setupTwoFactorAuthentication(user, true)
}

/**
* Login with 2FA
* @param user
* @param code
* @returns
*/
async loginWith2fa(
user: UserEntity,
code: string
): Promise<LoginResponseInterface> {
const isCodeValid = this.isTwoFactorAuthenticationCodeValid(user, code)

if (!isCodeValid) {
throw new UnauthorizedException('Code is incorrect.')
}

const payload = {
email: user.email,
isTwoFactorAuthenticationEnabled: !!user.isTwoFAEnabled,
isTwoFactorAuthenticated: true
}

const { refresh_token, access_token } = this.signToken(payload)

await this.storeRefreshToken(user, refresh_token)

return {
...user,
access_token: access_token,
refresh_token: refresh_token
}
}

async requestRecovery2FA(secret: string) {
const user = await this.userService.findOne({
where: { twoFASecret: secret }
})
if (user) {
throw new UnauthorizedException(`There isn't any user with this code`)
}

if (process.env.NODE_ENV !== 'test') {
const code = await this.confirmCode.genNewCode(user)
if (code.code) {
await this.mailerService.sendEmail(
user,
'Email verification',
HandlebarsTemplate.EMAIL_CONFIRMATION,
{
name: user.firstName,
code: code.code
}
)
return user
}
throw new HttpException(
"Couldn't generate the code",
HttpStatus.INTERNAL_SERVER_ERROR
)
} else {
return user
}
}

async confirmRecovery2FACode({
code,
email
}: Pure<ConfirmationCodeDto>): Promise<UserEntity> {
const user = await this.confirmCode.verifyCode(code, email)
return this.userService.turnOfTwoFactorAuthentication(user)
}
}
5 changes: 5 additions & 0 deletions apps/api/src/auth/guards/jwt-2fa-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common'
import { AuthGuard } from '@nestjs/passport'

@Injectable()
export class Jwt2faAuthGuard extends AuthGuard('jwt-2fa') {}
32 changes: 32 additions & 0 deletions apps/api/src/auth/strategies/jwt-2fa.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ExtractJwt, Strategy } from 'passport-jwt'
import { PassportStrategy } from '@nestjs/passport'
import { Injectable } from '@nestjs/common'
import { UserService } from '../../user/user.service'
import { LoginWith2FAPayload } from '@isomera/interfaces'

@Injectable()
export class Jwt2faStrategy extends PassportStrategy(Strategy, 'jwt-2fa') {
constructor(private readonly userService: UserService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.APP_SECRET
})
}

async validate(payload: LoginWith2FAPayload) {
const user = await this.userService.findOne({
where: { email: payload.email }
})

if (!user.isTwoFAEnabled) {
return user
}

if (payload.isTwoFactorAuthenticated) {
return {
...user,
isTwoFactorAuthenticated: payload.isTwoFactorAuthenticated
}
}
}
}
Loading
Loading