WIP
This commit is contained in:
parent
8db8ce9f47
commit
b26e963bd6
|
|
@ -212,7 +212,6 @@ model IdentityAuthDevice {
|
|||
|
||||
attributes String
|
||||
preferred Boolean
|
||||
twoFactorPreferred Boolean
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
|
|
|
|||
|
|
@ -10,27 +10,6 @@ export namespace Authentication {
|
|||
}
|
||||
}
|
||||
|
||||
export namespace Login {
|
||||
export enum ResponseStatus {
|
||||
Success = 'success',
|
||||
TwoFactorRequired = '2fa',
|
||||
Failure = 'failure',
|
||||
Timedout = 'timedout',
|
||||
DeviceLocked = 'locked',
|
||||
}
|
||||
|
||||
export interface State {
|
||||
jwi: string;
|
||||
exp: number;
|
||||
tfa: boolean;
|
||||
}
|
||||
|
||||
export interface RequestContext {
|
||||
state: State;
|
||||
username: string;
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Oauth2 {
|
||||
export enum TokenClaims {
|
||||
Issuer = 'iss',
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ export namespace Identity {
|
|||
readonly deviceType: AuthDevice.Type,
|
||||
readonly preferred: boolean;
|
||||
readonly twoFactorEligible: boolean;
|
||||
readonly twoFactorPreferred: boolean;
|
||||
readonly createdAt: Date,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export enum RegisteredCookies {
|
||||
Theme = 'mvc:theme',
|
||||
}
|
||||
|
||||
export const Cookies = createParamDecorator((data: string, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return data ? request.cookies?.[data] : request.cookies;
|
||||
});
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { ExecutionContext, createParamDecorator } from '@nestjs/common';
|
||||
|
||||
import { Request, UserAgentSettings as UserAgentSettingsType } from '../request.type';
|
||||
|
||||
export const UserAgentSettings = createParamDecorator((data: keyof UserAgentSettingsType, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest<Request>();
|
||||
return data ? request.userAgentSettings[data] : request.cookies;
|
||||
});
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
|
||||
import { FoundException } from '../exceptions/found.exception';
|
||||
import { SeeOtherException } from '../exceptions/see-other.exception';
|
||||
|
||||
@Catch(FoundException, SeeOtherException)
|
||||
export class RedirectExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: FoundException | SeeOtherException, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
if (exception.trueRedirect) {
|
||||
response.setHeader('hx-redirect', exception.redirectUri);
|
||||
return;
|
||||
}
|
||||
|
||||
return response.redirect(exception.exposedStatus, exception.redirectUri);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/302
|
||||
// Use for GET or HEAD responses
|
||||
export class FoundException extends HttpException {
|
||||
|
||||
readonly exposedStatus = HttpStatus.FOUND;
|
||||
|
||||
constructor(
|
||||
readonly redirectUri: string,
|
||||
readonly trueRedirect: boolean = false,
|
||||
) {
|
||||
super(`<a href="${redirectUri}">Found.</a>`, HttpStatus.FOUND);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/303
|
||||
// User for POST or PUT responses
|
||||
export class SeeOtherException extends HttpException {
|
||||
|
||||
readonly exposedStatus = HttpStatus.SEE_OTHER;
|
||||
|
||||
constructor(
|
||||
readonly redirectUri: string,
|
||||
readonly trueRedirect: boolean = false,
|
||||
) {
|
||||
super(`<a href="${redirectUri}">See other.</a>`, HttpStatus.SEE_OTHER);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { LoginModule } from './mvc/login/login.module';
|
||||
import { MvcModule } from './mvc/mvc.module';
|
||||
import { RestModule } from './rest/rest.module';
|
||||
import { UserAgentSettingsInterceptor } from './interceptors/user-agent-settings.interceptor';
|
||||
|
||||
@Module({
|
||||
imports: [LoginModule],
|
||||
imports: [MvcModule, RestModule],
|
||||
providers: [UserAgentSettingsInterceptor],
|
||||
})
|
||||
export class HttpModule {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
||||
import { Observable, map } from 'rxjs';
|
||||
|
||||
import { Request } from '../request.type';
|
||||
import { RegisteredCookies } from '../decorators/cookies.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class UserAgentSettingsInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> | Promise<Observable<any>> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
|
||||
return next.handle().pipe(
|
||||
map(data => ({
|
||||
...data,
|
||||
user_agent_settings: {
|
||||
theme: request.cookies[RegisteredCookies.Theme] ?? 'light',
|
||||
links: {
|
||||
toggle_theme: '/v1/user-agent/toggle-theme',
|
||||
}
|
||||
}
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,19 @@
|
|||
import { BadRequestException, Body, Controller, Get, InternalServerErrorException, Param, Post, Req, Res, UseInterceptors } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { Body, Controller, Get, InternalServerErrorException, Param, Post, Query, Render, Req, Res, UseInterceptors } from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import * as Joi from 'joi';
|
||||
|
||||
import { Authentication } from '../../../domain/authentication.types';
|
||||
import { Identity } from '../../../domain/identity.types';
|
||||
import { SecureStringUtil } from '../../../utils';
|
||||
import { TooManyRequestsException } from '../../exceptions/too-many-requests.exception';
|
||||
import { MVCResponse, Views } from '../mvc.types';
|
||||
import { Views } from '../mvc.types';
|
||||
import { LoginContextInterceptor } from './login-context.interceptor';
|
||||
import { Context } from './context.decorator';
|
||||
import { StateManagerService } from './state-manager.service';
|
||||
import { AccessAttemptService } from './access-attempt.service';
|
||||
import { IdentityAuthDeviceDao } from '../../../persistence/identity-auth-device.dao';
|
||||
import { ChallengeRenderContext, RequestContext } from './login.types';
|
||||
import { DeviceCalculatorService } from './device-calculator.service';
|
||||
import { FoundException } from '../../exceptions/found.exception';
|
||||
import { SeeOtherException } from '../../exceptions/see-other.exception';
|
||||
|
||||
@Controller({
|
||||
version: '1',
|
||||
|
|
@ -22,7 +24,7 @@ export class ChallengeController {
|
|||
|
||||
constructor(
|
||||
private readonly stateManager: StateManagerService,
|
||||
private readonly identityAuthDeviceDao: IdentityAuthDeviceDao,
|
||||
private readonly deviceCalcService: DeviceCalculatorService,
|
||||
private readonly accessAttemptService: AccessAttemptService,
|
||||
) {}
|
||||
|
||||
|
|
@ -31,41 +33,78 @@ export class ChallengeController {
|
|||
|
||||
}
|
||||
|
||||
@Get(':device_urn')
|
||||
@Render(Views.LoginChallenge)
|
||||
async getDevice(
|
||||
@Param('realm') realm: string,
|
||||
@Param('device_urn') deviceUrn: string,
|
||||
@Context() context: RequestContext,
|
||||
@Res() res: Response,
|
||||
): Promise<ChallengeRenderContext> {
|
||||
|
||||
const deviceInfo = await this.deviceCalcService.considerDevicesForUser(realm, context.username);
|
||||
const device = deviceInfo.primaryDevices.find(d => d.urn === deviceUrn);
|
||||
|
||||
const refreshedState = this.stateManager.updateState(context.state);
|
||||
const redirectQueryParams = new URLSearchParams();
|
||||
redirectQueryParams.append('username', context.username);
|
||||
redirectQueryParams.append('state', refreshedState);
|
||||
|
||||
if (!device) {
|
||||
throw new FoundException(`/v1/auth/${realm}/signin/identifier`);
|
||||
}
|
||||
|
||||
return {
|
||||
state: this.stateManager.updateState(context.state),
|
||||
username: context.username,
|
||||
device,
|
||||
links: {
|
||||
select_device: deviceInfo.primaryDevices.length > 1 ? `/v1/auth/${realm}/signin/challenge?${redirectQueryParams.toString()}` : null,
|
||||
challenge_form: `/v1/auth/${realm}/signin/challenge/${deviceUrn}`,
|
||||
try_different_user: `/v1/auth/${realm}/signin/identifier`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Post(':device_urn')
|
||||
@Render(Views.LoginChallenge)
|
||||
async postDevice(
|
||||
@Req() req: Request,
|
||||
@Body() body: unknown,
|
||||
@Param('realm') realm: string,
|
||||
@Param('device_urn') deviceUrn: string,
|
||||
@Context() context: Authentication.Login.RequestContext,
|
||||
@Res() res: MVCResponse,
|
||||
) {
|
||||
@Context() context: RequestContext,
|
||||
@Res() res: Response,
|
||||
): Promise<ChallengeRenderContext> {
|
||||
|
||||
const devicesForUser = await this.identityAuthDeviceDao.findByRealmAndUsername(realm, context.username);
|
||||
const device = devicesForUser.find(d => d.urn === deviceUrn);
|
||||
const twoFactorRequired = devicesForUser.some(d => d.twoFactorEligible);
|
||||
const deviceInfo = await this.deviceCalcService.considerDevicesForUser(realm, context.username);
|
||||
const device = deviceInfo.primaryDevices.find(d => d.urn === deviceUrn);
|
||||
|
||||
if (!device || devicesForUser.length === 0) {
|
||||
const refreshedState = this.stateManager.updateState(context.state);
|
||||
const redirectQueryParams = new URLSearchParams();
|
||||
redirectQueryParams.append('username', context.username);
|
||||
redirectQueryParams.append('state', refreshedState);
|
||||
|
||||
const selectDeviceUrl = deviceInfo.primaryDevices.length > 1 ? `/v1/auth/${realm}/signin/challenge?${redirectQueryParams.toString()}` : null;
|
||||
|
||||
if (!device || deviceInfo.primaryDevices.length === 0) {
|
||||
const { isLocked } = await this.accessAttemptService.recordAttempt(req, context.username, false);
|
||||
|
||||
if (isLocked) {
|
||||
throw new TooManyRequestsException();
|
||||
}
|
||||
|
||||
return res.render(Views.LoginPasswordChallenge, {
|
||||
realm,
|
||||
state: this.stateManager.updateState(context.state),
|
||||
return {
|
||||
state: refreshedState,
|
||||
username: context.username,
|
||||
user_settings: {
|
||||
theme: 'dark',
|
||||
},
|
||||
device: null,
|
||||
links: {
|
||||
select_device: null,
|
||||
challenge_form: `/auth/${realm}/signin/challenge/${deviceUrn}`,
|
||||
try_different_user: `/auth/${realm}/signin/identifier`,
|
||||
challenge_form: `/v1/auth/${realm}/signin/challenge/${deviceUrn}`,
|
||||
try_different_user: `/v1/auth/${realm}/signin/identifier`,
|
||||
},
|
||||
error: 'Invalid username or password.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (device.deviceType === Identity.AuthDevice.Type.Password) {
|
||||
|
|
@ -77,37 +116,32 @@ export class ChallengeController {
|
|||
}).validate(body, { allowUnknown: true });
|
||||
|
||||
if (error) {
|
||||
return res.render(Views.LoginPasswordChallenge, {
|
||||
realm,
|
||||
return {
|
||||
state: this.stateManager.updateState(context.state),
|
||||
username: context.username,
|
||||
user_settings: {
|
||||
theme: 'dark',
|
||||
},
|
||||
device,
|
||||
links: {
|
||||
select_device: null,
|
||||
challenge_form: `/auth/${realm}/signin/challenge/${deviceUrn}`,
|
||||
try_different_user: `/auth/${realm}/signin/identifier`,
|
||||
select_device: selectDeviceUrl,
|
||||
challenge_form: `/v1/auth/${realm}/signin/challenge/${deviceUrn}`,
|
||||
try_different_user: `/v1/auth/${realm}/signin/identifier`,
|
||||
},
|
||||
error: 'Password is required.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (await SecureStringUtil.compare(value.password, passwordDevice.passwordHashString)) {
|
||||
|
||||
if (twoFactorRequired) {
|
||||
const twoFactorDevices = devicesForUser.filter(d => d.twoFactorEligible && d.urn !== deviceUrn);
|
||||
const selectedDevice = twoFactorDevices.length === 1 ? twoFactorDevices[0] : twoFactorDevices.find(d => d.twoFactorPreferred);
|
||||
if (deviceInfo.hasSecondaryDevice) {
|
||||
|
||||
if (!selectedDevice) {
|
||||
throw new InternalServerErrorException();
|
||||
if (!deviceInfo.preferredSecondaryDevice) {
|
||||
throw new SeeOtherException(`/v1/auth/${realm}/signing/2fa/challenge?${redirectQueryParams.toString()}`);
|
||||
}
|
||||
|
||||
return res.render(Views.LoginCodeChallenge, {});
|
||||
throw new SeeOtherException(`/v1/auth/${realm}/signing/2fa/challenge/${deviceInfo.preferredSecondaryDevice.urn}?${redirectQueryParams.toString()}`);
|
||||
}
|
||||
|
||||
await this.accessAttemptService.recordAttempt(req, context.username, true);
|
||||
return res.redirect('https://google.com');
|
||||
throw new SeeOtherException(context.state.htu ?? 'https://google.com', true);
|
||||
}
|
||||
|
||||
const { isLocked } = await this.accessAttemptService.recordAttempt(req, context.username, false);
|
||||
|
|
@ -116,20 +150,19 @@ export class ChallengeController {
|
|||
throw new TooManyRequestsException();
|
||||
}
|
||||
|
||||
return res.render(Views.LoginPasswordChallenge, {
|
||||
realm,
|
||||
state: this.stateManager.updateState(context.state),
|
||||
return {
|
||||
state: refreshedState,
|
||||
username: context.username,
|
||||
user_settings: {
|
||||
theme: 'dark',
|
||||
},
|
||||
device,
|
||||
links: {
|
||||
select_device: devicesForUser.length > 1 ? `/auth/${realm}/signin/challenge` : null,
|
||||
challenge_form: `/auth/${realm}/signin/challenge/${deviceUrn}`,
|
||||
try_different_user: `/auth/${realm}/signin/identifier`,
|
||||
select_device: selectDeviceUrl,
|
||||
challenge_form: `/v1/auth/${realm}/signin/challenge/${deviceUrn}`,
|
||||
try_different_user: `/v1/auth/${realm}/signin/identifier`,
|
||||
},
|
||||
error: 'Invalid username or password.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new SeeOtherException(`/v1/auth/${realm}/signin/error`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
import { Authentication } from '../../../domain/authentication.types';
|
||||
import { LoginRequest, RequestContext } from './login.types';
|
||||
|
||||
export const Context = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext): Authentication.Login.RequestContext => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request?.context;
|
||||
(data: unknown, ctx: ExecutionContext): RequestContext | null => {
|
||||
const request = ctx.switchToHttp().getRequest<LoginRequest>();
|
||||
return request.context!;
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { Identity } from '../../../domain/identity.types';
|
||||
import { IdentityAuthDeviceDao } from '../../../persistence/identity-auth-device.dao';
|
||||
|
||||
interface DeviceResults {
|
||||
hasSecondaryDevice: boolean;
|
||||
preferredPrimaryDevice: Identity.AuthDevice | null;
|
||||
preferredSecondaryDevice: Identity.AuthDevice | null;
|
||||
primaryDevices: Identity.AuthDevice[];
|
||||
secondaryDevices: Identity.AuthDevice[]
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DeviceCalculatorService {
|
||||
|
||||
constructor(
|
||||
private readonly identityAuthDeviceDao: IdentityAuthDeviceDao,
|
||||
) {}
|
||||
|
||||
async considerDevicesForUser(realm: string, username: string): Promise<DeviceResults> {
|
||||
const devicesForUser = await this.identityAuthDeviceDao.findByRealmAndUsername(realm, username);
|
||||
|
||||
const primaryDevices = devicesForUser.filter(d => !d.twoFactorEligible);
|
||||
const secondaryDevices = devicesForUser.filter(d => d.twoFactorEligible);
|
||||
const hasSecondaryDevice = devicesForUser.some(d => d.twoFactorEligible);
|
||||
|
||||
return {
|
||||
hasSecondaryDevice,
|
||||
preferredPrimaryDevice: primaryDevices.find(d => d.preferred) ?? null,
|
||||
preferredSecondaryDevice: secondaryDevices.find(d => d.preferred) ?? null,
|
||||
primaryDevices,
|
||||
secondaryDevices,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Controller } from '@nestjs/common';
|
||||
|
||||
@Controller({
|
||||
version: '1',
|
||||
path: 'auth/:realm/signin/challenge',
|
||||
})
|
||||
export class ErrorController {
|
||||
|
||||
}
|
||||
|
|
@ -1,13 +1,15 @@
|
|||
import { Controller, Get, Param, Post, Query, Res, UseInterceptors } from '@nestjs/common';
|
||||
import { Controller, Get, Param, Post, Render, Res, UseInterceptors } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { MVCResponse, Views } from '../mvc.types';
|
||||
import { Views } from '../mvc.types';
|
||||
import { StateManagerService } from './state-manager.service';
|
||||
import { Authentication } from '../../../domain/authentication.types';
|
||||
import { Identity } from '../../../domain/identity.types';
|
||||
import { LoginContextInterceptor } from './login-context.interceptor';
|
||||
import { IdentityAuthDeviceDao } from '../../../persistence/identity-auth-device.dao';
|
||||
import { Context } from './context.decorator';
|
||||
import { IdentifierRenderContext, RequestContext } from './login.types';
|
||||
import { DeviceCalculatorService } from './device-calculator.service';
|
||||
import { SeeOtherException } from '../../exceptions/see-other.exception';
|
||||
|
||||
@Controller({
|
||||
version: '1',
|
||||
|
|
@ -17,71 +19,54 @@ export class IdentifierController {
|
|||
|
||||
constructor(
|
||||
private readonly stateManager: StateManagerService,
|
||||
private readonly identityAuthDeviceDao: IdentityAuthDeviceDao,
|
||||
private readonly deviceCalcService: DeviceCalculatorService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@Render(Views.LoginIdentifier)
|
||||
async getLogin(
|
||||
@Param('realm') realm: string,
|
||||
@Res() res: MVCResponse,
|
||||
) {
|
||||
): Promise<IdentifierRenderContext> {
|
||||
|
||||
const state = await this.stateManager.getNewState();
|
||||
|
||||
return res.render(Views.LoginView, {
|
||||
realm,
|
||||
return {
|
||||
state,
|
||||
user_settings: {
|
||||
theme: 'dark',
|
||||
},
|
||||
links: {
|
||||
identifier_form: `/auth/${realm}/signin/identifier`
|
||||
identifier_form: `/v1/auth/${realm}/signin/identifier`
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Render(Views.LoginIdentifier)
|
||||
@UseInterceptors(LoginContextInterceptor)
|
||||
async postLogin(
|
||||
@Param('realm') realm: string,
|
||||
@Context() context: Authentication.Login.RequestContext,
|
||||
@Res() res: MVCResponse,
|
||||
) {
|
||||
@Context() context: RequestContext,
|
||||
): Promise<IdentifierRenderContext> {
|
||||
|
||||
const devicesForUser = await this.identityAuthDeviceDao.findByRealmAndUsername(realm, context.username);
|
||||
const deviceInfo = await this.deviceCalcService.considerDevicesForUser(realm, context.username);
|
||||
const refreshedState = this.stateManager.updateState(context.state);
|
||||
|
||||
if (devicesForUser.length === 0) {
|
||||
return res.render(Views.LoginPasswordChallenge, {
|
||||
realm,
|
||||
state: this.stateManager.updateState(context.state),
|
||||
username: context.username,
|
||||
user_settings: {
|
||||
theme: 'dark',
|
||||
},
|
||||
links: {
|
||||
select_device: null,
|
||||
challenge_form: `/auth/${realm}/signin/challenge/${randomUUID()}`,
|
||||
try_different_user: `/auth/${realm}/signin/identifier`,
|
||||
}
|
||||
});
|
||||
const redirectQueryParams = new URLSearchParams();
|
||||
redirectQueryParams.append('username', context.username);
|
||||
redirectQueryParams.append('state', refreshedState);
|
||||
|
||||
|
||||
if (deviceInfo.primaryDevices.length === 0) {
|
||||
throw new SeeOtherException(`/v1/auth/${realm}/signin/challenge/${randomUUID()}?${redirectQueryParams.toString()}`);
|
||||
}
|
||||
|
||||
const selectedDevice = devicesForUser.length === 1 ? devicesForUser[0] : devicesForUser.find(d => d.preferred);
|
||||
if (deviceInfo.preferredPrimaryDevice?.deviceType === Identity.AuthDevice.Type.Password) {
|
||||
throw new SeeOtherException(`/v1/auth/${realm}/signin/challenge/${deviceInfo.preferredPrimaryDevice.urn}?${redirectQueryParams.toString()}`);
|
||||
}
|
||||
|
||||
if (selectedDevice?.deviceType === Identity.AuthDevice.Type.Password) {
|
||||
return res.render(Views.LoginPasswordChallenge, {
|
||||
realm,
|
||||
state: this.stateManager.updateState(context.state),
|
||||
username: context.username,
|
||||
user_settings: {
|
||||
theme: 'dark',
|
||||
},
|
||||
links: {
|
||||
select_device: devicesForUser.length > 1 ? `/auth/${realm}/signin/challenge` : null,
|
||||
challenge_form: `/auth/${realm}/signin/challenge/${selectedDevice.urn}`,
|
||||
try_different_user: `/auth/${realm}/signin/identifier`,
|
||||
}
|
||||
});
|
||||
return {
|
||||
state: refreshedState,
|
||||
links: {
|
||||
identifier_form: `/v1/auth/${realm}/signin/identifier`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ import { Observable } from 'rxjs';
|
|||
|
||||
import { TokenManagementService } from '../../../token-management/token-management.service';
|
||||
import { AccessAttemptService } from './access-attempt.service';
|
||||
import { Authentication } from '../../../domain/authentication.types';
|
||||
import { DateUtil } from '../../../utils';
|
||||
import { DeepPartial } from '../../../utils/deep-partial.type';
|
||||
import { TooManyRequestsException } from '../../exceptions/too-many-requests.exception';
|
||||
import { LoginRequest, RequestContext, State } from './login.types';
|
||||
|
||||
type RequestContextMutation = DeepPartial<Authentication.Login.RequestContext>
|
||||
type RequestContextMutation = DeepPartial<RequestContext>
|
||||
|
||||
const standardBadRequestError = 'State was mutated or expired';
|
||||
|
||||
|
|
@ -24,10 +24,10 @@ export class LoginContextInterceptor implements NestInterceptor {
|
|||
) {}
|
||||
|
||||
async intercept(context: ExecutionContext, next: CallHandler<any>): Promise<Observable<any>> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const request = context.switchToHttp().getRequest<LoginRequest>();
|
||||
const loginContextMutable: RequestContextMutation = {};
|
||||
|
||||
loginContextMutable.username = request?.body?.username;
|
||||
loginContextMutable.username = request.method === 'POST' ? request?.body?.username : request?.query?.username;
|
||||
|
||||
if (!loginContextMutable.username) {
|
||||
this.logger.debug('Request did not provide username');
|
||||
|
|
@ -40,22 +40,25 @@ export class LoginContextInterceptor implements NestInterceptor {
|
|||
throw new TooManyRequestsException();
|
||||
}
|
||||
|
||||
if (!request?.body?.state) {
|
||||
const state = request.method === 'POST' ? request?.body?.state : request?.query?.state;
|
||||
|
||||
if (!state) {
|
||||
this.logger.debug('State was not provided in body');
|
||||
throw new BadRequestException(standardBadRequestError);
|
||||
}
|
||||
|
||||
const receipt = this.tokenService.parseToken<Authentication.Login.State>(request.body.state);
|
||||
const receipt = this.tokenService.parseToken<State>(state);
|
||||
|
||||
if (receipt.isToken && receipt.signatureValid) {
|
||||
loginContextMutable.state = receipt.payload;
|
||||
}
|
||||
|
||||
const { error, value: loginContext } = Joi.object<Authentication.Login.RequestContext, true>({
|
||||
state: Joi.object<Authentication.Login.State, true>({
|
||||
const { error, value: loginContext } = Joi.object<RequestContext, true>({
|
||||
state: Joi.object<State, true>({
|
||||
jwi: Joi.string().required(),
|
||||
exp: Joi.number().required(),
|
||||
tfa: Joi.boolean().required(),
|
||||
htu: Joi.string(),
|
||||
}),
|
||||
username: Joi.string().required(),
|
||||
}).validate(loginContextMutable, { allowUnknown: false });
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { TokenManagementModule } from '../../../token-management/token-managemen
|
|||
import { StateManagerService } from './state-manager.service';
|
||||
import { IdentifierController } from './identifier.controller';
|
||||
import { ChallengeController } from './challenge.controller';
|
||||
import { DeviceCalculatorService } from './device-calculator.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -25,6 +26,7 @@ import { ChallengeController } from './challenge.controller';
|
|||
AccessAttemptService,
|
||||
LoginContextInterceptor,
|
||||
StateManagerService,
|
||||
DeviceCalculatorService,
|
||||
],
|
||||
})
|
||||
export class LoginModule {}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,24 @@
|
|||
import { GlobalProps } from '../mvc.types';
|
||||
|
||||
interface CommonContext extends GlobalProps {
|
||||
state: string;
|
||||
realm: string;
|
||||
import { Request as ExpressRequest, Response as ExpressResponse } from 'express';
|
||||
import { Identity } from '../../../domain/identity.types';
|
||||
import { Views } from '../mvc.types';
|
||||
|
||||
export interface LoginRequest extends ExpressRequest {
|
||||
context?: RequestContext;
|
||||
}
|
||||
|
||||
export interface IdentifierRenderContext extends CommonContext {
|
||||
export interface IdentifierRenderContext {
|
||||
state: string;
|
||||
realm: string;
|
||||
links: {
|
||||
identifier_form: string;
|
||||
}
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PasswordChallengeRenderContext extends CommonContext {
|
||||
export interface ChallengeRenderContext {
|
||||
state: string;
|
||||
username: string;
|
||||
device: Identity.AuthDevice | null;
|
||||
links: {
|
||||
select_device: string | null;
|
||||
challenge_form: string;
|
||||
|
|
@ -22,3 +26,24 @@ export interface PasswordChallengeRenderContext extends CommonContext {
|
|||
},
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
||||
export interface State {
|
||||
jwi: string;
|
||||
exp: number;
|
||||
tfa: boolean;
|
||||
htu?: string;
|
||||
}
|
||||
|
||||
export interface RequestContext {
|
||||
state: State;
|
||||
username: string;
|
||||
}
|
||||
|
||||
type ViewContextPairs =
|
||||
[view: Views.LoginChallenge, ctx: ChallengeRenderContext] |
|
||||
[view: Views.LoginIdentifier, ctx: IdentifierRenderContext] ;
|
||||
|
||||
export interface LoginResponse extends Omit<ExpressResponse, 'render'> {
|
||||
render(...args: ViewContextPairs): Promise<void>
|
||||
}
|
||||
|
|
@ -2,9 +2,9 @@ import { Injectable } from '@nestjs/common';
|
|||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { CacheService } from '../../../cache/cache.service';
|
||||
import { Authentication } from '../../../domain/authentication.types';
|
||||
import { TokenManagementService } from '../../../token-management/token-management.service';
|
||||
import { DateUtil } from '../../../utils';
|
||||
import { State } from './login.types';
|
||||
|
||||
const getStateKey = (jwi: string) => `login:state:${jwi}`;
|
||||
|
||||
|
|
@ -19,7 +19,7 @@ export class StateManagerService {
|
|||
async getNewState(): Promise<string> {
|
||||
const date = DateUtil.fromDate(new Date()).addNMinutes(15);
|
||||
|
||||
const stateObj: Authentication.Login.State = {
|
||||
const stateObj: State = {
|
||||
jwi: randomUUID(),
|
||||
exp: date.seconds,
|
||||
tfa: false,
|
||||
|
|
@ -30,7 +30,7 @@ export class StateManagerService {
|
|||
return this.tokenService.createToken(stateObj);
|
||||
}
|
||||
|
||||
updateState(state: Authentication.Login.State, dto: { tfa?: boolean } = {}): string {
|
||||
updateState(state: State, dto: { tfa?: boolean } = {}): string {
|
||||
return this.tokenService.createToken({ ...state, ...dto });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { LoginModule } from './login/login.module';
|
||||
import { UserAgentController } from './user-agent.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
LoginModule,
|
||||
],
|
||||
controllers: [
|
||||
UserAgentController,
|
||||
]
|
||||
})
|
||||
export class MvcModule {}
|
||||
|
|
@ -1,26 +1,6 @@
|
|||
import { Response } from 'express';
|
||||
|
||||
import { IdentifierRenderContext, PasswordChallengeRenderContext } from './login/login.types';
|
||||
|
||||
export interface GlobalProps {
|
||||
user_settings: {
|
||||
theme: 'light' | 'dark';
|
||||
}
|
||||
}
|
||||
|
||||
export enum Views {
|
||||
LoginPasswordChallenge = 'login-password-challenge',
|
||||
LoginCodeChallenge = 'login-code-challenge',
|
||||
LoginView = 'login-view',
|
||||
LoginChallenge = 'login-challenge',
|
||||
LoginIdentifier = 'login-identifier',
|
||||
NotFound = 'not-found',
|
||||
}
|
||||
|
||||
type ViewAndContext =
|
||||
[view: Views.LoginPasswordChallenge, context: PasswordChallengeRenderContext] |
|
||||
[view: Views.LoginView, context: IdentifierRenderContext] |
|
||||
[view: Views.LoginCodeChallenge, context: {}] |
|
||||
[view: Views.NotFound, context: GlobalProps];
|
||||
|
||||
export interface MVCResponse extends Omit<Response, 'render'> {
|
||||
render: (...args: ViewAndContext) => void;
|
||||
UserAgentThemeSwitch = 'user-agent-theme-switch',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import { Controller, Put, Render, Req, Res } from '@nestjs/common';
|
||||
import { Cookies, RegisteredCookies } from '../decorators/cookies.decorator';
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { Views } from './mvc.types';
|
||||
|
||||
@Controller({
|
||||
version: '1',
|
||||
path: 'user-agent',
|
||||
})
|
||||
export class UserAgentController {
|
||||
|
||||
@Put('toggle-theme')
|
||||
@Render(Views.UserAgentThemeSwitch)
|
||||
async toggleTheme(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Cookies(RegisteredCookies.Theme) theme?: string,
|
||||
) {
|
||||
|
||||
if (theme === 'dark') {
|
||||
req.cookies[RegisteredCookies.Theme] = 'light';
|
||||
res.cookie(RegisteredCookies.Theme, 'light');
|
||||
return {};
|
||||
}
|
||||
|
||||
req.cookies[RegisteredCookies.Theme] = 'dark';
|
||||
res.cookie(RegisteredCookies.Theme, 'dark');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { Request as ExpressRequest } from 'express';
|
||||
|
||||
export interface UserAgentSettings {
|
||||
theme: 'light' | 'dark';
|
||||
links: {
|
||||
toggle_theme: string;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Request extends ExpressRequest {
|
||||
userAgentSettings: UserAgentSettings;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
|
||||
@Module({
|
||||
controllers: [
|
||||
],
|
||||
})
|
||||
export class RestModule {}
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Logger, VERSION_NEUTRAL, VersioningType } from '@nestjs/common';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import * as cookieParser from 'cookie-parser';
|
||||
import { join } from 'path';
|
||||
|
||||
import { AppModule } from './app.module';
|
||||
import { ConfigService } from './config/config.service';
|
||||
import { SystemSettings } from './domain/system-settings.types';
|
||||
import { UserAgentSettingsInterceptor } from './http/interceptors/user-agent-settings.interceptor';
|
||||
import { RedirectExceptionFilter } from './http/exception-filters/redirect.exception-filter';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
|
|
@ -21,6 +24,18 @@ async function bootstrap() {
|
|||
app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal']);
|
||||
}
|
||||
|
||||
app.use(cookieParser());
|
||||
|
||||
app.enableVersioning({
|
||||
type: VersioningType.URI,
|
||||
defaultVersion: VERSION_NEUTRAL,
|
||||
});
|
||||
|
||||
const userAgentSettingsInterceptor = app.get(UserAgentSettingsInterceptor);
|
||||
app.useGlobalInterceptors(userAgentSettingsInterceptor);
|
||||
|
||||
app.useGlobalFilters(new RedirectExceptionFilter());
|
||||
|
||||
await app.listen(3000, () => {
|
||||
logger.log('Listening on port 3000', 'main.ts');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -54,7 +54,6 @@ export class _00ApplicationBootstrapDataMigration implements DataMigration {
|
|||
deviceType: Identity.AuthDevice.Type.Password,
|
||||
attributes: JSON.stringify(passwordDevice),
|
||||
preferred: true,
|
||||
twoFactorPreferred: false,
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ export class IdentityAuthDeviceDao {
|
|||
deviceType: model.deviceType as Identity.AuthDevice.Type,
|
||||
preferred: model.preferred,
|
||||
twoFactorEligible: eligibleForTwoFactor.includes(model.deviceType as Identity.AuthDevice.Type),
|
||||
twoFactorPreferred: model.twoFactorPreferred,
|
||||
createdAt: new Date(model.createdAt),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
doctype html
|
||||
html(data-theme=user_settings.theme)
|
||||
doctype html
|
||||
html(data-theme=user_agent_settings.theme)
|
||||
head
|
||||
link(rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css")
|
||||
script(src="https://unpkg.com/htmx.org@1.9.12" integrity="sha384-ujb1lZYygJmzgSwoxRggbCHcjc0rB2XoQrxeTUQyRjrOnlCoYta87iKBWq3EsdM2" crossorigin="anonymous")
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ extends base.pug
|
|||
|
||||
block content
|
||||
main
|
||||
include user-agent-theme-switch.pug
|
||||
form(hx-post=links.identifier_form hx-swap="outerHTML")
|
||||
fieldset
|
||||
input(type="hidden" name="state" value=state)
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
- var isChecked = user_agent_settings.theme === 'dark'
|
||||
div#themeToggleContainer
|
||||
input(name="terms" type="checkbox" role="switch" hx-put=user_agent_settings.links.toggle_theme hx-swap="innerHTML" hx-select='div#themeToggleContainer' checked=isChecked)
|
||||
span Toggle Light/Dark Mode
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
{
|
||||
"name": "homelab-personal-cloud",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"cookie-parser": "^1.4.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie-parser": "^1.4.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
|
||||
"integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/connect": {
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie-parser": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||
"integrity": "sha512-Fvuyi354Z+uayxzIGCwYTayFKocfV7TuDYZClCdIP9ckhvAu/ixDtCB6qx2TT0FKjPLf1f3P/J1rgf6lPs64mw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/express": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz",
|
||||
"integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^4.17.33",
|
||||
"@types/qs": "*",
|
||||
"@types/serve-static": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/express-serve-static-core": {
|
||||
"version": "4.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.0.tgz",
|
||||
"integrity": "sha512-bGyep3JqPCRry1wq+O5n7oiBgGWmeIJXPjXXCo8EK0u8duZGSYar7cGqd3ML2JUsLGeB7fmc06KYo9fLGWqPvQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/qs": "*",
|
||||
"@types/range-parser": "*",
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/http-errors": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz",
|
||||
"integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/mime": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.12.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz",
|
||||
"integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.9.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz",
|
||||
"integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/range-parser": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "0.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz",
|
||||
"integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/mime": "^1",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/serve-static": {
|
||||
"version": "1.15.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz",
|
||||
"integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
"@types/node": "*",
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz",
|
||||
"integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser": {
|
||||
"version": "1.4.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz",
|
||||
"integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==",
|
||||
"dependencies": {
|
||||
"cookie": "0.4.1",
|
||||
"cookie-signature": "1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"cookie-parser": "^1.4.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie-parser": "^1.4.7"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue