add 2fa initial features
This commit is contained in:
parent
6f8d20e327
commit
df463924fe
@ -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');
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
Войти как <b>{user?.displayName}</b>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
7
apps/web/context/auth-mode.ts
Normal file
7
apps/web/context/auth-mode.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
type AuthMode = {
|
||||
tfa: boolean;
|
||||
};
|
||||
|
||||
export const AuthModeContext = createContext<AuthMode>({ tfa: false });
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
1
apps/web/public/assets/images/telegram.svg
Normal file
1
apps/web/public/assets/images/telegram.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg height="512" width="512" xmlns="http://www.w3.org/2000/svg"><path d="m470.4354553 45.4225006-453.6081524 175.8265381c-18.253809 8.1874695-24.4278889 24.5854034-4.4127407 33.4840851l116.3710175 37.1726685 281.3674316-174.789505c15.3625488-10.9733887 31.0910339-8.0470886 17.5573425 4.023468l-241.6571311 219.9348907-7.5913849 93.0762329c7.0313721 14.3716125 19.9055786 14.4378967 28.1172485 7.2952881l66.8582916-63.5891418 114.5050659 86.1867065c26.5942688 15.8265076 41.0652466 5.6130371 46.7870789-23.3935242l75.1055603-357.4697647c7.7979126-35.7059288-5.5005798-51.437891-39.3996277-37.7579422z"/></svg>
|
||||
|
After Width: | Height: | Size: 610 B |
3
apps/web/styles/colors.css
Normal file
3
apps/web/styles/colors.css
Normal file
@ -0,0 +1,3 @@
|
||||
.primary {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
@ -2,3 +2,4 @@
|
||||
@import './input.css';
|
||||
@import './h.css';
|
||||
@import './error.css';
|
||||
@import './colors.css';
|
||||
|
||||
@ -15,3 +15,8 @@ input::placeholder {
|
||||
filter: brightness(0.25);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
opacity: 0.8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user