This commit is contained in:
Matthew Bessette 2024-04-22 00:20:21 +00:00
parent 8db8ce9f47
commit b26e963bd6
34 changed files with 557 additions and 170 deletions

View File

@ -212,7 +212,6 @@ model IdentityAuthDevice {
attributes String
preferred Boolean
twoFactorPreferred Boolean
createdAt DateTime @default(now())
@@index([userId])

View File

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

View File

@ -6,7 +6,6 @@ export namespace Identity {
readonly deviceType: AuthDevice.Type,
readonly preferred: boolean;
readonly twoFactorEligible: boolean;
readonly twoFactorPreferred: boolean;
readonly createdAt: Date,
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,9 @@
import { Controller } from '@nestjs/common';
@Controller({
version: '1',
path: 'auth/:realm/signin/challenge',
})
export class ErrorController {
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
@Module({
controllers: [
],
})
export class RestModule {}

View File

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

View File

@ -54,7 +54,6 @@ export class _00ApplicationBootstrapDataMigration implements DataMigration {
deviceType: Identity.AuthDevice.Type.Password,
attributes: JSON.stringify(passwordDevice),
preferred: true,
twoFactorPreferred: false,
}
});

View File

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

View File

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

View File

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

View File

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

152
package-lock.json generated Normal file
View File

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

8
package.json Normal file
View File

@ -0,0 +1,8 @@
{
"dependencies": {
"cookie-parser": "^1.4.6"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.7"
}
}