From dc9e0852acacd6fcdbdc951d7b25970c6aa8dc09 Mon Sep 17 00:00:00 2001 From: vchikalkin Date: Thu, 6 Jun 2024 21:15:56 +0300 Subject: [PATCH] feature: continue as @user --- apps/api/src/app.controller.ts | 18 +++++-- apps/api/src/ldap-tfa/ldap-tfa.controller.ts | 14 +++++ apps/api/src/ldap-tfa/ldap-tfa.module.ts | 1 + apps/api/src/ldap-tfa/ldap-tfa.service.ts | 10 ++-- apps/api/src/ldap/ldap.service.ts | 8 +-- apps/web/components/Form.tsx | 55 +++++++++++++++++++- apps/web/config/schema/env.js | 1 + apps/web/context/form-state.tsx | 8 ++- apps/web/package.json | 1 + apps/web/pages/index.jsx | 29 +++++++++-- apps/web/pages/telegram.jsx | 6 ++- pnpm-lock.yaml | 36 +++++++++++-- 12 files changed, 164 insertions(+), 23 deletions(-) diff --git a/apps/api/src/app.controller.ts b/apps/api/src/app.controller.ts index 5511eb8..445c162 100644 --- a/apps/api/src/app.controller.ts +++ b/apps/api/src/app.controller.ts @@ -1,8 +1,8 @@ import { AppService } from './app.service'; import { AuthParams, Params } from './decorators/auth-mode.decorator'; import { AuthToken } from './decorators/token.decorator'; -import { Controller, Get, HttpStatus, Req, Res, UnauthorizedException } from '@nestjs/common'; -import { ApiExcludeController } from '@nestjs/swagger'; +import { Controller, Get, HttpStatus, Req, Res } from '@nestjs/common'; +import { ApiExcludeController, ApiResponse } from '@nestjs/swagger'; import { FastifyReply, FastifyRequest } from 'fastify'; @Controller() @@ -33,7 +33,19 @@ export class AppController { return reply.send(); } catch (error) { - throw new UnauthorizedException(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`); + } } diff --git a/apps/api/src/ldap-tfa/ldap-tfa.controller.ts b/apps/api/src/ldap-tfa/ldap-tfa.controller.ts index 401526d..f50b876 100644 --- a/apps/api/src/ldap-tfa/ldap-tfa.controller.ts +++ b/apps/api/src/ldap-tfa/ldap-tfa.controller.ts @@ -111,4 +111,18 @@ export class LdapTfaController extends LdapController { return reply.setCookie(env.COOKIE_TOKEN_NAME, activatedToken, cookieOptions).status(200).send(); } + + @Get('/check-auth') + @ApiResponse({ + status: HttpStatus.OK, + }) + async checkAuth(@AuthToken() token: string, @Res() reply: FastifyReply) { + const { authId } = await this.ldapTfaService.parseToken(token, { ignoreExpiration: true }); + + if (authId) return reply.status(HttpStatus.UNAUTHORIZED).send(); + + const user = await this.ldapTfaService.getUser(token, { ignoreExpiration: true }); + + return reply.status(200).send(user); + } } diff --git a/apps/api/src/ldap-tfa/ldap-tfa.module.ts b/apps/api/src/ldap-tfa/ldap-tfa.module.ts index 05be84e..eb7d9f5 100644 --- a/apps/api/src/ldap-tfa/ldap-tfa.module.ts +++ b/apps/api/src/ldap-tfa/ldap-tfa.module.ts @@ -7,6 +7,7 @@ import { LdapTfaGateway } from 'src/ldap-tfa/ldap-tfa.gateway'; @Module({ controllers: [LdapTfaController], + exports: [LdapTfaService], imports: [LdapModule], providers: [LdapTfaGateway, LdapTfaService], }) diff --git a/apps/api/src/ldap-tfa/ldap-tfa.service.ts b/apps/api/src/ldap-tfa/ldap-tfa.service.ts index f09bfaa..62723e2 100644 --- a/apps/api/src/ldap-tfa/ldap-tfa.service.ts +++ b/apps/api/src/ldap-tfa/ldap-tfa.service.ts @@ -1,6 +1,8 @@ +/* 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'; @@ -36,17 +38,17 @@ export class LdapTfaService extends LdapService { } } - public async parseToken(token: string) { + public async parseToken(token: string, options: JwtVerifyOptions = { audience: 'auth' }) { try { - return this.jwtService.verify(token, { audience: 'auth' }); + return this.jwtService.verify(token, options); } catch (error) { throw new UnauthorizedException(error); } } - public async activateToken(token: string) { + public async activateToken(token: string, options: JwtVerifyOptions = { audience: 'auth' }) { try { - const { username } = this.jwtService.verify(token, { audience: 'auth' }); + const { username } = this.jwtService.verify(token, options); const user = await ldap.authenticate(username); await this.cacheManager.set(username, user); diff --git a/apps/api/src/ldap/ldap.service.ts b/apps/api/src/ldap/ldap.service.ts index 395dce0..ec250f1 100644 --- a/apps/api/src/ldap/ldap.service.ts +++ b/apps/api/src/ldap/ldap.service.ts @@ -1,7 +1,7 @@ import type { DecodedToken, TokenPayload } from '../types/jwt'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; -import type { JwtSignOptions } from '@nestjs/jwt'; +import type { JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt'; import { Cache } from 'cache-manager'; import { env } from 'src/config/env'; @@ -64,11 +64,11 @@ export class LdapService { } } - public async getUser(token: string) { + public async getUser(token: string, options?: JwtVerifyOptions) { try { - const { username } = this.jwtService.verify(token) as DecodedToken; + const { username } = this.jwtService.verify(token, options) as DecodedToken; - const cachedUser = (await this.cacheManager.get(username)) as ldap.User; + const cachedUser = await this.cacheManager.get(username); if (!cachedUser) { const user = await ldap.authenticate(username); diff --git a/apps/web/components/Form.tsx b/apps/web/components/Form.tsx index 52950ed..b90e02c 100644 --- a/apps/web/components/Form.tsx +++ b/apps/web/components/Form.tsx @@ -71,7 +71,34 @@ function BaseForm({ children, onSubmit }: FormProps & PropsWithChildren) { export const Form = { Default() { - const { dispatch } = useContext(FormStateContext); + const { + dispatch, + state: { step, user }, + } = useContext(FormStateContext); + + function handleRefreshToken() { + axios + .get('/refresh-token') + .then(() => redirect()) + .catch(() => + dispatch({ + payload: { error: ERROR_SERVER }, + type: 'set-error', + }) + ); + } + + if (step === 'login' && user) { + return ( + + ); + } function handleLogin(data: FormData) { return axios @@ -147,7 +174,6 @@ export const Form = { } function handleTelegramLogin() { - // window.open(TELEGRAM_BOT_URL); axios .post('/login-telegram') .then(() => { @@ -172,6 +198,31 @@ export const Form = { }); } + // eslint-disable-next-line sonarjs/no-identical-functions + function handleRefreshToken() { + axios + .get('/refresh-token') + .then(() => redirect()) + .catch(() => + dispatch({ + payload: { error: ERROR_SERVER }, + type: 'set-error', + }) + ); + } + + if (step === 'login' && user) { + return ( + + ); + } + if (step === 'telegram') { return ( handleTelegramLogin()}> diff --git a/apps/web/config/schema/env.js b/apps/web/config/schema/env.js index 8cc4899..d0f8158 100644 --- a/apps/web/config/schema/env.js +++ b/apps/web/config/schema/env.js @@ -4,6 +4,7 @@ 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; diff --git a/apps/web/context/form-state.tsx b/apps/web/context/form-state.tsx index d71afcb..dbba47c 100644 --- a/apps/web/context/form-state.tsx +++ b/apps/web/context/form-state.tsx @@ -57,11 +57,15 @@ type Context = { export const FormStateContext = createContext({} as Context); -export function FormStateProvider({ children }: PropsWithChildren) { +type FormStateProviderProps = { + readonly user?: LdapUser; +} & PropsWithChildren; + +export function FormStateProvider({ children, user = undefined }: FormStateProviderProps) { const [state, dispatch] = useReducer(reducer, { error: undefined, step: 'login', - user: undefined, + user, }); const value = useMemo(() => ({ dispatch, state }), [state]); diff --git a/apps/web/package.json b/apps/web/package.json index 70c5ad1..9fc1b91 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,6 +17,7 @@ "axios": "^1.5.1", "modern-normalize": "^2.0.0", "next": "^14.2.3", + "radash": "^11.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.51.3", diff --git a/apps/web/pages/index.jsx b/apps/web/pages/index.jsx index a1b0dd2..b946d50 100644 --- a/apps/web/pages/index.jsx +++ b/apps/web/pages/index.jsx @@ -1,8 +1,11 @@ import { Login } from '@/components'; -import { publicRuntimeConfig } from '@/config/runtime'; +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; function PageHead() { @@ -14,11 +17,31 @@ function PageHead() { ); } -export default function Page() { +export default function Page(props) { return ( - + ); } + +/** @type {import('next').GetServerSideProps} */ +export async function getServerSideProps({ req }) { + try { + const headers = pick(req.headers, ['auth-mode', 'cookie', 'refresh-token']); + const { data: user } = await axios.get(URL_API_CHECK_AUTH, { + headers, + }); + + return { + props: { + user, + }, + }; + } catch { + return { + props: {}, + }; + } +} diff --git a/apps/web/pages/telegram.jsx b/apps/web/pages/telegram.jsx index 892a1d8..383ca1b 100644 --- a/apps/web/pages/telegram.jsx +++ b/apps/web/pages/telegram.jsx @@ -14,11 +14,13 @@ function PageHead() { ); } -export default function Page() { +export default function Page(props) { return ( - + ); } + +export { getServerSideProps } from '.'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b50ef38..3bf3df9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -195,6 +195,9 @@ importers: next: specifier: ^14.2.3 version: 14.2.3(@babel/core@7.23.3)(react-dom@18.2.0)(react@18.2.0)(sass@1.69.5) + radash: + specifier: ^11.0.0 + version: 11.0.0 react: specifier: ^18.2.0 version: 18.2.0 @@ -219,7 +222,7 @@ importers: devDependencies: '@vchikalkin/eslint-config-awesome': specifier: ^1.1.6 - version: 1.1.6(@babel/eslint-plugin@7.22.10)(@babel/plugin-syntax-flow@7.23.3)(@babel/plugin-transform-react-jsx@7.23.4)(@types/node@20.10.0)(eslint-plugin-import@2.29.0)(eslint@8.54.0)(graphql@16.8.1)(prettier@3.2.5)(typescript@5.3.2) + version: 1.1.6(@babel/eslint-plugin@7.22.10)(@babel/plugin-syntax-flow@7.23.3)(@babel/plugin-transform-react-jsx@7.23.4)(@types/node@20.10.0)(eslint-plugin-import@2.29.0)(eslint@8.54.0)(graphql@16.8.1)(prettier@3.3.1)(typescript@5.3.2) eslint: specifier: ^8.51.0 version: 8.54.0 @@ -2510,7 +2513,7 @@ packages: - vitest dev: true - /@vchikalkin/eslint-config-awesome@1.1.6(@babel/eslint-plugin@7.22.10)(@babel/plugin-syntax-flow@7.23.3)(@babel/plugin-transform-react-jsx@7.23.4)(@types/node@20.10.0)(eslint-plugin-import@2.29.0)(eslint@8.54.0)(graphql@16.8.1)(prettier@3.2.5)(typescript@5.3.2): + /@vchikalkin/eslint-config-awesome@1.1.6(@babel/eslint-plugin@7.22.10)(@babel/plugin-syntax-flow@7.23.3)(@babel/plugin-transform-react-jsx@7.23.4)(@types/node@20.10.0)(eslint-plugin-import@2.29.0)(eslint@8.54.0)(graphql@16.8.1)(prettier@3.3.1)(typescript@5.3.2): resolution: {integrity: sha512-GMgbUe9CupcCpQnvrZMalfwnuQwoYCH8mMYLYsBtVLbpjyzI3OVsX0GGvqPJbLDO1mBVH3H8DsMMFwwmOpP81A==} peerDependencies: '@babel/eslint-plugin': ^7.22.10 @@ -2522,7 +2525,7 @@ packages: eslint-config-canonical: 42.8.0(@babel/plugin-syntax-flow@7.23.3)(@babel/plugin-transform-react-jsx@7.23.4)(@types/node@20.10.0)(eslint@8.54.0)(graphql@16.8.1)(jest@29.7.0)(typescript@5.3.2) eslint-config-prettier: 9.0.0(eslint@8.54.0) eslint-plugin-canonical: 4.18.0(@typescript-eslint/parser@6.21.0)(eslint-plugin-import@2.29.0)(eslint@8.54.0)(typescript@5.3.2) - eslint-plugin-prettier: 5.0.1(eslint-config-prettier@9.0.0)(eslint@8.54.0)(prettier@3.2.5) + eslint-plugin-prettier: 5.0.1(eslint-config-prettier@9.0.0)(eslint@8.54.0)(prettier@3.3.1) eslint-plugin-sonarjs: 0.22.0(eslint@8.54.0) transitivePeerDependencies: - '@babel/plugin-syntax-flow' @@ -4596,6 +4599,27 @@ packages: synckit: 0.8.5 dev: true + /eslint-plugin-prettier@5.0.1(eslint-config-prettier@9.0.0)(eslint@8.54.0)(prettier@3.3.1): + resolution: {integrity: sha512-m3u5RnR56asrwV/lDC4GHorlW75DsFfmUcjfCYylTUs85dBRnB7VM6xG8eCMJdeDRnppzmxZVf1GEPJvl1JmNg==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '*' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true + dependencies: + eslint: 8.54.0 + eslint-config-prettier: 9.0.0(eslint@8.54.0) + prettier: 3.3.1 + prettier-linter-helpers: 1.0.0 + synckit: 0.8.5 + dev: true + /eslint-plugin-promise@6.1.1(eslint@8.54.0): resolution: {integrity: sha512-tjqWDwVZQo7UIPMeDReOpUgHCmCiH+ePnVT+5zVapL0uuHnegBUs2smM13CzOs2Xb5+MHMRFTs9v24yjba4Oig==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -7748,6 +7772,12 @@ packages: hasBin: true dev: true + /prettier@3.3.1: + resolution: {integrity: sha512-7CAwy5dRsxs8PHXT3twixW9/OEll8MLE0VRPCJyl7CkS6VHGPSlsVaWTiASPTyGyYRyApxlaWTzwUxVNrhcwDg==} + engines: {node: '>=14'} + hasBin: true + dev: true + /pretty-format@29.7.0: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}