This commit is contained in:
Matthew Bessette 2024-04-21 16:47:27 +00:00
parent 59540510b7
commit 8db8ce9f47
89 changed files with 620 additions and 5076 deletions

View File

@ -4,6 +4,6 @@
"sourceRoot": "src", "sourceRoot": "src",
"compilerOptions": { "compilerOptions": {
"deleteOutDir": true, "deleteOutDir": true,
"plugins": ["@nestjs/graphql"] "plugins": []
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -23,25 +23,18 @@
"migrate:post": "nest start --entryFile prisma-post-migrations" "migrate:post": "nest start --entryFile prisma-post-migrations"
}, },
"dependencies": { "dependencies": {
"@apollo/server": "^4.10.2",
"@nestjs/apollo": "^12.1.0",
"@nestjs/common": "^10.0.0", "@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0", "@nestjs/core": "^10.0.0",
"@nestjs/graphql": "^12.1.1",
"@nestjs/platform-express": "^10.0.0", "@nestjs/platform-express": "^10.0.0",
"@prisma/client": "^5.12.1", "@prisma/client": "^5.12.1",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"graphql": "^16.8.1", "express": "^4.19.2",
"joi": "17.6.4", "joi": "17.6.4",
"pug": "^3.0.2", "pug": "^3.0.2",
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
"devDependencies": { "devDependencies": {
"@graphql-codegen/cli": "^5.0.2",
"@graphql-codegen/introspection": "4.0.3",
"@graphql-codegen/typescript": "^4.0.6",
"@graphql-codegen/typescript-resolvers": "^4.0.6",
"@nestjs/cli": "^10.0.0", "@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0", "@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0", "@nestjs/testing": "^10.0.0",

View File

@ -157,6 +157,7 @@ CREATE TABLE "IdentityAuthDevice" (
"deviceType" TEXT NOT NULL, "deviceType" TEXT NOT NULL,
"attributes" TEXT NOT NULL, "attributes" TEXT NOT NULL,
"preferred" BOOLEAN NOT NULL, "preferred" BOOLEAN NOT NULL,
"twoFactorPreferred" BOOLEAN NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "IdentityAuthDevice_userId_fkey" FOREIGN KEY ("userId") REFERENCES "IdentityUser" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, CONSTRAINT "IdentityAuthDevice_userId_fkey" FOREIGN KEY ("userId") REFERENCES "IdentityUser" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "IdentityAuthDevice_deviceType_fkey" FOREIGN KEY ("deviceType") REFERENCES "EnumIdentityAuthDeviceType" ("enumValue") ON DELETE RESTRICT ON UPDATE CASCADE CONSTRAINT "IdentityAuthDevice_deviceType_fkey" FOREIGN KEY ("deviceType") REFERENCES "EnumIdentityAuthDeviceType" ("enumValue") ON DELETE RESTRICT ON UPDATE CASCADE

View File

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

View File

@ -3,7 +3,6 @@ import { Module } from '@nestjs/common';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { ConfigModule } from './config/config.module'; import { ConfigModule } from './config/config.module';
import { PersistenceModule } from './persistence/persistence.module'; import { PersistenceModule } from './persistence/persistence.module';
import { GraphqlServerModule } from './graphql-server/graphql-server.module';
import { HttpModule } from './http/http.module'; import { HttpModule } from './http/http.module';
@Module({ @Module({
@ -11,7 +10,6 @@ import { HttpModule } from './http/http.module';
HttpModule, HttpModule,
CacheModule, CacheModule,
ConfigModule, ConfigModule,
GraphqlServerModule,
PersistenceModule, PersistenceModule,
], ],
controllers: [], controllers: [],

View File

@ -1,5 +1,5 @@
export interface CacheDriver { export interface CacheDriver {
get(key: string): Promise<string>; get(key: string): Promise<string | null>;
set(key: string, val: string): Promise<'OK'>; set(key: string, val: string): Promise<'OK'>;
exists(...keys: string[]): Promise<number>; exists(...keys: string[]): Promise<number>;
expire(key: string, seconds: number): Promise<number>; expire(key: string, seconds: number): Promise<number>;

View File

@ -5,13 +5,15 @@ export class InMemoryDriver implements CacheDriver {
private readonly cache: Record<string, { val: string, ttl?: DateUtil }> = {}; private readonly cache: Record<string, { val: string, ttl?: DateUtil }> = {};
get(key: string): Promise<string> { async get(key: string): Promise<string | null> {
if (!this.cache[key] || !this.cache[key].ttl?.isInTheFuture()) { const property = this.cache[key];
return Promise.resolve(null);
if (!property || !property.ttl?.isInTheFuture()) {
return null;
} }
return Promise.resolve(this.cache[key].val); return property.val;
} }
set(key: string, val: string): Promise<'OK'> { set(key: string, val: string): Promise<'OK'> {
this.cache[key] = { val }; this.cache[key] = { val };
@ -29,10 +31,16 @@ export class InMemoryDriver implements CacheDriver {
); );
} }
expire(key: string, seconds: number): Promise<number> { async expire(key: string, seconds: number): Promise<number> {
const dateUtil = DateUtil.fromDate(new Date()).addNSeconds(seconds); const dateUtil = DateUtil.fromDate(new Date()).addNSeconds(seconds);
this.cache[key].ttl = dateUtil; const property = this.cache[key];
return Promise.resolve(1);
if (!property) {
return 0;
}
property.ttl = dateUtil;
return 1;
} }
del(key: string): Promise<number> { del(key: string): Promise<number> {

View File

@ -9,7 +9,4 @@ export const configValidator: Joi.ObjectSchema<SystemSettings.Config> = Joi.obje
[SystemSettings.Auth.Oauth2.Enabled]: Joi.boolean().required(), [SystemSettings.Auth.Oauth2.Enabled]: Joi.boolean().required(),
[SystemSettings.Auth.Oauth2.EncryptionSecret]: Joi.string().required(), [SystemSettings.Auth.Oauth2.EncryptionSecret]: Joi.string().required(),
[SystemSettings.Auth.TokenManagement.SigningSecret]: Joi.string().required(), [SystemSettings.Auth.TokenManagement.SigningSecret]: Joi.string().required(),
[SystemSettings.Graphql.Debug]: Joi.boolean().required(),
[SystemSettings.Graphql.IntrospectionEnabled]: Joi.boolean().required(),
[SystemSettings.Graphql.PlaygroundEnabled]: Joi.boolean().required(),
}); });

View File

@ -26,11 +26,8 @@ export namespace Authentication {
} }
export interface RequestContext { export interface RequestContext {
isComplete: boolean; state: State;
rawState?: Object; username: string;
state?: State;
username?: string;
isLocked?: boolean;
} }
} }

View File

@ -5,6 +5,8 @@ export namespace Identity {
readonly userId: number, readonly userId: number,
readonly deviceType: AuthDevice.Type, readonly deviceType: AuthDevice.Type,
readonly preferred: boolean; readonly preferred: boolean;
readonly twoFactorEligible: boolean;
readonly twoFactorPreferred: boolean;
readonly createdAt: Date, readonly createdAt: Date,
} }
@ -14,19 +16,13 @@ export namespace Identity {
ApplicationPassword = 'application_password', ApplicationPassword = 'application_password',
} }
export enum PasswordHashKey {
Expiry = 'expiry',
PasswordHashString = 'password_hash_string',
Locked = 'locked',
}
export interface PasswordAttributes { export interface PasswordAttributes {
readonly expiry: Date; readonly expiry: Date;
readonly passwordHashString: string; readonly passwordHashString: string;
readonly locked: boolean; readonly locked: boolean;
} }
export type Password = AuthDevice | PasswordAttributes; export interface Password extends AuthDevice, PasswordAttributes {}
} }
export namespace Group { export namespace Group {

View File

@ -7,9 +7,6 @@ export namespace SystemSettings {
[Auth.Oauth2.Enabled]: boolean; [Auth.Oauth2.Enabled]: boolean;
[Auth.Oauth2.EncryptionSecret]: string; [Auth.Oauth2.EncryptionSecret]: string;
[Auth.TokenManagement.SigningSecret]: string; [Auth.TokenManagement.SigningSecret]: string;
[Graphql.Debug]: boolean;
[Graphql.IntrospectionEnabled]: boolean;
[Graphql.PlaygroundEnabled]: boolean;
} }
export namespace Auth { export namespace Auth {
@ -29,12 +26,6 @@ export namespace SystemSettings {
} }
} }
export enum Graphql {
Debug = 'graphql.debug.enabled',
IntrospectionEnabled = 'graphql.introspection.enabled',
PlaygroundEnabled = 'graphql.playground.enabled',
}
export namespace Dav { export namespace Dav {
export enum Contacts { export enum Contacts {
Enabled = 'dav.contacts.enabled', Enabled = 'dav.contacts.enabled',

View File

@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Request } from 'express'; import { Request } from 'express';
import { ConfigService } from '../../../config/config.service'; import { ConfigService } from '../../../config/config.service';
@ -21,6 +21,8 @@ interface RequestData {
@Injectable() @Injectable()
export class AccessAttemptService { export class AccessAttemptService {
private readonly logger: Logger = new Logger(AccessAttemptService.name);
private readonly maxAttemptsAllowed: number; private readonly maxAttemptsAllowed: number;
private readonly lockTimeout: number; private readonly lockTimeout: number;
@ -35,26 +37,31 @@ export class AccessAttemptService {
async checkIfLocked(request: Request, username: string): Promise<boolean> { async checkIfLocked(request: Request, username: string): Promise<boolean> {
const { ipAddressKey, usernameKey } = this.parseRequestData(request, username); const { ipAddressKey, usernameKey, ip } = this.parseRequestData(request, username);
const ipAttempts = +await this.cacheService.get(ipAddressKey) ?? 0; const ipAttempts = +(await this.cacheService.get(ipAddressKey) ?? 0);
const usernameAttempts = +await this.cacheService.get(usernameKey) ?? 0; const usernameAttempts = +(await this.cacheService.get(usernameKey) ?? 0);
if (ipAttempts >= this.maxAttemptsAllowed || usernameAttempts >= this.maxAttemptsAllowed) { if (ipAttempts >= this.maxAttemptsAllowed || usernameAttempts >= this.maxAttemptsAllowed) {
await this.cacheService.set(ipAddressKey, (ipAttempts + 1).toString(), this.lockTimeout); const newIpAttempts = (ipAttempts + 1).toString();
await this.cacheService.set(usernameKey, (usernameAttempts + 1).toString(), this.lockTimeout); await this.cacheService.set(ipAddressKey, newIpAttempts, this.lockTimeout);
this.logger.debug(`${ip} now has ${newIpAttempts} failed attempts tracked`);
const newUsernameAttempts = (usernameAttempts + 1).toString();
await this.cacheService.set(usernameKey, newUsernameAttempts, this.lockTimeout);
this.logger.debug(`${username} now has ${newUsernameAttempts} failed attempts tracked`);
return true; return true;
} }
return false; return false;
} }
async recordAttempt(request: Request, username: string, loginSucceeded: boolean): Promise<void> { async recordAttempt(request: Request, username: string, loginSucceeded: boolean): Promise<{ isLocked: boolean }> {
const isLocked = await this.checkIfLocked(request, username); const isLocked = await this.checkIfLocked(request, username);
if (isLocked) { if (isLocked) {
return; return { isLocked: true };
} }
const { const {
@ -76,14 +83,28 @@ export class AccessAttemptService {
if (loginSucceeded) { if (loginSucceeded) {
await this.cacheService.del(ipAddressKey); await this.cacheService.del(ipAddressKey);
await this.cacheService.del(usernameKey); await this.cacheService.del(usernameKey);
return { isLocked: false }
} }
const ipAttempts = +(await this.cacheService.get(ipAddressKey) ?? 0);
const usernameAttempts = +(await this.cacheService.get(usernameKey) ?? 0);
const newIpAttempts = (ipAttempts + 1).toString();
await this.cacheService.set(ipAddressKey, newIpAttempts, this.lockTimeout);
this.logger.debug(`${ip} now has ${newIpAttempts} failed attempts tracked`);
const newUsernameAttempts = (usernameAttempts + 1).toString();
await this.cacheService.set(usernameKey, newUsernameAttempts, this.lockTimeout);
this.logger.debug(`${username} now has ${newUsernameAttempts} failed attempts tracked`);
return { isLocked: false };
} }
private parseRequestData(request: Request, username: string): RequestData { private parseRequestData(request: Request, username: string): RequestData {
const userAgent = request.headers['user-agent']; const userAgent = request.headers['user-agent'] ?? '';
const requestPath = request.path; const requestPath = request.path;
const ip = request.ip; const ip = request.ip ?? '';
const ipAddressKey = `access-attempt:ip:${ip}`; const ipAddressKey = `access-attempt:ip:${ip}`;
const usernameKey = `access-attempt:username:${username}`; const usernameKey = `access-attempt:username:${username}`;

View File

@ -0,0 +1,135 @@
import { BadRequestException, Body, Controller, Get, InternalServerErrorException, Param, Post, Req, Res, UseInterceptors } from '@nestjs/common';
import { Request } 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 { 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';
@Controller({
version: '1',
path: 'auth/:realm/signin/challenge',
})
@UseInterceptors(LoginContextInterceptor)
export class ChallengeController {
constructor(
private readonly stateManager: StateManagerService,
private readonly identityAuthDeviceDao: IdentityAuthDeviceDao,
private readonly accessAttemptService: AccessAttemptService,
) {}
@Get()
async selectDevice() {
}
@Post(':device_urn')
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,
) {
const devicesForUser = await this.identityAuthDeviceDao.findByRealmAndUsername(realm, context.username);
const device = devicesForUser.find(d => d.urn === deviceUrn);
const twoFactorRequired = devicesForUser.some(d => d.twoFactorEligible);
if (!device || devicesForUser.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),
username: context.username,
user_settings: {
theme: 'dark',
},
links: {
select_device: null,
challenge_form: `/auth/${realm}/signin/challenge/${deviceUrn}`,
try_different_user: `/auth/${realm}/signin/identifier`,
},
error: 'Invalid username or password.',
});
}
if (device.deviceType === Identity.AuthDevice.Type.Password) {
const passwordDevice = device as Identity.AuthDevice.Password;
const { error, value } = Joi.object<{ password: string }, true>({
password: Joi.string().required(),
}).validate(body, { allowUnknown: true });
if (error) {
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/${deviceUrn}`,
try_different_user: `/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 (!selectedDevice) {
throw new InternalServerErrorException();
}
return res.render(Views.LoginCodeChallenge, {});
}
await this.accessAttemptService.recordAttempt(req, context.username, true);
return res.redirect('https://google.com');
}
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),
username: context.username,
user_settings: {
theme: 'dark',
},
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`,
},
error: 'Invalid username or password.',
});
}
}
}

View File

@ -0,0 +1,10 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Authentication } from '../../../domain/authentication.types';
export const Context = createParamDecorator(
(data: unknown, ctx: ExecutionContext): Authentication.Login.RequestContext => {
const request = ctx.switchToHttp().getRequest();
return request?.context;
},
);

View File

@ -0,0 +1,87 @@
import { Controller, Get, Param, Post, Query, Res, UseInterceptors } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { MVCResponse, 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';
@Controller({
version: '1',
path: 'auth/:realm/signin/identifier',
})
export class IdentifierController {
constructor(
private readonly stateManager: StateManagerService,
private readonly identityAuthDeviceDao: IdentityAuthDeviceDao,
) {}
@Get()
async getLogin(
@Param('realm') realm: string,
@Res() res: MVCResponse,
) {
const state = await this.stateManager.getNewState();
return res.render(Views.LoginView, {
realm,
state,
user_settings: {
theme: 'dark',
},
links: {
identifier_form: `/auth/${realm}/signin/identifier`
}
});
}
@Post()
@UseInterceptors(LoginContextInterceptor)
async postLogin(
@Param('realm') realm: string,
@Context() context: Authentication.Login.RequestContext,
@Res() res: MVCResponse,
) {
const devicesForUser = await this.identityAuthDeviceDao.findByRealmAndUsername(realm, context.username);
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 selectedDevice = devicesForUser.length === 1 ? devicesForUser[0] : devicesForUser.find(d => d.preferred);
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`,
}
});
}
}
}

View File

@ -0,0 +1,71 @@
import { BadRequestException, CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
import * as Joi from 'joi';
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';
type RequestContextMutation = DeepPartial<Authentication.Login.RequestContext>
const standardBadRequestError = 'State was mutated or expired';
@Injectable()
export class LoginContextInterceptor implements NestInterceptor {
private readonly logger: Logger = new Logger(LoginContextInterceptor.name);
constructor(
private readonly tokenService: TokenManagementService,
private readonly accessAttemptService: AccessAttemptService,
) {}
async intercept(context: ExecutionContext, next: CallHandler<any>): Promise<Observable<any>> {
const request = context.switchToHttp().getRequest();
const loginContextMutable: RequestContextMutation = {};
loginContextMutable.username = request?.body?.username;
if (!loginContextMutable.username) {
this.logger.debug('Request did not provide username');
throw new BadRequestException(standardBadRequestError);
}
const isLocked = await this.accessAttemptService.checkIfLocked(request, loginContextMutable.username);
if (isLocked) {
throw new TooManyRequestsException();
}
if (!request?.body?.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);
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>({
jwi: Joi.string().required(),
exp: Joi.number().required(),
tfa: Joi.boolean().required(),
}),
username: Joi.string().required(),
}).validate(loginContextMutable, { allowUnknown: false });
if (error || DateUtil.fromSecondsSinceEpoch(loginContext.state.exp).isInThePast()) {
this.logger.debug(error ? error.message : 'State is past expiration');
throw new BadRequestException(standardBadRequestError);
}
request.context = loginContext;
return next.handle();
}
}

View File

@ -2,11 +2,13 @@ import { Module } from '@nestjs/common';
import { CacheModule } from '../../../cache/cache.module'; import { CacheModule } from '../../../cache/cache.module';
import { PersistenceModule } from '../../../persistence/persistence.module'; import { PersistenceModule } from '../../../persistence/persistence.module';
import { LoginController } from './login.controller';
import { AccessAttemptService } from './access-attempt.service'; import { AccessAttemptService } from './access-attempt.service';
import { LoginContextInterceptor } from './login-context.interceptor'; import { LoginContextInterceptor } from './login-context.interceptor';
import { ConfigModule } from '../../../config/config.module'; import { ConfigModule } from '../../../config/config.module';
import { TokenManagementModule } from '../../../token-management/token-management.module'; import { TokenManagementModule } from '../../../token-management/token-management.module';
import { StateManagerService } from './state-manager.service';
import { IdentifierController } from './identifier.controller';
import { ChallengeController } from './challenge.controller';
@Module({ @Module({
imports: [ imports: [
@ -16,11 +18,13 @@ import { TokenManagementModule } from '../../../token-management/token-managemen
TokenManagementModule, TokenManagementModule,
], ],
controllers: [ controllers: [
LoginController, IdentifierController,
ChallengeController,
], ],
providers: [ providers: [
AccessAttemptService, AccessAttemptService,
LoginContextInterceptor, LoginContextInterceptor,
StateManagerService,
], ],
}) })
export class LoginModule {} export class LoginModule {}

View File

@ -0,0 +1,24 @@
import { GlobalProps } from '../mvc.types';
interface CommonContext extends GlobalProps {
state: string;
realm: string;
}
export interface IdentifierRenderContext extends CommonContext {
state: string;
realm: string;
links: {
identifier_form: string;
}
}
export interface PasswordChallengeRenderContext extends CommonContext {
username: string;
links: {
select_device: string | null;
challenge_form: string;
try_different_user: string;
},
error?: string;
}

View File

@ -0,0 +1,40 @@
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';
const getStateKey = (jwi: string) => `login:state:${jwi}`;
@Injectable()
export class StateManagerService {
constructor(
private readonly tokenService: TokenManagementService,
private readonly cacheService: CacheService,
) {}
async getNewState(): Promise<string> {
const date = DateUtil.fromDate(new Date()).addNMinutes(15);
const stateObj: Authentication.Login.State = {
jwi: randomUUID(),
exp: date.seconds,
tfa: false,
}
await this.cacheService.set(getStateKey(stateObj.jwi), new Date().toISOString(), date.toDate().getTime());
return this.tokenService.createToken(stateObj);
}
updateState(state: Authentication.Login.State, dto: { tfa?: boolean } = {}): string {
return this.tokenService.createToken({ ...state, ...dto });
}
async isStateActive(jwi: string): Promise<boolean> {
return this.cacheService.exists(getStateKey(jwi));
}
}

View File

@ -0,0 +1,26 @@
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',
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;
}

View File

@ -15,7 +15,7 @@ async function bootstrap() {
app.useLogger(logger); app.useLogger(logger);
app.set('view engine', 'pug'); app.set('view engine', 'pug');
app.setBaseViewsDir(join(__dirname, '../../views')); app.setBaseViewsDir(join(__dirname, '../views'));
if (configService.get(SystemSettings.Auth.AccessAttempts.CheckForwardedFor)) { if (configService.get(SystemSettings.Auth.AccessAttempts.CheckForwardedFor)) {
app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal']); app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal']);

View File

@ -54,6 +54,7 @@ export class _00ApplicationBootstrapDataMigration implements DataMigration {
deviceType: Identity.AuthDevice.Type.Password, deviceType: Identity.AuthDevice.Type.Password,
attributes: JSON.stringify(passwordDevice), attributes: JSON.stringify(passwordDevice),
preferred: true, preferred: true,
twoFactorPreferred: false,
} }
}); });
@ -64,9 +65,6 @@ export class _00ApplicationBootstrapDataMigration implements DataMigration {
[SystemSettings.Auth.Oauth2.Enabled]: true, [SystemSettings.Auth.Oauth2.Enabled]: true,
[SystemSettings.Auth.Oauth2.EncryptionSecret]: crypto.randomBytes(16).toString('hex'), [SystemSettings.Auth.Oauth2.EncryptionSecret]: crypto.randomBytes(16).toString('hex'),
[SystemSettings.Auth.TokenManagement.SigningSecret]: crypto.randomBytes(16).toString('hex'), [SystemSettings.Auth.TokenManagement.SigningSecret]: crypto.randomBytes(16).toString('hex'),
[SystemSettings.Graphql.Debug]: false,
[SystemSettings.Graphql.IntrospectionEnabled]: true,
[SystemSettings.Graphql.PlaygroundEnabled]: true,
} }
const paired = Object.entries(startingConfig) const paired = Object.entries(startingConfig)

