From df463924fe4539e979be0b8bac4f1fa61734cb81 Mon Sep 17 00:00:00 2001 From: vchikalkin Date: Fri, 3 May 2024 18:08:46 +0300 Subject: [PATCH] add 2fa initial features --- apps/api/src/ldap/ldap.controller.ts | 18 +++- apps/api/src/ldap/ldap.service.ts | 42 ++++++---- apps/web/components/Form.module.scss | 20 +++++ apps/web/components/Form.tsx | 95 +++++++++++++++++++--- apps/web/context/auth-mode.ts | 7 ++ apps/web/pages/index.jsx | 5 +- apps/web/pages/telegram.jsx | 8 +- apps/web/public/assets/images/telegram.svg | 1 + apps/web/styles/colors.css | 3 + apps/web/styles/globals.css | 1 + apps/web/styles/input.css | 5 ++ 11 files changed, 171 insertions(+), 34 deletions(-) create mode 100644 apps/web/context/auth-mode.ts create mode 100644 apps/web/public/assets/images/telegram.svg create mode 100644 apps/web/styles/colors.css diff --git a/apps/api/src/ldap/ldap.controller.ts b/apps/api/src/ldap/ldap.controller.ts index 2d6076b..4e89835 100644 --- a/apps/api/src/ldap/ldap.controller.ts +++ b/apps/api/src/ldap/ldap.controller.ts @@ -41,8 +41,20 @@ export class LdapController { @ApiResponse({ status: HttpStatus.OK, }) - async login(@Body() credentials: Credentials, @Res() reply: FastifyReply) { + async login( + @Req() req: FastifyRequest, + @Body() credentials: Credentials, + @Res() reply: FastifyReply + ) { + const twoFactor = req.headers['tfa'] === 'telegram'; + try { + if (twoFactor) { + const user = await this.ldapService.getUser(credentials.login); + + return reply.status(200).send(user); + } + const token = await this.ldapService.login(credentials); return reply.setCookie(env.COOKIE_TOKEN_NAME, token, cookieOptions).status(200).send(); @@ -71,7 +83,9 @@ export class LdapController { if (!token) throw new UnauthorizedException(); - const user = await this.ldapService.getUser(token); + const { username } = this.ldapService.parseToken(token); + + const user = await this.ldapService.getUser(username); if (!user) throw new UnauthorizedException('User not found'); diff --git a/apps/api/src/ldap/ldap.service.ts b/apps/api/src/ldap/ldap.service.ts index fc43615..16442fe 100644 --- a/apps/api/src/ldap/ldap.service.ts +++ b/apps/api/src/ldap/ldap.service.ts @@ -14,6 +14,22 @@ export class LdapService { private readonly jwtService: JwtService ) {} + public parseToken(token: string) { + try { + return this.jwtService.decode(token) as DecodedToken; + } catch { + throw new UnauthorizedException('Invalid token'); + } + } + + public checkToken(token: string) { + try { + return this.jwtService.verify(token) as DecodedToken; + } catch { + throw new UnauthorizedException('Invalid token'); + } + } + public async login({ login, password }: Credentials) { try { const user = await ldap.authenticate(login, password); @@ -33,7 +49,7 @@ export class LdapService { } public async logout(token: string) { - const { username } = this.jwtService.decode(token) as DecodedToken; + const { username } = this.parseToken(token); if (this.cacheManager.get(username)) { await this.cacheManager.del(username); @@ -42,7 +58,7 @@ export class LdapService { public async refreshToken(token: string) { try { - const { username } = this.jwtService.decode(token) as DecodedToken; + const { username } = this.parseToken(token); const user = await ldap.authenticate(username); await this.cacheManager.set(username, user); @@ -58,23 +74,17 @@ export class LdapService { } } - public async getUser(token: string) { - try { - const { username } = this.jwtService.verify(token) as DecodedToken; + public async getUser(username: string) { + const cachedUser = (await this.cacheManager.get(username)) as ldap.User; - const cachedUser = (await this.cacheManager.get(username)) as ldap.User; + if (!cachedUser) { + const user = await ldap.authenticate(username); - if (!cachedUser) { - const user = await ldap.authenticate(username); + await this.cacheManager.set(username, user); - await this.cacheManager.set(username, user); - - return user; - } - - return cachedUser; - } catch { - throw new UnauthorizedException('Invalid token'); + return user; } + + return cachedUser; } } diff --git a/apps/web/components/Form.module.scss b/apps/web/components/Form.module.scss index 2b86c4b..a6f918d 100644 --- a/apps/web/components/Form.module.scss +++ b/apps/web/components/Form.module.scss @@ -24,3 +24,23 @@ vertical-align: middle; width: 100%; } + +.button-telegram { + border: 0; + border-radius: 20px; + background-color: #54a9eb; + color: white; + font-size: 16px; + padding: 0.55rem 0.75rem; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; +} + +.button-telegram-icon { + filter: brightness(0) invert(1); + margin: 0 !important; + margin-right: 13px !important; + margin-left: none !important; +} diff --git a/apps/web/components/Form.tsx b/apps/web/components/Form.tsx index 4cfb838..e865c89 100644 --- a/apps/web/components/Form.tsx +++ b/apps/web/components/Form.tsx @@ -1,7 +1,12 @@ +/* eslint-disable react/jsx-curly-newline */ +/* eslint-disable sonarjs/no-small-switch */ +import TelegramIcon from '../public/assets/images/telegram.svg'; import styles from './Form.module.scss'; import { publicRuntimeConfig } from '@/config/runtime'; +import { AuthModeContext } from '@/context/auth-mode'; import axios from 'axios'; -import { useState } from 'react'; +import Image from 'next/image'; +import { useContext, useReducer, useState } from 'react'; import { useForm } from 'react-hook-form'; const { APP_BASE_PATH } = publicRuntimeConfig; @@ -11,6 +16,16 @@ type FormData = { readonly password: string; }; +type User = { + department: string; + displayName: string; + domain: string; + domainName: string; + mail: string; + position: string; + username: string; +}; + function handleDefaultLogin(data: FormData) { const redirectUrl = (window.location.pathname.replace(APP_BASE_PATH, '') || '/') + (window.location.search || ''); @@ -20,22 +35,58 @@ function handleDefaultLogin(data: FormData) { }); } -type LoginFormProps = { - readonly onLogin: (data: FormData) => Promise; +function handleTelegramLogin(data: FormData) { + return axios.post('/login', data); +} + +type State = { + step: 'login' | 'telegram'; + user: User | undefined; }; -function LoginForm({ onLogin }: LoginFormProps) { +type Action = { + payload: State; + type: 'set-step'; +}; + +const reducer = (state: State, action: Action): State => { + switch (action.type) { + case 'set-step': + return { + ...state, + ...action.payload, + }; + + default: + return state; + } +}; + +export function Form() { const [hasError, setHasError] = useState(false); const { handleSubmit, register } = useForm(); + const { tfa } = useContext(AuthModeContext); + const [{ step, user }, dispatch] = useReducer(reducer, { step: 'login', user: undefined }); return (
{ - onLogin(data).catch(() => setHasError(true)); + if (!tfa) return handleDefaultLogin(data).catch(() => setHasError(true)); + + return handleTelegramLogin(data).then((res) => + dispatch({ + payload: { + step: 'telegram', + user: res.data, + }, + type: 'set-step', + }) + ); })} > {hasError ? Неверный логин или пароль : null} - + ); } -export function Form() { - return ; +type ButtonSumitProps = { + readonly step: string; + readonly tfa: boolean; + readonly user: User | undefined; +}; + +function ButtonSubmit({ step, tfa, user }: ButtonSumitProps) { + if (!tfa || step === 'login') { + return ( + + ); + } + + return ( + + ); } diff --git a/apps/web/context/auth-mode.ts b/apps/web/context/auth-mode.ts new file mode 100644 index 0000000..a7ba3fd --- /dev/null +++ b/apps/web/context/auth-mode.ts @@ -0,0 +1,7 @@ +import { createContext } from 'react'; + +type AuthMode = { + tfa: boolean; +}; + +export const AuthModeContext = createContext({ tfa: false }); diff --git a/apps/web/pages/index.jsx b/apps/web/pages/index.jsx index cf79ca6..a2ae8d8 100644 --- a/apps/web/pages/index.jsx +++ b/apps/web/pages/index.jsx @@ -1,5 +1,6 @@ import { Login } from '@/components'; import { publicRuntimeConfig } from '@/config/runtime'; +import { AuthModeContext } from '@/context/auth-mode'; import Head from 'next/head'; const { APP_DESCRIPTION } = publicRuntimeConfig; @@ -15,9 +16,9 @@ function PageHead() { export default function Page() { return ( - <> + - + ); } diff --git a/apps/web/pages/telegram.jsx b/apps/web/pages/telegram.jsx index bbe39b0..9567b57 100644 --- a/apps/web/pages/telegram.jsx +++ b/apps/web/pages/telegram.jsx @@ -1,5 +1,6 @@ import { Login } from '@/components'; import { publicRuntimeConfig } from '@/config/runtime'; +import { AuthModeContext } from '@/context/auth-mode'; import Head from 'next/head'; const { APP_DESCRIPTION } = publicRuntimeConfig; @@ -15,10 +16,9 @@ function PageHead() { export default function Page() { return ( - <> + - Telegram - - + + ); } diff --git a/apps/web/public/assets/images/telegram.svg b/apps/web/public/assets/images/telegram.svg new file mode 100644 index 0000000..20ac9b3 --- /dev/null +++ b/apps/web/public/assets/images/telegram.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/styles/colors.css b/apps/web/styles/colors.css new file mode 100644 index 0000000..1948da2 --- /dev/null +++ b/apps/web/styles/colors.css @@ -0,0 +1,3 @@ +.primary { + color: var(--color-primary); +} diff --git a/apps/web/styles/globals.css b/apps/web/styles/globals.css index a9905f2..71ae558 100644 --- a/apps/web/styles/globals.css +++ b/apps/web/styles/globals.css @@ -2,3 +2,4 @@ @import './input.css'; @import './h.css'; @import './error.css'; +@import './colors.css'; diff --git a/apps/web/styles/input.css b/apps/web/styles/input.css index b6d7e72..fce6f5e 100644 --- a/apps/web/styles/input.css +++ b/apps/web/styles/input.css @@ -15,3 +15,8 @@ input::placeholder { filter: brightness(0.25); opacity: 0.9; } + +input:disabled { + opacity: 0.8; + cursor: not-allowed; +}