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",
"compilerOptions": {
"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"
},
"dependencies": {
"@apollo/server": "^4.10.2",
"@nestjs/apollo": "^12.1.0",
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/graphql": "^12.1.1",
"@nestjs/platform-express": "^10.0.0",
"@prisma/client": "^5.12.1",
"bcrypt": "^5.1.1",
"graphql": "^16.8.1",
"express": "^4.19.2",
"joi": "17.6.4",
"pug": "^3.0.2",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1"
},
"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/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",

View File

@ -157,6 +157,7 @@ CREATE TABLE "IdentityAuthDevice" (
"deviceType" TEXT NOT NULL,
"attributes" TEXT NOT NULL,
"preferred" BOOLEAN NOT NULL,
"twoFactorPreferred" BOOLEAN NOT NULL,
"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_deviceType_fkey" FOREIGN KEY ("deviceType") REFERENCES "EnumIdentityAuthDeviceType" ("enumValue") ON DELETE RESTRICT ON UPDATE CASCADE

View File

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

View File

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

View File

@ -1,5 +1,5 @@
export interface CacheDriver {
get(key: string): Promise<string>;
get(key: string): Promise<string | null>;
set(key: string, val: string): Promise<'OK'>;
exists(...keys: string[]): 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 }> = {};
get(key: string): Promise<string> {
async get(key: string): Promise<string | null> {
if (!this.cache[key] || !this.cache[key].ttl?.isInTheFuture()) {
return Promise.resolve(null);
const property = this.cache[key];
if (!property || !property.ttl?.isInTheFuture()) {
return null;
}
return Promise.resolve(this.cache[key].val);
return property.val;
}
set(key: string, val: string): Promise<'OK'> {
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);
this.cache[key].ttl = dateUtil;
return Promise.resolve(1);
const property = this.cache[key];
if (!property) {
return 0;
}
property.ttl = dateUtil;
return 1;
}
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.EncryptionSecret]: 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 {
isComplete: boolean;
rawState?: Object;
state?: State;
username?: string;
isLocked?: boolean;
state: State;
username: string;
}
}

View File

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

View File

