merge branch release/dyn-4251_2fa-telegram-auth
This commit is contained in:
parent
26a7092d74
commit
712142a474
24
.env
24
.env
@ -1,24 +0,0 @@
|
||||
COMPOSE_PROJECT_NAME=
|
||||
TRAEFIK_APP_NAME=
|
||||
TRAEFIK_ENTRYPOINTS=web-secure
|
||||
# TRAEFIK_ENTRYPOINTS=web-secure-ext
|
||||
WEB_HOST=
|
||||
|
||||
# WEB
|
||||
APP_BASE_PATH=/login
|
||||
APP_TITLE=
|
||||
APP_DESCRIPTION=Лизинговая компания Эволюция
|
||||
|
||||
# API
|
||||
LDAP_BIND_DN=
|
||||
LDAP_BIND_CREDENTIALS=
|
||||
LDAP_DOMAIN=
|
||||
LDAP_URL=
|
||||
LDAP_BASE=
|
||||
LDAP_ATTRIBUTE=
|
||||
|
||||
API_SECRET=
|
||||
API_TOKEN_TTL=
|
||||
API_CACHE_TTL=
|
||||
COOKIE_TOKEN_NAME=token
|
||||
COOKIE_TOKEN_MAX_AGE=
|
||||
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@ -13,9 +13,8 @@
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": true,
|
||||
"source.fixAll.eslint": true
|
||||
// "source.removeUnusedImports": true
|
||||
"source.fixAll": "explicit",
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"workbench.editor.labelFormat": "short",
|
||||
"eslint.workingDirectories": [
|
||||
@ -30,5 +29,5 @@
|
||||
"yaml"
|
||||
],
|
||||
"eslint.lintTask.enable": true,
|
||||
"editor.inlineSuggest.showToolbar": "onHover"
|
||||
"editor.inlineSuggest.showToolbar": "always"
|
||||
}
|
||||
|
||||
@ -2,20 +2,20 @@
|
||||
# Make sure you update both files!
|
||||
|
||||
FROM node:alpine AS builder
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable && corepack prepare pnpm@8.9.0 --activate
|
||||
ENV PNPM_HOME=/usr/local/bin
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
RUN pnpm add -g turbo dotenv-cli
|
||||
RUN pnpm add -g turbo@1.12.4 dotenv-cli
|
||||
COPY . .
|
||||
RUN turbo prune --scope=api --docker
|
||||
|
||||
# Add lockfile and package.json's of isolated subworkspace
|
||||
FROM node:alpine AS installer
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable && corepack prepare pnpm@8.9.0 --activate
|
||||
ENV PNPM_HOME=/usr/local/bin
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
@ -31,7 +31,6 @@ RUN pnpm install
|
||||
# Build the project and its dependencies
|
||||
COPY --from=builder /app/out/full/ .
|
||||
COPY turbo.json turbo.json
|
||||
COPY .env .env
|
||||
RUN pnpm dotenv -e .env turbo run build --filter=api...
|
||||
|
||||
FROM node:alpine AS runner
|
||||
|
||||
@ -32,9 +32,11 @@
|
||||
"@nestjs/jwt": "^10.1.1",
|
||||
"@nestjs/mapped-types": "*",
|
||||
"@nestjs/mongoose": "^10.0.1",
|
||||
"@nestjs/platform-express": "^10.2.7",
|
||||
"@nestjs/platform-fastify": "^10.2.7",
|
||||
"@nestjs/platform-socket.io": "^10.3.8",
|
||||
"@nestjs/swagger": "^7.1.14",
|
||||
"@nestjs/websockets": "^10.3.8",
|
||||
"axios": "^1.5.1",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cache-manager": "^5.2.4",
|
||||
"cache-manager-ioredis": "^2.1.0",
|
||||
@ -46,6 +48,7 @@
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^5.0.5",
|
||||
"rxjs": "^7.8.1",
|
||||
"socket.io": "^4.7.5",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -60,7 +63,7 @@
|
||||
"@types/supertest": "^2.0.14",
|
||||
"@vchikalkin/eslint-config-awesome": "^1.1.6",
|
||||
"eslint": "^8.51.0",
|
||||
"fastify": "^4.24.3",
|
||||
"fastify": "4.24.3",
|
||||
"jest": "29.7.0",
|
||||
"prettier": "^3.0.3",
|
||||
"source-map-support": "^0.5.21",
|
||||
|
||||
@ -23,12 +23,15 @@ import { ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { cookieOptions } from 'src/config/cookie';
|
||||
import { env } from 'src/config/env';
|
||||
import { AuthParams, Params } from 'src/decorators/auth-mode.decorator';
|
||||
import { AuthToken } from 'src/decorators/token.decorator';
|
||||
import { Credentials } from 'src/dto/credentials';
|
||||
import { Account } from 'src/schemas/account.schema';
|
||||
import type { BaseAuthController } from 'src/types/auth-controller';
|
||||
|
||||
@Controller('account')
|
||||
@ApiTags('account')
|
||||
export class AccountController {
|
||||
export class AccountController implements BaseAuthController {
|
||||
constructor(private readonly accountService: AccountService) {}
|
||||
|
||||
private clearCookies(req, reply) {
|
||||
@ -102,7 +105,11 @@ export class AccountController {
|
||||
}
|
||||
|
||||
@Post('/login')
|
||||
async login(@Body() credentials: Credentials, @Res() reply: FastifyReply) {
|
||||
async login(
|
||||
@Body() credentials: Credentials,
|
||||
@Req() _req: FastifyRequest,
|
||||
@Res() reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const token = await this.accountService.login(credentials);
|
||||
|
||||
@ -119,17 +126,50 @@ export class AccountController {
|
||||
async logout(@Req() req: FastifyRequest, @Res() reply: FastifyReply) {
|
||||
this.clearCookies(req, reply);
|
||||
|
||||
return reply.status(302).redirect('/login');
|
||||
return reply.status(302).redirect('/');
|
||||
}
|
||||
|
||||
@Get('/refresh-token')
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
})
|
||||
async refreshToken(
|
||||
@AuthToken() token: string,
|
||||
@AuthParams() { refreshToken }: Params,
|
||||
@Res() reply: FastifyReply
|
||||
) {
|
||||
if (!refreshToken) return reply.status(HttpStatus.UNAUTHORIZED).send();
|
||||
|
||||
const newToken = await this.accountService.refreshToken(token);
|
||||
|
||||
reply.header('Authorization', `Bearer ${newToken}`);
|
||||
|
||||
return reply.setCookie(env.COOKIE_TOKEN_NAME, newToken, cookieOptions).send();
|
||||
}
|
||||
|
||||
@Get('/get-user')
|
||||
async getUser(@Req() req: FastifyRequest, @Res() reply: FastifyReply) {
|
||||
const token = req.cookies[env.COOKIE_TOKEN_NAME];
|
||||
if (!token) throw new UnauthorizedException();
|
||||
|
||||
async getUser(
|
||||
@Req() req: FastifyRequest,
|
||||
@Res() reply: FastifyReply,
|
||||
@AuthToken() token: string
|
||||
) {
|
||||
const account = await this.accountService.getUser(token);
|
||||
if (!account) throw new UnauthorizedException('Account not found');
|
||||
|
||||
return reply.send(account);
|
||||
}
|
||||
|
||||
@Get('/check-auth')
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
})
|
||||
async checkAuth(@AuthToken() token: string, @Res() reply: FastifyReply) {
|
||||
const { authId } = await this.accountService.parseToken(token, { ignoreExpiration: true });
|
||||
|
||||
if (authId) return reply.status(HttpStatus.UNAUTHORIZED).send();
|
||||
|
||||
const user = await this.accountService.getUser(token, { ignoreExpiration: true });
|
||||
|
||||
return reply.status(200).send(user);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,14 +2,15 @@ import type { CreateAccountDto } from './dto/create-account.dto';
|
||||
import type { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
import type { UpdateAccountDto } from './dto/update-account.dto';
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import type { JwtVerifyOptions } from '@nestjs/jwt';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { Model } from 'mongoose';
|
||||
import { omit } from 'radash';
|
||||
import type { Credentials } from 'src/dto/credentials';
|
||||
import type { DecodedToken, TokenPayload } from 'src/ldap/types/jwt';
|
||||
import { Account } from 'src/schemas/account.schema';
|
||||
import type { DecodedToken, TokenPayload } from 'src/types/jwt';
|
||||
import { generatePassword } from 'src/utils/password';
|
||||
|
||||
@Injectable()
|
||||
@ -94,7 +95,7 @@ export class AccountService {
|
||||
|
||||
public async refreshToken(token: string) {
|
||||
try {
|
||||
const { username } = this.jwtService.decode(token) as DecodedToken;
|
||||
const { username } = this.jwtService.verify<DecodedToken>(token, { ignoreExpiration: true });
|
||||
|
||||
const account = await this.accountModel.findOne({ username });
|
||||
if (!account) {
|
||||
@ -112,9 +113,9 @@ export class AccountService {
|
||||
}
|
||||
}
|
||||
|
||||
public async getUser(token: string) {
|
||||
public async getUser(token: string, options?: JwtVerifyOptions) {
|
||||
try {
|
||||
const { username } = this.jwtService.verify(token) as TokenPayload;
|
||||
const { username } = this.jwtService.verify<DecodedToken>(token, options);
|
||||
|
||||
return this.accountModel.findOne({
|
||||
username,
|
||||
@ -123,4 +124,12 @@ export class AccountService {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
public async parseToken(token: string, options?: JwtVerifyOptions) {
|
||||
try {
|
||||
return this.jwtService.verify<TokenPayload>(token, options);
|
||||
} catch (error) {
|
||||
throw new UnauthorizedException(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,62 +1,51 @@
|
||||
import { AccountService } from './account/account.service';
|
||||
import { AppService } from './app.service';
|
||||
import { env } from './config/env';
|
||||
import { LdapService } from './ldap/ldap.service';
|
||||
import { AuthParams, Params } from './decorators/auth-mode.decorator';
|
||||
import { AuthToken } from './decorators/token.decorator';
|
||||
import { Controller, Get, HttpStatus, Req, Res } from '@nestjs/common';
|
||||
import { ApiExcludeController } from '@nestjs/swagger';
|
||||
import { ApiExcludeController, ApiResponse } from '@nestjs/swagger';
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { cookieOptions } from 'src/config/cookie';
|
||||
|
||||
@Controller()
|
||||
@ApiExcludeController()
|
||||
export class AppController {
|
||||
constructor(
|
||||
private readonly appService: AppService,
|
||||
private readonly accountService: AccountService,
|
||||
private readonly ldapService: LdapService
|
||||
) {}
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get('auth')
|
||||
public async auth(@Req() req: FastifyRequest, @Res() reply: FastifyReply) {
|
||||
const token = req.cookies[env.COOKIE_TOKEN_NAME] || req.headers?.authorization?.split(' ')[1];
|
||||
if (!token) return reply.status(HttpStatus.UNAUTHORIZED).send();
|
||||
|
||||
public async auth(
|
||||
@Req() req: FastifyRequest,
|
||||
@Res() reply: FastifyReply,
|
||||
@AuthToken() token: string,
|
||||
@AuthParams() { authMode }: Params
|
||||
) {
|
||||
try {
|
||||
return this.handleDefaultCheck(req, reply, token);
|
||||
} catch (error) {
|
||||
const _err = error as Error;
|
||||
const isTokenExpired = _err.name?.toLocaleLowerCase().includes('expired');
|
||||
const refreshToken = req.headers['refresh-token'] === '1';
|
||||
const { aud } = this.appService.checkToken(token);
|
||||
const originalUri = req.headers['x-original-uri'];
|
||||
|
||||
if (isTokenExpired && refreshToken) return this.handleExpiredToken(req, reply, token);
|
||||
|
||||
return this.handleError(req, reply);
|
||||
}
|
||||
if (
|
||||
authMode === 'ldap-tfa' &&
|
||||
aud === 'auth' &&
|
||||
!['/auth', '/login', '/socket.io'].some((x) => originalUri.includes(x))
|
||||
) {
|
||||
return reply.status(HttpStatus.UNAUTHORIZED).send();
|
||||
}
|
||||
|
||||
private handleDefaultCheck(req: FastifyRequest, reply: FastifyReply, token: string) {
|
||||
this.appService.checkToken(token);
|
||||
reply.header('Authorization', `Bearer ${token}`);
|
||||
|
||||
return reply.send();
|
||||
}
|
||||
|
||||
private async handleExpiredToken(req: FastifyRequest, reply: FastifyReply, token: string) {
|
||||
try {
|
||||
const authMode = req.headers['auth-mode'];
|
||||
const newToken =
|
||||
authMode === 'account'
|
||||
? await this.accountService.refreshToken(token)
|
||||
: await this.ldapService.refreshToken(token);
|
||||
reply.header('Authorization', `Bearer ${newToken}`);
|
||||
|
||||
return reply.setCookie(env.COOKIE_TOKEN_NAME, newToken, cookieOptions).send();
|
||||
} catch {
|
||||
return this.handleError(req, reply);
|
||||
} catch (error) {
|
||||
return reply.status(HttpStatus.UNAUTHORIZED).send({ message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(req: FastifyRequest, reply: FastifyReply) {
|
||||
return reply.status(HttpStatus.UNAUTHORIZED).send();
|
||||
@Get('/check-auth')
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
})
|
||||
public async checkAuth(
|
||||
@AuthParams() { authMode }: Params,
|
||||
@Req() req: FastifyRequest,
|
||||
@Res() reply: FastifyReply
|
||||
) {
|
||||
return reply.redirect(308, `${req.protocol}://${req.headers.host}/${authMode}/check-auth`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,10 +3,14 @@ import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { env } from './config/env';
|
||||
import { LdapModule } from './ldap/ldap.module';
|
||||
import { LdapTfaModule } from './ldap-tfa/ldap-tfa.module';
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import * as redisStore from 'cache-manager-ioredis';
|
||||
import type { RedisOptions } from 'ioredis';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
@ -24,7 +28,15 @@ import { MongooseModule } from '@nestjs/mongoose';
|
||||
}),
|
||||
LdapModule,
|
||||
AccountModule,
|
||||
LdapTfaModule,
|
||||
MongooseModule.forRoot(`mongodb://${env.MONGO_HOST}`),
|
||||
CacheModule.register<RedisOptions>({
|
||||
host: env.REDIS_HOST,
|
||||
isGlobal: true,
|
||||
port: env.REDIS_PORT,
|
||||
store: redisStore,
|
||||
ttl: env.API_CACHE_TTL,
|
||||
}),
|
||||
],
|
||||
providers: [AppService],
|
||||
})
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { DecodedToken } from './ldap/types/jwt';
|
||||
import type { DecodedToken } from './types/jwt';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { omit } from 'radash';
|
||||
@ -8,11 +8,11 @@ export class AppService {
|
||||
constructor(private readonly jwtService: JwtService) {}
|
||||
|
||||
public checkToken(token: string) {
|
||||
this.jwtService.verify(token);
|
||||
return this.jwtService.verify<DecodedToken>(token);
|
||||
}
|
||||
|
||||
public refreshToken(token: string) {
|
||||
const payload = this.jwtService.decode(token) as DecodedToken;
|
||||
const payload = this.jwtService.decode<DecodedToken>(token);
|
||||
|
||||
return this.jwtService.sign(omit(payload, ['iat', 'exp']));
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
@ -23,6 +27,9 @@ const envSchema = z.object({
|
||||
.string()
|
||||
.transform((val) => Number.parseInt(val, 10))
|
||||
.default('6379'),
|
||||
TELEGRAM_URL_SEND_AUTH_LOGIN: z.string(),
|
||||
TELEGRAM_URL_SEND_AUTH_MESSAGE: z.string(),
|
||||
TELEGRAM_URL_SEND_AUTH_PASSWORD: z.string(),
|
||||
});
|
||||
|
||||
export default envSchema;
|
||||
|
||||
23
apps/api/src/decorators/auth-mode.decorator.ts
Normal file
23
apps/api/src/decorators/auth-mode.decorator.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import { createParamDecorator, UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
export type AuthMode = 'ldap' | 'ldap-tfa' | 'account' | undefined;
|
||||
export type RefreshToken = '1' | undefined;
|
||||
export type Params = {
|
||||
authMode: AuthMode;
|
||||
refreshToken: boolean;
|
||||
};
|
||||
|
||||
export const AuthParams = createParamDecorator<Params>((_data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
|
||||
const authMode = request.headers['auth-mode'] as AuthMode;
|
||||
const refreshToken = (request.headers['refresh-token'] as RefreshToken) === '1';
|
||||
|
||||
if (!authMode) throw new UnauthorizedException('Auth mode is missing');
|
||||
|
||||
return {
|
||||
authMode,
|
||||
refreshToken,
|
||||
} as Params;
|
||||
});
|
||||
14
apps/api/src/decorators/token.decorator.ts
Normal file
14
apps/api/src/decorators/token.decorator.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { env } from '../config/env';
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import { createParamDecorator, UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
export const AuthToken = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
|
||||
const token =
|
||||
request.cookies[env.COOKIE_TOKEN_NAME] || request.headers?.authorization?.split(' ')[1];
|
||||
|
||||
if (!token) throw new UnauthorizedException('Token is missing');
|
||||
|
||||
return 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;
|
||||
}
|
||||
114
apps/api/src/ldap-tfa/ldap-tfa.controller.ts
Normal file
114
apps/api/src/ldap-tfa/ldap-tfa.controller.ts
Normal file
@ -0,0 +1,114 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-member-accessibility */
|
||||
import { LdapTfaService } from './ldap-tfa.service';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import axios from 'axios';
|
||||
import { Cache } from 'cache-manager';
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
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 { LdapTfaGateway } from 'src/ldap-tfa/ldap-tfa.gateway';
|
||||
|
||||
@Controller('ldap-tfa')
|
||||
@ApiTags('ldap-tfa')
|
||||
export class LdapTfaController extends LdapController {
|
||||
constructor(
|
||||
protected readonly ldapTfaService: LdapTfaService,
|
||||
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
|
||||
private readonly ldapTfaGateway: LdapTfaGateway
|
||||
) {
|
||||
super(ldapTfaService);
|
||||
}
|
||||
@Post('/login')
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
})
|
||||
async login(
|
||||
@Body() credentials: Credentials,
|
||||
@Req() _req: FastifyRequest,
|
||||
@Res() reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
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 {
|
||||
throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('/login-telegram')
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
})
|
||||
async loginTelegram(@AuthToken() token: string, @Res() reply: FastifyReply) {
|
||||
const { employeeID } = await this.ldapTfaService.getUser(token, { audience: 'auth' });
|
||||
const { authId } = await this.ldapTfaService.parseToken(token, { audience: 'auth' });
|
||||
|
||||
return axios
|
||||
.get(env.TELEGRAM_URL_SEND_AUTH_MESSAGE, {
|
||||
auth: {
|
||||
password: env.TELEGRAM_URL_SEND_AUTH_PASSWORD,
|
||||
username: env.TELEGRAM_URL_SEND_AUTH_LOGIN,
|
||||
},
|
||||
params: {
|
||||
authId,
|
||||
employeeID,
|
||||
},
|
||||
})
|
||||
.then((res) => reply.status(200).send(res.data))
|
||||
.catch((error) => reply.status(500).send(error.response.data));
|
||||
}
|
||||
|
||||
@Get('/telegram-confirm')
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
})
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async telegramConfirm(@Query() query: TelegramDto, @Res() reply: FastifyReply) {
|
||||
this.ldapTfaGateway.notify('auth-allow', query);
|
||||
|
||||
return reply.status(200).send({ success: true });
|
||||
}
|
||||
|
||||
@Get('/telegram-reject')
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
})
|
||||
@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, { audience: 'auth' });
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
14
apps/api/src/ldap-tfa/ldap-tfa.module.ts
Normal file
14
apps/api/src/ldap-tfa/ldap-tfa.module.ts
Normal file
@ -0,0 +1,14 @@
|
||||
/* 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';
|
||||
|
||||
@Module({
|
||||
controllers: [LdapTfaController],
|
||||
exports: [LdapTfaService],
|
||||
imports: [LdapModule],
|
||||
providers: [LdapTfaGateway, LdapTfaService],
|
||||
})
|
||||
export class LdapTfaModule {}
|
||||
57
apps/api/src/ldap-tfa/ldap-tfa.service.ts
Normal file
57
apps/api/src/ldap-tfa/ldap-tfa.service.ts
Normal file
@ -0,0 +1,57 @@
|
||||
/* eslint-disable unicorn/no-object-as-default-parameter */
|
||||
import type { TokenPayload } from '../types/jwt';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { Inject, UnauthorizedException } from '@nestjs/common';
|
||||
import type { JwtVerifyOptions } from '@nestjs/jwt';
|
||||
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 activateToken(token: string, options: JwtVerifyOptions = { audience: 'auth' }) {
|
||||
try {
|
||||
const { username } = this.jwtService.verify<TokenPayload>(token, options);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -19,13 +19,16 @@ import { ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { cookieOptions } from 'src/config/cookie';
|
||||
import { env } from 'src/config/env';
|
||||
import { AuthParams, Params } from 'src/decorators/auth-mode.decorator';
|
||||
import { AuthToken } from 'src/decorators/token.decorator';
|
||||
import type { BaseAuthController } from 'src/types/auth-controller';
|
||||
import { User } from 'src/utils/ldap';
|
||||
|
||||
@Controller('ldap')
|
||||
@ApiTags('ldap')
|
||||
export class LdapController {
|
||||
export class LdapController implements BaseAuthController {
|
||||
cookieOptions: CookieSerializeOptions;
|
||||
constructor(private readonly ldapService: LdapService) {}
|
||||
constructor(protected readonly ldapService: LdapService) {}
|
||||
|
||||
private clearCookies(req, reply) {
|
||||
if (req.cookies) {
|
||||
@ -41,7 +44,11 @@ export class LdapController {
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
})
|
||||
async login(@Body() credentials: Credentials, @Res() reply: FastifyReply) {
|
||||
async login(
|
||||
@Body() credentials: Credentials,
|
||||
@Req() _req: FastifyRequest,
|
||||
@Res() reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const token = await this.ldapService.login(credentials);
|
||||
|
||||
@ -52,13 +59,30 @@ export class LdapController {
|
||||
}
|
||||
|
||||
@Get('/logout')
|
||||
async logout(@Req() req: FastifyRequest, @Res() reply: FastifyReply) {
|
||||
const token = req.cookies[env.COOKIE_TOKEN_NAME];
|
||||
async logout(@Req() req: FastifyRequest, @Res() reply: FastifyReply, @AuthToken() token: string) {
|
||||
if (token) await this.ldapService.logout(token);
|
||||
|
||||
this.clearCookies(req, reply);
|
||||
|
||||
return reply.status(302).redirect('/login');
|
||||
return reply.status(302).redirect('/');
|
||||
}
|
||||
|
||||
@Get('/refresh-token')
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
})
|
||||
async refreshToken(
|
||||
@AuthToken() token: string,
|
||||
@AuthParams() { refreshToken }: Params,
|
||||
@Res() reply: FastifyReply
|
||||
) {
|
||||
if (!refreshToken) return reply.status(HttpStatus.UNAUTHORIZED).send();
|
||||
|
||||
const newToken = await this.ldapService.refreshToken(token);
|
||||
|
||||
reply.header('Authorization', `Bearer ${newToken}`);
|
||||
|
||||
return reply.setCookie(env.COOKIE_TOKEN_NAME, newToken, cookieOptions).send();
|
||||
}
|
||||
|
||||
@Get('/get-user')
|
||||
@ -66,15 +90,29 @@ export class LdapController {
|
||||
status: HttpStatus.OK,
|
||||
type: User,
|
||||
})
|
||||
async getUser(@Req() req: FastifyRequest, @Res() reply: FastifyReply) {
|
||||
const token = req.cookies[env.COOKIE_TOKEN_NAME];
|
||||
|
||||
if (!token) throw new UnauthorizedException();
|
||||
|
||||
async getUser(
|
||||
@Req() _req: FastifyRequest,
|
||||
@Res() reply: FastifyReply,
|
||||
@AuthToken() token: string
|
||||
) {
|
||||
const user = await this.ldapService.getUser(token);
|
||||
|
||||
if (!user) throw new UnauthorizedException('User not found');
|
||||
|
||||
return reply.send(user);
|
||||
}
|
||||
|
||||
@Get('/check-auth')
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
})
|
||||
async checkAuth(@AuthToken() token: string, @Res() reply: FastifyReply) {
|
||||
const { authId } = await this.ldapService.parseToken(token, { ignoreExpiration: true });
|
||||
|
||||
if (authId) return reply.status(HttpStatus.UNAUTHORIZED).send();
|
||||
|
||||
const user = await this.ldapService.getUser(token, { ignoreExpiration: true });
|
||||
|
||||
return reply.status(200).send(user);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,22 +1,11 @@
|
||||
import { LdapController } from './ldap.controller';
|
||||
import { LdapService } from './ldap.service';
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { Module } from '@nestjs/common';
|
||||
import * as redisStore from 'cache-manager-ioredis';
|
||||
import type { RedisOptions } from 'ioredis';
|
||||
import { env } from 'src/config/env';
|
||||
|
||||
@Module({
|
||||
controllers: [LdapController],
|
||||
exports: [LdapService],
|
||||
imports: [
|
||||
CacheModule.register<RedisOptions>({
|
||||
host: env.REDIS_HOST,
|
||||
port: env.REDIS_PORT,
|
||||
store: redisStore,
|
||||
ttl: env.API_CACHE_TTL,
|
||||
}),
|
||||
],
|
||||
imports: [],
|
||||
providers: [LdapService],
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { DecodedToken, TokenPayload } from './types/jwt';
|
||||
import type { DecodedToken, TokenPayload } from '../types/jwt';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import type { JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Cache } from 'cache-manager';
|
||||
import { env } from 'src/config/env';
|
||||
@ -10,13 +11,13 @@ import * as ldap from 'src/utils/ldap';
|
||||
@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({ login, password }: Credentials) {
|
||||
public async login(credentials: Credentials, options?: JwtSignOptions) {
|
||||
try {
|
||||
const user = await ldap.authenticate(login, password);
|
||||
const user = await ldap.authenticate(credentials.login, credentials.password);
|
||||
const { username } = user;
|
||||
|
||||
await this.cacheManager.set(username, user);
|
||||
@ -26,7 +27,7 @@ export class LdapService {
|
||||
username,
|
||||
};
|
||||
|
||||
return this.jwtService.sign(payload);
|
||||
return this.jwtService.sign(payload, options);
|
||||
} catch (error) {
|
||||
throw new UnauthorizedException(error);
|
||||
}
|
||||
@ -42,7 +43,12 @@ export class LdapService {
|
||||
|
||||
public async refreshToken(token: string) {
|
||||
try {
|
||||
const { username } = this.jwtService.decode(token) as DecodedToken;
|
||||
const { username, aud = '' } = this.jwtService.verify<DecodedToken>(token, {
|
||||
ignoreExpiration: true,
|
||||
});
|
||||
|
||||
if (aud === 'auth') throw new UnauthorizedException();
|
||||
|
||||
const user = await ldap.authenticate(username);
|
||||
|
||||
await this.cacheManager.set(username, user);
|
||||
@ -58,11 +64,11 @@ export class LdapService {
|
||||
}
|
||||
}
|
||||
|
||||
public async getUser(token: string) {
|
||||
public async getUser(token: string, options?: JwtVerifyOptions) {
|
||||
try {
|
||||
const { username } = this.jwtService.verify(token) as DecodedToken;
|
||||
const { username } = this.jwtService.verify(token, options) as DecodedToken;
|
||||
|
||||
const cachedUser = (await this.cacheManager.get(username)) as ldap.User;
|
||||
const cachedUser = await this.cacheManager.get<ldap.User>(username);
|
||||
|
||||
if (!cachedUser) {
|
||||
const user = await ldap.authenticate(username);
|
||||
@ -77,4 +83,12 @@ export class LdapService {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
public async parseToken(token: string, options?: JwtVerifyOptions) {
|
||||
try {
|
||||
return this.jwtService.verify<TokenPayload>(token, options);
|
||||
} catch (error) {
|
||||
throw new UnauthorizedException(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
apps/api/src/types/auth-controller.ts
Normal file
8
apps/api/src/types/auth-controller.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import type { Credentials } from 'src/dto/credentials';
|
||||
|
||||
export type BaseAuthController = {
|
||||
getUser: (req: FastifyRequest, reply: FastifyReply, token: string) => Promise<never>;
|
||||
login: (credentials: Credentials, req: FastifyRequest, reply: FastifyReply) => Promise<never>;
|
||||
logout: (req: FastifyRequest, reply: FastifyReply, token: string) => Promise<never>;
|
||||
};
|
||||
@ -1,9 +1,13 @@
|
||||
import type { JwtSignOptions } from '@nestjs/jwt';
|
||||
|
||||
export type TokenPayload = {
|
||||
[key: string]: unknown;
|
||||
authId?: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
export type DecodedToken = {
|
||||
aud?: JwtSignOptions['audience'];
|
||||
exp: number;
|
||||
iat: number;
|
||||
} & TokenPayload;
|
||||
3
apps/api/src/utils/error.ts
Normal file
3
apps/api/src/utils/error.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function isTokenExpired(error: Error) {
|
||||
return error.name?.toLocaleLowerCase().includes('expired');
|
||||
}
|
||||
@ -18,6 +18,8 @@ export class User {
|
||||
public position: string;
|
||||
@ApiResponseProperty()
|
||||
public username: string;
|
||||
@ApiResponseProperty()
|
||||
public employeeID: string;
|
||||
}
|
||||
|
||||
export type LdapUser = {
|
||||
@ -108,6 +110,7 @@ export async function authenticate(login: string, password?: string) {
|
||||
title,
|
||||
mail,
|
||||
sAMAccountName: username,
|
||||
employeeID,
|
||||
}: LdapUser = await ldap.authenticate(options);
|
||||
|
||||
const user: User = {
|
||||
@ -115,6 +118,7 @@ export async function authenticate(login: string, password?: string) {
|
||||
displayName,
|
||||
domain: env.LDAP_DOMAIN,
|
||||
domainName: `${env.LDAP_DOMAIN}\\${username}`,
|
||||
employeeID,
|
||||
mail,
|
||||
position: title,
|
||||
username,
|
||||
|
||||
1
apps/api/src/utils/types.ts
Normal file
1
apps/api/src/utils/types.ts
Normal file
@ -0,0 +1 @@
|
||||
export type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
||||
@ -2,20 +2,20 @@
|
||||
# Make sure you update both files!
|
||||
|
||||
FROM node:alpine AS builder
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable && corepack prepare pnpm@8.9.0 --activate
|
||||
ENV PNPM_HOME=/usr/local/bin
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
RUN pnpm add -g turbo dotenv-cli
|
||||
RUN pnpm add -g turbo@1.12.4 dotenv-cli
|
||||
COPY . .
|
||||
RUN turbo prune --scope=web --docker
|
||||
|
||||
# Add lockfile and package.json's of isolated subworkspace
|
||||
FROM node:alpine AS installer
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN corepack enable && corepack prepare pnpm@8.9.0 --activate
|
||||
ENV PNPM_HOME=/usr/local/bin
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN apk update
|
||||
@ -31,7 +31,9 @@ RUN pnpm install
|
||||
# Build the project
|
||||
COPY --from=builder /app/out/full/ .
|
||||
COPY turbo.json turbo.json
|
||||
COPY .env .env
|
||||
ARG APP_BASE_PATH
|
||||
ARG APP_DESCRIPTION
|
||||
ARG TELEGRAM_BOT_URL
|
||||
RUN pnpm dotenv -e .env turbo run build --filter=web...
|
||||
|
||||
FROM node:alpine AS runner
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
import styles from './Form.module.scss';
|
||||
import { publicRuntimeConfig } from '@/config/runtime';
|
||||
import Button from '@/elements/Button';
|
||||
import Error from '@/elements/Error';
|
||||
import Input from '@/elements/Input';
|
||||
import axios from 'axios';
|
||||
import { useState } from 'react';
|
||||
|
||||
const { APP_BASE_PATH } = publicRuntimeConfig;
|
||||
|
||||
export default function Form() {
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const error = hasError ? <Error>Неверный логин или пароль</Error> : null;
|
||||
|
||||
return (
|
||||
<form
|
||||
className={styles.form}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const login = e.target[0].value;
|
||||
const password = e.target[1].value;
|
||||
const data = { login, password };
|
||||
|
||||
axios
|
||||
.post('/login', data)
|
||||
.then(() => {
|
||||
const url =
|
||||
(window.location.pathname.replace(APP_BASE_PATH, '') || '/') +
|
||||
(window.location.search || '');
|
||||
|
||||
window.location.replace(url);
|
||||
})
|
||||
.catch(() => {
|
||||
setHasError(true);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Input name="login" type="text" placeholder="Логин" required autoComplete="on" />
|
||||
<Input name="password" type="password" placeholder="Пароль" required autoComplete="on" />
|
||||
{error}
|
||||
<Button>Войти</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
44
apps/web/components/Form/base-form.tsx
Normal file
44
apps/web/components/Form/base-form.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import styles from './Form.module.scss';
|
||||
import type { FormData, FormProps } from './lib/types';
|
||||
import { publicRuntimeConfig } from '@/config/runtime';
|
||||
import { FormStateContext } from '@/context/form-state';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { useContext } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
const { TELEGRAM_BOT_URL } = publicRuntimeConfig;
|
||||
|
||||
export function BaseForm({ children, onSubmit }: FormProps & PropsWithChildren) {
|
||||
const { handleSubmit, register } = useForm<FormData>();
|
||||
const {
|
||||
state: { error, step },
|
||||
} = useContext(FormStateContext);
|
||||
|
||||
return (
|
||||
<form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
|
||||
<input
|
||||
disabled={step !== 'login'}
|
||||
type="text"
|
||||
placeholder="Логин"
|
||||
required
|
||||
autoComplete="on"
|
||||
{...register('login', { required: true })}
|
||||
/>
|
||||
<input
|
||||
disabled={step !== 'login'}
|
||||
type="password"
|
||||
placeholder="Пароль"
|
||||
required
|
||||
autoComplete="on"
|
||||
{...register('password', { required: true })}
|
||||
/>
|
||||
{step === 'telegram-login' ? (
|
||||
<a target="_blank" className="info" href={TELEGRAM_BOT_URL} rel="noreferrer">
|
||||
Открыть чат с ботом
|
||||
</a>
|
||||
) : null}
|
||||
{error ? <span className="error">{error}</span> : null}
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
25
apps/web/components/Form/default-form.tsx
Normal file
25
apps/web/components/Form/default-form.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { BaseForm } from './base-form';
|
||||
import { useLogin } from './hooks/default';
|
||||
import { useRefreshToken } from './hooks/token';
|
||||
import { ButtonLoading, ButtonLogin } from './lib/buttons';
|
||||
import { FormStateContext } from '@/context/form-state';
|
||||
import { useContext } from 'react';
|
||||
|
||||
export function DefaultForm() {
|
||||
useRefreshToken();
|
||||
const { handleLogin } = useLogin();
|
||||
|
||||
const {
|
||||
state: { step, user },
|
||||
} = useContext(FormStateContext);
|
||||
|
||||
if (step === 'login' && user) {
|
||||
return <ButtonLoading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseForm onSubmit={(data) => handleLogin(data)}>
|
||||
<ButtonLogin />
|
||||
</BaseForm>
|
||||
);
|
||||
}
|
||||
24
apps/web/components/Form/hooks/default.ts
Normal file
24
apps/web/components/Form/hooks/default.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import type { FormData } from '../lib/types';
|
||||
import { redirect } from '@/components/Form/lib/utils';
|
||||
import { ERROR_INVALID_CREDENTIALS } from '@/constants/errors';
|
||||
import { FormStateContext } from '@/context/form-state';
|
||||
import axios from 'axios';
|
||||
import { useContext } from 'react';
|
||||
|
||||
export function useLogin() {
|
||||
const { dispatch } = useContext(FormStateContext);
|
||||
|
||||
function handleLogin(data: FormData) {
|
||||
return axios
|
||||
.post('/login', data)
|
||||
.then(() => redirect())
|
||||
.catch(() =>
|
||||
dispatch({
|
||||
payload: { error: ERROR_INVALID_CREDENTIALS },
|
||||
type: 'set-error',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return { handleLogin };
|
||||
}
|
||||
2
apps/web/components/Form/hooks/index.ts
Normal file
2
apps/web/components/Form/hooks/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './socket';
|
||||
export * from './token';
|
||||
11
apps/web/components/Form/hooks/socket.tsx
Normal file
11
apps/web/components/Form/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 };
|
||||
}
|
||||
99
apps/web/components/Form/hooks/telegram.ts
Normal file
99
apps/web/components/Form/hooks/telegram.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import type { FormData } from '../lib/types';
|
||||
import { useSocket } from './socket';
|
||||
import { redirect } from '@/components/Form/lib/utils';
|
||||
import { ERROR_INVALID_CREDENTIALS, ERROR_SERVER } from '@/constants/errors';
|
||||
import { FormStateContext } from '@/context/form-state';
|
||||
import type { TelegramUrlResponse } from '@/types/error';
|
||||
import type { LdapUser } from '@/types/user';
|
||||
import axios, { isAxiosError } from 'axios';
|
||||
import { useContext, useEffect } from 'react';
|
||||
|
||||
export function useLogin() {
|
||||
const { dispatch } = useContext(FormStateContext);
|
||||
|
||||
function handleLogin(data: FormData) {
|
||||
axios
|
||||
.post<LdapUser>('/login', data)
|
||||
.then((res) => {
|
||||
dispatch({
|
||||
payload: {
|
||||
step: 'telegram',
|
||||
user: res.data,
|
||||
},
|
||||
type: 'set-step',
|
||||
});
|
||||
})
|
||||
.catch(() =>
|
||||
dispatch({
|
||||
payload: { error: ERROR_INVALID_CREDENTIALS },
|
||||
type: 'set-error',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return { handleLogin };
|
||||
}
|
||||
|
||||
export function useTelegramLogin() {
|
||||
const { dispatch } = useContext(FormStateContext);
|
||||
|
||||
function handleTelegramLogin() {
|
||||
axios
|
||||
.post<LdapUser>('/login-telegram')
|
||||
.then(() => {
|
||||
dispatch({
|
||||
payload: {
|
||||
step: 'telegram-login',
|
||||
},
|
||||
type: 'set-step',
|
||||
});
|
||||
})
|
||||
.catch((error_) => {
|
||||
let error = ERROR_SERVER;
|
||||
|
||||
if (isAxiosError<TelegramUrlResponse>(error_) && error_.response?.data?.message) {
|
||||
error = error_.response?.data?.message;
|
||||
}
|
||||
|
||||
return dispatch({
|
||||
payload: { error },
|
||||
type: 'set-error',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { handleTelegramLogin };
|
||||
}
|
||||
|
||||
export function useTelegramConfirm() {
|
||||
const {
|
||||
dispatch,
|
||||
state: { step },
|
||||
} = 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]);
|
||||
}
|
||||
28
apps/web/components/Form/hooks/token.ts
Normal file
28
apps/web/components/Form/hooks/token.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { redirect } from '@/components/Form/lib/utils';
|
||||
import { ERROR_SERVER } from '@/constants/errors';
|
||||
import { FormStateContext } from '@/context/form-state';
|
||||
import axios from 'axios';
|
||||
import { useContext, useEffect } from 'react';
|
||||
|
||||
export function useRefreshToken() {
|
||||
const {
|
||||
dispatch,
|
||||
state: { step, user },
|
||||
} = useContext(FormStateContext);
|
||||
|
||||
function handleRefreshToken() {
|
||||
axios
|
||||
.get('/refresh-token')
|
||||
.then(() => redirect())
|
||||
.catch(() =>
|
||||
dispatch({
|
||||
payload: { error: ERROR_SERVER, user: undefined },
|
||||
type: 'set-error',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (step === 'login' && user) handleRefreshToken();
|
||||
}, []);
|
||||
}
|
||||
2
apps/web/components/Form/index.tsx
Normal file
2
apps/web/components/Form/index.tsx
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './default-form';
|
||||
export * from './telegram-form';
|
||||
55
apps/web/components/Form/lib/buttons.module.scss
Normal file
55
apps/web/components/Form/lib/buttons.module.scss
Normal file
@ -0,0 +1,55 @@
|
||||
.button-submit {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.button-telegram {
|
||||
@extend .button-submit;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-transform: none;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
animation: colorTransition 1s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.button-telegram {
|
||||
img {
|
||||
margin: 0;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes colorTransition {
|
||||
0% {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
100% {
|
||||
background-color: #54a9eb;
|
||||
}
|
||||
}
|
||||
|
||||
.button-telegram-icon {
|
||||
filter: brightness(0) invert(1);
|
||||
margin: 0 !important;
|
||||
margin-right: 13px !important;
|
||||
margin-left: none !important;
|
||||
}
|
||||
|
||||
.spinner-icon {
|
||||
filter: brightness(0) invert(1);
|
||||
fill: var(--color-primary);
|
||||
margin: 0 !important;
|
||||
margin-right: 6px !important;
|
||||
}
|
||||
|
||||
.loading-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
56
apps/web/components/Form/lib/buttons.tsx
Normal file
56
apps/web/components/Form/lib/buttons.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import styles from './buttons.module.scss';
|
||||
import Spinner from '@/public/assets/animated/90-ring.svg';
|
||||
import TelegramIcon from '@/public/assets/images/telegram.svg?url';
|
||||
import Image from 'next/image';
|
||||
import type { ButtonHTMLAttributes } from 'react';
|
||||
|
||||
type Props = ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
export function ButtonLogin(props: Props) {
|
||||
return (
|
||||
<button className={styles['button-submit']} type="submit" {...props}>
|
||||
Войти
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ButtonLoading(props: Props) {
|
||||
return (
|
||||
<button disabled type="button" className={styles['button-submit']} {...props}>
|
||||
<div className={styles['loading-wrapper']}>
|
||||
<Spinner alt="spinner" className={styles['spinner-icon']} />
|
||||
Подождите...
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ButtonTelegram(props: Props) {
|
||||
return (
|
||||
<button type="submit" className={styles['button-telegram']} {...props}>
|
||||
<Image
|
||||
className={styles['button-telegram-icon']}
|
||||
src={TelegramIcon}
|
||||
width={24}
|
||||
height={22}
|
||||
alt="Telegram icon"
|
||||
/>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ButtonTelegramLogin(props: Props) {
|
||||
return (
|
||||
<button disabled type="submit" className={styles['button-telegram']} {...props}>
|
||||
<Image
|
||||
className={styles['button-telegram-icon']}
|
||||
src={TelegramIcon}
|
||||
width={24}
|
||||
height={22}
|
||||
alt="Telegram icon"
|
||||
/>
|
||||
Ожидаем подтверждения...
|
||||
</button>
|
||||
);
|
||||
}
|
||||
7
apps/web/components/Form/lib/types.ts
Normal file
7
apps/web/components/Form/lib/types.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export type FormData = {
|
||||
readonly login: string;
|
||||
readonly password: string;
|
||||
};
|
||||
export type FormProps = {
|
||||
readonly onSubmit: (data: FormData) => void;
|
||||
};
|
||||
10
apps/web/components/Form/lib/utils.ts
Normal file
10
apps/web/components/Form/lib/utils.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { publicRuntimeConfig } from '@/config/runtime';
|
||||
|
||||
const { APP_BASE_PATH } = publicRuntimeConfig;
|
||||
|
||||
export function redirect() {
|
||||
const redirectUrl =
|
||||
(window.location.pathname.replace(APP_BASE_PATH, '') || '/') + (window.location.search || '');
|
||||
|
||||
window.location.replace(redirectUrl);
|
||||
}
|
||||
43
apps/web/components/Form/telegram-form.tsx
Normal file
43
apps/web/components/Form/telegram-form.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { BaseForm } from './base-form';
|
||||
import { useLogin, useTelegramConfirm, useTelegramLogin } from './hooks/telegram';
|
||||
import { useRefreshToken } from './hooks/token';
|
||||
import { ButtonLoading, ButtonLogin, ButtonTelegram, ButtonTelegramLogin } from './lib/buttons';
|
||||
import { FormStateContext } from '@/context/form-state';
|
||||
import { useContext } from 'react';
|
||||
|
||||
export function TelegramForm() {
|
||||
useRefreshToken();
|
||||
const { handleLogin } = useLogin();
|
||||
const { handleTelegramLogin } = useTelegramLogin();
|
||||
useTelegramConfirm();
|
||||
|
||||
const {
|
||||
state: { step, user },
|
||||
} = useContext(FormStateContext);
|
||||
|
||||
if (step === 'login' && user) {
|
||||
return <ButtonLoading />;
|
||||
}
|
||||
|
||||
if (step === 'telegram') {
|
||||
return (
|
||||
<BaseForm onSubmit={() => handleTelegramLogin()}>
|
||||
<ButtonTelegram>Войти как {user?.displayName}</ButtonTelegram>
|
||||
</BaseForm>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'telegram-login') {
|
||||
return (
|
||||
<BaseForm onSubmit={() => {}}>
|
||||
<ButtonTelegramLogin />
|
||||
</BaseForm>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseForm onSubmit={(data) => handleLogin(data)}>
|
||||
<ButtonLogin />
|
||||
</BaseForm>
|
||||
);
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
import Form from './Form';
|
||||
import styles from './Login.module.scss';
|
||||
import Logo from '@/elements/Logo';
|
||||
|
||||
export default function Login() {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.login}>
|
||||
<Logo />
|
||||
<Form />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -22,12 +22,6 @@ $layout-breakpoint-desktop: 1680px;
|
||||
width: 100%;
|
||||
padding: 25px 10px;
|
||||
margin-bottom: 0;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: $layout-breakpoint-desktop) {
|
||||
17
apps/web/components/Login/index.jsx
Normal file
17
apps/web/components/Login/index.jsx
Normal file
@ -0,0 +1,17 @@
|
||||
import styles from './Login.module.scss';
|
||||
import { Logo } from '@/elements';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const DynamicDefaultForm = dynamic(() => import('../Form').then((m) => m.DefaultForm));
|
||||
const DynamicTelegramForm = dynamic(() => import('../Form').then((m) => m.TelegramForm));
|
||||
|
||||
export function Login({ tfa }) {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.login}>
|
||||
<Logo />
|
||||
{tfa ? <DynamicTelegramForm /> : <DynamicDefaultForm />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
apps/web/components/index.js
Normal file
2
apps/web/components/index.js
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './Form';
|
||||
export * from './Login';
|
||||
@ -1,8 +1,10 @@
|
||||
const { z } = require('zod');
|
||||
|
||||
const envSchema = z.object({
|
||||
APP_DESCRIPTION: z.string(),
|
||||
APP_BASE_PATH: z.string().optional().default(''),
|
||||
APP_DESCRIPTION: z.string(),
|
||||
TELEGRAM_BOT_URL: z.string(),
|
||||
URL_API_CHECK_AUTH: z.string().default('http://auth_api:3001/check-auth'),
|
||||
});
|
||||
|
||||
module.exports = envSchema;
|
||||
|
||||
2
apps/web/constants/errors.ts
Normal file
2
apps/web/constants/errors.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const ERROR_INVALID_CREDENTIALS = 'Неверный логин или пароль';
|
||||
export const ERROR_SERVER = 'Не удалось войти. Повторите попытку позже';
|
||||
74
apps/web/context/form-state.tsx
Normal file
74
apps/web/context/form-state.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
/* eslint-disable sonarjs/no-small-switch */
|
||||
import type { LdapUser } from '@/types/user';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { createContext, useMemo, useReducer } from 'react';
|
||||
|
||||
type State = {
|
||||
error: string | undefined;
|
||||
step: 'login' | 'telegram' | 'telegram-login';
|
||||
user: LdapUser | undefined;
|
||||
};
|
||||
|
||||
type Action = {
|
||||
payload: Partial<State>;
|
||||
type: 'set-step' | 'set-error' | 'reset-error';
|
||||
};
|
||||
|
||||
const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case 'set-step': {
|
||||
if (action.payload.step)
|
||||
return {
|
||||
...state,
|
||||
step: action.payload.step,
|
||||
user: action.payload.user || state.user,
|
||||
};
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'set-error': {
|
||||
if (action.payload.error) {
|
||||
return {
|
||||
...state,
|
||||
error: action.payload.error,
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'reset-error': {
|
||||
return {
|
||||
...state,
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
type Context = {
|
||||
dispatch: React.Dispatch<Action>;
|
||||
state: State;
|
||||
};
|
||||
|
||||
export const FormStateContext = createContext<Context>({} as Context);
|
||||
|
||||
type FormStateProviderProps = {
|
||||
readonly user?: LdapUser;
|
||||
} & PropsWithChildren;
|
||||
|
||||
export function FormStateProvider({ children, user = undefined }: FormStateProviderProps) {
|
||||
const [state, dispatch] = useReducer(reducer, {
|
||||
error: undefined,
|
||||
step: 'login',
|
||||
user,
|
||||
});
|
||||
|
||||
const value = useMemo(() => ({ dispatch, state }), [state]);
|
||||
|
||||
return <FormStateContext.Provider value={value}>{children}</FormStateContext.Provider>;
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
/* eslint-disable react/button-has-type */
|
||||
import styles from './Button.module.css';
|
||||
|
||||
type ButtonProps = JSX.IntrinsicElements['button'];
|
||||
|
||||
export default function Button({ children, ...props }: ButtonProps) {
|
||||
return (
|
||||
<button className={styles.btn} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
import styles from './Error.module.css';
|
||||
|
||||
export default function Error({ children }) {
|
||||
return <span className={styles.error}>{children}</span>;
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
import styles from './H.module.css';
|
||||
|
||||
export function H3({ children }) {
|
||||
return <h3 className={styles.h3}>{children}</h3>;
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
import styles from './Input.module.css';
|
||||
|
||||
type InputProps = JSX.IntrinsicElements['input'];
|
||||
|
||||
export default function Input(props: InputProps) {
|
||||
return <input className={styles.input} {...props} />;
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import Image from 'next/image';
|
||||
import logo from 'public/assets/images/logo-primary.svg';
|
||||
import logo from 'public/assets/images/logo-primary.svg?url';
|
||||
|
||||
export default function Logo() {
|
||||
return <Image src={logo} alt="logo" width={154} />;
|
||||
export function Logo() {
|
||||
return <Image className="logo" src={logo} alt="logo" width={154} />;
|
||||
}
|
||||
|
||||
1
apps/web/elements/index.js
Normal file
1
apps/web/elements/index.js
Normal file
@ -0,0 +1 @@
|
||||
export * from './Logo';
|
||||
@ -17,4 +17,29 @@ module.exports = {
|
||||
reactStrictMode: true,
|
||||
serverRuntimeConfig: runtimeConfig,
|
||||
swcMinify: true,
|
||||
webpack(config) {
|
||||
// Grab the existing rule that handles SVG imports
|
||||
const fileLoaderRule = config.module.rules.find((rule) => rule.test?.test?.('.svg'));
|
||||
|
||||
config.module.rules.push(
|
||||
// Reapply the existing rule, but only for svg imports ending in ?url
|
||||
{
|
||||
...fileLoaderRule,
|
||||
test: /\.svg$/i,
|
||||
resourceQuery: /url/, // *.svg?url
|
||||
},
|
||||
// Convert all other *.svg imports to React components
|
||||
{
|
||||
test: /\.svg$/i,
|
||||
issuer: fileLoaderRule.issuer,
|
||||
resourceQuery: { not: [...fileLoaderRule.resourceQuery.not, /url/] }, // exclude if *.svg?url
|
||||
use: ['@svgr/webpack'],
|
||||
}
|
||||
);
|
||||
|
||||
// Modify the file loader rule to ignore *.svg, since we have it handled now.
|
||||
fileLoaderRule.exclude = /\.svg$/i;
|
||||
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
@ -11,15 +11,19 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/montserrat": "^5.0.13",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/react": "^18.2.39",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"axios": "^1.5.1",
|
||||
"modern-normalize": "^2.0.0",
|
||||
"next": "^14.2.3",
|
||||
"radash": "^11.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.51.3",
|
||||
"sass": "^1.69.3",
|
||||
"socket.io-client": "^4.7.5",
|
||||
"typescript": "5.3.2",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
|
||||
1
apps/web/pages/account.jsx
Normal file
1
apps/web/pages/account.jsx
Normal file
@ -0,0 +1 @@
|
||||
export { default, getServerSideProps } from './ldap';
|
||||
@ -1,23 +0,0 @@
|
||||
import Login from '@/components/Login';
|
||||
import { publicRuntimeConfig } from '@/config/runtime';
|
||||
import Head from 'next/head';
|
||||
|
||||
const { APP_DESCRIPTION } = publicRuntimeConfig;
|
||||
|
||||
function PageHead() {
|
||||
return (
|
||||
<Head>
|
||||
<title>{`Вход - ${APP_DESCRIPTION}`}</title>
|
||||
<meta name="description" content={APP_DESCRIPTION} />
|
||||
</Head>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<PageHead />
|
||||
<Login />
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
apps/web/pages/ldap-tfa.jsx
Normal file
1
apps/web/pages/ldap-tfa.jsx
Normal file
@ -0,0 +1 @@
|
||||
export { default, getServerSideProps } from './ldap';
|
||||
52
apps/web/pages/ldap.jsx
Normal file
52
apps/web/pages/ldap.jsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { Login } from '@/components';
|
||||
import { publicRuntimeConfig, serverRuntimeConfig } from '@/config/runtime';
|
||||
import { FormStateProvider } from '@/context/form-state';
|
||||
import axios from 'axios';
|
||||
import Head from 'next/head';
|
||||
import { pick } from 'radash';
|
||||
|
||||
const { URL_API_CHECK_AUTH } = serverRuntimeConfig;
|
||||
const { APP_DESCRIPTION } = publicRuntimeConfig;
|
||||
|
||||
export function PageHead() {
|
||||
return (
|
||||
<Head>
|
||||
<title>{`Вход - ${APP_DESCRIPTION}`}</title>
|
||||
<meta name="description" content={APP_DESCRIPTION} />
|
||||
</Head>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page(props) {
|
||||
return (
|
||||
<FormStateProvider {...props}>
|
||||
<PageHead />
|
||||
<Login tfa={props.tfa} />
|
||||
</FormStateProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/** @type {import('next').GetServerSideProps} */
|
||||
export async function getServerSideProps({ req }) {
|
||||
const headers = pick(req.headers, ['auth-mode', 'cookie', 'refresh-token']);
|
||||
const tfa = headers['auth-mode'] === 'ldap-tfa';
|
||||
|
||||
try {
|
||||
const { data: user } = await axios.get(URL_API_CHECK_AUTH, {
|
||||
headers,
|
||||
});
|
||||
|
||||
return {
|
||||
props: {
|
||||
tfa,
|
||||
user,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
props: {
|
||||
tfa,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
1
apps/web/public/assets/animated/90-ring.svg
Normal file
1
apps/web/public/assets/animated/90-ring.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>.spinner_P7sC{transform-origin:center;animation:spinner_svv2 .75s infinite linear}@keyframes spinner_svv2{100%{transform:rotate(360deg)}}</style><path d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z" class="spinner_P7sC"/></svg>
|
||||
|
After Width: | Height: | Size: 428 B |
1
apps/web/public/assets/images/telegram.svg
Normal file
1
apps/web/public/assets/images/telegram.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg height="512" width="512" xmlns="http://www.w3.org/2000/svg"><path d="m470.4354553 45.4225006-453.6081524 175.8265381c-18.253809 8.1874695-24.4278889 24.5854034-4.4127407 33.4840851l116.3710175 37.1726685 281.3674316-174.789505c15.3625488-10.9733887 31.0910339-8.0470886 17.5573425 4.023468l-241.6571311 219.9348907-7.5913849 93.0762329c7.0313721 14.3716125 19.9055786 14.4378967 28.1172485 7.2952881l66.8582916-63.5891418 114.5050659 86.1867065c26.5942688 15.8265076 41.0652466 5.6130371 46.7870789-23.3935242l75.1055603-357.4697647c7.7979126-35.7059288-5.5005798-51.437891-39.3996277-37.7579422z"/></svg>
|
||||
|
After Width: | Height: | Size: 610 B |
@ -1,16 +1,22 @@
|
||||
.btn {
|
||||
background-color: var(--color-primary);
|
||||
font-family: Montserrat;
|
||||
button {
|
||||
border: 0;
|
||||
background-color: var(--color-primary);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-family: Montserrat;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
line-height: 2;
|
||||
height: 40px;
|
||||
outline: 0;
|
||||
outline: none;
|
||||
padding: 0.55rem 0.75rem;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
vertical-align: middle;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
3
apps/web/styles/colors.css
Normal file
3
apps/web/styles/colors.css
Normal file
@ -0,0 +1,3 @@
|
||||
.primary {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
@ -4,4 +4,7 @@
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
color: red;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@ -1 +1,8 @@
|
||||
@import 'node_modules/modern-normalize/modern-normalize.css';
|
||||
@import './input.css';
|
||||
@import './h.css';
|
||||
@import './error.css';
|
||||
@import './colors.css';
|
||||
@import './info.css';
|
||||
@import './logo.css';
|
||||
@import './button.css';
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
.h3 {
|
||||
h3 {
|
||||
color: var(--color-primary);
|
||||
font-family: Montserrat;
|
||||
font-weight: 700;
|
||||
9
apps/web/styles/info.css
Normal file
9
apps/web/styles/info.css
Normal file
@ -0,0 +1,9 @@
|
||||
.info {
|
||||
display: inline-block;
|
||||
font-family: Montserrat;
|
||||
font-size: smaller;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
.input {
|
||||
input {
|
||||
font-family: Montserrat;
|
||||
border: 1px solid rgba(0,16,61,.12);
|
||||
border: 1px solid rgba(0, 16, 61, 0.12);
|
||||
box-sizing: border-box;
|
||||
height: 40px;
|
||||
background: #fff;
|
||||
@ -10,8 +10,13 @@
|
||||
/* font-size: 15px; */
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
input::placeholder {
|
||||
color: var(--color-primary);
|
||||
filter: brightness(0.25);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
opacity: 0.8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
5
apps/web/styles/logo.css
Normal file
5
apps/web/styles/logo.css
Normal file
@ -0,0 +1,5 @@
|
||||
.logo {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
@ -26,6 +26,6 @@
|
||||
"@/*": ["*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"],
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "types/svgr.d.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
3
apps/web/types/error.ts
Normal file
3
apps/web/types/error.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export type TelegramUrlResponse = {
|
||||
message: string;
|
||||
};
|
||||
12
apps/web/types/svgr.d.ts
vendored
Normal file
12
apps/web/types/svgr.d.ts
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
declare module '*.svg' {
|
||||
import type { FC, SVGProps } from 'react';
|
||||
|
||||
const content: FC<SVGProps<SVGElement>>;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.svg?url' {
|
||||
const content: any;
|
||||
export default content;
|
||||
}
|
||||
9
apps/web/types/user.ts
Normal file
9
apps/web/types/user.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export type LdapUser = {
|
||||
department: string;
|
||||
displayName: string;
|
||||
domain: string;
|
||||
domainName: string;
|
||||
mail: string;
|
||||
position: string;
|
||||
username: string;
|
||||
};
|
||||
@ -3,8 +3,16 @@ version: '3'
|
||||
services:
|
||||
auth_web:
|
||||
build:
|
||||
args:
|
||||
- APP_BASE_PATH=${APP_BASE_PATH}
|
||||
- APP_DESCRIPTION=${APP_DESCRIPTION}
|
||||
- TELEGRAM_BOT_URL=${TELEGRAM_BOT_URL}
|
||||
context: .
|
||||
dockerfile: ./apps/web/Dockerfile
|
||||
environment:
|
||||
- APP_BASE_PATH=${APP_BASE_PATH}
|
||||
- APP_DESCRIPTION=${APP_DESCRIPTION}
|
||||
- TELEGRAM_BOT_URL=${TELEGRAM_BOT_URL}
|
||||
restart: always
|
||||
networks:
|
||||
- auth_network
|
||||
@ -27,6 +35,9 @@ services:
|
||||
- COOKIE_TOKEN_MAX_AGE=${COOKIE_TOKEN_MAX_AGE}
|
||||
- REDIS_HOST=redis
|
||||
- MONGO_HOST=mongo
|
||||
- TELEGRAM_URL_SEND_AUTH_MESSAGE=${TELEGRAM_URL_SEND_AUTH_MESSAGE}
|
||||
- TELEGRAM_URL_SEND_AUTH_LOGIN=${TELEGRAM_URL_SEND_AUTH_LOGIN}
|
||||
- TELEGRAM_URL_SEND_AUTH_PASSWORD=${TELEGRAM_URL_SEND_AUTH_PASSWORD}
|
||||
restart: always
|
||||
networks:
|
||||
- auth_network
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
"devDependencies": {
|
||||
"dotenv-cli": "^7.3.0",
|
||||
"prettier": "latest",
|
||||
"turbo": "latest"
|
||||
"turbo": "^1.12.4"
|
||||
},
|
||||
"packageManager": "pnpm@8.9.0",
|
||||
"engines": {
|
||||
|
||||
2413
pnpm-lock.yaml
generated
2413
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user