complete
This commit is contained in:
parent
d2c41fb983
commit
2e5f9fd001
@ -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}`);
|
||||
|
||||
|
||||
@ -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
14
apps/api/src/dto/tfa.ts
Normal 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;
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
49
apps/api/src/ldap-tfa/ldap-tfa.gateway.ts
Normal file
49
apps/api/src/ldap-tfa/ldap-tfa.gateway.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
|
||||
63
apps/api/src/ldap-tfa/ldap-tfa.service.ts
Normal file
63
apps/api/src/ldap-tfa/ldap-tfa.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -2,6 +2,7 @@ import type { JwtSignOptions } from '@nestjs/jwt';
|
||||
|
||||
export type TokenPayload = {
|
||||
[key: string]: unknown;
|
||||
authId?: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
|
||||
@ -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
11
apps/web/hooks/socket.tsx
Normal 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 };
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user