Compare commits

..

66 Commits

Author SHA1 Message Date
vchikalkin
5cde4b0385 span.error: fix overflow text 2024-07-18 21:15:27 +03:00
vchikalkin
1d8e40535e Form: fix telegram-bot link 2024-07-18 21:11:06 +03:00
vchikalkin
e0e84a7638 button: disable uppercase 2024-07-18 21:09:03 +03:00
vchikalkin
658b678d80 ws: send auth-deny and reload page 2024-07-18 20:57:59 +03:00
vchikalkin
67019e3aba apps/web: Form: show telegram link on step login-success 2024-07-18 20:32:09 +03:00
vchikalkin
40d5771845 context/form-state: add step refresh-token 2024-07-18 20:27:52 +03:00
vchikalkin
fd43833aca context/form-state: rename steps 2024-07-18 20:22:44 +03:00
vchikalkin
e41d6e3c46 show default telegram error | remove sending error from api 2024-07-18 20:18:51 +03:00
vchikalkin
cc8b59011c apps/web: pass children to buttons 2024-07-18 19:45:38 +03:00
vchikalkin
3bdfbbbfb1 apps/web: add margin-bottom for logo 2024-07-18 19:04:20 +03:00
vchikalkin
4eaf62da0b apps/web: always show telegram bot link for ldap-tfa 2024-07-18 19:02:52 +03:00
vchikalkin
2d41e403ce apps/web: fix reset error on next step 2024-07-18 18:44:09 +03:00
vchikalkin
06ced758d1 apps/web: enable wrap for error string 2024-07-18 18:07:56 +03:00
vchikalkin
5d1dba3a2f apps/web: fix login form width > vw 2024-07-18 18:00:03 +03:00
vchikalkin
712142a474 merge branch release/dyn-4251_2fa-telegram-auth 2024-07-14 17:15:29 +03:00
vchikalkin
26a7092d74 packages: upgrade @vchikalkin/eslint-config-awesome 2024-04-25 12:17:04 +03:00
vchikalkin
e8824d6b8c apps/web: replace normalize.css -> modern-normalize
upgrade next
2024-04-25 12:14:51 +03:00
vchikalkin
8dbdbd8053 docker: fix build
redis: add restart option
2024-02-17 21:37:07 +03:00
vchikalkin
ab4612ff12 apps/api: fix /reset-password 2024-01-17 17:54:25 +03:00
vchikalkin
f8c78bfa40 apps/api:add UpdateAccountDto 2024-01-17 17:45:57 +03:00
vchikalkin
01f4378e11 apps/api: add /reset-password method 2024-01-17 17:40:30 +03:00
vchikalkin
76c1e0f8d1 apps/api: refresh token (ldap mode) 2024-01-16 14:19:32 +03:00
vchikalkin
fd8837c835 apps/api: refresh account mode token 2024-01-16 14:07:51 +03:00
vchikalkin
85f1976386 docker-compose.yml: add COOKIE_TOKEN_NAME & COOKIE_TOKEN_MAX_AGE envvariables 2024-01-16 12:45:45 +03:00
vchikalkin
69ff7a8ff7 merge fix/refresh-token 2024-01-16 12:32:42 +03:00
vchikalkin
946d977db8 apps/api: check if account exists before create 2024-01-12 16:08:24 +03:00
vchikalkin
7ac3f0cc6a apps/api: update: return updated account 2024-01-12 13:54:10 +03:00
vchikalkin
0643080e68 apps/api: get-user: return data from db 2024-01-12 13:51:31 +03:00
vchikalkin
2f3f0183e5 apps/api: add /update method 2024-01-11 13:39:57 +03:00
vchikalkin
b28c5a4f3f merge experimental/migrate-to-pnpm 2023-11-28 23:47:19 +03:00
vchikalkin
1a7cf3f3c5 apps/api: read token from headers 2023-11-17 12:29:02 +03:00
vchikalkin
6e323803bd apps/api: send token after login 2023-11-17 12:21:59 +03:00
vchikalkin
741a1d69ee apps/api: pass token to Authorization header 2023-11-17 11:36:29 +03:00
vchikalkin
bca8a64efd apps/api: change route */signin -> */login 2023-11-02 12:46:22 +03:00
vchikalkin
5c2aacdb11 Revert "app/api: check if account already exists"
This reverts commit 8637ed2565eb55348bebb55ecd68cad0bada1ed1.
2023-11-02 10:10:35 +03:00
vchikalkin
8637ed2565 app/api: check if account already exists 2023-11-02 10:03:58 +03:00
vchikalkin
3bbdf1b8a7 Merge feature/ldap-account-auth-modes:
Squashed commit of the following:

commit 0cf879b38318f9b27a74ddf556ea02f49ab8ea02
Author: vchikalkin <djchikalkin@gmail.com>
Date:   Wed Nov 1 14:12:17 2023 +0300

    apps/api: add swagger

commit 5d99d2bbbc186e40bfcd8a4aa84ea57e76f8d046
Author: vchikalkin <djchikalkin@gmail.com>
Date:   Wed Nov 1 12:50:38 2023 +0300

    apps/api: rename users module -> account

commit 724d8ccf2844109593567dc004bd0338b9a5d64c
Author: vchikalkin <djchikalkin@gmail.com>
Date:   Wed Nov 1 12:29:48 2023 +0300

    apps/api: add users functional

commit 9c7665440623a7bc8780186b0f4aeb86039b7e3f
Author: vchikalkin <djchikalkin@gmail.com>
Date:   Wed Nov 1 00:21:15 2023 +0300

    docker-compose.yml: fix mongo volume path

commit bc82c05afd3b8829b48b2a956398b05ef15bd361
Author: vchikalkin <djchikalkin@gmail.com>
Date:   Wed Nov 1 00:18:37 2023 +0300

    docker-compose.yml: add mongo

commit d372007e0e841b1ca0fc89acf5bdf98c2a27fe72
Author: vchikalkin <djchikalkin@gmail.com>
Date:   Wed Nov 1 00:06:12 2023 +0300

    apps/api: move /auth method to root

commit a42aa89aec567845d26748d43fae5554e8a29e6c
Author: vchikalkin <djchikalkin@gmail.com>
Date:   Tue Oct 31 21:58:39 2023 +0300

    apps/api: move redis caching feature to ldap module

commit 01422661e82f82fd8080a760fe30428247517c9b
Author: vchikalkin <djchikalkin@gmail.com>
Date:   Tue Oct 31 17:50:55 2023 +0300

    apps/api: remove apps controller & service

commit e0f9893a1aea546839086e84940adb820583be27
Author: vchikalkin <djchikalkin@gmail.com>
Date:   Tue Oct 31 17:49:26 2023 +0300

    apps/api: rename authModule -> ldapModule

commit f1114cb703c4759d2b2b899031182adace81f898
Author: vchikalkin <djchikalkin@gmail.com>
Date:   Tue Oct 31 17:29:01 2023 +0300

    apps/api: remove ldap module and replace with utils

commit a8179a324a23e4553ce0a58de02ad303ce49ca1e
Author: vchikalkin <djchikalkin@gmail.com>
Date:   Tue Oct 31 16:30:15 2023 +0300

    apps/api: add CRUD account feature
2023-11-01 14:22:09 +03:00
vchikalkin
7b73758d70 apps/api: add cookie options (httpOnly, secure) 2023-10-30 16:39:02 +03:00
vchikalkin
79d8e89874 docker-compose.yml: make auth_network external 2023-10-30 14:57:52 +03:00
vchikalkin
426d9c80a1 Evo.Auth as single service: pt.2 2023-10-30 13:28:34 +03:00
vchikalkin
dc40b71faa Evo.Auth as single service: pt.1 2023-10-30 12:50:16 +03:00
vchikalkin
d63a6ed199 .env: add variable COOKIE_TOKEN_NAME 2023-10-24 12:40:07 +03:00
vchikalkin
2932db30eb docker-compose.yml: fix server depends_on 2023-10-24 12:12:59 +03:00
vchikalkin
2f1dc2030c docker-compose.yml: rename auth services 2023-10-24 12:09:45 +03:00
vchikalkin
c536ba630e .env: add APPLICATION variable for nginx.conf 2023-10-24 11:49:30 +03:00
vchikalkin
9085fa39c8 docker-compose.yml: add network app_network 2023-10-18 13:51:23 +03:00
vchikalkin
18e28c52e0 package.json: change project name 2023-10-18 13:21:00 +03:00
vchikalkin
a683bc30cc config: add include custom upstream and location 2023-10-18 13:17:34 +03:00
vchikalkin
43126e3425 NOW THE NAME OF THE PROJECT IS EVO.AUTH 2023-10-18 12:06:20 +03:00
vchikalkin
68fc67913f docker-compose.yml: revert api environment 2023-10-16 18:06:34 +03:00
vchikalkin
b3641554e1 [2] fix build 2023-10-16 18:01:22 +03:00
vchikalkin
80c57e6440 fix build 2023-10-16 17:55:50 +03:00
vchikalkin
e8772073a6 apps/api: downgrade typescript version 2023-10-16 17:42:59 +03:00
vchikalkin
9dc49758fc .env: fix web app variables names 2023-10-16 17:17:16 +03:00
vchikalkin
45d30e1263 update docker-compose.yml & .env 2023-10-16 12:32:44 +03:00
vchikalkin
e6ff4ab199 apps/api: check and pass env variables 2023-10-16 12:15:27 +03:00
vchikalkin
27ad1e96dd apps/web: check and pass env variables
convert next.config to mjs
2023-10-15 15:22:58 +03:00
vchikalkin
ef071bbd8a apps/api: downgrade ldap-authentication@2.3.1 2023-10-14 17:10:18 +03:00
vchikalkin
2a840c1949 apps/web: upgrade packages 2023-10-14 16:11:06 +03:00
vchikalkin
70d1a1b10c apps/api: upgrade packages 2023-10-14 15:57:45 +03:00
vchikalkin
993126cfa5 apps/web: fix replace location after login 2023-10-14 14:46:11 +03:00
vchikalkin
490fdef2ce example/nginx.conf: fix 2023-10-13 12:23:14 +03:00
vchikalkin
9e890044b5 example/nginx.conf: update 2023-10-13 12:20:37 +03:00
vchikalkin
1423575379 apps/web: upgrade next.js 2023-10-13 12:16:28 +03:00
vchikalkin
fd5972f17a apps/api: fix update token 2023-10-13 12:15:52 +03:00
vchikalkin
6be2af972c merge project/eslint-rules 2023-08-01 15:23:30 +03:00
124 changed files with 13225 additions and 14955 deletions

17
.env
View File

@ -1,17 +0,0 @@
COMPOSE_PROJECT_NAME=
NETWORK_NAME=
WEB_APP_BASE_PATH=/login
WEB_APP_TITLE=
WEB_APP_DESCRIPTION=
LDAP_BIND_DN=
LDAP_BIND_CREDENTIALS=
LDAP_DOMAIN=
LDAP_URL=
LDAP_BASE=
LDAP_ATTRIBUTE=
API_SECRET=
API_TOKEN_TTL=
API_CACHE_TTL=

6
.eslintignore Normal file
View File

@ -0,0 +1,6 @@
node_modules
dist
*.config.*
next-env.d.ts
.next
.eslintrc.js

1
.npmrc Normal file
View File

@ -0,0 +1 @@
auto-install-peers = true

View File

@ -13,9 +13,8 @@
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll": true, "source.fixAll": "explicit",
"source.fixAll.eslint": true, "source.fixAll.eslint": "explicit"
"source.removeUnusedImports": true
}, },
"workbench.editor.labelFormat": "short", "workbench.editor.labelFormat": "short",
"eslint.workingDirectories": [ "eslint.workingDirectories": [
@ -29,5 +28,6 @@
"typescriptreact", "typescriptreact",
"yaml" "yaml"
], ],
"eslint.lintTask.enable": true "eslint.lintTask.enable": true,
"editor.inlineSuggest.showToolbar": "always"
} }

View File

