diff --git a/apps/api/src/app.controller.ts b/apps/api/src/app.controller.ts index ca60618..c3fab23 100644 --- a/apps/api/src/app.controller.ts +++ b/apps/api/src/app.controller.ts @@ -77,7 +77,11 @@ export class AppController { private handleDefaultCheck(req: FastifyRequest, reply: FastifyReply, token: string) { const { aud } = this.appService.checkToken(token); - if (aud === 'auth') return this.handleError(req, reply); + const originalUri = req.headers['x-original-uri']; + + if (aud === 'auth' && !['/auth', '/login', '/socket.io'].some((x) => originalUri.includes(x))) { + return this.handleError(req, reply); + } reply.header('Authorization', `Bearer ${token}`); diff --git a/apps/api/src/config/schema/env.ts b/apps/api/src/config/schema/env.ts index 628a5e8..2ba1236 100644 --- a/apps/api/src/config/schema/env.ts +++ b/apps/api/src/config/schema/env.ts @@ -4,6 +4,10 @@ const envSchema = z.object({ API_CACHE_TTL: z.string().transform((val) => Number.parseInt(val, 10)), API_PORT: z.number().optional().default(3001), API_SECRET: z.string(), + API_TOKEN_TFA_TTL: z + .string() + .transform((val) => Number.parseInt(val, 10)) + .default('300'), API_TOKEN_TTL: z.string().transform((val) => Number.parseInt(val, 10)), COOKIE_TOKEN_MAX_AGE: z.string().transform((val) => Number.parseInt(val, 10)), COOKIE_TOKEN_NAME: z.string().default('token'), diff --git a/apps/api/src/dto/tfa.ts b/apps/api/src/dto/tfa.ts new file mode 100644 index 0000000..dc95f08 --- /dev/null +++ b/apps/api/src/dto/tfa.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class TelegramDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + readonly authId: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + readonly employeeID: string; +} diff --git a/apps/api/src/ldap-tfa/ldap-tfa.controller.ts b/apps/api/src/ldap-tfa/ldap-tfa.controller.ts index f7e1d9c..dd9c064 100644 --- a/apps/api/src/ldap-tfa/ldap-tfa.controller.ts +++ b/apps/api/src/ldap-tfa/ldap-tfa.controller.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/explicit-member-accessibility */ +import { LdapTfaService } from './ldap-tfa.service'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Body, @@ -11,6 +12,8 @@ import { Query, Req, Res, + UsePipes, + ValidationPipe, } from '@nestjs/common'; import { ApiResponse, ApiTags } from '@nestjs/swagger'; import axios from 'axios'; @@ -20,18 +23,19 @@ import { cookieOptions } from 'src/config/cookie'; import { env } from 'src/config/env'; import { AuthToken } from 'src/decorators/token.decorator'; import { Credentials } from 'src/dto/credentials'; +import { TelegramDto } from 'src/dto/tfa'; import { LdapController } from 'src/ldap/ldap.controller'; -import { LdapService } from 'src/ldap/ldap.service'; -import type { User } from 'src/utils/ldap'; +import { LdapTfaGateway } from 'src/ldap-tfa/ldap-tfa.gateway'; @Controller('ldap-tfa') @ApiTags('ldap-tfa') export class LdapTfaController extends LdapController { constructor( - protected readonly ldapService: LdapService, - @Inject(CACHE_MANAGER) private readonly cacheManager: Cache + protected readonly ldapTfaService: LdapTfaService, + @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, + private readonly ldapTfaGateway: LdapTfaGateway ) { - super(ldapService); + super(ldapTfaService); } @Post('/login') @ApiResponse({ @@ -43,8 +47,11 @@ export class LdapTfaController extends LdapController { @Res() reply: FastifyReply ) { try { - const token = await this.ldapService.login(credentials, { audience: 'auth' }); - const user = await this.ldapService.getUser(token); + const authId = crypto.randomUUID(); + const token = await this.ldapTfaService.login(credentials, { authId }); + const user = await this.ldapTfaService.getUser(token); + + await this.cacheManager.set(authId, user, env.API_TOKEN_TFA_TTL); return reply.setCookie(env.COOKIE_TOKEN_NAME, token, cookieOptions).status(200).send(user); } catch { @@ -57,13 +64,8 @@ export class LdapTfaController extends LdapController { status: HttpStatus.OK, }) async loginTelegram(@AuthToken() token: string, @Res() reply: FastifyReply) { - const user = await this.ldapService.getUser(token); - - const authId = crypto.randomUUID(); - const { employeeID } = user; - - // Change TTL - this.cacheManager.set(authId, user); + const { employeeID } = await this.ldapTfaService.getUser(token); + const { authId } = await this.ldapTfaService.parseToken(token); return axios .get(env.TELEGRAM_URL_SEND_AUTH_MESSAGE, { @@ -80,27 +82,29 @@ export class LdapTfaController extends LdapController { @ApiResponse({ status: HttpStatus.OK, }) - async telegramConfirm( - @Query('authId') authId: string, - @Query('employeeID') employeeID: string, - @Res() reply: FastifyReply - ) { - const user = (await this.cacheManager.get(authId)) as User; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const token = await this.ldapService.login({ login: user.username }); + @UsePipes(new ValidationPipe({ transform: true })) + async telegramConfirm(@Query() query: TelegramDto, @Res() reply: FastifyReply) { + this.ldapTfaGateway.notify('auth-allow', query); - return reply.status(200).send(); + return reply.status(200).send({ success: true }); } @Get('/telegram-reject') @ApiResponse({ status: HttpStatus.OK, }) - async telegramReject( - @Query('authId') authId: string, - @Query('employeeID') employeeID: string, - @Res() reply: FastifyReply - ) { - return reply.status(200).send(); + @UsePipes(new ValidationPipe({ transform: true })) + async telegramReject(@Query() _query: TelegramDto, @Res() reply: FastifyReply) { + return reply.status(200).send({ success: true }); + } + + @Get('/login-confirm') + @ApiResponse({ + status: HttpStatus.OK, + }) + async loginConfirm(@AuthToken() token: string, @Res() reply: FastifyReply) { + const activatedToken = await this.ldapTfaService.activateToken(token); + + return reply.setCookie(env.COOKIE_TOKEN_NAME, activatedToken, cookieOptions).status(200).send(); } } diff --git a/apps/api/src/ldap-tfa/ldap-tfa.gateway.ts b/apps/api/src/ldap-tfa/ldap-tfa.gateway.ts new file mode 100644 index 0000000..113e6bd --- /dev/null +++ b/apps/api/src/ldap-tfa/ldap-tfa.gateway.ts @@ -0,0 +1,49 @@ +/* eslint-disable @typescript-eslint/explicit-member-accessibility */ +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Inject } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import type { OnGatewayConnection, OnGatewayDisconnect } from '@nestjs/websockets'; +import { WebSocketGateway, WebSocketServer } from '@nestjs/websockets'; +import { Cache } from 'cache-manager'; +import type { Socket } from 'socket.io'; +import { Server } from 'socket.io'; +import { env } from 'src/config/env'; +import type { TelegramDto } from 'src/dto/tfa'; +import type { DecodedToken } from 'src/types/jwt'; +import type { User } from 'src/utils/ldap'; + +type UserWithSocketId = User & { socketId: string }; + +@WebSocketGateway({ cors: { credentials: true } }) +export class LdapTfaGateway implements OnGatewayConnection, OnGatewayDisconnect { + constructor( + @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, + private readonly jwtService: JwtService + ) { + this.cacheManager = cacheManager; + } + + @WebSocketServer() server: Server; + + async handleConnection(client: Socket, ...args: any[]) { + const token = client.request.headers?.authorization?.split(' ')[1]; + const { authId } = this.jwtService.decode(token) as DecodedToken; + const cached = this.cacheManager.get(authId); + + await this.cacheManager.set(authId, { ...cached, socketId: client.id }, env.API_TOKEN_TFA_TTL); + } + + async handleDisconnect(client: Socket) { + const token = client.request.headers?.authorization?.split(' ')[1]; + const { authId } = this.jwtService.decode(token) as DecodedToken; + + await this.cacheManager.del(authId); + } + + async notify(event: string, { authId }: TelegramDto): Promise { + const { socketId } = await this.cacheManager.get(authId); + this.server.to([socketId]).emit(event); + + await this.cacheManager.del(authId); + } +} diff --git a/apps/api/src/ldap-tfa/ldap-tfa.module.ts b/apps/api/src/ldap-tfa/ldap-tfa.module.ts index 9a96f45..05be84e 100644 --- a/apps/api/src/ldap-tfa/ldap-tfa.module.ts +++ b/apps/api/src/ldap-tfa/ldap-tfa.module.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-extraneous-class */ import { LdapTfaController } from './ldap-tfa.controller'; +import { LdapTfaService } from './ldap-tfa.service'; import { Module } from '@nestjs/common'; import { LdapModule } from 'src/ldap/ldap.module'; import { LdapTfaGateway } from 'src/ldap-tfa/ldap-tfa.gateway'; @@ -7,6 +8,6 @@ import { LdapTfaGateway } from 'src/ldap-tfa/ldap-tfa.gateway'; @Module({ controllers: [LdapTfaController], imports: [LdapModule], - providers: [LdapTfaGateway], + providers: [LdapTfaGateway, LdapTfaService], }) export class LdapTfaModule {} diff --git a/apps/api/src/ldap-tfa/ldap-tfa.service.ts b/apps/api/src/ldap-tfa/ldap-tfa.service.ts new file mode 100644 index 0000000..f09bfaa --- /dev/null +++ b/apps/api/src/ldap-tfa/ldap-tfa.service.ts @@ -0,0 +1,63 @@ +import type { TokenPayload } from '../types/jwt'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Inject, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Cache } from 'cache-manager'; +import { env } from 'src/config/env'; +import type { Credentials } from 'src/dto/credentials'; +import { LdapService } from 'src/ldap/ldap.service'; +import * as ldap from 'src/utils/ldap'; +import type { PartialBy } from 'src/utils/types'; + +export class LdapTfaService extends LdapService { + constructor( + @Inject(CACHE_MANAGER) protected readonly cacheManager: Cache, + protected readonly jwtService: JwtService + ) { + super(cacheManager, jwtService); + } + + public async login(credentials: PartialBy, additionalPayload?: object) { + try { + const user = await ldap.authenticate(credentials.login, credentials.password); + const { username } = user; + + await this.cacheManager.set(username, user); + + const payload: TokenPayload = { + domain: env.LDAP_DOMAIN, + username, + ...additionalPayload, + }; + + return this.jwtService.sign(payload, { audience: 'auth' }); + } catch (error) { + throw new UnauthorizedException(error); + } + } + + public async parseToken(token: string) { + try { + return this.jwtService.verify(token, { audience: 'auth' }); + } catch (error) { + throw new UnauthorizedException(error); + } + } + + public async activateToken(token: string) { + try { + const { username } = this.jwtService.verify(token, { audience: 'auth' }); + const user = await ldap.authenticate(username); + await this.cacheManager.set(username, user); + + const payload: TokenPayload = { + domain: env.LDAP_DOMAIN, + username, + }; + + return this.jwtService.sign(payload); + } catch (error) { + throw new UnauthorizedException(error); + } + } +} diff --git a/apps/api/src/ldap/ldap.controller.ts b/apps/api/src/ldap/ldap.controller.ts index 6e5dab5..31ed2f5 100644 --- a/apps/api/src/ldap/ldap.controller.ts +++ b/apps/api/src/ldap/ldap.controller.ts @@ -27,7 +27,7 @@ import { User } from 'src/utils/ldap'; @ApiTags('ldap') export class LdapController implements BaseAuthController { cookieOptions: CookieSerializeOptions; - constructor(protected readonly ldapService: LdapService) {} + constructor(protected readonly ldapTfaService: LdapService) {} private clearCookies(req, reply) { if (req.cookies) { @@ -49,7 +49,7 @@ export class LdapController implements BaseAuthController { @Res() reply: FastifyReply ) { try { - const token = await this.ldapService.login(credentials); + const token = await this.ldapTfaService.login(credentials); return reply.setCookie(env.COOKIE_TOKEN_NAME, token, cookieOptions).status(200).send(); } catch { @@ -59,7 +59,7 @@ export class LdapController implements BaseAuthController { @Get('/logout') async logout(@Req() req: FastifyRequest, @Res() reply: FastifyReply, @AuthToken() token: string) { - if (token) await this.ldapService.logout(token); + if (token) await this.ldapTfaService.logout(token); this.clearCookies(req, reply); @@ -76,7 +76,7 @@ export class LdapController implements BaseAuthController { @Res() reply: FastifyReply, @AuthToken() token: string ) { - const user = await this.ldapService.getUser(token); + const user = await this.ldapTfaService.getUser(token); if (!user) throw new UnauthorizedException('User not found'); diff --git a/apps/api/src/ldap/ldap.service.ts b/apps/api/src/ldap/ldap.service.ts index a5a19b7..9fdde97 100644 --- a/apps/api/src/ldap/ldap.service.ts +++ b/apps/api/src/ldap/ldap.service.ts @@ -7,16 +7,15 @@ import { Cache } from 'cache-manager'; import { env } from 'src/config/env'; import type { Credentials } from 'src/dto/credentials'; import * as ldap from 'src/utils/ldap'; -import type { PartialBy } from 'src/utils/types'; @Injectable() export class LdapService { constructor( - @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, - private readonly jwtService: JwtService + @Inject(CACHE_MANAGER) protected readonly cacheManager: Cache, + protected readonly jwtService: JwtService ) {} - public async login(credentials: PartialBy, options?: JwtSignOptions) { + public async login(credentials: Credentials, options?: JwtSignOptions) { try { const user = await ldap.authenticate(credentials.login, credentials.password); const { username } = user; diff --git a/apps/api/src/types/jwt.ts b/apps/api/src/types/jwt.ts index bafe566..dc103be 100644 --- a/apps/api/src/types/jwt.ts +++ b/apps/api/src/types/jwt.ts @@ -2,6 +2,7 @@ import type { JwtSignOptions } from '@nestjs/jwt'; export type TokenPayload = { [key: string]: unknown; + authId?: string; username: string; }; diff --git a/apps/web/components/Form.tsx b/apps/web/components/Form.tsx index 551a20f..b70217c 100644 --- a/apps/web/components/Form.tsx +++ b/apps/web/components/Form.tsx @@ -4,11 +4,12 @@ import TelegramIcon from '../public/assets/images/telegram.svg'; import styles from './Form.module.scss'; import { publicRuntimeConfig } from '@/config/runtime'; import { FormStateContext } from '@/context/form-state'; +import { useSocket } from '@/hooks/socket'; import type { LdapUser } from '@/types/user'; import axios from 'axios'; import Image from 'next/image'; import type { PropsWithChildren } from 'react'; -import { useContext } from 'react'; +import { useContext, useEffect } from 'react'; import { useForm } from 'react-hook-form'; const ERROR_INVALID_CREDENTIALS = 'Неверный логин или пароль'; @@ -25,6 +26,13 @@ type FormProps = { readonly onSubmit: (data: FormData) => void; }; +function redirect() { + const redirectUrl = + (window.location.pathname.replace(APP_BASE_PATH, '') || '/') + (window.location.search || ''); + + window.location.replace(redirectUrl); +} + function BaseForm({ children, onSubmit }: FormProps & PropsWithChildren) { const { handleSubmit, register } = useForm(); const { @@ -65,13 +73,9 @@ export const Form = { const { dispatch } = useContext(FormStateContext); function handleLogin(data: FormData) { - const redirectUrl = - (window.location.pathname.replace(APP_BASE_PATH, '') || '/') + - (window.location.search || ''); - return axios .post('/login', data) - .then(() => window.location.replace(redirectUrl)) + .then(() => redirect()) .catch(() => dispatch({ payload: { error: ERROR_INVALID_CREDENTIALS }, @@ -95,6 +99,32 @@ export const Form = { state: { step, user }, } = useContext(FormStateContext); + const { socket } = useSocket(); + + useEffect(() => { + if (step === 'telegram-login') { + socket.open(); + socket.on('connect', () => {}); + + socket.on('auth-allow', () => { + socket.off('connect'); + axios + .get('/login-confirm') + .then(() => redirect()) + .catch(() => + dispatch({ + payload: { error: ERROR_SERVER }, + type: 'set-error', + }) + ); + }); + } + + return () => { + socket.off('connect'); + }; + }, [dispatch, socket, step]); + function handleLogin(data: FormData) { axios .post('/login', data) diff --git a/apps/web/hooks/socket.tsx b/apps/web/hooks/socket.tsx new file mode 100644 index 0000000..4e1a53f --- /dev/null +++ b/apps/web/hooks/socket.tsx @@ -0,0 +1,11 @@ +import { useMemo } from 'react'; +import { io } from 'socket.io-client'; + +export function useSocket() { + const socket = useMemo( + () => io({ autoConnect: false, path: '/socket.io', reconnectionAttempts: 3 }), + [] + ); + + return { socket }; +}