WIP
This commit is contained in:
parent
59540510b7
commit
8db8ce9f47
|
|
@ -4,6 +4,6 @@
|
|||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"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"
|
||||
},
|
||||
"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",
|
||||
|
|
@ -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
|
||||
|
|
@ -212,6 +212,7 @@ model IdentityAuthDevice {
|
|||
|
||||
attributes String
|
||||
preferred Boolean
|
||||
twoFactorPreferred Boolean
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
|
|
@ -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: [],
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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> {
|
||||
|
|
@ -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(),
|
||||
});
|
||||
|
|
@ -26,11 +26,8 @@ export namespace Authentication {
|
|||
}
|
||||
|
||||
export interface RequestContext {
|
||||
isComplete: boolean;
|
||||
rawState?: Object;
|
||||
state?: State;
|
||||
username?: string;
|
||||
isLocked?: boolean;
|
||||
state: State;
|
||||
username: string;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
@ -14,19 +16,13 @@ export namespace Identity {
|
|||
ApplicationPassword = 'application_password',
|
||||
}
|
||||
|
||||
export enum PasswordHashKey {
|
||||
Expiry = 'expiry',
|
||||
PasswordHashString = 'password_hash_string',
|
||||
Locked = 'locked',
|
||||
}
|
||||
|
||||
export interface PasswordAttributes {
|
||||
readonly expiry: Date;
|
||||
readonly passwordHashString: string;
|
||||
readonly locked: boolean;
|
||||
}
|
||||
|
||||
export type Password = AuthDevice | PasswordAttributes;
|
||||
export interface Password extends AuthDevice, PasswordAttributes {}
|
||||
}
|
||||
|
||||
export namespace Group {
|
||||
|
|
@ -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',
|
||||
|
|
@ -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}`;
|
||||
|
|
@ -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 { 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 {}
|
||||
|
|
@ -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.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']);
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 })),
|
||||
});
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
@ -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
|
||||
|
|
@ -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