@ -1,24 +1,32 @@
# Turborepo starter # Turborepo starter
This is an official Yarn v1 starter turborepo. This is an official starter Turborepo.
## Using this example
Run the following command:
```sh
npx create-turbo@latest
```
## What's inside? ## What's inside?
This turborepo uses [Yarn](https://classic.yarnpkg.com/) as a package manager. It includes the following packages/apps: This Turborepo includes the following packages/apps:
### Apps and Packages ### Apps and Packages
- `docs`: a [Next.js](https://nextjs.org/) app - `docs`: a [Next.js](https://nextjs.org/) app
- `web`: another [Next.js](https://nextjs.org/) app - `web`: another [Next.js](https://nextjs.org/) app
- `ui`: a stub React component library shared by both `web` and `docs` applications - `@repo/ui`: a stub React component library shared by both `web` and `docs` applications
- `eslint-config-custom`: `eslint` configurations (includes `eslint-config-next` and `eslint-config-prettier`) - `@repo/eslint-config`: `eslint` configurations (includes `eslint-config-next` and `eslint-config-prettier`)
- `tsconfig`: `tsconfig.json`s used throughout the monorepo - `@repo/typescript-config`: `tsconfig.json`s used throughout the monorepo
Each package/app is 100% [TypeScript](https://www.typescriptlang.org/). Each package/app is 100% [TypeScript](https://www.typescriptlang.org/).
### Utilities ### Utilities
This turborepo has some additional tools already setup for you: This Turborepo has some additional tools already setup for you:
- [TypeScript](https://www.typescriptlang.org/) for static type checking - [TypeScript](https://www.typescriptlang.org/) for static type checking
- [ESLint](https://eslint.org/) for code linting - [ESLint](https://eslint.org/) for code linting
@ -30,7 +38,7 @@ To build all apps and packages, run the following command:
``` ```
cd my-turborepo cd my-turborepo
yarn run build pnpm build
``` ```
### Develop ### Develop
@ -39,7 +47,7 @@ To develop all apps and packages, run the following command:
``` ```
cd my-turborepo cd my-turborepo
yarn run dev pnpm dev
``` ```
### Remote Caching ### Remote Caching
@ -55,7 +63,7 @@ npx turbo login
This will authenticate the Turborepo CLI with your [Vercel account](https://vercel.com/docs/concepts/personal-accounts/overview). This will authenticate the Turborepo CLI with your [Vercel account](https://vercel.com/docs/concepts/personal-accounts/overview).
Next, you can link your Turborepo to your Remote Cache by running the following command from the root of your turborepo: Next, you can link your Turborepo to your Remote Cache by running the following command from the root of your Turborepo:
``` ```
npx turbo link npx turbo link
@ -65,7 +73,7 @@ npx turbo link
Learn more about the power of Turborepo: Learn more about the power of Turborepo:
- [Pipelines](https://turbo.build/repo/docs/core-concepts/monorepos/running-tasks) - [Tasks](https://turbo.build/repo/docs/core-concepts/monorepos/running-tasks)
- [Caching](https://turbo.build/repo/docs/core-concepts/caching) - [Caching](https://turbo.build/repo/docs/core-concepts/caching)
- [Remote Caching](https://turbo.build/repo/docs/core-concepts/remote-caching) - [Remote Caching](https://turbo.build/repo/docs/core-concepts/remote-caching)
- [Filtering](https://turbo.build/repo/docs/core-concepts/monorepos/filtering) - [Filtering](https://turbo.build/repo/docs/core-concepts/monorepos/filtering)

View File

@ -1,4 +0,0 @@
SECRET=secret
TOKEN_TTL=3600
CACHE_TTL=3600
COOKIE_TOKEN_NAME=token

View File

@ -1,2 +0,0 @@
node_modules
dist

View File

@ -1,12 +1,13 @@
module.exports = { const { createConfig } = require('@vchikalkin/eslint-config-awesome');
root: true,
extends: [ module.exports = createConfig('typescript', {
'@vchikalkin/eslint-config-awesome/typescript/config',
'@vchikalkin/eslint-config-awesome/typescript/rules',
],
parserOptions: { parserOptions: {
project: './tsconfig.json', project: './tsconfig.json',
tsconfigRootDir: __dirname, tsconfigRootDir: __dirname,
sourceType: 'module',
}, },
}; ignorePatterns: ['*.config.js', '.eslintrc.js'],
rules: {
'import/no-duplicates': 'off',
'import/consistent-type-specifier-style': 'off',
},
});

View File

@ -1,18 +1,22 @@
# The web Dockerfile is copy-pasted into our main docs at /docs/handbook/deploying-with-docker. # This Dockerfile is copy-pasted into our main docs at /docs/handbook/deploying-with-docker.
# Make sure you update this Dockerfile, the Dockerfile in the web workspace and copy that over to Dockerfile in the docs. # Make sure you update both files!
FROM node:16-alpine AS builder FROM node:alpine AS builder
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. # 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 add --no-cache libc6-compat
RUN apk update RUN apk update
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
RUN yarn global add turbo RUN pnpm add -g turbo@1.12.4 dotenv-cli
COPY . . COPY . .
RUN turbo prune --scope=api --docker RUN turbo prune --scope=api --docker
# Add lockfile and package.json's of isolated subworkspace # Add lockfile and package.json's of isolated subworkspace
FROM node:16-alpine AS installer FROM node:alpine AS installer
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 add --no-cache libc6-compat
RUN apk update RUN apk update
WORKDIR /app WORKDIR /app
@ -20,15 +24,16 @@ WORKDIR /app
# First install dependencies (as they change less often) # First install dependencies (as they change less often)
COPY .gitignore .gitignore COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ . COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/yarn.lock ./yarn.lock COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN yarn install COPY --from=builder /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
RUN pnpm install
# Build the project and its dependencies # Build the project and its dependencies
COPY --from=builder /app/out/full/ . COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json COPY turbo.json turbo.json
RUN yarn turbo run build --filter=api... RUN pnpm dotenv -e .env turbo run build --filter=api...
FROM node:16-alpine AS runner FROM node:alpine AS runner
WORKDIR /app WORKDIR /app
# Don't run production as root # Don't run production as root

View File

@ -1,5 +1,20 @@
{ {
"$schema": "https://json.schemastore.org/nest-cli", "$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics", "collection": "@nestjs/schematics",
"sourceRoot": "src" "sourceRoot": "src",
"compilerOptions": {
"plugins": [
{
"name": "@nestjs/swagger/plugin",
"options": {
"dtoFileNameSuffix": [".entity.ts", ".dto.ts"],
"controllerFileNameSuffix": [".controller.ts"],
"classValidatorShim": true,
"dtoKeyOfComment": "description",
"controllerKeyOfComment": "description",
"introspectComments": true
}
}
]
}
} }

View File

@ -22,42 +22,57 @@
"lint:fix": "eslint --fix" "lint:fix": "eslint --fix"
}, },
"dependencies": { "dependencies": {
"@fastify/cookie": "^8.0.0", "@fastify/cookie": "^9.1.0",
"@nestjs/cli": "^9.0.0", "@fastify/static": "^6.12.0",
"@nestjs/common": "^9.0.0", "@nestjs/cache-manager": "^2.1.0",
"@nestjs/config": "^2.2.0", "@nestjs/cli": "^10.1.18",
"@nestjs/core": "^9.0.0", "@nestjs/common": "^10.2.7",
"@nestjs/jwt": "^9.0.0", "@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.2.7",
"@nestjs/jwt": "^10.1.1",
"@nestjs/mapped-types": "*", "@nestjs/mapped-types": "*",
"@nestjs/platform-express": "^9.0.0", "@nestjs/mongoose": "^10.0.1",
"@nestjs/platform-fastify": "^9.0.11", "@nestjs/platform-fastify": "^10.2.7",
"cache-manager": "^4.1.0", "@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", "cache-manager-ioredis": "^2.1.0",
"ldap-authentication": "^2.3.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"ldap-authentication": "2.3.1",
"mongoose": "^7.6.3",
"radash": "^11.0.0",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2", "rimraf": "^5.0.5",
"rxjs": "^7.2.0" "rxjs": "^7.8.1",
"socket.io": "^4.7.5",
"zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/schematics": "^9.0.0", "@nestjs/schematics": "^10.0.2",
"@nestjs/testing": "^9.0.0", "@nestjs/testing": "^10.2.7",
"@types/cache-manager": "^4.0.1", "@types/bcrypt": "^5.0.1",
"@types/cache-manager": "^4.0.3",
"@types/ioredis": "^4.28.10", "@types/ioredis": "^4.28.10",
"@types/jest": "28.1.4", "@types/jest": "29.5.5",
"@types/ldap-authentication": "^2.2.0", "@types/ldap-authentication": "^2.2.1",
"@types/node": "^16.0.0", "@types/node": "^20.8.6",
"@types/supertest": "^2.0.11", "@types/supertest": "^2.0.14",
"@vchikalkin/eslint-config-awesome": "^1.1.1", "@vchikalkin/eslint-config-awesome": "^1.1.6",
"eslint": "^8.46.0", "eslint": "^8.51.0",
"jest": "28.1.2", "fastify": "4.24.3",
"prettier": "^2.3.2", "jest": "29.7.0",
"source-map-support": "^0.5.20", "prettier": "^3.0.3",
"supertest": "^6.1.3", "source-map-support": "^0.5.21",
"ts-jest": "28.0.5", "supertest": "^6.3.3",
"ts-loader": "^9.2.3", "ts-jest": "29.1.1",
"ts-node": "^10.0.0", "ts-loader": "^9.5.0",
"tsconfig-paths": "4.0.0", "ts-node": "^10.9.1",
"typescript": "^4.3.5" "tsconfig-paths": "4.2.0",
"typescript": "5.3.2"
}, },
"jest": { "jest": {
"moduleFileExtensions": [ "moduleFileExtensions": [

View File

@ -0,0 +1,175 @@
/* eslint-disable @typescript-eslint/explicit-member-accessibility */
/* eslint-disable class-methods-use-this */
/* eslint-disable import/no-extraneous-dependencies */
import { AccountService } from './account.service';
import { CreateAccountDto } from './dto/create-account.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { UpdateAccountDto } from './dto/update-account.dto';
import {
Body,
Controller,
Delete,
Get,
HttpException,
HttpStatus,
Patch,
Post,
Query,
Req,
Res,
UnauthorizedException,
} from '@nestjs/common';
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 implements BaseAuthController {
constructor(private readonly accountService: AccountService) {}
private clearCookies(req, reply) {
if (req.cookies) {
Object.keys(req.cookies).forEach((cookieName) => {
reply.clearCookie(cookieName, {
path: '/',
});
});
}
}
@Post('/create')
@ApiResponse({
status: HttpStatus.CREATED,
type: Account,
})
async create(@Body() createAccountDto: CreateAccountDto, @Res() reply: FastifyReply) {
try {
const createdAccount = await this.accountService.create(createAccountDto);
return reply.status(HttpStatus.CREATED).send(createdAccount);
} catch (error) {
throw new HttpException(error, HttpStatus.BAD_REQUEST);
}
}
@Get()
async findAll() {
return this.accountService.findAll();
}
@Delete('/delete')
@ApiResponse({
status: HttpStatus.OK,
type: Account,
})
// @ApiQuery({ name: 'username', type: CreateAccountDto['username'] })
async delete(@Query('username') username: string) {
return this.accountService.delete(username);
}
@Patch('/update')
@ApiResponse({
status: HttpStatus.OK,
type: Account,
})
async update(@Body() updateAccountDto: UpdateAccountDto, @Res() reply: FastifyReply) {
try {
const updatedAccount = await this.accountService.update(updateAccountDto);
return reply.status(HttpStatus.OK).send(updatedAccount);
} catch (error) {
throw new HttpException(error, HttpStatus.BAD_REQUEST);
}
}
@Post('/reset-password')
@ApiResponse({
status: HttpStatus.OK,
type: Account,
})
async resetPassword(@Body() resetPasswordDto: ResetPasswordDto, @Res() reply: FastifyReply) {
try {
const updatedAccount = await this.accountService.resetPassword(resetPasswordDto);
return reply.status(HttpStatus.OK).send(updatedAccount);
} catch (error) {
throw new HttpException(error, HttpStatus.BAD_REQUEST);
}
}
@Post('/login')
async login(
@Body() credentials: Credentials,
@Req() _req: FastifyRequest,
@Res() reply: FastifyReply
) {
try {
const token = await this.accountService.login(credentials);
return reply
.setCookie(env.COOKIE_TOKEN_NAME, token, cookieOptions)
.status(200)
.send({ token });
} catch {
throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED);
}
}
@Get('/logout')
async logout(@Req() req: FastifyRequest, @Res() reply: FastifyReply) {
this.clearCookies(req, reply);
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,
@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);
}
}

View File

@ -0,0 +1,14 @@
import { AccountController } from './account.controller';
import { AccountService } from './account.service';
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { Account, AccountSchema } from 'src/schemas/account.schema';
@Module({
controllers: [AccountController],
exports: [AccountService],
imports: [MongooseModule.forFeature([{ name: Account.name, schema: AccountSchema }])],
providers: [AccountService],
})
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
export class AccountModule {}

View File

@ -0,0 +1,135 @@
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 { Account } from 'src/schemas/account.schema';
import type { DecodedToken, TokenPayload } from 'src/types/jwt';
import { generatePassword } from 'src/utils/password';
@Injectable()
export class AccountService {
constructor(
private readonly jwtService: JwtService,
@InjectModel(Account.name) private accountModel: Model<Account>
) {}
public async create(createAccountDto: CreateAccountDto): Promise<Account> {
const isExist = await this.accountModel.exists({ username: createAccountDto.username }).exec();
if (isExist)
throw new BadRequestException(
`Account with username '${createAccountDto.username}' already exists`
);
Object.keys(createAccountDto).forEach((field) => {
if (['_id', '__v'].includes(field))
throw new BadRequestException(`Prop ${field} is not allowed`);
});
const password = createAccountDto.password || generatePassword();
const createdAccount = new this.accountModel({ ...createAccountDto, password });
createdAccount.save();
return { ...createdAccount.toJSON(), password };
}
public async findAll(): Promise<Account[]> {
return this.accountModel.find().exec();
}
public async delete(username: string) {
return this.accountModel.findOneAndDelete({ username }).exec();
}
public async update({ username, ...props }: UpdateAccountDto): Promise<Account> {
Object.keys(props).forEach((field) => {
if (['_id', '__v', 'password'].includes(field))
throw new BadRequestException(`Prop ${field} is not allowed`);
});
await this.accountModel.findOneAndUpdate({ username }, props).exec();
return this.accountModel.findOne({ username });
}
public async resetPassword({ username }: ResetPasswordDto): Promise<Account> {
const account = await this.accountModel.findOne({ username });
if (!account) throw new UnauthorizedException('Account not found');
const new_password = generatePassword();
await this.accountModel.findOneAndUpdate({ username }, { password: new_password }).exec();
return { password: new_password, username };
}
public async login({ login, password }: Credentials) {
try {
const account = await this.accountModel.findOne({ username: login });
if (!account) {
throw new UnauthorizedException('Account not found');
}
const passwordMatch = await bcrypt.compare(password, account.password);
if (!passwordMatch) {
throw new UnauthorizedException('Invalid login credentials');
}
const payload: TokenPayload = {
username: login,
...omit(account.toJSON(), ['password', '_id', '__v']),
};
return this.jwtService.sign(payload);
} catch (error) {
throw new UnauthorizedException(error);
}
}
public async refreshToken(token: string) {
try {
const { username } = this.jwtService.verify<DecodedToken>(token, { ignoreExpiration: true });
const account = await this.accountModel.findOne({ username });
if (!account) {
throw new UnauthorizedException('Account not found');
}
const payload: TokenPayload = {
username,
...omit(account.toJSON(), ['password', '_id', '__v']),
};
return this.jwtService.sign(payload);
} catch (error) {
throw new UnauthorizedException(error);
}
}
public async getUser(token: string, options?: JwtVerifyOptions) {
try {
const { username } = this.jwtService.verify<DecodedToken>(token, options);
return this.accountModel.findOne({
username,
});
} catch {
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);
}
}
}

View File

@ -0,0 +1,17 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
export class CreateAccountDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
public readonly username: string;
@ApiPropertyOptional({})
@IsString()
@MinLength(10)
@IsOptional()
public readonly password: string;
readonly [key: string]: unknown;
}

View File

@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class ResetPasswordDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
public readonly username: string;
}

View File

@ -0,0 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class UpdateAccountDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
public readonly username: string;
readonly [key: string]: unknown;
}

View File

@ -1,23 +0,0 @@
import { AppController } from './app.controller';
import { AppService } from './app.service';
import type { TestingModule } from '@nestjs/testing';
import { Test } from '@nestjs/testing';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View File

@ -1,12 +1,51 @@
import { AppService } from './app.service'; import { AppService } from './app.service';
import { Controller, Get } from '@nestjs/common'; 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, ApiResponse } from '@nestjs/swagger';
import { FastifyReply, FastifyRequest } from 'fastify';
@Controller() @Controller()
@ApiExcludeController()
export class AppController { export class AppController {
constructor(private readonly appService: AppService) {} constructor(private readonly appService: AppService) {}
@Get() @Get('auth')
getHello(): string { public async auth(
return this.appService.getHello(); @Req() req: FastifyRequest,
@Res() reply: FastifyReply,
@AuthToken() token: string,
@AuthParams() { authMode }: Params
) {
try {
const { aud } = this.appService.checkToken(token);
const originalUri = req.headers['x-original-uri'];
if (
authMode === 'ldap-tfa' &&
aud === 'auth' &&
!['/auth', '/login', '/socket.io'].some((x) => originalUri.includes(x))
) {
return reply.status(HttpStatus.UNAUTHORIZED).send();
}
reply.header('Authorization', `Bearer ${token}`);
return reply.send();
} catch (error) {
return reply.status(HttpStatus.UNAUTHORIZED).send({ message: error.message });
}
}
@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`);
} }
} }

View File

@ -1,11 +1,16 @@
import { AccountModule } from './account/account.module';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AppService } from './app.service'; import { AppService } from './app.service';
import { AuthModule } from './auth/auth.module'; import { env } from './config/env';
import { LdapModule } from './ldap/ldap.module'; import { LdapModule } from './ldap/ldap.module';
import { UsersModule } from './users/users.module'; import { LdapTfaModule } from './ldap-tfa/ldap-tfa.module';
import { CacheModule } from '@nestjs/cache-manager';
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { MongooseModule } from '@nestjs/mongoose';
import * as redisStore from 'cache-manager-ioredis';
import type { RedisOptions } from 'ioredis';
@Global() @Global()
@Module({ @Module({
@ -16,14 +21,22 @@ import { JwtModule } from '@nestjs/jwt';
isGlobal: true, isGlobal: true,
}), }),
JwtModule.register({ JwtModule.register({
secret: process.env.SECRET, secret: env.API_SECRET,
signOptions: { signOptions: {
expiresIn: process.env.TOKEN_TTL, expiresIn: env.API_TOKEN_TTL,
}, },
}), }),
AuthModule,
UsersModule,
LdapModule, 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], providers: [AppService],
}) })

View File

@ -1,8 +1,19 @@
import type { DecodedToken } from './types/jwt';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { omit } from 'radash';
@Injectable() @Injectable()
export class AppService { export class AppService {
getHello(): string { constructor(private readonly jwtService: JwtService) {}
return 'Hello World!';
public checkToken(token: string) {
return this.jwtService.verify<DecodedToken>(token);
}
public refreshToken(token: string) {
const payload = this.jwtService.decode<DecodedToken>(token);
return this.jwtService.sign(omit(payload, ['iat', 'exp']));
} }
} }

View File

@ -1,21 +0,0 @@
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import type { TestingModule } from '@nestjs/testing';
import { Test } from '@nestjs/testing';
describe('AuthController', () => {
let controller: AuthController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
providers: [AuthService],
}).compile();
controller = module.get<AuthController>(AuthController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -1,70 +0,0 @@
/* eslint-disable class-methods-use-this */
/* eslint-disable import/no-extraneous-dependencies */
import { AuthService } from './auth.service';
import { COOKIE_TOKEN_NAME } from './lib/constants';
import { Credentials } from './types/request';
import { Body, Controller, Get, HttpException, HttpStatus, Post, Req, Res } from '@nestjs/common';
import { FastifyReply, FastifyRequest } from 'fastify';
@Controller()
export class AuthController {
cookieOptions: { maxAge: number; path: string };
constructor(private readonly authService: AuthService) {
this.cookieOptions = {
maxAge: Number.parseInt(process.env.TOKEN_TTL, 10),
path: '/',
};
}
private clearCookies(req, reply) {
if (req.cookies) {
Object.keys(req.cookies).forEach((cookieName) => {
reply.clearCookie(cookieName, {
path: '/',
});
});
}
}
@Post('/signin')
async login(@Body() credentials: Credentials, @Res() reply: FastifyReply) {
const { login, password } = credentials;
try {
const token = await this.authService.login(login, password);
return await reply.setCookie(COOKIE_TOKEN_NAME, token, this.cookieOptions).status(200).send();
} catch {
throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED);
}
}
@Get('/logout')
async logout(@Req() req: FastifyRequest, @Res() reply: FastifyReply) {
const token = req.cookies[COOKIE_TOKEN_NAME];
if (token) await this.authService.logout(token);
this.clearCookies(req, reply);
return reply.status(302).redirect('/login');
}
@Get('/auth')
async auth(@Req() req: FastifyRequest, @Res() reply: FastifyReply) {
const token = req.cookies[COOKIE_TOKEN_NAME];
try {
this.authService.checkToken(token);
return await reply.send();
} catch (error) {
if (error.name === 'TokenExpiredError') {
const newToken = this.authService.refreshToken(token);
return await reply.setCookie(COOKIE_TOKEN_NAME, newToken, this.cookieOptions).send();
}
throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED);
}
}
}

View File

@ -1,13 +0,0 @@
import { LdapModule } from '../ldap/ldap.module';
import { UsersModule } from '../users/users.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { Module } from '@nestjs/common';
@Module({
controllers: [AuthController],
imports: [UsersModule, LdapModule],
providers: [AuthService],
})
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
export class AuthModule {}

View File

@ -1,19 +0,0 @@
import { AuthService } from './auth.service';
import type { TestingModule } from '@nestjs/testing';
import { Test } from '@nestjs/testing';
describe('AuthService', () => {
let service: AuthService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [AuthService],
}).compile();
service = module.get<AuthService>(AuthService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -1,43 +0,0 @@
import { LdapService } from '../ldap/ldap.service';
import { UsersCache } from '../users/users.cache';
import type { DecodedToken, TokenPayload } from './types/jwt';
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class AuthService {
constructor(
private readonly ldapService: LdapService,
private readonly usersCache: UsersCache,
private readonly jwtService: JwtService
) {}
public async login(login: string, password: string) {
const user = await this.ldapService.authenticate(login, password);
const { username } = user;
await this.usersCache.addUser(username, user);
const payload: TokenPayload = {
domain: process.env.LDAP_DOMAIN,
username,
};
return this.jwtService.sign(payload);
}
public async logout(token: string) {
const { username } = this.jwtService.decode(token) as DecodedToken;
await this.usersCache.deleteUser(username);
}
public checkToken(token: string) {
this.jwtService.verify(token);
}
public refreshToken(token: string) {
const { exp, iat, ...payload } = this.jwtService.decode(token) as DecodedToken;
return this.jwtService.sign(payload);
}
}

View File

@ -1 +0,0 @@
export const COOKIE_TOKEN_NAME = 'token';

View File

@ -1,9 +0,0 @@
export type TokenPayload = {
username: string;
domain: string;
};
export type DecodedToken = {
exp: number;
iat: number;
} & TokenPayload;

View File

@ -1,4 +0,0 @@
export type Credentials = {
login: string;
password: string;
};

View File

@ -0,0 +1,9 @@
import type { CookieSerializeOptions } from '@fastify/cookie';
import { env } from 'src/config/env';
export const cookieOptions: CookieSerializeOptions = {
httpOnly: true,
maxAge: env.COOKIE_TOKEN_MAX_AGE,
path: '/',
secure: true,
};

View File

@ -0,0 +1,3 @@
import envSchema from './schema/env';
export const env = envSchema.parse(process.env);

View File

@ -0,0 +1,35 @@
import { z } from 'zod';
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'),
LDAP_ATTRIBUTE: z.string(),
LDAP_BASE: z.string(),
LDAP_BIND_CREDENTIALS: z.string(),
LDAP_BIND_DN: z.string(),
LDAP_DOMAIN: z.string(),
LDAP_URL: z.string().url(),
MONGO_HOST: z.string(),
MONGO_PORT: z
.string()
.transform((val) => Number.parseInt(val, 10))
.default('27017'),
REDIS_HOST: z.string(),
REDIS_PORT: z
.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;

View 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;
});

View 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;
});

View File

@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class Credentials {
@ApiProperty()
@IsString()
@IsNotEmpty()
readonly login: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
readonly password: string;
}

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

@ -0,0 +1,116 @@
/* 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(() => reply.status(500).send());
}
@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) {
this.ldapTfaGateway.notify('auth-deny', query);
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();
}
}

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

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

View 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);
}
}
}

View File

@ -0,0 +1,118 @@
/* eslint-disable @typescript-eslint/explicit-member-accessibility */
/* eslint-disable class-methods-use-this */
/* eslint-disable import/no-extraneous-dependencies */
import { Credentials } from '../dto/credentials';
import { LdapService } from './ldap.service';
import type { CookieSerializeOptions } from '@fastify/cookie';
import {
Body,
Controller,
Get,
HttpException,
HttpStatus,
Post,
Req,
Res,
UnauthorizedException,
} from '@nestjs/common';
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 implements BaseAuthController {
cookieOptions: CookieSerializeOptions;
constructor(protected readonly ldapService: LdapService) {}
private clearCookies(req, reply) {
if (req.cookies) {
Object.keys(req.cookies).forEach((cookieName) => {
reply.clearCookie(cookieName, {
path: '/',
});
});
}
}
@Post('/login')
@ApiResponse({
status: HttpStatus.OK,
})
async login(
@Body() credentials: Credentials,
@Req() _req: FastifyRequest,
@Res() reply: FastifyReply
) {
try {
const token = await this.ldapService.login(credentials);
return reply.setCookie(env.COOKIE_TOKEN_NAME, token, cookieOptions).status(200).send();
} catch {
throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED);
}
}
@Get('/logout')
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('/');
}
@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')
@ApiResponse({
status: HttpStatus.OK,
type: User,
})
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);
}
}

View File

@ -1,8 +1,11 @@
import { LdapController } from './ldap.controller';
import { LdapService } from './ldap.service'; import { LdapService } from './ldap.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@Module({ @Module({
controllers: [LdapController],
exports: [LdapService], exports: [LdapService],
imports: [],
providers: [LdapService], providers: [LdapService],
}) })
// eslint-disable-next-line @typescript-eslint/no-extraneous-class // eslint-disable-next-line @typescript-eslint/no-extraneous-class

View File

@ -1,19 +0,0 @@
import { LdapService } from './ldap.service';
import type { TestingModule } from '@nestjs/testing';
import { Test } from '@nestjs/testing';
describe('LdapService', () => {
let service: LdapService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [LdapService],
}).compile();
service = module.get<LdapService>(LdapService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -1,43 +1,94 @@
import type { User } from '../types/user'; import type { DecodedToken, TokenPayload } from '../types/jwt';
import type { LdapUser } from './types/user'; import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Injectable } from '@nestjs/common'; import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import type { AuthenticationOptions } from 'ldap-authentication'; import type { JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt';
import { authenticate } from 'ldap-authentication'; import { JwtService } from '@nestjs/jwt';
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';
@Injectable() @Injectable()
export class LdapService { export class LdapService {
async authenticate(login: string, password?: string) { constructor(
const options: AuthenticationOptions = { @Inject(CACHE_MANAGER) protected readonly cacheManager: Cache,
adminDn: process.env.LDAP_BIND_DN, protected readonly jwtService: JwtService
adminPassword: process.env.LDAP_BIND_CREDENTIALS, ) {}
ldapOpts: {
url: process.env.LDAP_URL,
},
userPassword: password,
userSearchBase: process.env.LDAP_BASE,
username: login,
usernameAttribute: process.env.LDAP_ATTRIBUTE,
verifyUserExists: password === undefined,
};
const { public async login(credentials: Credentials, options?: JwtSignOptions) {
displayName, try {
department, const user = await ldap.authenticate(credentials.login, credentials.password);
title, const { username } = user;
mail,
sAMAccountName: username,
}: LdapUser = await authenticate(options);
const user: User = { await this.cacheManager.set(username, user);
department,
displayName,
domain: process.env.LDAP_DOMAIN,
domainName: `${process.env.LDAP_DOMAIN}\\${username}`,
mail,
position: title,
username,
};
return user; const payload: TokenPayload = {
domain: env.LDAP_DOMAIN,
username,
};
return this.jwtService.sign(payload, options);
} catch (error) {
throw new UnauthorizedException(error);
}
}
public async logout(token: string) {
const { username } = this.jwtService.decode(token) as DecodedToken;
if (this.cacheManager.get(username)) {
await this.cacheManager.del(username);
}
}
public async refreshToken(token: string) {
try {
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);
const payload: TokenPayload = {
domain: env.LDAP_DOMAIN,
username,
};
return this.jwtService.sign(payload);
} catch (error) {
throw new UnauthorizedException(error);
}
}
public async getUser(token: string, options?: JwtVerifyOptions) {
try {
const { username } = this.jwtService.verify(token, options) as DecodedToken;
const cachedUser = await this.cacheManager.get<ldap.User>(username);
if (!cachedUser) {
const user = await ldap.authenticate(username);
await this.cacheManager.set(username, user);
return user;
}
return cachedUser;
} catch {
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);
}
} }
} }

View File

@ -1,9 +1,24 @@
/* eslint-disable import/no-duplicates */
/* eslint-disable unicorn/prefer-top-level-await */ /* eslint-disable unicorn/prefer-top-level-await */
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { env } from './config/env';
import { fastifyCookie } from '@fastify/cookie'; import { fastifyCookie } from '@fastify/cookie';
import type { INestApplication } from '@nestjs/common';
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import type { NestFastifyApplication } from '@nestjs/platform-fastify'; import type { NestFastifyApplication } from '@nestjs/platform-fastify';
import { FastifyAdapter } from '@nestjs/platform-fastify'; import { FastifyAdapter } from '@nestjs/platform-fastify';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
function setupOpenApi(app: INestApplication) {
const config = new DocumentBuilder()
.setTitle('Evo.Auth')
.setVersion('1.0')
// .addTag('api')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('swagger', app, document, { useGlobalPrefix: true });
}
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>( const app = await NestFactory.create<NestFastifyApplication>(
@ -14,10 +29,14 @@ async function bootstrap() {
); );
await app.register(fastifyCookie, { await app.register(fastifyCookie, {
secret: process.env.SECRET, secret: env.API_SECRET,
}); });
await app.listen(process.env.API_PORT || 3001, '0.0.0.0'); app.useGlobalPipes(new ValidationPipe({ stopAtFirstError: true }));
setupOpenApi(app);
await app.listen(env.API_PORT, '0.0.0.0');
} }
bootstrap(); bootstrap();

View File

@ -0,0 +1,51 @@
/* eslint-disable @babel/no-invalid-this */
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { ApiProperty, ApiResponseProperty } from '@nestjs/swagger';
import * as bcrypt from 'bcrypt';
import type { HydratedDocument } from 'mongoose';
export type UserDocument = HydratedDocument<Account>;
@Schema({ strict: false })
export class Account {
@ApiResponseProperty()
@ApiProperty()
@Prop({ index: { unique: true }, required: true })
public username: string;
@ApiResponseProperty()
@ApiProperty()
@Prop({ required: true })
public password: string;
readonly [key: string]: unknown;
}
export const AccountSchema = SchemaFactory.createForClass(Account);
AccountSchema.pre('save', async function (next) {
try {
if (!this.isModified('password')) return next();
const hash = await bcrypt.hash(this.password, 10);
this.password = hash;
return next();
} catch (error) {
return next(error);
}
});
AccountSchema.pre('findOneAndUpdate', async function (next) {
try {
const password = this.get('password');
if (password) {
const hash = await bcrypt.hash(password, 10);
this.set('password', hash);
}
return next();
} catch (error) {
return next(error);
}
});

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

13
apps/api/src/types/jwt.ts Normal file
View File

@ -0,0 +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;

View File

@ -1,21 +0,0 @@
import type { User } from '../types/user';
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager';
@Injectable()
export class UsersCache {
constructor(@Inject(CACHE_MANAGER) private readonly cacheManager: Cache) {}
async getUser(username: string) {
return (await this.cacheManager.get(username)) as User;
}
async addUser(username: string, user: User) {
await this.cacheManager.set(username, user);
}
async deleteUser(username: string) {
if (this.cacheManager.get(username)) {
await this.cacheManager.del(username);
}
}
}

View File

@ -1,21 +0,0 @@
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import type { TestingModule } from '@nestjs/testing';
import { Test } from '@nestjs/testing';
describe('UsersController', () => {
let controller: UsersController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [UsersController],
providers: [UsersService],
}).compile();
controller = module.get<UsersController>(UsersController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -1,20 +0,0 @@
/* eslint-disable class-methods-use-this */
/* eslint-disable import/no-extraneous-dependencies */
import { COOKIE_TOKEN_NAME } from '../auth/lib/constants';
import { UsersService } from './users.service';
import { Controller, Get, Req, Res } from '@nestjs/common';
import { FastifyReply, FastifyRequest } from 'fastify';
@Controller()
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get('/get-user')
async getUser(@Req() req: FastifyRequest, @Res() reply: FastifyReply) {
const token = req.cookies[COOKIE_TOKEN_NAME];
const user = await this.usersService.getUser(token);
return reply.send(user);
}
}

View File

@ -1,24 +0,0 @@
import { LdapModule } from '../ldap/ldap.module';
import { UsersCache } from './users.cache';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { CacheModule, Module } from '@nestjs/common';
import * as redisStore from 'cache-manager-ioredis';
import type { RedisOptions } from 'ioredis';
@Module({
controllers: [UsersController],
exports: [UsersCache],
imports: [
CacheModule.register<RedisOptions>({
host: process.env.REDIS_HOST,
port: Number.parseInt(process.env.REDIS_PORT, 10) || 6379,
store: redisStore,
ttl: Number.parseInt(process.env.CACHE_TTL, 10),
}),
LdapModule,
],
providers: [UsersService, UsersCache],
})
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
export class UsersModule {}

View File

@ -1,19 +0,0 @@
import { UsersService } from './users.service';
import type { TestingModule } from '@nestjs/testing';
import { Test } from '@nestjs/testing';
describe('UsersService', () => {
let service: UsersService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [UsersService],
}).compile();
service = module.get<UsersService>(UsersService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -1,30 +0,0 @@
import type { DecodedToken } from '../auth/types/jwt';
import { LdapService } from '../ldap/ldap.service';
import { UsersCache } from './users.cache';
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class UsersService {
constructor(
private readonly usersCache: UsersCache,
private readonly jwtService: JwtService,
private readonly ldapService: LdapService
) {}
public async getUser(token: string) {
const { username } = this.jwtService.decode(token) as DecodedToken;
const cachedUser = await this.usersCache.getUser(username);
if (!cachedUser) {
const user = await this.ldapService.authenticate(username);
await this.usersCache.addUser(username, user);
return user;
}
return cachedUser;
}
}

View File

@ -0,0 +1,3 @@
export function isTokenExpired(error: Error) {
return error.name?.toLocaleLowerCase().includes('expired');
}

View File

@ -1,3 +1,27 @@
import { ApiResponseProperty } from '@nestjs/swagger';
import type { AuthenticationOptions } from 'ldap-authentication';
import * as ldap from 'ldap-authentication';
import { env } from 'src/config/env';
export class User {
@ApiResponseProperty()
public department: string;
@ApiResponseProperty()
public displayName: string;
@ApiResponseProperty()
public domain: string;
@ApiResponseProperty()
public domainName: string;
@ApiResponseProperty()
public mail: string;
@ApiResponseProperty()
public position: string;
@ApiResponseProperty()
public username: string;
@ApiResponseProperty()
public employeeID: string;
}
export type LdapUser = { export type LdapUser = {
accountExpires: string; accountExpires: string;
badPasswordTime: string; badPasswordTime: string;
@ -61,3 +85,44 @@ export type LdapUser = {
whenChanged: string; whenChanged: string;
whenCreated: string; whenCreated: string;
}; };
const BASE_OPTIONS: AuthenticationOptions = {
adminDn: env.LDAP_BIND_DN,
adminPassword: env.LDAP_BIND_CREDENTIALS,
ldapOpts: {
url: env.LDAP_URL,
},
userSearchBase: env.LDAP_BASE,
usernameAttribute: env.LDAP_ATTRIBUTE,
};
export async function authenticate(login: string, password?: string) {
const options: AuthenticationOptions = {
...BASE_OPTIONS,
userPassword: password,
username: login,
verifyUserExists: password === undefined,
};
const {
displayName,
department,
title,
mail,
sAMAccountName: username,
employeeID,
}: LdapUser = await ldap.authenticate(options);
const user: User = {
department,
displayName,
domain: env.LDAP_DOMAIN,
domainName: `${env.LDAP_DOMAIN}\\${username}`,
employeeID,
mail,
position: title,
username,
};
return user;
}

View File

@ -0,0 +1,7 @@
const characters = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz~!@-#$';
export function generatePassword(length = 10) {
return Array.from(crypto.getRandomValues(new Uint32Array(length)))
.map((x) => characters[x % characters.length])
.join('');
}

View File

@ -0,0 +1 @@
export type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

View File

@ -6,7 +6,7 @@
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"target": "es2017", "target": "ES2021",
"sourceMap": true, "sourceMap": true,
"outDir": "./dist", "outDir": "./dist",
"baseUrl": "./", "baseUrl": "./",
@ -17,5 +17,6 @@
"strictBindCallApply": false, "strictBindCallApply": false,
"forceConsistentCasingInFileNames": false, "forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false "noFallthroughCasesInSwitch": false
} },
"exclude": ["node_modules"]
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,14 @@
module.exports = { const { createConfig } = require('@vchikalkin/eslint-config-awesome');
extends: [
'@vchikalkin/eslint-config-awesome/next-typescript/config', module.exports = createConfig('next-typescript', {
'@vchikalkin/eslint-config-awesome/next-typescript/rules',
],
parserOptions: { parserOptions: {
project: './tsconfig.json', project: './tsconfig.json',
tsconfigRootDir: __dirname, tsconfigRootDir: __dirname,
}, },
}; rules: {
'import/no-duplicates': 'off',
'import/consistent-type-specifier-style': 'off',
'react/forbid-component-props': 'off',
},
ignorePatterns: ['*.config.js', '.eslintrc.js'],
});

View File

@ -1,49 +1,44 @@
# This Dockerfile is copy-pasted into our main docs at /docs/handbook/deploying-with-docker. # This Dockerfile is copy-pasted into our main docs at /docs/handbook/deploying-with-docker.
# Make sure you update both files! # Make sure you update both files!
FROM node:16-alpine AS builder FROM node:alpine AS builder
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. # 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 add --no-cache libc6-compat
RUN apk update RUN apk update
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
RUN yarn global add turbo RUN pnpm add -g turbo@1.12.4 dotenv-cli
COPY . . COPY . .
RUN turbo prune --scope=web --docker RUN turbo prune --scope=web --docker
# Add lockfile and package.json's of isolated subworkspace # Add lockfile and package.json's of isolated subworkspace
FROM node:16-alpine AS installer FROM node:alpine AS installer
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 add --no-cache libc6-compat
RUN apk update RUN apk update
WORKDIR /app WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED 1
# First install the dependencies (as they change less often) # First install the dependencies (as they change less often)
COPY .gitignore .gitignore COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ . COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/yarn.lock ./yarn.lock COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN yarn install COPY --from=builder /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
RUN pnpm install
# Build the project # Build the project
COPY --from=builder /app/out/full/ . COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json COPY turbo.json turbo.json
ARG APP_BASE_PATH
# Pass variables from .env
ARG BASE_PATH
ARG APP_TITLE
ARG APP_DESCRIPTION ARG APP_DESCRIPTION
ENV BASE_PATH=${BASE_PATH} ARG TELEGRAM_BOT_URL
ENV APP_TITLE=${APP_TITLE} RUN pnpm dotenv -e .env turbo run build --filter=web...
ENV APP_DESCRIPTION=${APP_DESCRIPTION}
RUN yarn turbo run build --filter=web... FROM node:alpine AS runner
FROM node:16-alpine AS runner
WORKDIR /app WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED 1
# Don't run production as root # Don't run production as root
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs

View File

@ -1,45 +0,0 @@
import styles from './Form.module.scss';
import axios from 'axios';
import Button from 'elements/Button';
import Error from 'elements/Error';
import { H3 } from 'elements/H';
import Input from 'elements/Input';
import getConfig from 'next/config';
import { useRouter } from 'next/router';
import { useState } from 'react';
const { publicRuntimeConfig: config } = getConfig();
export default function Form() {
const router = useRouter();
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('/signin', data)
.then(() => {
router.reload();
})
.catch(() => {
setHasError(true);
});
}}
>
<H3>{config.appTitle}</H3>
<Input name="login" type="text" placeholder="Логин" required autoComplete="on" />
<Input name="password" type="password" placeholder="Пароль" required autoComplete="on" />
{error}
<Button>Войти</Button>
</form>
);
}

View 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 === 'login-success' || step === 'telegram-notification' ? (
<a target="_blank" className="info" href={TELEGRAM_BOT_URL} rel="noreferrer">
Открыть чат с ботом
</a>
) : null}
{error ? <span className="error">{error}</span> : null}
{children}
</form>
);
}

View 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 },
} = useContext(FormStateContext);
if (step === 'refresh-token') {
return <ButtonLoading>Подождите...</ButtonLoading>;
}
return (
<BaseForm onSubmit={(data) => handleLogin(data)}>
<ButtonLogin>Войти</ButtonLogin>
</BaseForm>
);
}

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

View File

@ -0,0 +1,2 @@
export * from './socket';
export * from './token';

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

View File

@ -0,0 +1,101 @@
import type { FormData } from '../lib/types';
import { useSocket } from './socket';
import { redirect } from '@/components/Form/lib/utils';
import {
ERROR_INVALID_CREDENTIALS,
ERROR_SERVER,
ERROR_TELEGRAM_SEND_MESSAGE,
} from '@/constants/errors';
import { FormStateContext } from '@/context/form-state';
import type { LdapUser } from '@/types/user';
import axios 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: 'login-success',
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-notification',
},
type: 'set-step',
});
})
.catch(() =>
dispatch({
payload: { error: ERROR_TELEGRAM_SEND_MESSAGE },
type: 'set-error',
})
);
}
return { handleTelegramLogin };
}
export function useTelegramConfirm() {
const {
dispatch,
state: { step },
} = useContext(FormStateContext);
const { socket } = useSocket();
useEffect(() => {
if (step === 'telegram-notification') {
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',
})
);
});
socket.on('auth-deny', () => {
socket.off('connect');
window.location.reload();
});
}
return () => {
socket.off('connect');
};
}, [dispatch, socket, step]);
}

View 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 === 'refresh-token') handleRefreshToken();
}, []);
}

View File

@ -0,0 +1,2 @@
export * from './default-form';
export * from './telegram-form';

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

View 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}>
{props.children}
</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']} />
{props.children}
</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"
/>
{props.children}
</button>
);
}

View File

@ -0,0 +1,7 @@
export type FormData = {
readonly login: string;
readonly password: string;
};
export type FormProps = {
readonly onSubmit: (data: FormData) => void;
};

View 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);
}

View 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 },
} = useContext(FormStateContext);
if (step === 'refresh-token') {
return <ButtonLoading>Подождите...</ButtonLoading>;
}
if (step === 'login-success') {
return (
<BaseForm onSubmit={() => handleTelegramLogin()}>
<ButtonTelegram>Войти через Telegram</ButtonTelegram>
</BaseForm>
);
}
if (step === 'telegram-notification') {
return (
<BaseForm onSubmit={() => {}}>
<ButtonTelegramLogin>Ожидаем подтверждения...</ButtonTelegramLogin>
</BaseForm>
);
}
return (
<BaseForm onSubmit={(data) => handleLogin(data)}>
<ButtonLogin>Далее</ButtonLogin>
</BaseForm>
);
}

View File

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

View File

@ -1,9 +1,9 @@
$layout-breakpoint-desktop: 768px; $layout-breakpoint-tablet: 768px;
$layout-breakpoint-desktop: 1680px;
@mixin center-elements { @mixin center-elements {
display: flex; display: grid;
justify-content: center; place-items: center;
align-items: center;
} }
.wrapper { .wrapper {
@ -19,23 +19,23 @@ $layout-breakpoint-desktop: 768px;
background-color: white; background-color: white;
margin: 0; margin: 0;
height: 250px; height: 250px;
width: 100%; width: 100vw;
padding: 25px 10px; padding: 25px 10px;
margin-bottom: 0;
}
img { @media screen and (min-width: $layout-breakpoint-desktop) {
display: block; .login {
margin-left: auto; margin-bottom: 100px;
margin-right: auto;
} }
} }
@media (min-width: $layout-breakpoint-desktop) { @media screen and (min-width: $layout-breakpoint-tablet) {
.login { .login {
box-shadow: 4px 5px 17px -11px rgba(0, 0, 0, 0.75); box-shadow: 4px 5px 17px -11px rgba(0, 0, 0, 0.75);
height: 320px; height: 370px;
width: 380px; width: 440px;
padding: 25px 30px; padding: 25px 30px;
margin-bottom: 100px;
} }
.wrapper { .wrapper {

View 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>
);
}

View File

@ -0,0 +1,2 @@
export * from './Form';
export * from './Login';

View File

@ -0,0 +1,14 @@
import type envSchema from './schema/env';
import getConfig from 'next/config';
import type { z } from 'zod';
type Config = z.infer<typeof envSchema>;
type RunTimeConfig = {
publicRuntimeConfig: Config;
serverRuntimeConfig: Config;
};
const { publicRuntimeConfig, serverRuntimeConfig } = getConfig() as RunTimeConfig;
export { publicRuntimeConfig, serverRuntimeConfig };

View File

@ -0,0 +1,10 @@
const { z } = require('zod');
const envSchema = z.object({
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;

View File

@ -0,0 +1,3 @@
export const ERROR_INVALID_CREDENTIALS = 'Неверный логин или пароль';
export const ERROR_SERVER = 'Не удалось войти. Повторите попытку позже';
export const ERROR_TELEGRAM_SEND_MESSAGE = 'Не удалось отправить сообщение в Telegram';

View File

@ -0,0 +1,78 @@
/* 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' | 'login-success' | 'telegram-notification' | 'refresh-token';
tfa: boolean;
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,
error: undefined,
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 tfa: boolean;
readonly user?: LdapUser;
} & PropsWithChildren;
export function FormStateProvider({ children, tfa, user = undefined }: FormStateProviderProps) {
const [state, dispatch] = useReducer(reducer, {
error: undefined,
step: user ? 'refresh-token' : 'login',
tfa,
user,
});
const value = useMemo(() => ({ dispatch, state }), [state]);
return <FormStateContext.Provider value={value}>{children}</FormStateContext.Provider>;
}

View File

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

View File

@ -1,5 +0,0 @@
import styles from './Error.module.css';
export default function Error({ children }) {
return <span className={styles.error}>{children}</span>;
}

View File

@ -1,5 +0,0 @@
import styles from './H.module.css';
export function H3({ children }) {
return <h3 className={styles.h3}>{children}</h3>;
}

View File

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

View File

@ -1,6 +1,6 @@
import Image from 'next/image'; 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() { export function Logo() {
return <Image src={logo} alt="logo" width={154} />; return <Image className="logo" src={logo} alt="logo" width={154} />;
} }

View File

@ -0,0 +1 @@
export * from './Logo';

View File

@ -1,13 +0,0 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
if (request.nextUrl.pathname === '/') {
if (request.cookies.get('token')) {
const url = request.nextUrl.clone();
const uri = url?.searchParams.get('uri') || '/';
return NextResponse.redirect(new URL(uri, request.url));
}
}
}

View File

@ -1,25 +1,45 @@
const path = require('path'); const envSchema = require('./config/schema/env.js');
const { join } = require('path');
const runtimeConfig = { const runtimeConfig = envSchema.parse(process.env);
appTitle: process.env.APP_TITLE,
description: process.env.APP_DESCRIPTION,
basePath: process.env.BASE_PATH,
};
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { module.exports = {
basePath: process.env.BASE_PATH, basePath: process.env.APP_BASE_PATH,
output: 'standalone',
reactStrictMode: true,
swcMinify: true,
eslint: { eslint: {
ignoreDuringBuilds: true, ignoreDuringBuilds: true,
}, },
serverRuntimeConfig: runtimeConfig,
publicRuntimeConfig: runtimeConfig,
experimental: { experimental: {
outputFileTracingRoot: path.join(__dirname, '../../'), outputFileTracingRoot: join(__dirname, '../../'),
},
output: 'standalone',
publicRuntimeConfig: runtimeConfig,
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;
}, },
}; };
module.exports = nextConfig;

View File

@ -10,20 +10,25 @@
"lint:fix": "next lint -- --fix" "lint:fix": "next lint -- --fix"
}, },
"dependencies": { "dependencies": {
"@fontsource/montserrat": "^4.5.13", "@fontsource/montserrat": "^5.0.13",
"@types/node": "18.11.9", "@svgr/webpack": "^8.1.0",
"@types/react": "18.0.25", "@types/node": "^20.10.0",
"@types/react-dom": "18.0.9", "@types/react": "^18.2.39",
"axios": "^1.2.1", "@types/react-dom": "^18.2.17",
"next": "13.0.5", "axios": "^1.5.1",
"normalize.css": "^8.0.1", "modern-normalize": "^2.0.0",
"react": "18.2.0", "next": "^14.2.3",
"react-dom": "18.2.0", "radash": "^11.0.0",
"sass": "^1.56.1", "react": "^18.2.0",
"typescript": "4.9.3" "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"
}, },
"devDependencies": { "devDependencies": {
"@vchikalkin/eslint-config-awesome": "^1.1.1", "@vchikalkin/eslint-config-awesome": "^1.1.6",
"eslint": "^8.46.0" "eslint": "^8.51.0"
} }
} }

View File

@ -1,5 +1,5 @@
/* eslint-disable react/no-unknown-property */ /* eslint-disable react/no-unknown-property */
import 'normalize.css'; import '../styles/globals.css';
import '@fontsource/montserrat/400.css'; import '@fontsource/montserrat/400.css';
import '@fontsource/montserrat/600.css'; import '@fontsource/montserrat/600.css';
import '@fontsource/montserrat/700.css'; import '@fontsource/montserrat/700.css';

View File

@ -1,8 +1,8 @@
import getConfig from 'next/config'; /* eslint-disable @typescript-eslint/explicit-member-accessibility */
import { serverRuntimeConfig } from '@/config/runtime';
import Document, { Head, Html, Main, NextScript } from 'next/document'; import Document, { Head, Html, Main, NextScript } from 'next/document';
const { serverRuntimeConfig: config } = getConfig(); const { APP_BASE_PATH } = serverRuntimeConfig;
const { basePath = '', description } = config;
export default class MyDocument extends Document { export default class MyDocument extends Document {
render() { render() {
@ -10,13 +10,25 @@ export default class MyDocument extends Document {
<Html lang="ru" translate="no"> <Html lang="ru" translate="no">
<Head> <Head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta name="description" content={description} /> <link
rel="apple-touch-icon"
<link rel="apple-touch-icon" sizes="120x120" href={`${basePath}/apple-touch-icon.png`} /> sizes="120x120"
<link rel="icon" type="image/png" sizes="32x32" href={`${basePath}/favicon-32x32.png`} /> href={`${APP_BASE_PATH}/apple-touch-icon.png`}
<link rel="icon" type="image/png" sizes="16x16" href={`${basePath}/favicon-16x16.png`} /> />
<link rel="manifest" href={`${basePath}/site.webmanifest`} /> <link
<link rel="mask-icon" href={`${basePath}/safari-pinned-tab.svg`} color="#5bbad5" /> rel="icon"
type="image/png"
sizes="32x32"
href={`${APP_BASE_PATH}/favicon-32x32.png`}
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href={`${APP_BASE_PATH}/favicon-16x16.png`}
/>
<link rel="manifest" href={`${APP_BASE_PATH}/site.webmanifest`} />
<link rel="mask-icon" href={`${APP_BASE_PATH}/safari-pinned-tab.svg`} color="#5bbad5" />
<meta name="msapplication-TileColor" content="#1c01a9" /> <meta name="msapplication-TileColor" content="#1c01a9" />
<meta name="theme-color" content="#ffffff" /> <meta name="theme-color" content="#ffffff" />
</Head> </Head>

View File

@ -0,0 +1 @@
export { default, getServerSideProps } from './ldap';

View File

@ -1,22 +0,0 @@
import Login from 'components/Login';
import getConfig from 'next/config';
import Head from 'next/head';
const { publicRuntimeConfig: config } = getConfig();
function PageHead() {
return (
<Head>
<title>{`Вход - ${config.description}`}</title>
</Head>
);
}
export default function Home() {
return (
<>
<PageHead title={config.description} />
<Login />
</>
);
}

View File

@ -0,0 +1 @@
export { default, getServerSideProps } from './ldap';

52
apps/web/pages/ldap.jsx Normal file
View 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,
},
};
}
}

Some files were not shown because too many files have changed in this diff Show More