View File

@ -4,8 +4,13 @@ import { IdentityAuthDevice } from '@prisma/client';
import { PrismaService } from './prisma.service'; import { PrismaService } from './prisma.service';
import { Identity } from '../domain/identity.types'; import { Identity } from '../domain/identity.types';
import { safeJsonParse } from '../utils'; import { safeJsonParse } from '../utils';
import * as Joi from 'joi';
const eligibleForTwoFactor: Identity.AuthDevice.Type[] = [
]
@Injectable() @Injectable()
export class IdentityAuthDeviceDao { export class IdentityAuthDeviceDao {
@ -26,45 +31,56 @@ export class IdentityAuthDeviceDao {
}, },
}); });
return devices.map(d => IdentityAuthDeviceDao.modelToEntity(d, true)); return devices.map(d => IdentityAuthDeviceDao.modelToEntity(d));
} }
async findOneByUrn(urn: string): Promise<Identity.AuthDevice> { async findOneByUrn(urn: string): Promise<Identity.AuthDevice | null> {
const device = await this.prismaService.identityAuthDevice.findFirst({ const device = await this.prismaService.identityAuthDevice.findFirst({
where: { where: {
id: urn.replace('urn:identity:auth-device:', ''), id: urn.replace('urn:identity:auth-device:', ''),
}, },
}); });
if (!device) {
return null;
}
return IdentityAuthDeviceDao.modelToEntity(device); return IdentityAuthDeviceDao.modelToEntity(device);
} }
private static modelToEntity(model: IdentityAuthDevice, skipAttributes = false): Identity.AuthDevice { private static modelToEntity(model: IdentityAuthDevice, skipAttributes = false): Identity.AuthDevice {
const [_, attributes] = safeJsonParse(model.attributes);
const entity: Identity.AuthDevice = { const entity: Identity.AuthDevice = {
urn: `urn:identity:auth-device:${model.id}`, urn: `urn:identity:auth-device:${model.id}`,
userId: model.userId, userId: model.userId,
deviceType: model.deviceType as Identity.AuthDevice.Type, deviceType: model.deviceType as Identity.AuthDevice.Type,
preferred: model.preferred, preferred: model.preferred,
twoFactorEligible: eligibleForTwoFactor.includes(model.deviceType as Identity.AuthDevice.Type),
twoFactorPreferred: model.twoFactorPreferred,
createdAt: new Date(model.createdAt), createdAt: new Date(model.createdAt),
} }
if (skipAttributes) { if (skipAttributes || !model.attributes) {
return entity; return entity;
} }
if (model.deviceType === Identity.AuthDevice.Type.Password) { if (model.deviceType === Identity.AuthDevice.Type.Password) {
const passwordEntity: Identity.AuthDevice.PasswordAttributes = {
expiry: new Date(attributes[Identity.AuthDevice.PasswordHashKey.Expiry] as string), const [_, attributes] = safeJsonParse<Identity.AuthDevice.PasswordAttributes>(model.attributes);
passwordHashString: attributes[Identity.AuthDevice.PasswordHashKey.PasswordHashString] as string,
locked: attributes[Identity.AuthDevice.PasswordHashKey.Locked] as boolean, const { error, value } = Joi.object<Identity.AuthDevice.PasswordAttributes, true>({
expiry: Joi.date().required(),
passwordHashString: Joi.string().required(),
locked: Joi.boolean().required(),
}).validate(attributes);
if (error) {
return entity;
} }
return { return {
...entity, ...entity,
...passwordEntity, ...value,
} }
} }

View File

@ -1,11 +1,14 @@
import { Injectable } from '@nestjs/common'; import { Injectable, Type } from '@nestjs/common';
import { PrismaService } from './prisma.service'; import { PrismaService } from './prisma.service';
import { SystemSettings } from '../domain/system-settings.types'; import { SystemSettings } from '../domain/system-settings.types';
type TypeMap = Record<string, 'string' | 'boolean' | 'number'>;
type ValueMap = Record<string, string>;
type Result = { type Result = {
typeMap: Record<string, 'string' | 'boolean' | 'number'>; typeMap: TypeMap;
valueMap: Record<string, string>; valueMap: ValueMap;
} }
@Injectable() @Injectable()
@ -17,10 +20,19 @@ export class SystemSettingsDao {
async getSettings(): Promise<Result> { async getSettings(): Promise<Result> {
const settings = await this.prismaService.systemSetting.findMany(); const settings = await this.prismaService.systemSetting.findMany();
const typeMap = {} const typeMap: TypeMap = {}
const valueMap = {} const valueMap: ValueMap = {}
for (const { hashKey, hashValue, hashValueType } of settings) { for (const { hashKey, hashValue, hashValueType } of settings) {
if (
hashValueType !== 'boolean' &&
hashValueType !== 'number' &&
hashValueType !== 'string'
) {
continue;
}
typeMap[hashKey] = hashValueType; typeMap[hashKey] = hashValueType;
valueMap[hashKey] = hashValue; valueMap[hashKey] = hashValue;
} }

View File

@ -1,4 +1,4 @@
import { NestFactory, repl } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { PersistenceModule } from './persistence/persistence.module'; import { PersistenceModule } from './persistence/persistence.module';
import { PrismaService } from './persistence/prisma.service'; import { PrismaService } from './persistence/prisma.service';
@ -40,9 +40,12 @@ async function ensureEnumIntegrity(prisma: PrismaService) {
for (const [dbRunner, known] of enumsRegistered) { for (const [dbRunner, known] of enumsRegistered) {
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
// @ts-ignore
const result = await tx[dbRunner].findMany(); const result = await tx[dbRunner].findMany();
const existing = result.map(e => e.enumValue); // @ts-ignore
const existing: string[] = result.map(e => e.enumValue);
const missing = known.filter(k => !existing.includes(k)); const missing = known.filter(k => !existing.includes(k));
// @ts-ignore
await tx[dbRunner].createMany({ await tx[dbRunner].createMany({
data: missing.map(enumValue => ({ enumValue })), data: missing.map(enumValue => ({ enumValue })),
}); });

View File

@ -6,10 +6,10 @@ import { SystemSettings } from '../domain/system-settings.types';
import { JWA, TokenSignatureUtil, safeJsonParse } from '../utils'; import { JWA, TokenSignatureUtil, safeJsonParse } from '../utils';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
type Receipt = { type Receipt<T extends Object = Object> = {
isToken: true; isToken: true;
signatureValid: boolean; signatureValid: boolean;
payload: Object; payload: Partial<T>;
revoked: boolean; revoked: boolean;
} | { isToken: false }; } | { isToken: false };
@ -40,7 +40,7 @@ export class TokenManagementService {
return `${encodedHeader}.${encodedPayload}.${signature}`; return `${encodedHeader}.${encodedPayload}.${signature}`;
} }
parseToken(token: string): Receipt { parseToken<T extends Object = Object>(token: string): Receipt<T> {
const parts = token.split('.'); const parts = token.split('.');
@ -48,13 +48,13 @@ export class TokenManagementService {
return { isToken: false } return { isToken: false }
} }
const [encodedHeader, encodedPayload, signature] = parts; const [encodedHeader, encodedPayload, signature] = parts as [string, string, string];
const headerJson = Buffer.from(encodedHeader, 'base64url').toString('utf-8'); const headerJson = Buffer.from(encodedHeader, 'base64url').toString('utf-8');
const payloadJson = Buffer.from(encodedPayload, 'base64url').toString('utf-8'); const payloadJson = Buffer.from(encodedPayload, 'base64url').toString('utf-8');
const [err1, headerObj] = safeJsonParse(headerJson); const [err1, headerObj] = safeJsonParse(headerJson);
const [err2, payloadObj] = safeJsonParse(payloadJson); const [err2, payloadObj] = safeJsonParse<T>(payloadJson);
if (err1 || err2) { if (err1 || err2) {
return { isToken: false } return { isToken: false }

View File

@ -1,9 +1,9 @@
export class DateUtil { export class DateUtil {
private readonly original: Date | null; private readonly original: Date;
constructor( constructor(
private _date: Date | null, private _date: Date,
) { ) {
this.original = DateUtil.newDate(_date); this.original = DateUtil.newDate(_date);
} }
@ -85,7 +85,7 @@ export class DateUtil {
return DateUtil.newDate(this._date); return DateUtil.newDate(this._date);
} }
private static newDate(date: string | Date | null): Date | null { private static newDate(date: string | Date): Date {
return date ? new Date(date) : null; return new Date(date);
} }
} }

View File

@ -0,0 +1,3 @@
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
}

View File

@ -0,0 +1,7 @@
export const safeJsonParse = <T extends Object = Object>(json: string): [Error, null] | [null, Partial<T>] => {
try {
return [null, JSON.parse(json)];
} catch (err) {
return [err, null];
}
}

View File

@ -41,21 +41,25 @@ const saltStringLengthDictionary: Record<NodeCryptoAlgs, number> = {
[NodeCryptoAlgs.sha512]: 23, [NodeCryptoAlgs.sha512]: 23,
} }
type HashingFunction = (rawString: string, workFactor: number, salt: string) => Promise<string>; interface HashingProvider {
hash: (plainText: string, saltRounds: number) => Promise<string>;
const algImplementationMap: Record<NodeCryptoAlgs, HashingFunction> = { compare: (plainText: string, hash: string) => Promise<boolean>;
[NodeCryptoAlgs.md5]: async function (rawString: string, workFactor: number, salt: string): Promise<string> {
throw new Error('Function not implemented.');
},
[NodeCryptoAlgs.bcrypt]: async function (rawString: string, _workFactor: number, salt: string): Promise<string> {
return bcrypt.hash(rawString, salt);
},
[NodeCryptoAlgs.sha256]: async function (rawString: string, workFactor: number, salt: string): Promise<string> {
throw new Error('Function not implemented.');
},
[NodeCryptoAlgs.sha512]: async function (rawString: string, workFactor: number, salt: string): Promise<string> {
throw new Error('Function not implemented.');
} }
const algImplementationMap: Record<NodeCryptoAlgs, HashingProvider> = {
[NodeCryptoAlgs.md5]: {
hash: (plainText: string, saltRounds: number): Promise<string> => { throw new Error('Not implemented') },
compare: (plainText: string, hash: string): Promise<boolean> => { throw new Error('Not implemented') },
},
[NodeCryptoAlgs.bcrypt]: bcrypt,
[NodeCryptoAlgs.sha256]: {
hash: (plainText: string, saltRounds: number): Promise<string> => { throw new Error('Not implemented') },
compare: (plainText: string, hash: string): Promise<boolean> => { throw new Error('Not implemented') },
},
[NodeCryptoAlgs.sha512]: {
hash: (plainText: string, saltRounds: number): Promise<string> => { throw new Error('Not implemented') },
compare: (plainText: string, hash: string): Promise<boolean> => { throw new Error('Not implemented') },
},
} }
export enum JWA { export enum JWA {
@ -75,22 +79,14 @@ export class SecureStringUtil {
private static readonly defaultWorkFactor = 15; private static readonly defaultWorkFactor = 15;
static async generateNewHash(rawString: string): Promise<string> { static async generateNewHash(rawString: string): Promise<string> {
const alg = NodeCryptoAlgs.bcrypt; const alg = NodeCryptoAlgs.bcrypt;
const workFactor = this.defaultWorkFactor; const saltRounds = this.defaultWorkFactor;
const salt = await bcrypt.genSalt(workFactor); return algImplementationMap[alg].hash(rawString, saltRounds);
return algImplementationMap[alg](rawString, workFactor, salt);
} }
static async generateHash(rawString: string, alg: NodeCryptoAlgs, workFactor: number, salt: string): Promise<string> { static async compare(rawString: string, hash: string): Promise<boolean> {
return algImplementationMap[alg](rawString, workFactor, salt); const { alg } = HashMeta.deserialize(hash);
} return algImplementationMap[alg].compare(rawString, hash);
static async compare(rawString: string, hashMetaSerialized: string): Promise<boolean> {
const { alg, salt, workFactor, hash: hashA} = HashMeta.deserialize(hashMetaSerialized);
const hashB = await this.generateHash(rawString, alg, workFactor, salt);
return hashA === hashB;
} }
} }
@ -111,14 +107,22 @@ class HashMeta {
static deserialize(hashMeta: string): HashMeta { static deserialize(hashMeta: string): HashMeta {
const [_, alg, workFactor, salthash] = hashMeta.split('$'); const parts = hashMeta.split('$');
const saltStringLength = saltStringLengthDictionary[alg]; if (parts.length !== 4) {
throw new Error();
}
const [_, alg, workFactor, salthash] = parts as [string, string, string, string];
const wrappedAlg = `$${alg}$` as OSDefinedHashPrefix;
const nodeCryptoAlg = hashDictionary[wrappedAlg];
const saltStringLength = saltStringLengthDictionary[nodeCryptoAlg];
const salt = salthash.substring(0, saltStringLength); const salt = salthash.substring(0, saltStringLength);
const hash = salthash.substring(saltStringLength); const hash = salthash.substring(saltStringLength);
return new HashMeta( return new HashMeta(
hashDictionary[alg], nodeCryptoAlg,
+workFactor, +workFactor,
salt, salt,
hash hash

View File

@ -1,5 +1,5 @@
// https://stackoverflow.com/questions/40710628/how-to-convert-snake-case-to-camelcase // https://stackoverflow.com/questions/40710628/how-to-convert-snake-case-to-camelcase
export const snakeToCamel = str => export const snakeToCamel = (str: string) =>
str.toLowerCase().replace(/([-_][a-z])/g, group => str.toLowerCase().replace(/([-_][a-z])/g, group =>
group group
.toUpperCase() .toUpperCase()

View File

@ -25,12 +25,13 @@ class WhenClosure<T, K extends Literal> {
return resolved(); return resolved();
} }
return resolved; return resolved as T;
} }
resolveOrThrow(errorMessage = defaultErrorMessage): T { resolveOrThrow(errorMessage = defaultErrorMessage): T {
if (this.expression in this.stack) { if (this.expression in this.stack) {
const resolved = this.stack[this.expression]; const resolved = this.stack[this.expression] as Resolved<T>;
if (resolved instanceof Function) { if (resolved instanceof Function) {
return resolved(); return resolved();
} }

View File

@ -12,8 +12,10 @@
"baseUrl": "./src", "baseUrl": "./src",
"incremental": true, "incremental": true,
"skipLibCheck": true, "skipLibCheck": true,
"strictNullChecks": false, "strictNullChecks": true,
"noImplicitAny": false, "strictFunctionTypes": true,
"noUncheckedIndexedAccess": true,
"noImplicitAny": true,
"strictBindCallApply": false, "strictBindCallApply": false,
"forceConsistentCasingInFileNames": false, "forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false, "noFallthroughCasesInSwitch": false,

View File

@ -4,6 +4,8 @@ div#innerTarget
input(type="hidden" name="state" value=state) input(type="hidden" name="state" value=state)
input(type="text" name="username" placeholder="Username" value=username readonly) input(type="text" name="username" placeholder="Username" value=username readonly)
input(type="password" name="password" placeholder="Password") input(type="password" name="password" placeholder="Password")
if error
span=error
input(type="submit" name="submit" value="Submit") input(type="submit" name="submit" value="Submit")
if links.select_device if links.select_device
a(hx-get=links.select_device hx-select="#innerTarget" hx-swap="outerHTML" href='#') Use a different device a(hx-get=links.select_device hx-select="#innerTarget" hx-swap="outerHTML" href='#') Use a different device

View File

@ -1,9 +0,0 @@
type AuthOauth2ClientToAuthRealmEdge {
data: AuthRealm
error: AuthOauth2ClientToAuthRealmError
}
type AuthOauth2ClientToAuthOauth2ScopesEdge {
data: [AuthOauth2Scope]
error: AuthOauth2ClientToAuthOauth2ScopesError
}

View File

@ -1,4 +0,0 @@
enum AuthOauth2ClientTypeEnum {
CONFIDENTIAL
PUBLIC
}

View File

@ -1,7 +0,0 @@
enum AuthOauth2ClientToAuthRealmError {
UNKNOWN
}
enum AuthOauth2ClientToAuthOauth2ScopesError {
UNKNOWN
}

View File

@ -1,20 +0,0 @@
type AuthRealm {
urn: ID!
name: String
createdAt: String
}
type AuthOauth2Client {
urn: ID!
clientId: String!
clientType: AuthOauth2ClientTypeEnum!
clientSecret: String
Realm: AuthOauth2ClientToAuthRealmEdge
Scopes: AuthOauth2ClientToAuthOauth2ScopesEdge
}
type AuthOauth2Scope {
urn: ID!
scope: String
}

View File

@ -1,29 +0,0 @@
type CloudDavContact {
urn: ID!
identityGroupUrn: String!
firstName: String
lastName: String
company: String
phones: [String]
addresses: [CloudDavContactAddress]
dates: [CloudDavContactLabeledString]
urls: [CloudDavContactLabeledString]
notes: String
}
type CloudDavContactAddress {
urn: ID!
type: String
street1: String
street2: String
city: String
state: String
postalCode: String
country: String
}
type CloudDavContactLabeledString {
context: String!
value: String!
}

View File

@ -1,29 +0,0 @@
type IdentityGroupToIdentityUserEdge {
data: [IdentityUser]
error: IdentityGroupToIdentityUserError
}
type IdentityUserToIdentityGroupEdge {
data: [IdentityGroup]
error: IdentityUserToIdentityGroupError
}
type IdentityUserToIdentityProfileEdge {
data: IdentityProfile
error: IdentityUserToIdentityProfileError
}
type IdentityUserToIdentityEmailEdge {
data: [IdentityEmail]
error: IdentityUserToIdentityEmailError
}
type IdentityUserToIdentityAuthDeviceEdge {
data: [IdentityAuthDevice]
error: IdentityUserToIdentityAuthDeviceError
}
type IdentityGroupToCloudDavContactEdge {
data: [CloudDavContact]
error: IdentityGroupToCloudDavContactError
}

View File

@ -1,10 +0,0 @@
enum IdentityAuthDeviceTypeEnum {
PASSWORD
APPLICATION_PASSWORD
}
enum IdentityGroupRoleEnum {
SYSTEM_ADMIN
REALM_ADMIN
STANDARD
}

View File

@ -1,24 +0,0 @@
enum IdentityGroupToIdentityUserError {
UNKNOWN
}
enum IdentityUserToIdentityGroupError {
UNKNOWN
}
enum IdentityUserToIdentityProfileError {
UNKNOWN
NOT_FOUND
}
enum IdentityUserToIdentityEmailError {
UNKNOWN
}
enum IdentityUserToIdentityAuthDeviceError {
UNKNOWN
}
enum IdentityGroupToCloudDavContactError {
UNKNOWN
}

View File

@ -1,10 +0,0 @@
type IdentityUserOutput {
error: String
data: IdentityUser
}
# type Query {
# # identityUsers()
# # identityUser(urn: String!): IdentityUserOutput!
# # myUser
# }

View File

@ -1,47 +0,0 @@
type IdentityGroup {
urn: ID!
isAdmin: Boolean!
role: IdentityGroupRoleEnum!
name: String
Users: IdentityGroupToIdentityUserEdge!
Contacts: IdentityGroupToCloudDavContactEdge!
}
type IdentityUser {
urn: ID!
externalId: String!
username: String!
Groups: IdentityUserToIdentityGroupEdge!
Profile: IdentityUserToIdentityProfileEdge!
Emails: IdentityUserToIdentityEmailEdge!
AuthDevices: IdentityUserToIdentityAuthDeviceEdge!
}
type IdentityProfile {
urn: ID!
firstName: String
lastName: String
}
type IdentityEmail {
urn: ID!
email: String!
userUrn: String!
verified: Boolean!
default: Boolean!
}
type IdentityAuthDevice {
urn: ID!
userUrn: String!
deviceType: IdentityAuthDeviceTypeEnum!
IdentityAuthDevicePassword: IdentityAuthDevicePassword
}
type IdentityAuthDevicePassword {
urn: ID!
expiry: String!
}

View File

@ -1,5 +0,0 @@
enum SystemSettingHashValueTypeEnum {
BOOLEAN
STRING
NUMBER
}

View File

@ -1,8 +0,0 @@
enum SystemSettingsQueryOutputError {
UNKNOWN
}
enum UpdateSystemSettingOutputError {
UNKNOWN
NOT_FOUND
}

View File

@ -1,13 +0,0 @@
input UpdateSystemSettingInput {
urn: ID!
hashValueType: SystemSettingHashValueTypeEnum!
hashValue: String!
}
type UpdateSystemSettingOutput {
error: UpdateSystemSettingOutputError
}
type Mutation {
updateSystemSetting(input: UpdateSystemSettingInput!): UpdateSystemSettingOutput!
}

View File

@ -1,8 +0,0 @@
type SystemSettingsQueryOutput {
data: [SystemSetting]
error: SystemSettingsQueryOutputError
}
type Query {
systemSettings: SystemSettingsQueryOutput!
}

View File

@ -1,6 +0,0 @@
type SystemSetting {
urn: ID!
hashKey: String!
hashValueType: SystemSettingHashValueTypeEnum!
hashValue: String!
}

View File

@ -1,17 +0,0 @@
import type { CodegenConfig } from '@graphql-codegen/cli';
const config: CodegenConfig = {
overwrite: true,
schema: "../graphql",
generates: {
"src/.generated/graphql.ts": {
plugins: ["typescript", "typescript-resolvers"]
},
"./graphql.schema.json": {
plugins: ["introspection"]
}
}
};
export default config;

View File

@ -1,31 +0,0 @@
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { ConfigService } from '../config/config.service';
import { SystemSettingsResolver } from './system-settings.resolver';
import { ConfigModule } from '../config/config.module';
import { SystemSettings } from '../domain/system-settings.types';
import { PersistenceModule } from '../persistence/persistence.module';
@Module({
imports: [
ConfigModule,
PersistenceModule,
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
useFactory: async (configService: ConfigService) => ({
debug: await configService.get(SystemSettings.Graphql.Debug),
playground: await configService.get(SystemSettings.Graphql.PlaygroundEnabled),
introspection: await configService.get(SystemSettings.Graphql.IntrospectionEnabled),
typePaths: ['../graphql/**/*.graphql'],
}),
inject: [ConfigService],
imports: [ConfigModule],
})
],
providers: [
SystemSettingsResolver,
]
})
export class GraphqlServerModule {}

View File

@ -1,79 +0,0 @@
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { Logger } from '@nestjs/common';
import { SystemSettingsQueryOutput, SystemSettingsQueryOutputError, UpdateSystemSettingInput, UpdateSystemSettingOutput, UpdateSystemSettingOutputError, SystemSettingHashValueTypeEnum } from '../.generated/graphql';
import { SystemSettingsDao } from '../persistence/system-settings.dao';
const getUrn = (hashKey: string): string => `urn:system-settings:${hashKey}`;
const parseUrn = (urn: string): string => urn.split(':').pop();
const t2g: Record<'string' | 'boolean' | 'number', SystemSettingHashValueTypeEnum> = {
string: SystemSettingHashValueTypeEnum.String,
boolean: SystemSettingHashValueTypeEnum.Boolean,
number: SystemSettingHashValueTypeEnum.Number,
}
const g2t: Record<SystemSettingHashValueTypeEnum, 'string' | 'boolean' | 'number'> = {
[SystemSettingHashValueTypeEnum.String]: 'string',
[SystemSettingHashValueTypeEnum.Boolean]: 'boolean',
[SystemSettingHashValueTypeEnum.Number]: 'number',
}
@Resolver('SystemSettings')
export class SystemSettingsResolver {
private readonly logger: Logger = new Logger(SystemSettingsResolver.name);
constructor(
private readonly systemSettingsDao: SystemSettingsDao,
) {}
@Query()
async systemSettings(): Promise<SystemSettingsQueryOutput> {
try {
const { valueMap, typeMap } = await this.systemSettingsDao.getSettings();
return {
data: Object.entries(valueMap).map(([hashKey, hashValue]) => ({
urn: getUrn(hashKey),
hashKey,
hashValue: hashValue.toString(),
hashValueType: t2g[typeMap[hashKey]],
})),
}
} catch (e) {
this.logger.error(e.message);
return {
error: SystemSettingsQueryOutputError.Unknown,
}
}
}
@Mutation()
async updateSystemSetting(
@Args('input') { urn, hashValue, hashValueType }: UpdateSystemSettingInput
): Promise<UpdateSystemSettingOutput> {
try {
const hashKey = parseUrn(urn);
const exists = await this.systemSettingsDao.hashKeyExists(hashKey);
if (!exists) {
return {
error: UpdateSystemSettingOutputError.NotFound,
}
}
await this.systemSettingsDao.setSettingWithType(hashKey, g2t[hashValueType], hashValue);
return {};
} catch (e) {
this.logger.error(e.message);
return {
error: UpdateSystemSettingOutputError.Unknown,
}
}
}
}

View File

@ -1,42 +0,0 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import * as Joi from 'joi';
import { DateUtil } from '../../../utils';
import { Authentication } from '../../../domain/authentication.types';
export const Context = createParamDecorator(
(data: unknown, ctx: ExecutionContext): Authentication.Login.RequestContext => {
const request = ctx.switchToHttp().getRequest();
const loginContext: Authentication.Login.RequestContext = request?.context;
if (!loginContext.username) {
return loginContext;
}
const state = loginContext?.rawState;
if (!state) {
return loginContext;
}
const { error, value } = Joi.object<Authentication.Login.State, true>({
jwi: Joi.string().required(),
exp: Joi.number().required(),
tfa: Joi.boolean().required(),
}).validate(state, { allowUnknown: false });
if (error) {
return loginContext;
}
if (DateUtil.fromSecondsSinceEpoch(value?.exp).isInThePast()) {
return loginContext;
}
return {
...loginContext,
isComplete: true,
state: value,
};
},
);

View File

@ -1,39 +0,0 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { TokenManagementService } from '../../../token-management/token-management.service';
import { AccessAttemptService } from './access-attempt.service';
import { Authentication } from '../../../domain/authentication.types';
@Injectable()
export class LoginContextInterceptor implements NestInterceptor {
constructor(
private readonly tokenService: TokenManagementService,
private readonly accessAttemptService: AccessAttemptService,
) {}
async intercept(context: ExecutionContext, next: CallHandler<any>): Promise<Observable<any>> {
const request = context.switchToHttp().getRequest();
const loginContext: Authentication.Login.RequestContext = { isComplete: false };
if (!request?.body?.state) {
return next.handle();
}
const receipt = this.tokenService.parseToken(request.body.state);
if (receipt.isToken && receipt.signatureValid) {
loginContext.rawState = receipt.payload;
}
loginContext.username = request?.body?.username;
if (loginContext.username) {
loginContext.isLocked = await this.accessAttemptService.checkIfLocked(request, loginContext.username);
}
request.context = loginContext;
return next.handle();
}
}

View File

@ -1,130 +0,0 @@
import { BadRequestException, Controller, Get, Param, Post, Query, Render, Res, UseInterceptors } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { Authentication } from '../../../domain/authentication.types';
import { TokenManagementService } from '../../../token-management/token-management.service';
import { DateUtil } from '../../../utils';
import { TooManyRequestsException } from '../../exceptions/too-many-requests.exception';
import { AccessAttemptService } from './access-attempt.service';
import { Context } from './context.decorator';
import { LoginContextInterceptor } from './login-context.interceptor';
import { LoginStages } from './login.types';
import { IdentityAuthDeviceDao } from '../../../persistence/identity-auth-device.dao';
import { Response } from 'express';
import { CacheService } from '../../../cache/cache.service';
import { Identity } from '../../../domain/identity.types';
const getStateKey = (jwi: string) => `login:state:${jwi}`;
const challengeToTemplate = {
[Identity.AuthDevice.Type.Password]: 'login-password-challenge',
}
@Controller({
version: '1',
path: 'auth/:realm/signin',
})
@UseInterceptors(LoginContextInterceptor)
export class LoginController {
constructor(
private readonly tokenService: TokenManagementService,
private readonly cacheService: CacheService,
private readonly accessAttemptService: AccessAttemptService,
private readonly identityAuthDeviceDao: IdentityAuthDeviceDao,
) {}
@Get('identifier')
@Render('login-view')
async getLogin(
@Param('realm') realm: string,
@Query('error') error?: string,
): Promise<LoginStages.IdentifierView.RenderContext> {
const date = DateUtil.fromDate(new Date()).addNMinutes(15);
const stateObj: Authentication.Login.State = {
jwi: randomUUID(),
exp: date.seconds,
tfa: false,
}
await this.cacheService.set(getStateKey(stateObj.jwi), new Date().toISOString(), date.toDate().getTime());
return {
realm,
state: this.tokenService.createToken(stateObj),
user_settings: {
theme: 'dark',
},
links: {
identifier_form: `/auth/${realm}/signin/identifier`
}
}
}
@Post('identifier')
async postLogin(
@Param('realm') realm: string,
@Context() context: Authentication.Login.RequestContext,
@Res() res: Response,
) {
if (!context.isComplete) {
throw new BadRequestException();
}
if (context.isLocked) {
throw new TooManyRequestsException();
}
const devicesForUser = await this.identityAuthDeviceDao.findByRealmAndUsername(realm, context.username);
const selectedDevice = devicesForUser.length === 1 ? devicesForUser[0] : devicesForUser.find(d => d.preferred);
if (selectedDevice) {
const renderContext: LoginStages.ChallengeView.RenderContext = {
realm,
state: this.tokenService.createToken(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}`,
}
}
return res.render(challengeToTemplate[selectedDevice.deviceType], renderContext);
}
}
@Get('challenge')
async selectDevice() {
}
@Post('challenge/:device_urn')
async postDevice(
@Param('realm') realm: string,
@Context() context: Authentication.Login.RequestContext,
@Res() res: Response,
) {
}
@Get('2fa/challenge')
async select2faDevice() {
}
@Post('2fa/challenge/:device_urn')
async post2faDevice() {
}
@Post('logout')
async logout() {
}
}

View File

@ -1,70 +0,0 @@
import { Identity } from '../../../domain/identity.types';
import { Authentication } from '../../../domain/authentication.types';
import { GlobalProps } from '../mvc.types';
export namespace LoginStages {
export enum Error {
INVALID,
}
export const errorDescriptionMap: Record<Error, string> = {
[Error.INVALID]: 'Invalid username'
}
export namespace IdentifierView {
export interface RenderContext extends GlobalProps {
state: string;
realm: string;
links: {
identifier_form: string;
}
}
}
export namespace ChallengeView {
export interface RenderContext extends GlobalProps {
state: string;
realm: string;
username: string;
links: {
select_device: string | null;
challenge_form: string;
}
}
}
export namespace $7PostLoginContext_Result {
export type Context = Success | TwoFactorRequired | Failure | Timedout | DeviceLocked;
type Success = {
status: Authentication.Login.ResponseStatus.Success;
state: string;
redirectUri: string;
}
type TwoFactorRequired = {
status: Authentication.Login.ResponseStatus.TwoFactorRequired;
state: string;
availableDevices: Identity.AuthDevice.Type[];
}
type Failure = {
status: Authentication.Login.ResponseStatus.Failure;
state: string;
}
type Timedout = {
status: Authentication.Login.ResponseStatus.Timedout;
}
type DeviceLocked = {
status: Authentication.Login.ResponseStatus.DeviceLocked;
}
}
}
export const supportedDevices = [
Identity.AuthDevice.Type.Password,
]

View File

@ -1,5 +0,0 @@
export interface GlobalProps {
user_settings: {
theme: 'light' | 'dark';
}
}

View File

@ -1,7 +0,0 @@
export const safeJsonParse = (json: string): [Error, null] | [null, Object] => {
try {
return [null, JSON.parse(json)];
} catch (err) {
return [err, null];
}
}