add 2fa initial features

This commit is contained in:
vchikalkin 2024-05-03 18:08:46 +03:00
parent 6f8d20e327
commit df463924fe
11 changed files with 171 additions and 34 deletions

View File

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

View File

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

View File

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

View File

@ -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<void>;
function handleTelegramLogin(data: FormData) {
return axios.post<User>('/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<FormData>();
const { tfa } = useContext(AuthModeContext);
const [{ step, user }, dispatch] = useReducer(reducer, { step: 'login', user: undefined });
return (
<form
className={styles.form}
onSubmit={handleSubmit((data) => {
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',
})
);
})}
>
<input
disabled={step === 'telegram'}
type="text"
placeholder="Логин"
required
@ -43,6 +94,7 @@ function LoginForm({ onLogin }: LoginFormProps) {
{...register('login', { required: true })}
/>
<input
disabled={step === 'telegram'}
type="password"
placeholder="Пароль"
required
@ -50,13 +102,36 @@ function LoginForm({ onLogin }: LoginFormProps) {
{...register('password', { required: true })}
/>
{hasError ? <span className="error">Неверный логин или пароль</span> : null}
<button className={styles['button-submit']} type="submit">
Войти
</button>
<ButtonSubmit tfa={tfa} step={step} user={user} />
</form>
);
}
export function Form() {
return <LoginForm onLogin={handleDefaultLogin} />;
type ButtonSumitProps = {
readonly step: string;
readonly tfa: boolean;
readonly user: User | undefined;
};
function ButtonSubmit({ step, tfa, user }: ButtonSumitProps) {
if (!tfa || step === 'login') {
return (
<button className={styles['button-submit']} type="submit">
Войти
</button>
);
}
return (
<button type="button" className={styles['button-telegram']}>
<Image
className={styles['button-telegram-icon']}
src={TelegramIcon}
width={24}
height={22}
alt="Telegram icon"
/>
Войти как &nbsp;<b>{user?.displayName}</b>
</button>
);
}

View File

@ -0,0 +1,7 @@
import { createContext } from 'react';
type AuthMode = {
tfa: boolean;
};
export const AuthModeContext = createContext<AuthMode>({ tfa: false });

View File

@ -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 (
<>
<AuthModeContext.Provider value={{ tfa: false }}>
<PageHead />
<Login />
</>
</AuthModeContext.Provider>
);
}

View File

@ -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 (
<>
<AuthModeContext.Provider value={{ tfa: true }}>
<PageHead />
Telegram
<Login />
</>
<Login mode="telegram" />
</AuthModeContext.Provider>
);
}

View File

@ -0,0 +1 @@
<svg height="512" width="512" xmlns="http://www.w3.org/2000/svg"><path d="m470.4354553 45.4225006-453.6081524 175.8265381c-18.253809 8.1874695-24.4278889 24.5854034-4.4127407 33.4840851l116.3710175 37.1726685 281.3674316-174.789505c15.3625488-10.9733887 31.0910339-8.0470886 17.5573425 4.023468l-241.6571311 219.9348907-7.5913849 93.0762329c7.0313721 14.3716125 19.9055786 14.4378967 28.1172485 7.2952881l66.8582916-63.5891418 114.5050659 86.1867065c26.5942688 15.8265076 41.0652466 5.6130371 46.7870789-23.3935242l75.1055603-357.4697647c7.7979126-35.7059288-5.5005798-51.437891-39.3996277-37.7579422z"/></svg>

After

Width:  |  Height:  |  Size: 610 B

View File

@ -0,0 +1,3 @@
.primary {
color: var(--color-primary);
}

View File

@ -2,3 +2,4 @@
@import './input.css';
@import './h.css';
@import './error.css';
@import './colors.css';

View File

@ -15,3 +15,8 @@ input::placeholder {
filter: brightness(0.25);
opacity: 0.9;
}
input:disabled {
opacity: 0.8;
cursor: not-allowed;
}