WIP
This commit is contained in:
parent
59540510b7
commit
8db8ce9f47
|
|
@ -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
|
|
@ -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",
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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])
|
||||||
|
|
@ -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: [],
|
||||||
|
|
@ -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>;
|
||||||
|
|
@ -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> {
|
||||||
|
|
@ -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(),
|
|
||||||
});
|
});
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -13,12 +15,6 @@ export namespace Identity {
|
||||||
Password = 'password',
|
Password = 'password',
|
||||||
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;
|
||||||
|
|
@ -26,7 +22,7 @@ export namespace Identity {
|
||||||
readonly locked: boolean;
|
readonly locked: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Password = AuthDevice | PasswordAttributes;
|
export interface Password extends AuthDevice, PasswordAttributes {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace Group {
|
export namespace Group {
|
||||||
|
|
@ -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',
|
||||||
|
|
@ -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}`;
|
||||||
|
|
@ -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.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -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`,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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']);
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
@ -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 })),
|
||||||
});
|
});
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export type DeepPartial<T> = {
|
||||||
|
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>;
|
||||||
|
compare: (plainText: string, hash: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
const algImplementationMap: Record<NodeCryptoAlgs, HashingFunction> = {
|
const algImplementationMap: Record<NodeCryptoAlgs, HashingProvider> = {
|
||||||
[NodeCryptoAlgs.md5]: async function (rawString: string, workFactor: number, salt: string): Promise<string> {
|
[NodeCryptoAlgs.md5]: {
|
||||||
throw new Error('Function not implemented.');
|
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]: async function (rawString: string, _workFactor: number, salt: string): Promise<string> {
|
[NodeCryptoAlgs.bcrypt]: bcrypt,
|
||||||
return bcrypt.hash(rawString, salt);
|
[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.sha256]: async function (rawString: string, workFactor: number, salt: string): Promise<string> {
|
[NodeCryptoAlgs.sha512]: {
|
||||||
throw new Error('Function not implemented.');
|
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]: async function (rawString: string, workFactor: number, salt: string): Promise<string> {
|
|
||||||
throw new Error('Function 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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -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
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
type AuthOauth2ClientToAuthRealmEdge {
|
|
||||||
data: AuthRealm
|
|
||||||
error: AuthOauth2ClientToAuthRealmError
|
|
||||||
}
|
|
||||||
|
|
||||||
type AuthOauth2ClientToAuthOauth2ScopesEdge {
|
|
||||||
data: [AuthOauth2Scope]
|
|
||||||
error: AuthOauth2ClientToAuthOauth2ScopesError
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
enum AuthOauth2ClientTypeEnum {
|
|
||||||
CONFIDENTIAL
|
|
||||||
PUBLIC
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
enum AuthOauth2ClientToAuthRealmError {
|
|
||||||
UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
enum AuthOauth2ClientToAuthOauth2ScopesError {
|
|
||||||
UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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!
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
enum IdentityAuthDeviceTypeEnum {
|
|
||||||
PASSWORD
|
|
||||||
APPLICATION_PASSWORD
|
|
||||||
}
|
|
||||||
|
|
||||||
enum IdentityGroupRoleEnum {
|
|
||||||
SYSTEM_ADMIN
|
|
||||||
REALM_ADMIN
|
|
||||||
STANDARD
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
enum IdentityGroupToIdentityUserError {
|
|
||||||
UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
enum IdentityUserToIdentityGroupError {
|
|
||||||
UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
enum IdentityUserToIdentityProfileError {
|
|
||||||
UNKNOWN
|
|
||||||
NOT_FOUND
|
|
||||||
}
|
|
||||||
|
|
||||||
enum IdentityUserToIdentityEmailError {
|
|
||||||
UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
enum IdentityUserToIdentityAuthDeviceError {
|
|
||||||
UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
enum IdentityGroupToCloudDavContactError {
|
|
||||||
UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
type IdentityUserOutput {
|
|
||||||
error: String
|
|
||||||
data: IdentityUser
|
|
||||||
}
|
|
||||||
|
|
||||||
# type Query {
|
|
||||||
# # identityUsers()
|
|
||||||
# # identityUser(urn: String!): IdentityUserOutput!
|
|
||||||
# # myUser
|
|
||||||
# }
|
|
||||||
|
|
@ -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!
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
enum SystemSettingHashValueTypeEnum {
|
|
||||||
BOOLEAN
|
|
||||||
STRING
|
|
||||||
NUMBER
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
enum SystemSettingsQueryOutputError {
|
|
||||||
UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
enum UpdateSystemSettingOutputError {
|
|
||||||
UNKNOWN
|
|
||||||
NOT_FOUND
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
input UpdateSystemSettingInput {
|
|
||||||
urn: ID!
|
|
||||||
hashValueType: SystemSettingHashValueTypeEnum!
|
|
||||||
hashValue: String!
|
|
||||||
}
|
|
||||||
|
|
||||||
type UpdateSystemSettingOutput {
|
|
||||||
error: UpdateSystemSettingOutputError
|
|
||||||
}
|
|
||||||
|
|
||||||
type Mutation {
|
|
||||||
updateSystemSetting(input: UpdateSystemSettingInput!): UpdateSystemSettingOutput!
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
type SystemSettingsQueryOutput {
|
|
||||||
data: [SystemSetting]
|
|
||||||
error: SystemSettingsQueryOutputError
|
|
||||||
}
|
|
||||||
|
|
||||||
type Query {
|
|
||||||
systemSettings: SystemSettingsQueryOutput!
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
type SystemSetting {
|
|
||||||
urn: ID!
|
|
||||||
hashKey: String!
|
|
||||||
hashValueType: SystemSettingHashValueTypeEnum!
|
|
||||||
hashValue: String!
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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 {}
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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() {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
]
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
export interface GlobalProps {
|
|
||||||
user_settings: {
|
|
||||||
theme: 'light' | 'dark';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
export const safeJsonParse = (json: string): [Error, null] | [null, Object] => {
|
|
||||||
try {
|
|
||||||
return [null, JSON.parse(json)];
|
|
||||||
} catch (err) {
|
|
||||||
return [err, null];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue