feature: continue as @user

This commit is contained in:
vchikalkin 2024-06-06 21:15:56 +03:00
parent eff2103c3e
commit dc9e0852ac
12 changed files with 164 additions and 23 deletions

View File

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

View File

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

View File

@ -7,6 +7,7 @@ import { LdapTfaGateway } from 'src/ldap-tfa/ldap-tfa.gateway';
@Module({
controllers: [LdapTfaController],
exports: [LdapTfaService],
imports: [LdapModule],
providers: [LdapTfaGateway, LdapTfaService],
})

View File

@ -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<TokenPayload>(token, { audience: 'auth' });
return this.jwtService.verify<TokenPayload>(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<TokenPayload>(token, { audience: 'auth' });
const { username } = this.jwtService.verify<TokenPayload>(token, options);
const user = await ldap.authenticate(username);
await this.cacheManager.set(username, user);

View File

@ -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<ldap.User>(username);
if (!cachedUser) {
const user = await ldap.authenticate(username);

View File

@ -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 (
<button
className={styles['button-submit']}
type="submit"
onClick={() => handleRefreshToken()}
>
Продолжить как <b>{user?.displayName}</b>
</button>
);
}
function handleLogin(data: FormData) {
return axios
@ -147,7 +174,6 @@ export const Form = {
}
function handleTelegramLogin() {
// window.open(TELEGRAM_BOT_URL);
axios
.post<LdapUser>('/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 (
<button
className={styles['button-submit']}
type="submit"
onClick={() => handleRefreshToken()}
>
Продолжить как <b>{user?.displayName}</b>
</button>
);
}
if (step === 'telegram') {
return (
<BaseForm onSubmit={() => handleTelegramLogin()}>

View File

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

View File

@ -57,11 +57,15 @@ type Context = {
export const FormStateContext = createContext<Context>({} 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]);

View File

@ -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",

View File

@ -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 (
<FormStateProvider>
<FormStateProvider {...props}>
<PageHead />
<Login />
</FormStateProvider>
);
}
/** @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: {},
};
}
}

View File

@ -14,11 +14,13 @@ function PageHead() {
);
}
export default function Page() {
export default function Page(props) {
return (
<FormStateProvider>
<FormStateProvider {...props}>
<PageHead />
<Login tfa />
</FormStateProvider>
);
}
export { getServerSideProps } from '.';

36
pnpm-lock.yaml generated
View File

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