This commit is contained in:
vchikalkin 2024-06-04 18:45:02 +03:00
parent d2c41fb983
commit 2e5f9fd001
12 changed files with 225 additions and 45 deletions

View File

@ -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}`);

View File

@ -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'),

14
apps/api/src/dto/tfa.ts Normal file
View File

@ -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;
}

View File

@ -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();
}
}

View File

@ -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<User>(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<T>(event: string, { authId }: TelegramDto): Promise<void> {
const { socketId } = await this.cacheManager.get<UserWithSocketId>(authId);
this.server.to([socketId]).emit(event);
await this.cacheManager.del(authId);
}
}

View File

@ -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 {}

View File

@ -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<Credentials, 'password'>, 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<TokenPayload>(token, { audience: 'auth' });
} catch (error) {
throw new UnauthorizedException(error);
}
}
public async activateToken(token: string) {
try {
const { username } = this.jwtService.verify<TokenPayload>(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);
}
}
}

View File

@ -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');

View File

@ -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<Credentials, 'password'>, options?: JwtSignOptions) {
public async login(credentials: Credentials, options?: JwtSignOptions) {
try {
const user = await ldap.authenticate(credentials.login, credentials.password);
const { username } = user;

View File

@ -2,6 +2,7 @@ import type { JwtSignOptions } from '@nestjs/jwt';
export type TokenPayload = {
[key: string]: unknown;
authId?: string;
username: string;
};

View File

@ -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<FormData>();
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<LdapUser>('/login', data)

11
apps/web/hooks/socket.tsx Normal file
View File

@ -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 };
}