@ -7,9 +7,6 @@ export namespace SystemSettings {
[Auth.Oauth2.Enabled]: boolean;
[Auth.Oauth2.EncryptionSecret]: string;
[Auth.TokenManagement.SigningSecret]: string;
[Graphql.Debug]: boolean;
[Graphql.IntrospectionEnabled]: boolean;
[Graphql.PlaygroundEnabled]: boolean;
}
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 enum Contacts {
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 { ConfigService } from '../../../config/config.service';
@ -21,6 +21,8 @@ interface RequestData {
@Injectable()
export class AccessAttemptService {
private readonly logger: Logger = new Logger(AccessAttemptService.name);
private readonly maxAttemptsAllowed: number;
private readonly lockTimeout: number;
@ -35,26 +37,31 @@ export class AccessAttemptService {
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 usernameAttempts = +await this.cacheService.get(usernameKey) ?? 0;
const ipAttempts = +(await this.cacheService.get(ipAddressKey) ?? 0);
const usernameAttempts = +(await this.cacheService.get(usernameKey) ?? 0);
if (ipAttempts >= this.maxAttemptsAllowed || usernameAttempts >= this.maxAttemptsAllowed) {
await this.cacheService.set(ipAddressKey, (ipAttempts + 1).toString(), this.lockTimeout);
await this.cacheService.set(usernameKey, (usernameAttempts + 1).toString(), this.lockTimeout);
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 true;
}
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);
if (isLocked) {
return;
return { isLocked: true };
}
const {
@ -76,14 +83,28 @@ export class AccessAttemptService {
if (loginSucceeded) {
await this.cacheService.del(ipAddressKey);
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 {
const userAgent = request.headers['user-agent'];
const userAgent = request.headers['user-agent'] ?? '';
const requestPath = request.path;
const ip = request.ip;
const ip = request.ip ?? '';
const ipAddressKey = `access-attempt:ip:${ip}`;
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 { PersistenceModule } from '../../../persistence/persistence.module';
import { LoginController } from './login.controller';
import { AccessAttemptService } from './access-attempt.service';
import { LoginContextInterceptor } from './login-context.interceptor';
import { ConfigModule } from '../../../config/config.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({
imports: [
@ -16,11 +18,13 @@ import { TokenManagementModule } from '../../../token-management/token-managemen
TokenManagementModule,
],
controllers: [
LoginController,
IdentifierController,
ChallengeController,
],
providers: [
AccessAttemptService,
LoginContextInterceptor,
StateManagerService,
],
})
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.set('view engine', 'pug');
app.setBaseViewsDir(join(__dirname, '../../views'));
app.setBaseViewsDir(join(__dirname, '../views'));
if (configService.get(SystemSettings.Auth.AccessAttempts.CheckForwardedFor)) {
app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal']);

View File

@ -54,6 +54,7 @@ export class _00ApplicationBootstrapDataMigration implements DataMigration {
deviceType: Identity.AuthDevice.Type.Password,
attributes: JSON.stringify(passwordDevice),
preferred: true,
twoFactorPreferred: false,
}
});
@ -64,9 +65,6 @@ export class _00ApplicationBootstrapDataMigration implements DataMigration {
[SystemSettings.Auth.Oauth2.Enabled]: true,
[SystemSettings.Auth.Oauth2.EncryptionSecret]: 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)

View File

@ -4,8 +4,13 @@ import { IdentityAuthDevice } from '@prisma/client';
import { PrismaService } from './prisma.service';
import { Identity } from '../domain/identity.types';
import { safeJsonParse } from '../utils';
import * as Joi from 'joi';
const eligibleForTwoFactor: Identity.AuthDevice.Type[] = [
]
@Injectable()
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({
where: {
id: urn.replace('urn:identity:auth-device:', ''),
},
});
if (!device) {
return null;
}
return IdentityAuthDeviceDao.modelToEntity(device);
}
private static modelToEntity(model: IdentityAuthDevice, skipAttributes = false): Identity.AuthDevice {
const [_, attributes] = safeJsonParse(model.attributes);
const entity: Identity.AuthDevice = {
urn: `urn:identity:auth-device:${model.id}`,
userId: model.userId,
deviceType: model.deviceType as Identity.AuthDevice.Type,
preferred: model.preferred,
twoFactorEligible: eligibleForTwoFactor.includes(model.deviceType as Identity.AuthDevice.Type),
twoFactorPreferred: model.twoFactorPreferred,
createdAt: new Date(model.createdAt),
}
if (skipAttributes) {
if (skipAttributes || !model.attributes) {
return entity;
}
if (model.deviceType === Identity.AuthDevice.Type.Password) {
const passwordEntity: Identity.AuthDevice.PasswordAttributes = {
expiry: new Date(attributes[Identity.AuthDevice.PasswordHashKey.Expiry] as string),
passwordHashString: attributes[Identity.AuthDevice.PasswordHashKey.PasswordHashString] as string,
locked: attributes[Identity.AuthDevice.PasswordHashKey.Locked] as boolean,
const [_, attributes] = safeJsonParse<Identity.AuthDevice.PasswordAttributes>(model.attributes);
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 {
...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 { SystemSettings } from '../domain/system-settings.types';
type TypeMap = Record<string, 'string' | 'boolean' | 'number'>;
type ValueMap = Record<string, string>;
type Result = {
typeMap: Record<string, 'string' | 'boolean' | 'number'>;
valueMap: Record<string, string>;
typeMap: TypeMap;
valueMap: ValueMap;
}
@Injectable()
@ -17,10 +20,19 @@ export class SystemSettingsDao {
async getSettings(): Promise<Result> {
const settings = await this.prismaService.systemSetting.findMany();
const typeMap = {}
const valueMap = {}
const typeMap: TypeMap = {}
const valueMap: ValueMap = {}
for (const { hashKey, hashValue, hashValueType } of settings) {
if (
hashValueType !== 'boolean' &&
hashValueType !== 'number' &&
hashValueType !== 'string'
) {
continue;
}
typeMap[hashKey] = hashValueType;
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 { PrismaService } from './persistence/prisma.service';
@ -40,9 +40,12 @@ async function ensureEnumIntegrity(prisma: PrismaService) {
for (const [dbRunner, known] of enumsRegistered) {
await prisma.$transaction(async (tx) => {
// @ts-ignore
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));
// @ts-ignore
await tx[dbRunner].createMany({
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 { CacheService } from '../cache/cache.service';
type Receipt = {
type Receipt<T extends Object = Object> = {
isToken: true;
signatureValid: boolean;
payload: Object;
payload: Partial<T>;
revoked: boolean;
} | { isToken: false };
@ -40,7 +40,7 @@ export class TokenManagementService {
return `${encodedHeader}.${encodedPayload}.${signature}`;
}
parseToken(token: string): Receipt {
parseToken<T extends Object = Object>(token: string): Receipt<T> {
const parts = token.split('.');
@ -48,13 +48,13 @@ export class TokenManagementService {
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 payloadJson = Buffer.from(encodedPayload, 'base64url').toString('utf-8');
const [err1, headerObj] = safeJsonParse(headerJson);
const [err2, payloadObj] = safeJsonParse(payloadJson);
const [err2, payloadObj] = safeJsonParse<T>(payloadJson);
if (err1 || err2) {
return { isToken: false }

View File

@ -1,9 +1,9 @@
export class DateUtil {
private readonly original: Date | null;
private readonly original: Date;
constructor(
private _date: Date | null,
private _date: Date,
) {
this.original = DateUtil.newDate(_date);
}
@ -85,7 +85,7 @@ export class DateUtil {
return DateUtil.newDate(this._date);
}
private static newDate(date: string | Date | null): Date | null {
return date ? new Date(date) : null;
private static newDate(date: string | Date): Date {
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,
}
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> = {
[NodeCryptoAlgs.md5]: 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]: async function (rawString: string, _workFactor: number, salt: string): Promise<string> {
return bcrypt.hash(rawString, salt);
[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.sha256]: async function (rawString: string, workFactor: number, salt: string): Promise<string> {
throw new Error('Function 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') },
},
[NodeCryptoAlgs.sha512]: async function (rawString: string, workFactor: number, salt: string): Promise<string> {
throw new Error('Function not implemented.');
}
}
export enum JWA {
@ -75,22 +79,14 @@ export class SecureStringUtil {
private static readonly defaultWorkFactor = 15;
static async generateNewHash(rawString: string): Promise<string> {
const alg = NodeCryptoAlgs.bcrypt;
const workFactor = this.defaultWorkFactor;
const salt = await bcrypt.genSalt(workFactor);
return algImplementationMap[alg](rawString, workFactor, salt);
const saltRounds = this.defaultWorkFactor;
return algImplementationMap[alg].hash(rawString, saltRounds);
}
static async generateHash(rawString: string, alg: NodeCryptoAlgs, workFactor: number, salt: string): Promise<string> {
return algImplementationMap[alg](rawString, workFactor, salt);
}
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;
static async compare(rawString: string, hash: string): Promise<boolean> {
const { alg } = HashMeta.deserialize(hash);
return algImplementationMap[alg].compare(rawString, hash);
}
}
@ -111,14 +107,22 @@ class 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 hash = salthash.substring(saltStringLength);
return new HashMeta(
hashDictionary[alg],
nodeCryptoAlg,
+workFactor,
salt,
hash

View File

@ -1,5 +1,5 @@
// 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 =>
group
.toUpperCase()

View File

@ -25,12 +25,13 @@ class WhenClosure<T, K extends Literal> {
return resolved();
}
return resolved;
return resolved as T;
}
resolveOrThrow(errorMessage = defaultErrorMessage): T {
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) {
return resolved();
}

View File

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

View File

@ -4,6 +4,8 @@ div#innerTarget
input(type="hidden" name="state" value=state)
input(type="text" name="username" placeholder="Username" value=username readonly)
input(type="password" name="password" placeholder="Password")
if error
span=error
input(type="submit" name="submit" value="Submit")
if links.select_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];
}
}