Compare commits

...

4 Commits

Author SHA1 Message Date
Matthew Bessette b26e963bd6 WIP 2024-04-22 00:20:21 +00:00
Matthew Bessette 8db8ce9f47 WIP 2024-04-21 16:47:27 +00:00
Matthew Bessette 59540510b7 WIP2 2024-04-21 06:37:19 +00:00
Matthew Bessette 2410ef056c WIP 2024-04-15 01:45:41 +00:00
112 changed files with 2969 additions and 5344 deletions

View File

@ -1,5 +1,5 @@
node_modules
.env
__generated__
.generated
graphql.schema.json
dist

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,24 +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",
"joi": "^17.12.3",
"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

@ -0,0 +1,206 @@
-- CreateTable
CREATE TABLE "SystemSetting" (
"hashKey" TEXT NOT NULL PRIMARY KEY,
"hashValueType" TEXT NOT NULL,
"hashValue" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "SystemPostMigration" (
"name" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "AuthRealm" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "AuthOauth2Client" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"realmId" INTEGER NOT NULL,
"clientId" TEXT NOT NULL,
"clientSecret" TEXT,
"consentRequired" BOOLEAN NOT NULL DEFAULT false,
"authorizationCodeFlowEnabled" BOOLEAN NOT NULL DEFAULT false,
"resourceOwnerPasswordCredentialsFlowEnabled" BOOLEAN NOT NULL DEFAULT false,
"clientCredentialsFlowEnabled" BOOLEAN NOT NULL DEFAULT false,
"idTokenEnabled" BOOLEAN NOT NULL DEFAULT false,
"refreshTokenEnabled" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "AuthOauth2Client_realmId_fkey" FOREIGN KEY ("realmId") REFERENCES "AuthRealm" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "AuthOauth2Scope" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"realmId" INTEGER NOT NULL,
"scope" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "AuthOauth2ClientToAuthOauth2Scope" (
"clientId" INTEGER NOT NULL,
"scopeId" INTEGER NOT NULL,
PRIMARY KEY ("clientId", "scopeId"),
CONSTRAINT "AuthOauth2ClientToAuthOauth2Scope_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "AuthOauth2Client" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "AuthOauth2ClientToAuthOauth2Scope_scopeId_fkey" FOREIGN KEY ("scopeId") REFERENCES "AuthOauth2Scope" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "AuthOauth2ScopeToIdentityProfileAttributeName" (
"scopeId" INTEGER NOT NULL,
"claimName" TEXT NOT NULL,
"attributeId" INTEGER NOT NULL,
PRIMARY KEY ("scopeId", "attributeId"),
CONSTRAINT "AuthOauth2ScopeToIdentityProfileAttributeName_scopeId_fkey" FOREIGN KEY ("scopeId") REFERENCES "AuthOauth2Scope" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "AuthOauth2ScopeToIdentityProfileAttributeName_attributeId_fkey" FOREIGN KEY ("attributeId") REFERENCES "IdentityProfileAttributeName" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "AuthRole" (
"realmId" INTEGER NOT NULL,
"roleName" TEXT NOT NULL,
PRIMARY KEY ("realmId", "roleName"),
CONSTRAINT "AuthRole_realmId_fkey" FOREIGN KEY ("realmId") REFERENCES "AuthRealm" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "AuthAccessAttempt" (
"id" TEXT NOT NULL PRIMARY KEY,
"username" TEXT NOT NULL,
"ip" TEXT NOT NULL,
"userAgent" TEXT NOT NULL,
"requestPath" TEXT NOT NULL,
"valid" BOOLEAN NOT NULL,
"attemptedOn" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "EnumIdentityGroupRole" (
"enumValue" TEXT NOT NULL PRIMARY KEY
);
-- CreateTable
CREATE TABLE "IdentityGroup" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"realmId" INTEGER NOT NULL,
"role" TEXT NOT NULL,
"name" TEXT,
CONSTRAINT "IdentityGroup_realmId_fkey" FOREIGN KEY ("realmId") REFERENCES "AuthRealm" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "IdentityGroup_role_fkey" FOREIGN KEY ("role") REFERENCES "EnumIdentityGroupRole" ("enumValue") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "IdentityGroupToIdentityUser" (
"groupId" INTEGER NOT NULL,
"userId" INTEGER NOT NULL,
"userIsGroupAdmin" BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY ("groupId", "userId"),
CONSTRAINT "IdentityGroupToIdentityUser_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "IdentityGroup" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "IdentityGroupToIdentityUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "IdentityUser" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "IdentityUser" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"externalId" TEXT NOT NULL,
"username" TEXT NOT NULL,
"realmId" INTEGER NOT NULL,
CONSTRAINT "IdentityUser_realmId_fkey" FOREIGN KEY ("realmId") REFERENCES "AuthRealm" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "IdentityProfileAttributeName" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"realmId" INTEGER NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "IdentityProfileAttributeName_realmId_fkey" FOREIGN KEY ("realmId") REFERENCES "AuthRealm" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "IdentityProfileNonNormalized" (
"userId" INTEGER NOT NULL,
"attributeNameId" INTEGER NOT NULL,
"attributeValue" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("userId", "attributeNameId"),
CONSTRAINT "IdentityProfileNonNormalized_userId_fkey" FOREIGN KEY ("userId") REFERENCES "IdentityUser" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "IdentityProfileNonNormalized_attributeNameId_fkey" FOREIGN KEY ("attributeNameId") REFERENCES "IdentityProfileAttributeName" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "IdentityUserEmails" (
"email" TEXT NOT NULL PRIMARY KEY,
"userId" INTEGER NOT NULL,
"verified" BOOLEAN NOT NULL DEFAULT false,
"default" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "IdentityUserEmails_userId_fkey" FOREIGN KEY ("userId") REFERENCES "IdentityUser" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "EnumIdentityAuthDeviceType" (
"enumValue" TEXT NOT NULL PRIMARY KEY
);
-- CreateTable
CREATE TABLE "IdentityAuthDevice" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" INTEGER NOT NULL,
"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
);
-- CreateTable
CREATE TABLE "EnumCloudDavResourceType" (
"enumValue" TEXT NOT NULL PRIMARY KEY
);
-- CreateTable
CREATE TABLE "CloudDavResource" (
"id" TEXT NOT NULL PRIMARY KEY,
"identityGroupId" INTEGER NOT NULL,
"resourceType" TEXT NOT NULL,
"attributes" TEXT NOT NULL,
CONSTRAINT "CloudDavResource_identityGroupId_fkey" FOREIGN KEY ("identityGroupId") REFERENCES "IdentityGroup" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "CloudDavResource_resourceType_fkey" FOREIGN KEY ("resourceType") REFERENCES "EnumCloudDavResourceType" ("enumValue") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "AuthRealm_name_key" ON "AuthRealm"("name");
-- CreateIndex
CREATE UNIQUE INDEX "AuthOauth2Client_realmId_clientId_key" ON "AuthOauth2Client"("realmId", "clientId");
-- CreateIndex
CREATE UNIQUE INDEX "AuthOauth2Scope_realmId_scope_key" ON "AuthOauth2Scope"("realmId", "scope");
-- CreateIndex
CREATE UNIQUE INDEX "AuthOauth2ScopeToIdentityProfileAttributeName_scopeId_claimName_key" ON "AuthOauth2ScopeToIdentityProfileAttributeName"("scopeId", "claimName");
-- CreateIndex
CREATE UNIQUE INDEX "IdentityUser_externalId_key" ON "IdentityUser"("externalId");
-- CreateIndex
CREATE UNIQUE INDEX "IdentityUser_username_key" ON "IdentityUser"("username");
-- CreateIndex
CREATE INDEX "IdentityAuthDevice_userId_idx" ON "IdentityAuthDevice"("userId");
-- CreateIndex
CREATE INDEX "IdentityAuthDevice_userId_deviceType_idx" ON "IdentityAuthDevice"("userId", "deviceType");
-- CreateIndex
CREATE INDEX "CloudDavResource_identityGroupId_idx" ON "CloudDavResource"("identityGroupId");

242
core/prisma/schema.prisma Normal file
View File

@ -0,0 +1,242 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:../../data/core.db"
}
//
// Namespace: System
//
model SystemSetting {
hashKey String @id
hashValueType String
hashValue String
}
model SystemPostMigration {
name String @id
createdAt DateTime @default(now())
}
//
// Namespace: Auth
//
model AuthRealm {
id Int @id @default(autoincrement())
name String @unique
createdAt DateTime @default(now())
oauth2Clients AuthOauth2Client[]
groups IdentityGroup[]
users IdentityUser[]
profileAttributeNames IdentityProfileAttributeName[]
roles AuthRole[]
}
model AuthOauth2Client {
id Int @id @default(autoincrement())
realmId Int
realm AuthRealm @relation(fields: [realmId], references: [id])
clientId String
clientSecret String?
consentRequired Boolean @default(false)
authorizationCodeFlowEnabled Boolean @default(false)
resourceOwnerPasswordCredentialsFlowEnabled Boolean @default(false)
clientCredentialsFlowEnabled Boolean @default(false)
idTokenEnabled Boolean @default(false)
refreshTokenEnabled Boolean @default(false)
scopeMappings AuthOauth2ClientToAuthOauth2Scope[]
@@unique([realmId, clientId])
}
model AuthOauth2Scope {
id Int @id @default(autoincrement())
realmId Int
scope String
profileAttributeMappings AuthOauth2ScopeToIdentityProfileAttributeName[]
clientMappings AuthOauth2ClientToAuthOauth2Scope[]
@@unique([realmId, scope])
}
model AuthOauth2ClientToAuthOauth2Scope {
clientId Int
oauth2Client AuthOauth2Client @relation(fields: [clientId], references: [id])
scopeId Int
scope AuthOauth2Scope @relation(fields: [scopeId], references: [id])
@@id([clientId, scopeId])
}
model AuthOauth2ScopeToIdentityProfileAttributeName {
scopeId Int
scope AuthOauth2Scope @relation(fields: [scopeId], references: [id])
claimName String
attributeId Int
attributes IdentityProfileAttributeName @relation(fields: [attributeId], references: [id])
@@id([scopeId, attributeId])
@@unique([scopeId, claimName])
}
model AuthRole {
realmId Int
realm AuthRealm @relation(fields: [realmId], references: [id])
roleName String
@@id([realmId, roleName])
}
model AuthAccessAttempt {
id String @id @default(uuid())
username String
ip String
userAgent String
requestPath String
valid Boolean
attemptedOn DateTime @default(now())
}
//
// Namespace: Identity
//
model EnumIdentityGroupRole {
enumValue String @id
groups IdentityGroup[]
}
model IdentityGroup {
id Int @id @default(autoincrement())
realmId Int
realm AuthRealm @relation(fields: [realmId], references: [id])
role String
roleRelation EnumIdentityGroupRole @relation(fields: [role], references: [enumValue])
name String?
users IdentityGroupToIdentityUser[]
davResources CloudDavResource[]
}
model IdentityGroupToIdentityUser {
groupId Int
group IdentityGroup @relation(fields: [groupId], references: [id])
userId Int
user IdentityUser @relation(fields: [userId], references: [id])
userIsGroupAdmin Boolean @default(false)
@@id([groupId, userId])
}
model IdentityUser {
id Int @id @default(autoincrement())
externalId String @unique @default(uuid())
username String @unique
realmId Int
realm AuthRealm @relation(fields: [realmId], references: [id])
groups IdentityGroupToIdentityUser[]
profileHashMapPairs IdentityProfileNonNormalized[]
emails IdentityUserEmails[]
authDevices IdentityAuthDevice[]
}
model IdentityProfileAttributeName {
id Int @id @default(autoincrement())
realmId Int
realm AuthRealm @relation(fields: [realmId], references: [id])
name String
attributeUses IdentityProfileNonNormalized[]
scopeMappings AuthOauth2ScopeToIdentityProfileAttributeName[]
}
model IdentityProfileNonNormalized {
userId Int
user IdentityUser @relation(fields: [userId], references: [id])
attributeNameId Int
attributeName IdentityProfileAttributeName @relation(fields: [attributeNameId], references: [id])
attributeValue String
createdAt DateTime @default(now())
@@id([userId, attributeNameId])
}
model IdentityUserEmails {
email String @id
userId Int
user IdentityUser @relation(fields: [userId], references: [id])
verified Boolean @default(false)
default Boolean @default(false)
}
model EnumIdentityAuthDeviceType {
enumValue String @id
authDevices IdentityAuthDevice[]
}
model IdentityAuthDevice {
id String @id @default(uuid())
userId Int
user IdentityUser @relation(fields: [userId], references: [id])
deviceType String
deviceTypeRelation EnumIdentityAuthDeviceType @relation(fields: [deviceType], references: [enumValue])
attributes String
preferred Boolean
createdAt DateTime @default(now())
@@index([userId])
@@index([userId, deviceType])
}
//
// Namespace: cloud-dav
//
model EnumCloudDavResourceType {
enumValue String @id
davResources CloudDavResource[]
}
model CloudDavResource {
id String @id @default(uuid())
identityGroupId Int
IdentityGroup IdentityGroup @relation(fields: [identityGroupId], references: [id])
resourceType String
resourceTypeRelation EnumCloudDavResourceType @relation(fields: [resourceType], references: [enumValue])
attributes String
@@index([identityGroupId])
}

18
core/src/app.module.ts Normal file
View File

@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { CacheModule } from './cache/cache.module';
import { ConfigModule } from './config/config.module';
import { PersistenceModule } from './persistence/persistence.module';
import { HttpModule } from './http/http.module';
@Module({
imports: [
HttpModule,
CacheModule,
ConfigModule,
PersistenceModule,
],
controllers: [],
providers: [],
})
export class AppModule {}

17
core/src/cache/cache.module.ts vendored Normal file
View File

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { CacheDriver } from './cache.symbols';
import { InMemoryDriver } from './in-memory.driver';
import { CacheService } from './cache.service';
@Module({
providers: [
{
provide: CacheDriver,
useValue: new InMemoryDriver(),
},
CacheService,
],
exports: [CacheService],
})
export class CacheModule {}

32
core/src/cache/cache.service.ts vendored Normal file
View File

@ -0,0 +1,32 @@
import { Inject, Injectable } from '@nestjs/common';
import { CacheDriver } from './cache.symbols';
@Injectable()
export class CacheService {
constructor(
@Inject(CacheDriver)
private readonly cacheDriver: CacheDriver,
) {}
async get(key: string): Promise<string | null> {
return this.cacheDriver.get(key);
}
async set(key: string, val: string, ttl?: number): Promise<void> {
await this.cacheDriver.set(key, val);
if (ttl) {
await this.cacheDriver.expire(key, ttl);
}
}
async exists(key: string): Promise<boolean> {
return await this.cacheDriver.exists(key) > 0;
}
async del(key: string): Promise<void> {
await this.cacheDriver.del(key);
}
}

8
core/src/cache/cache.symbols.ts vendored Normal file
View File

@ -0,0 +1,8 @@
export interface CacheDriver {
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>;
del(key: string): Promise<number>;
}
export const CacheDriver = Symbol.for('CACHE_DRIVER');

50
core/src/cache/in-memory.driver.ts vendored Normal file
View File

@ -0,0 +1,50 @@
import { DateUtil } from '../utils';
import { CacheDriver } from './cache.symbols';
export class InMemoryDriver implements CacheDriver {
private readonly cache: Record<string, { val: string, ttl?: DateUtil }> = {};
async get(key: string): Promise<string | null> {
const property = this.cache[key];
if (!property || !property.ttl?.isInTheFuture()) {
return null;
}
return property.val;
}
set(key: string, val: string): Promise<'OK'> {
this.cache[key] = { val };
return Promise.resolve('OK');
}
exists(...keys: string[]): Promise<number> {
return Promise.resolve(
keys.reduce((acc, k) => {
if (k in this.cache) {
return acc + 1;
}
return acc;
}, 0)
);
}
async expire(key: string, seconds: number): Promise<number> {
const dateUtil = DateUtil.fromDate(new Date()).addNSeconds(seconds);
const property = this.cache[key];
if (!property) {
return 0;
}
property.ttl = dateUtil;
return 1;
}
del(key: string): Promise<number> {
delete this.cache[key];
return Promise.resolve(1);
}
}

View File

@ -0,0 +1,36 @@
import { Module } from '@nestjs/common';
import { ConfigService } from './config.service';
import { PersistenceModule } from '../persistence/persistence.module';
import { SystemSettingsDao } from '../persistence/system-settings.dao';
import { LoadedConfig } from './config.symbol';
@Module({
imports: [
PersistenceModule,
],
providers: [
// {
// provide: LoadedConfig,
// useFactory: async () => {
// const prismaService = new PrismaService();
// await prismaService.onModuleInit();
// const sysSettingsService = new SystemSettingsDao(prismaService);
// const { valueMap } = await sysSettingsService.getSettings();
// await prismaService.onModuleDestroy();
// return valueMap;
// },
// },
{
provide: LoadedConfig,
useFactory: async (sysSettingsService: SystemSettingsDao) => {
const { valueMap } = await sysSettingsService.getSettings();
return valueMap;
},
inject: [SystemSettingsDao],
},
ConfigService
],
exports: [ConfigService],
})
export class ConfigModule {}

View File

@ -0,0 +1,28 @@
import { Global, Inject, Injectable } from '@nestjs/common';
import { configValidator} from './config.validator';
import { SystemSettings } from '../domain/system-settings.types';
import { LoadedConfig } from './config.symbol';
@Injectable()
export class ConfigService {
private readonly _config: SystemSettings.Config;
constructor(
@Inject(LoadedConfig)
loadedConfig: LoadedConfig,
) {
const { value: config, error } = configValidator.validate(loadedConfig, { abortEarly: false, allowUnknown: false });
if (error) {
throw error;
}
this._config = config;
}
get<K extends keyof SystemSettings.Config>(key: K): SystemSettings.Config[K] {
return this._config[key];
}
}

View File

@ -0,0 +1,2 @@
export type LoadedConfig = Record<string, string>;
export const LoadedConfig = Symbol.for('LOADED_CONFIG');

View File

@ -0,0 +1,12 @@
import * as Joi from 'joi';
import { SystemSettings } from '../domain/system-settings.types';
export const configValidator: Joi.ObjectSchema<SystemSettings.Config> = Joi.object<SystemSettings.Config, true>({
[SystemSettings.Auth.AccessAttempts.CheckForwardedFor]: Joi.boolean().required(),
[SystemSettings.Auth.AccessAttempts.MaxAttempts]: Joi.number().required(),
[SystemSettings.Auth.AccessAttempts.Timeout]: Joi.number().required(),
[SystemSettings.Auth.Oauth2.Enabled]: Joi.boolean().required(),
[SystemSettings.Auth.Oauth2.EncryptionSecret]: Joi.string().required(),
[SystemSettings.Auth.TokenManagement.SigningSecret]: Joi.string().required(),
});

View File

@ -0,0 +1,44 @@
export namespace Authentication {
export namespace AccessAttempt {
export interface CreateDto {
username: string;
ip: string;
userAgent: string;
requestPath: string;
valid: boolean;
}
}
export namespace Oauth2 {
export enum TokenClaims {
Issuer = 'iss',
Expiry = 'exp',
TokenId = 'jwi',
IssuedAt = 'iat',
}
export enum AuthorizationGrant {
AuthorizationCode = 'authorization_code',
Implicit = 'implicit',
ResourceOwnerPasswordCredentials = 'resource_owner_password_credentials',
ClientCredentials = 'client_credentials',
}
export enum GrantType {
RefreshToken = 'refresh_token',
Password = 'password',
}
export enum ResponseType {
Code = 'code',
Token = 'token',
}
export enum Error {
InvalidRequest = 'invalid_request',
UnauthorizedClient = 'unauthorized_client',
AccessDenied = 'access_denied',
UnsupportedResponseType = 'unsupported_response_type',
InvalidScope = 'invalid_scope',
ServerError = 'server_error',
TemporarilyUnavailable = 'temporarily_unavailable',
}
}
}

View File

@ -0,0 +1,34 @@
export namespace Identity {
export interface AuthDevice {
readonly urn: string,
readonly userId: number,
readonly deviceType: AuthDevice.Type,
readonly preferred: boolean;
readonly twoFactorEligible: boolean;
readonly createdAt: Date,
}
export namespace AuthDevice {
export enum Type {
Password = 'password',
ApplicationPassword = 'application_password',
}
export interface PasswordAttributes {
readonly expiry: Date;
readonly passwordHashString: string;
readonly locked: boolean;
}
export interface Password extends AuthDevice, PasswordAttributes {}
}
export namespace Group {
export enum Role {
SystemAdmin = 'SYSTEM_ADMIN',
RealmAdmin = 'REALM_ADMIN',
Standard = 'STANDARD',
}
}
}

View File

@ -0,0 +1,18 @@
export abstract class Serializable {
toJson(): string {
return JSON.stringify(this);
}
static fromJson(): ThisType<Serializable> {
throw new Error('Method not implemented! Use derived class');
}
protected static safeJsonParse(str: string): [Error, null] | [null, Object] {
try {
return [null, JSON.parse(str)];
} catch (err) {
return [err, null];
}
}
}

View File

@ -0,0 +1,34 @@
export namespace SystemSettings {
export interface Config {
[Auth.AccessAttempts.CheckForwardedFor]: boolean;
[Auth.AccessAttempts.MaxAttempts]: number;
[Auth.AccessAttempts.Timeout]: number;
[Auth.Oauth2.Enabled]: boolean;
[Auth.Oauth2.EncryptionSecret]: string;
[Auth.TokenManagement.SigningSecret]: string;
}
export namespace Auth {
export enum AccessAttempts {
MaxAttempts = 'auth.attempts.max',
Timeout = 'auth.attempts.timeout-ms',
CheckForwardedFor = 'auth.attempts.check-forwarded-for-header'
}
export enum Oauth2 {
Enabled = 'auth.oauth2.enabled',
EncryptionSecret = 'auth.oauth2.encryption_secret',
}
export enum TokenManagement {
SigningSecret = 'auth.token-management.signing_secret',
}
}
export namespace Dav {
export enum Contacts {
Enabled = 'dav.contacts.enabled',
}
}
}

View File

@ -0,0 +1,10 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export enum RegisteredCookies {
Theme = 'mvc:theme',
}
export const Cookies = createParamDecorator((data: string, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return data ? request.cookies?.[data] : request.cookies;
});

View File

@ -0,0 +1,8 @@
import { ExecutionContext, createParamDecorator } from '@nestjs/common';
import { Request, UserAgentSettings as UserAgentSettingsType } from '../request.type';
export const UserAgentSettings = createParamDecorator((data: keyof UserAgentSettingsType, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest<Request>();
return data ? request.userAgentSettings[data] : request.cookies;
});

View File

@ -0,0 +1,20 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
import { Response } from 'express';
import { FoundException } from '../exceptions/found.exception';
import { SeeOtherException } from '../exceptions/see-other.exception';
@Catch(FoundException, SeeOtherException)
export class RedirectExceptionFilter implements ExceptionFilter {
catch(exception: FoundException | SeeOtherException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
if (exception.trueRedirect) {
response.setHeader('hx-redirect', exception.redirectUri);
return;
}
return response.redirect(exception.exposedStatus, exception.redirectUri);
}
}

View File

@ -0,0 +1,15 @@
import { HttpException, HttpStatus } from '@nestjs/common';
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/302
// Use for GET or HEAD responses
export class FoundException extends HttpException {
readonly exposedStatus = HttpStatus.FOUND;
constructor(
readonly redirectUri: string,
readonly trueRedirect: boolean = false,
) {
super(`<a href="${redirectUri}">Found.</a>`, HttpStatus.FOUND);
}
}

View File

@ -0,0 +1,15 @@
import { HttpException, HttpStatus } from '@nestjs/common';
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/303
// User for POST or PUT responses
export class SeeOtherException extends HttpException {
readonly exposedStatus = HttpStatus.SEE_OTHER;
constructor(
readonly redirectUri: string,
readonly trueRedirect: boolean = false,
) {
super(`<a href="${redirectUri}">See other.</a>`, HttpStatus.SEE_OTHER);
}
}

View File

@ -0,0 +1,7 @@
import { HttpException, HttpStatus } from '@nestjs/common';
export class TooManyRequestsException extends HttpException {
constructor() {
super('Too Many Requests', HttpStatus.TOO_MANY_REQUESTS);
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { MvcModule } from './mvc/mvc.module';
import { RestModule } from './rest/rest.module';
import { UserAgentSettingsInterceptor } from './interceptors/user-agent-settings.interceptor';
@Module({
imports: [MvcModule, RestModule],
providers: [UserAgentSettingsInterceptor],
})
export class HttpModule {}

View File

@ -0,0 +1,24 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { Request } from '../request.type';
import { RegisteredCookies } from '../decorators/cookies.decorator';
@Injectable()
export class UserAgentSettingsInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> | Promise<Observable<any>> {
const request = context.switchToHttp().getRequest<Request>();
return next.handle().pipe(
map(data => ({
...data,
user_agent_settings: {
theme: request.cookies[RegisteredCookies.Theme] ?? 'light',
links: {
toggle_theme: '/v1/user-agent/toggle-theme',
}
}
}))
);
}
}

View File

@ -0,0 +1,120 @@
import { Injectable, Logger } from '@nestjs/common';
import { Request } from 'express';
import { ConfigService } from '../../../config/config.service';
import { SystemSettings } from '../../../domain/system-settings.types';
import { AuthAccessAttemptDao } from '../../../persistence/auth-access-attempt.dao';
import { CacheService } from '../../../cache/cache.service';
export enum AccessAttemptError {
}
interface RequestData {
userAgent: string;
requestPath: string;
ip: string;
ipAddressKey: string;
usernameKey: string;
}
@Injectable()
export class AccessAttemptService {
private readonly logger: Logger = new Logger(AccessAttemptService.name);
private readonly maxAttemptsAllowed: number;
private readonly lockTimeout: number;
constructor(
private readonly cacheService: CacheService,
private readonly accessAttemptService: AuthAccessAttemptDao,
configService: ConfigService,
) {
this.maxAttemptsAllowed = configService.get(SystemSettings.Auth.AccessAttempts.MaxAttempts);
this.lockTimeout = configService.get(SystemSettings.Auth.AccessAttempts.Timeout);
}
async checkIfLocked(request: Request, username: string): Promise<boolean> {
const { ipAddressKey, usernameKey, ip } = this.parseRequestData(request, username);
const ipAttempts = +(await this.cacheService.get(ipAddressKey) ?? 0);
const usernameAttempts = +(await this.cacheService.get(usernameKey) ?? 0);
if (ipAttempts >= this.maxAttemptsAllowed || usernameAttempts >= this.maxAttemptsAllowed) {
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<{ isLocked: boolean }> {
const isLocked = await this.checkIfLocked(request, username);
if (isLocked) {
return { isLocked: true };
}
const {
userAgent,
requestPath,
ip,
ipAddressKey,
usernameKey,
} = this.parseRequestData(request, username);
await this.accessAttemptService.createAttempt({
userAgent,
username,
ip,
requestPath,
valid: loginSucceeded,
});
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 requestPath = request.path;
const ip = request.ip ?? '';
const ipAddressKey = `access-attempt:ip:${ip}`;
const usernameKey = `access-attempt:username:${username}`;
return {
userAgent,
requestPath,
ip,
ipAddressKey,
usernameKey,
}
}
}

View File

@ -0,0 +1,168 @@
import { Body, Controller, Get, InternalServerErrorException, Param, Post, Query, Render, Req, Res, UseInterceptors } from '@nestjs/common';
import { Request, Response } from 'express';
import * as Joi from 'joi';
import { Identity } from '../../../domain/identity.types';
import { SecureStringUtil } from '../../../utils';
import { TooManyRequestsException } from '../../exceptions/too-many-requests.exception';
import { Views } from '../mvc.types';
import { LoginContextInterceptor } from './login-context.interceptor';
import { Context } from './context.decorator';
import { StateManagerService } from './state-manager.service';
import { AccessAttemptService } from './access-attempt.service';
import { ChallengeRenderContext, RequestContext } from './login.types';
import { DeviceCalculatorService } from './device-calculator.service';
import { FoundException } from '../../exceptions/found.exception';
import { SeeOtherException } from '../../exceptions/see-other.exception';
@Controller({
version: '1',
path: 'auth/:realm/signin/challenge',
})
@UseInterceptors(LoginContextInterceptor)
export class ChallengeController {
constructor(
private readonly stateManager: StateManagerService,
private readonly deviceCalcService: DeviceCalculatorService,
private readonly accessAttemptService: AccessAttemptService,
) {}
@Get()
async selectDevice() {
}
@Get(':device_urn')
@Render(Views.LoginChallenge)
async getDevice(
@Param('realm') realm: string,
@Param('device_urn') deviceUrn: string,
@Context() context: RequestContext,
@Res() res: Response,
): Promise<ChallengeRenderContext> {
const deviceInfo = await this.deviceCalcService.considerDevicesForUser(realm, context.username);
const device = deviceInfo.primaryDevices.find(d => d.urn === deviceUrn);
const refreshedState = this.stateManager.updateState(context.state);
const redirectQueryParams = new URLSearchParams();
redirectQueryParams.append('username', context.username);
redirectQueryParams.append('state', refreshedState);
if (!device) {
throw new FoundException(`/v1/auth/${realm}/signin/identifier`);
}
return {
state: this.stateManager.updateState(context.state),
username: context.username,
device,
links: {
select_device: deviceInfo.primaryDevices.length > 1 ? `/v1/auth/${realm}/signin/challenge?${redirectQueryParams.toString()}` : null,
challenge_form: `/v1/auth/${realm}/signin/challenge/${deviceUrn}`,
try_different_user: `/v1/auth/${realm}/signin/identifier`,
}
}
}
@Post(':device_urn')
@Render(Views.LoginChallenge)
async postDevice(
@Req() req: Request,
@Body() body: unknown,
@Param('realm') realm: string,
@Param('device_urn') deviceUrn: string,
@Context() context: RequestContext,
@Res() res: Response,
): Promise<ChallengeRenderContext> {
const deviceInfo = await this.deviceCalcService.considerDevicesForUser(realm, context.username);
const device = deviceInfo.primaryDevices.find(d => d.urn === deviceUrn);
const refreshedState = this.stateManager.updateState(context.state);
const redirectQueryParams = new URLSearchParams();
redirectQueryParams.append('username', context.username);
redirectQueryParams.append('state', refreshedState);
const selectDeviceUrl = deviceInfo.primaryDevices.length > 1 ? `/v1/auth/${realm}/signin/challenge?${redirectQueryParams.toString()}` : null;
if (!device || deviceInfo.primaryDevices.length === 0) {
const { isLocked } = await this.accessAttemptService.recordAttempt(req, context.username, false);
if (isLocked) {
throw new TooManyRequestsException();
}
return {
state: refreshedState,
username: context.username,
device: null,
links: {
select_device: null,
challenge_form: `/v1/auth/${realm}/signin/challenge/${deviceUrn}`,
try_different_user: `/v1/auth/${realm}/signin/identifier`,
},
error: 'Invalid username or password.',
}
}
if (device.deviceType === Identity.AuthDevice.Type.Password) {
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 {
state: this.stateManager.updateState(context.state),
username: context.username,
device,
links: {
select_device: selectDeviceUrl,
challenge_form: `/v1/auth/${realm}/signin/challenge/${deviceUrn}`,
try_different_user: `/v1/auth/${realm}/signin/identifier`,
},
error: 'Password is required.',
}
}
if (await SecureStringUtil.compare(value.password, passwordDevice.passwordHashString)) {
if (deviceInfo.hasSecondaryDevice) {
if (!deviceInfo.preferredSecondaryDevice) {
throw new SeeOtherException(`/v1/auth/${realm}/signing/2fa/challenge?${redirectQueryParams.toString()}`);
}
throw new SeeOtherException(`/v1/auth/${realm}/signing/2fa/challenge/${deviceInfo.preferredSecondaryDevice.urn}?${redirectQueryParams.toString()}`);
}
await this.accessAttemptService.recordAttempt(req, context.username, true);
throw new SeeOtherException(context.state.htu ?? 'https://google.com', true);
}
const { isLocked } = await this.accessAttemptService.recordAttempt(req, context.username, false);
if (isLocked) {
throw new TooManyRequestsException();
}
return {
state: refreshedState,
username: context.username,
device,
links: {
select_device: selectDeviceUrl,
challenge_form: `/v1/auth/${realm}/signin/challenge/${deviceUrn}`,
try_different_user: `/v1/auth/${realm}/signin/identifier`,
},
error: 'Invalid username or password.',
}
}
throw new SeeOtherException(`/v1/auth/${realm}/signin/error`);
}
}

View File

@ -0,0 +1,10 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { LoginRequest, RequestContext } from './login.types';
export const Context = createParamDecorator(
(data: unknown, ctx: ExecutionContext): RequestContext | null => {
const request = ctx.switchToHttp().getRequest<LoginRequest>();
return request.context!;
},
);

View File

@ -0,0 +1,36 @@
import { Injectable } from '@nestjs/common';
import { Identity } from '../../../domain/identity.types';
import { IdentityAuthDeviceDao } from '../../../persistence/identity-auth-device.dao';
interface DeviceResults {
hasSecondaryDevice: boolean;
preferredPrimaryDevice: Identity.AuthDevice | null;
preferredSecondaryDevice: Identity.AuthDevice | null;
primaryDevices: Identity.AuthDevice[];
secondaryDevices: Identity.AuthDevice[]
}
@Injectable()
export class DeviceCalculatorService {
constructor(
private readonly identityAuthDeviceDao: IdentityAuthDeviceDao,
) {}
async considerDevicesForUser(realm: string, username: string): Promise<DeviceResults> {
const devicesForUser = await this.identityAuthDeviceDao.findByRealmAndUsername(realm, username);
const primaryDevices = devicesForUser.filter(d => !d.twoFactorEligible);
const secondaryDevices = devicesForUser.filter(d => d.twoFactorEligible);
const hasSecondaryDevice = devicesForUser.some(d => d.twoFactorEligible);
return {
hasSecondaryDevice,
preferredPrimaryDevice: primaryDevices.find(d => d.preferred) ?? null,
preferredSecondaryDevice: secondaryDevices.find(d => d.preferred) ?? null,
primaryDevices,
secondaryDevices,
}
}
}

View File

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

View File

@ -0,0 +1,72 @@
import { Controller, Get, Param, Post, Render, Res, UseInterceptors } from '@nestjs/common';
import { Response } from 'express';
import { randomUUID } from 'node:crypto';
import { Views } from '../mvc.types';
import { StateManagerService } from './state-manager.service';
import { Identity } from '../../../domain/identity.types';
import { LoginContextInterceptor } from './login-context.interceptor';
import { Context } from './context.decorator';
import { IdentifierRenderContext, RequestContext } from './login.types';
import { DeviceCalculatorService } from './device-calculator.service';
import { SeeOtherException } from '../../exceptions/see-other.exception';
@Controller({
version: '1',
path: 'auth/:realm/signin/identifier',
})
export class IdentifierController {
constructor(
private readonly stateManager: StateManagerService,
private readonly deviceCalcService: DeviceCalculatorService,
) {}
@Get()
@Render(Views.LoginIdentifier)
async getLogin(
@Param('realm') realm: string,
): Promise<IdentifierRenderContext> {
const state = await this.stateManager.getNewState();
return {
state,
links: {
identifier_form: `/v1/auth/${realm}/signin/identifier`
}
};
}
@Post()
@Render(Views.LoginIdentifier)
@UseInterceptors(LoginContextInterceptor)
async postLogin(
@Param('realm') realm: string,
@Context() context: RequestContext,
): Promise<IdentifierRenderContext> {
const deviceInfo = await this.deviceCalcService.considerDevicesForUser(realm, context.username);
const refreshedState = this.stateManager.updateState(context.state);
const redirectQueryParams = new URLSearchParams();
redirectQueryParams.append('username', context.username);
redirectQueryParams.append('state', refreshedState);
if (deviceInfo.primaryDevices.length === 0) {
throw new SeeOtherException(`/v1/auth/${realm}/signin/challenge/${randomUUID()}?${redirectQueryParams.toString()}`);
}
if (deviceInfo.preferredPrimaryDevice?.deviceType === Identity.AuthDevice.Type.Password) {
throw new SeeOtherException(`/v1/auth/${realm}/signin/challenge/${deviceInfo.preferredPrimaryDevice.urn}?${redirectQueryParams.toString()}`);
}
return {
state: refreshedState,
links: {
identifier_form: `/v1/auth/${realm}/signin/identifier`
}
}
}
}

View File

@ -0,0 +1,74 @@
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 { DateUtil } from '../../../utils';
import { DeepPartial } from '../../../utils/deep-partial.type';
import { TooManyRequestsException } from '../../exceptions/too-many-requests.exception';
import { LoginRequest, RequestContext, State } from './login.types';
type RequestContextMutation = DeepPartial<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<LoginRequest>();
const loginContextMutable: RequestContextMutation = {};
loginContextMutable.username = request.method === 'POST' ? request?.body?.username : request?.query?.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();
}
const state = request.method === 'POST' ? request?.body?.state : request?.query?.state;
if (!state) {
this.logger.debug('State was not provided in body');
throw new BadRequestException(standardBadRequestError);
}
const receipt = this.tokenService.parseToken<State>(state);
if (receipt.isToken && receipt.signatureValid) {
loginContextMutable.state = receipt.payload;
}
const { error, value: loginContext } = Joi.object<RequestContext, true>({
state: Joi.object<State, true>({
jwi: Joi.string().required(),
exp: Joi.number().required(),
tfa: Joi.boolean().required(),
htu: Joi.string(),
}),
username: Joi.string().required(),
}).validate(loginContextMutable, { allowUnknown: false });
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

@ -0,0 +1,32 @@
import { Module } from '@nestjs/common';
import { CacheModule } from '../../../cache/cache.module';
import { PersistenceModule } from '../../../persistence/persistence.module';
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';
import { DeviceCalculatorService } from './device-calculator.service';
@Module({
imports: [
CacheModule,
PersistenceModule,
ConfigModule,
TokenManagementModule,
],
controllers: [
IdentifierController,
ChallengeController,
],
providers: [
AccessAttemptService,
LoginContextInterceptor,
StateManagerService,
DeviceCalculatorService,
],
})
export class LoginModule {}

View File

@ -0,0 +1,49 @@
import { Request as ExpressRequest, Response as ExpressResponse } from 'express';
import { Identity } from '../../../domain/identity.types';
import { Views } from '../mvc.types';
export interface LoginRequest extends ExpressRequest {
context?: RequestContext;
}
export interface IdentifierRenderContext {
state: string;
links: {
identifier_form: string;
}
error?: string;
}
export interface ChallengeRenderContext {
state: string;
username: string;
device: Identity.AuthDevice | null;
links: {
select_device: string | null;
challenge_form: string;
try_different_user: string;
},
error?: string;
}
export interface State {
jwi: string;
exp: number;
tfa: boolean;
htu?: string;
}
export interface RequestContext {
state: State;
username: string;
}
type ViewContextPairs =
[view: Views.LoginChallenge, ctx: ChallengeRenderContext] |
[view: Views.LoginIdentifier, ctx: IdentifierRenderContext] ;
export interface LoginResponse extends Omit<ExpressResponse, 'render'> {
render(...args: ViewContextPairs): Promise<void>
}

View File

@ -0,0 +1,40 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { CacheService } from '../../../cache/cache.service';
import { TokenManagementService } from '../../../token-management/token-management.service';
import { DateUtil } from '../../../utils';
import { State } from './login.types';
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: 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: 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,14 @@
import { Module } from '@nestjs/common';
import { LoginModule } from './login/login.module';
import { UserAgentController } from './user-agent.controller';
@Module({
imports: [
LoginModule,
],
controllers: [
UserAgentController,
]
})
export class MvcModule {}

View File

@ -0,0 +1,6 @@
export enum Views {
LoginChallenge = 'login-challenge',
LoginIdentifier = 'login-identifier',
NotFound = 'not-found',
UserAgentThemeSwitch = 'user-agent-theme-switch',
}

View File

@ -0,0 +1,48 @@
import { Controller, Get, Post } from '@nestjs/common';
@Controller({
version: '1',
path: 'auth/:realm/oauth2',
})
export class OauthController {
@Post('authorize')
async authorization() {
}
@Get('return')
async redirectReturn() {
}
@Post('token')
async token() {
}
@Get('user_info')
async userInfo() {
}
@Get('jwks_uri')
async jwksUri() {
}
@Get('introspection')
async introspection() {
}
@Get('revocation_endpoint')
async revokeBasic() {
}
@Post('revocation_endpoint')
async revoke() {
}
}

View File

@ -0,0 +1,70 @@
import { Authentication } from '../../../domain/authentication.types';
interface ErrorResponse {
state?: string;
error: Authentication.Oauth2.Error;
error_description?: string;
error_uri?: string;
}
namespace AuthorizationCode {
interface AuthorizationRequest {
response_type: Authentication.Oauth2.ResponseType.Code;
client_id: string;
redirect_uri?: string;
scope?: string;
state?: string;
}
interface AuthorizationResponse {
code: string; // 10min redis
state?: string;
}
interface AccessTokenRequest {
grant_type: Authentication.Oauth2.AuthorizationGrant.AuthorizationCode;
code: string;
redirect_uri?: string;
client_id: string;
}
interface AccessTokenResponse {
access_token: string;
token_type: 'bearer';
expires_in: number;
refresh_token?: string;
}
}
// application/x-www-form-urlencoded
// Authorization header required if of type `confidential`
// Basic base64(clientId:clientSecret)
namespace ResourceOwner {
interface AccessTokenRequest {
grant_type: Authentication.Oauth2.GrantType.Password;
username: string;
password: string;
scope?: string;
}
interface AccessTokenResponse {
access_token: string;
token_type: 'bearer'; // ?
expires_in: number;
refresh_token?: string;
}
}
// `confidential` only
namespace ClientCredentials {
interface AccessTokenRequest {
// grant_type: Authentication.Oauth2.GrantType.ClientCredentials;
scope?: string;
}
interface AccessTokenResponse {
access_token: string;
token_type: 'bearer';
expires_in: number;
}
}

View File

@ -0,0 +1,31 @@
import { Controller, Put, Render, Req, Res } from '@nestjs/common';
import { Cookies, RegisteredCookies } from '../decorators/cookies.decorator';
import { Request, Response } from 'express';
import { Views } from './mvc.types';
@Controller({
version: '1',
path: 'user-agent',
})
export class UserAgentController {
@Put('toggle-theme')
@Render(Views.UserAgentThemeSwitch)
async toggleTheme(
@Req() req: Request,
@Res() res: Response,
@Cookies(RegisteredCookies.Theme) theme?: string,
) {
if (theme === 'dark') {
req.cookies[RegisteredCookies.Theme] = 'light';
res.cookie(RegisteredCookies.Theme, 'light');
return {};
}
req.cookies[RegisteredCookies.Theme] = 'dark';
res.cookie(RegisteredCookies.Theme, 'dark');
return {};
}
}

View File

@ -0,0 +1,12 @@
import { Request as ExpressRequest } from 'express';
export interface UserAgentSettings {
theme: 'light' | 'dark';
links: {
toggle_theme: string;
}
}
export interface Request extends ExpressRequest {
userAgentSettings: UserAgentSettings;
}

View File

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

43
core/src/main.ts Normal file
View File

@ -0,0 +1,43 @@
import { NestFactory } from '@nestjs/core';
import { Logger, VERSION_NEUTRAL, VersioningType } from '@nestjs/common';
import { NestExpressApplication } from '@nestjs/platform-express';
import * as cookieParser from 'cookie-parser';
import { join } from 'path';
import { AppModule } from './app.module';
import { ConfigService } from './config/config.service';
import { SystemSettings } from './domain/system-settings.types';
import { UserAgentSettingsInterceptor } from './http/interceptors/user-agent-settings.interceptor';
import { RedirectExceptionFilter } from './http/exception-filters/redirect.exception-filter';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const configService: ConfigService = app.get(ConfigService);
const logger: Logger = new Logger();
app.useLogger(logger);
app.set('view engine', 'pug');
app.setBaseViewsDir(join(__dirname, '../views'));
if (configService.get(SystemSettings.Auth.AccessAttempts.CheckForwardedFor)) {
app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal']);
}
app.use(cookieParser());
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: VERSION_NEUTRAL,
});
const userAgentSettingsInterceptor = app.get(UserAgentSettingsInterceptor);
app.useGlobalInterceptors(userAgentSettingsInterceptor);
app.useGlobalFilters(new RedirectExceptionFilter());
await app.listen(3000, () => {
logger.log('Listening on port 3000', 'main.ts');
});
}
bootstrap();

View File

@ -0,0 +1,18 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import { Authentication } from '../domain/authentication.types';
@Injectable()
export class AuthAccessAttemptDao {
constructor(
private readonly prismaService: PrismaService,
) {}
async createAttempt(data: Authentication.AccessAttempt.CreateDto): Promise<void> {
await this.prismaService.authAccessAttempt.create({
data,
});
}
}

View File

@ -0,0 +1,78 @@
import * as crypto from 'node:crypto';
import { SecureStringUtil } from '../../utils';
import { PrismaService } from '../../persistence/prisma.service';
import { DataMigration } from './data-migration.interface';
import { Identity } from '../../domain/identity.types';
import { SystemSettings } from '../../domain/system-settings.types';
export class _00ApplicationBootstrapDataMigration implements DataMigration {
readonly name = '00-application-bootstrap-data-migration';
async run(prisma: PrismaService) {
await prisma.$queryRaw`PRAGMA journal_mode=WAL;`;
await prisma.$transaction(async (tx) => {
const realm = await tx.authRealm.create({
data: {
name: 'main',
}
});
const adminGroup = await tx.identityGroup.create({
data: {
role: Identity.Group.Role.RealmAdmin,
realmId: realm.id,
}
});
const adminUser = await tx.identityUser.create({
data: {
username: 'admin',
realmId: realm.id,
}
});
await tx.identityGroupToIdentityUser.create({
data: {
userId: adminUser.id,
groupId: adminGroup.id,
},
});
const passwordDevice: Identity.AuthDevice.PasswordAttributes = {
expiry: new Date(0),
passwordHashString: await SecureStringUtil.generateNewHash('ThisIsExpired123!'),
locked: false,
}
await tx.identityAuthDevice.create({
data: {
userId: adminUser.id,
deviceType: Identity.AuthDevice.Type.Password,
attributes: JSON.stringify(passwordDevice),
preferred: true,
}
});
const startingConfig: SystemSettings.Config = {
[SystemSettings.Auth.AccessAttempts.CheckForwardedFor]: true,
[SystemSettings.Auth.AccessAttempts.MaxAttempts]: 5,
[SystemSettings.Auth.AccessAttempts.Timeout]: 15 * 60 * 1000,
[SystemSettings.Auth.Oauth2.Enabled]: true,
[SystemSettings.Auth.Oauth2.EncryptionSecret]: crypto.randomBytes(16).toString('hex'),
[SystemSettings.Auth.TokenManagement.SigningSecret]: crypto.randomBytes(16).toString('hex'),
}
const paired = Object.entries(startingConfig)
.map(([hashKey, hashValue]) => ({ hashKey, hashValue: hashValue.toString(), hashValueType: typeof hashValue }));
await tx.systemSetting.createMany({
data: paired,
});
});
}
}

View File

@ -1,4 +1,4 @@
import { PrismaService } from '../prisma.service';
import { PrismaService } from '../../persistence/prisma.service';
export interface DataMigration {
name: string;

View File

@ -0,0 +1,88 @@
import { Injectable } from '@nestjs/common';
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 {
constructor(
private readonly prismaService: PrismaService,
) {}
async findByRealmAndUsername(realm: string, username: string): Promise<Identity.AuthDevice[]> {
const devices = await this.prismaService.identityAuthDevice.findMany({
where: {
user: {
username: username.toLowerCase(),
realm: {
name: realm.toLowerCase(),
},
},
},
});
return devices.map(d => IdentityAuthDeviceDao.modelToEntity(d));
}
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 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),
createdAt: new Date(model.createdAt),
}
if (skipAttributes || !model.attributes) {
return entity;
}
if (model.deviceType === Identity.AuthDevice.Type.Password) {
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,
...value,
}
}
return entity;
}
}

View File

@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import { AuthAccessAttemptDao } from './auth-access-attempt.dao';
import { IdentityAuthDeviceDao } from './identity-auth-device.dao';
import { SystemSettingsDao } from './system-settings.dao';
@Module({
providers: [
PrismaService,
AuthAccessAttemptDao,
IdentityAuthDeviceDao,
SystemSettingsDao,
],
exports: [
AuthAccessAttemptDao,
IdentityAuthDeviceDao,
SystemSettingsDao,
],
})
export class PersistenceModule {}

View File

@ -1,8 +1,6 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { _00ApplicationBootstrapDataMigration } from './data-migrations';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {

View File

@ -0,0 +1,73 @@
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: TypeMap;
valueMap: ValueMap;
}
@Injectable()
export class SystemSettingsDao {
constructor(
private readonly prismaService: PrismaService,
) {}
async getSettings(): Promise<Result> {
const settings = await this.prismaService.systemSetting.findMany();
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;
}
return {
valueMap,
typeMap,
}
}
async hashKeyExists(hashKey: string): Promise<boolean> {
return await this.prismaService.systemSetting.count({
where: {
hashKey
}
}) > 0;
}
async setSetting(hashKey: keyof SystemSettings.Config, hashValue: string | boolean | number): Promise<void> {
await this.prismaService.systemSetting.update({
where: { hashKey },
data: {
hashValue: hashValue.toString(),
hashValueType: typeof hashValue
},
});
}
async setSettingWithType(hashKey: string, hashValueType: 'string' | 'boolean' | 'number', hashValue: string): Promise<void> {
await this.prismaService.systemSetting.update({
where: { hashKey },
data: {
hashValue: hashValue,
hashValueType: hashValueType,
},
});
}
}

View File

@ -0,0 +1,56 @@
import { NestFactory } from '@nestjs/core';
import { PersistenceModule } from './persistence/persistence.module';
import { PrismaService } from './persistence/prisma.service';
import { DataMigration } from './persistence/data-migrations/data-migration.interface';
import { _00ApplicationBootstrapDataMigration } from './persistence/data-migrations';
import { CloudDav } from './domain/cloud-dav.types';
import { Identity } from './domain/identity.types';
async function bootstrap() {
const app = await NestFactory.create(PersistenceModule);
const prismaService = app.get(PrismaService);
await ensureEnumIntegrity(prismaService);
const registeredMigrations: DataMigration[] = [
new _00ApplicationBootstrapDataMigration,
];
const alreadyRan = await prismaService.systemPostMigration.findMany();
const alreadyRanList = alreadyRan.map(e => e.name);
for (const migration of registeredMigrations) {
if (alreadyRanList.includes(migration.name)) {
continue;
}
await migration.run(prismaService);
await prismaService.systemPostMigration.create({ data: { name: migration.name }});
alreadyRanList.push(migration.name);
}
}
async function ensureEnumIntegrity(prisma: PrismaService) {
const enumsRegistered: [string, string[]][] = [
['enumIdentityGroupRole', Object.values<string>(Identity.Group.Role)],
['enumIdentityAuthDeviceType', Object.values<string>(Identity.AuthDevice.Type)],
['enumCloudDavResourceType', Object.values<string>(CloudDav.Resource.Type)],
];
for (const [dbRunner, known] of enumsRegistered) {
await prisma.$transaction(async (tx) => {
// @ts-ignore
const result = await tx[dbRunner].findMany();
// @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 })),
});
});
}
}
bootstrap();

View File

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { CacheModule } from '../cache/cache.module';
import { TokenManagementService } from './token-management.service';
import { PersistenceModule } from '../persistence/persistence.module';
import { ConfigModule } from '../config/config.module';
@Module({
imports: [
CacheModule,
ConfigModule,
PersistenceModule,
],
providers: [TokenManagementService],
exports: [TokenManagementService],
})
export class TokenManagementModule {}

View File

@ -0,0 +1,82 @@
import { Injectable } from '@nestjs/common';
import * as Joi from 'joi';
import { ConfigService } from '../config/config.service';
import { SystemSettings } from '../domain/system-settings.types';
import { JWA, TokenSignatureUtil, safeJsonParse } from '../utils';
import { CacheService } from '../cache/cache.service';
type Receipt<T extends Object = Object> = {
isToken: true;
signatureValid: boolean;
payload: Partial<T>;
revoked: boolean;
} | { isToken: false };
type Header = {
typ: 'JWT';
alg: JWA.HSA256;
}
@Injectable()
export class TokenManagementService {
constructor(
private readonly configService: ConfigService,
private readonly cacheService: CacheService,
) {}
createToken(payload: Object): string {
const header: Header = {
alg: JWA.HSA256,
typ: 'JWT',
}
const encodedHeader = Buffer.from(JSON.stringify(header), 'utf-8').toString('base64url');
const encodedPayload = Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url');
const signature = TokenSignatureUtil.generateSignature(`${encodedHeader}.${encodedPayload}`, header.alg, this.configService.get(SystemSettings.Auth.TokenManagement.SigningSecret));
return `${encodedHeader}.${encodedPayload}.${signature}`;
}
parseToken<T extends Object = Object>(token: string): Receipt<T> {
const parts = token.split('.');
if (parts.length !== 3) {
return { isToken: false }
}
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<T>(payloadJson);
if (err1 || err2) {
return { isToken: false }
}
const { error: err3, value: header } = Joi.object<Header, true>({
alg: Joi.string().required().valid(JWA.HSA256),
typ: Joi.string().required().allow('JWT'),
}).validate(headerObj, { allowUnknown: false });
if (err3) {
return { isToken: false }
}
const regeneratedSignature = TokenSignatureUtil.generateSignature(`${encodedHeader}.${encodedPayload}`, header.alg, this.configService.get(SystemSettings.Auth.TokenManagement.SigningSecret));
const signatureValid = regeneratedSignature === signature;
return {
isToken: true,
signatureValid,
payload: payloadObj,
revoked: false,
}
}
}

View File

@ -0,0 +1,91 @@
export class DateUtil {
private readonly original: Date;
constructor(
private _date: Date,
) {
this.original = DateUtil.newDate(_date);
}
static fromIso(iso: string) {
return new DateUtil(DateUtil.newDate(iso));
}
static fromDate(date: Date) {
return new DateUtil(date);
}
static fromSecondsSinceEpoch(seconds: number) {
return new DateUtil(new Date(seconds * 1000));
}
get seconds() {
return Math.floor(this._date.getTime() / 1000);
}
isInThePast(onNull = false): boolean {
if (!this._date) {
return onNull;
}
return this._date < new Date();
}
isInTheFuture(onNull = false): boolean {
if (!this._date) {
return onNull;
}
return this._date > new Date();
}
removeNDays(n: number): DateUtil {
if (!this._date) {
return this;
}
this._date.setDate(this._date.getDate() - n);
return this;
}
addNDays(n: number): DateUtil {
if (!this._date) {
return this;
}
this._date.setDate(this._date.getDate() + n);
return this;
}
addNSeconds(n: number): DateUtil {
if (!this._date) {
return this;
}
this._date = new Date(this._date.getTime() + n * 1000);
return this;
}
addNMinutes(n: number): DateUtil {
return this.addNSeconds(n * 60);
}
reset(): DateUtil {
this._date = DateUtil.newDate(this.original);
return this;
}
toDate(): Date {
return DateUtil.newDate(this._date);
}
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];
}

5
core/src/utils/index.ts Normal file
View File

@ -0,0 +1,5 @@
export * from './date-util';
export * from './safe-json-parse';
export * from './secure-string';
export * from './snake-to-camel';
export * from './when-clause';

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

@ -0,0 +1,140 @@
import * as bcrypt from 'bcrypt';
import * as crypto from 'node:crypto';
// https://passlib.readthedocs.io/en/stable/modular_crypt_format.html#mcf-identifiers
type OSDefinedHashPrefix = |
'$1$' |
'$2$' | '$2a$' | '$2x$' | '$2y$' | '$2b$' |
'$5$' |
'$6$'
;
enum NodeCryptoAlgs {
md5 = 'md5',
bcrypt = 'bcrypt',
sha256 = 'sha256',
sha512 = 'sha512',
}
const hashDictionary: Record<OSDefinedHashPrefix, NodeCryptoAlgs> = {
$1$: NodeCryptoAlgs.md5,
$2$: NodeCryptoAlgs.bcrypt,
$2a$: NodeCryptoAlgs.bcrypt,
$2x$: NodeCryptoAlgs.bcrypt,
$2y$: NodeCryptoAlgs.bcrypt,
$2b$: NodeCryptoAlgs.bcrypt,
$5$: NodeCryptoAlgs.sha256,
$6$: NodeCryptoAlgs.sha512,
}
const inversedHashDictionary: Record<NodeCryptoAlgs, OSDefinedHashPrefix> = {
[NodeCryptoAlgs.md5]: '$1$',
[NodeCryptoAlgs.bcrypt]: '$2$',
[NodeCryptoAlgs.sha256]: '$5$',
[NodeCryptoAlgs.sha512]: '$6$',
}
const saltStringLengthDictionary: Record<NodeCryptoAlgs, number> = {
[NodeCryptoAlgs.md5]: 23,
[NodeCryptoAlgs.bcrypt]: 23,
[NodeCryptoAlgs.sha256]: 23,
[NodeCryptoAlgs.sha512]: 23,
}
interface HashingProvider {
hash: (plainText: string, saltRounds: number) => Promise<string>;
compare: (plainText: string, hash: string) => Promise<boolean>;
}
const algImplementationMap: Record<NodeCryptoAlgs, HashingProvider> = {
[NodeCryptoAlgs.md5]: {
hash: (plainText: string, saltRounds: number): Promise<string> => { throw new Error('Not implemented') },
compare: (plainText: string, hash: string): Promise<boolean> => { throw new Error('Not implemented') },
},
[NodeCryptoAlgs.bcrypt]: bcrypt,
[NodeCryptoAlgs.sha256]: {
hash: (plainText: string, saltRounds: number): Promise<string> => { throw new Error('Not implemented') },
compare: (plainText: string, hash: string): Promise<boolean> => { throw new Error('Not implemented') },
},
[NodeCryptoAlgs.sha512]: {
hash: (plainText: string, saltRounds: number): Promise<string> => { throw new Error('Not implemented') },
compare: (plainText: string, hash: string): Promise<boolean> => { throw new Error('Not implemented') },
},
}
export enum JWA {
HSA256 = 'HSA256',
}
type SigningFunction = (rawString: string, signingComponent: string) => string;
const jwaImplementationMap: Record<JWA, SigningFunction> = {
[JWA.HSA256]: function (rawString: string, signingComponent: string) {
return crypto.createHmac('sha-256', signingComponent).update(rawString).digest('base64url');
},
}
export class SecureStringUtil {
private static readonly defaultWorkFactor = 15;
static async generateNewHash(rawString: string): Promise<string> {
const alg = NodeCryptoAlgs.bcrypt;
const saltRounds = this.defaultWorkFactor;
return algImplementationMap[alg].hash(rawString, saltRounds);
}
static async compare(rawString: string, hash: string): Promise<boolean> {
const { alg } = HashMeta.deserialize(hash);
return algImplementationMap[alg].compare(rawString, hash);
}
}
export class TokenSignatureUtil {
static generateSignature(rawString: string, alg: JWA, signingComponent: string): string {
return jwaImplementationMap[alg](rawString, signingComponent);
}
}
class HashMeta {
constructor(
public readonly alg: NodeCryptoAlgs,
public readonly workFactor: number,
public readonly salt: string,
public readonly hash: string,
) {}
static deserialize(hashMeta: string): HashMeta {
const parts = hashMeta.split('$');
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(
nodeCryptoAlg,
+workFactor,
salt,
hash
);
}
serialize(): string {
return [
'',
inversedHashDictionary[this.alg],
this.workFactor,
`${this.salt}${this.hash}`
].join('$');
}
}

View File

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

View File

@ -0,0 +1,46 @@
type Literal = string | number;
type Resolved<T> = T | (() => T);
type Then<T, K extends Literal> = (result: Resolved<T>) => WhenClosure<T, K>;
const defaultErrorMessage = 'Runtime exception: failed to handle all scenarios of when clause';
class WhenClosure<T, K extends Literal> {
private readonly stack: {[key: Literal]: Resolved<T>} = {};
constructor(private readonly expression: Literal) {}
matches(literal: K): { then: Then<T, K> } {
return {
then: (result: Resolved<T>) => {
this.stack[literal] = result;
return this;
}
}
}
else(result: Resolved<T>): T {
const resolved = this.expression in this.stack ? this.stack[this.expression] : result;
if (resolved instanceof Function) {
return resolved();
}
return resolved as T;
}
resolveOrThrow(errorMessage = defaultErrorMessage): T {
if (this.expression in this.stack) {
const resolved = this.stack[this.expression] as Resolved<T>;
if (resolved instanceof Function) {
return resolved();
}
return resolved;
}
throw new Error(errorMessage);
}
}
export const declare = <T, K extends Literal = Literal>() => ({
when: (expression: K) => new WhenClosure<T, K>(expression)
});

View File

@ -9,13 +9,18 @@
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"baseUrl": "./src",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noUncheckedIndexedAccess": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
"noFallthroughCasesInSwitch": false,
},
"exclude": [
"codegen.ts"
]
}

7
core/views/base.pug Normal file
View File

@ -0,0 +1,7 @@
doctype html
html(data-theme=user_agent_settings.theme)
head
link(rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css")
script(src="https://unpkg.com/htmx.org@1.9.12" integrity="sha384-ujb1lZYygJmzgSwoxRggbCHcjc0rB2XoQrxeTUQyRjrOnlCoYta87iKBWq3EsdM2" crossorigin="anonymous")
body
block content

View File

@ -0,0 +1,11 @@
div#innerTarget
form(hx-post=links.challenge_form hx-select="#innerTarget" hx-swap="outerHTML")
fieldset
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

@ -0,0 +1,10 @@
extends base.pug
block content
main
include user-agent-theme-switch.pug
form(hx-post=links.identifier_form hx-swap="outerHTML")
fieldset
input(type="hidden" name="state" value=state)
input(type="text" name="username" placeholder="Username")
input(type="submit" name="submit" value="Submit")

View File

@ -0,0 +1,4 @@
- var isChecked = user_agent_settings.theme === 'dark'
div#themeToggleContainer
input(name="terms" type="checkbox" role="switch" hx-put=user_agent_settings.links.toggle_theme hx-swap="innerHTML" hx-select='div#themeToggleContainer' checked=isChecked)
span Toggle Light/Dark Mode

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,3 +0,0 @@
enum IdentityAuthDeviceTypeEnum {
PASSWORD
}

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,46 +0,0 @@
type IdentityGroup {
urn: ID!
isAdmin: Boolean!
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,12 +0,0 @@
input UpdateSystemSettingInput {
urn: ID!
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!
}

152
package-lock.json generated Normal file
View File

@ -0,0 +1,152 @@
{
"name": "homelab-personal-cloud",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"cookie-parser": "^1.4.6"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.7"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.5",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
"integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==",
"dev": true,
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-Fvuyi354Z+uayxzIGCwYTayFKocfV7TuDYZClCdIP9ckhvAu/ixDtCB6qx2TT0FKjPLf1f3P/J1rgf6lPs64mw==",
"dev": true,
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/express": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz",
"integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==",
"dev": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
"@types/qs": "*",
"@types/serve-static": "*"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "4.19.0",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.0.tgz",
"integrity": "sha512-bGyep3JqPCRry1wq+O5n7oiBgGWmeIJXPjXXCo8EK0u8duZGSYar7cGqd3ML2JUsLGeB7fmc06KYo9fLGWqPvQ==",
"dev": true,
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/@types/http-errors": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz",
"integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==",
"dev": true
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true
},
"node_modules/@types/node": {
"version": "20.12.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz",
"integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@types/qs": {
"version": "6.9.15",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz",
"integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==",
"dev": true
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true
},
"node_modules/@types/send": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz",
"integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==",
"dev": true,
"dependencies": {
"@types/mime": "^1",
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz",
"integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==",
"dev": true,
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*",
"@types/send": "*"
}
},
"node_modules/cookie": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz",
"integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz",
"integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==",
"dependencies": {
"cookie": "0.4.1",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
}
}
}

8
package.json Normal file
View File

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

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,121 +0,0 @@
-- CreateTable
CREATE TABLE "SystemSetting" (
"hashKey" TEXT NOT NULL PRIMARY KEY,
"hashValue" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "SystemPostMigration" (
"name" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "IdentityGroup" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
"name" TEXT
);
-- CreateTable
CREATE TABLE "IdentityGroupToIdentityUser" (
"groupId" INTEGER NOT NULL,
"userId" INTEGER NOT NULL,
PRIMARY KEY ("groupId", "userId"),
CONSTRAINT "IdentityGroupToIdentityUser_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "IdentityGroup" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "IdentityGroupToIdentityUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "IdentityUser" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "IdentityUser" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"externalId" TEXT NOT NULL,
"username" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "IdentityProfileNonNormalized" (
"userId" INTEGER NOT NULL,
"hashKey" TEXT NOT NULL,
"hashValue" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("userId", "hashKey"),
CONSTRAINT "IdentityProfileNonNormalized_userId_fkey" FOREIGN KEY ("userId") REFERENCES "IdentityUser" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "IdentityUserEmails" (
"email" TEXT NOT NULL PRIMARY KEY,
"userId" INTEGER NOT NULL,
"verified" BOOLEAN NOT NULL DEFAULT false,
"default" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "IdentityUserEmails_userId_fkey" FOREIGN KEY ("userId") REFERENCES "IdentityUser" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "EnumIdentityAuthDeviceType" (
"enumValue" TEXT NOT NULL PRIMARY KEY
);
-- CreateTable
CREATE TABLE "IdentityAuthDevice" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"userId" INTEGER NOT NULL,
"deviceType" TEXT 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
);
-- CreateTable
CREATE TABLE "IdentityAuthDeviceNonNormalized" (
"authDeviceId" INTEGER NOT NULL,
"hashKey" TEXT NOT NULL,
"hashValue" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("authDeviceId", "hashKey"),
CONSTRAINT "IdentityAuthDeviceNonNormalized_authDeviceId_fkey" FOREIGN KEY ("authDeviceId") REFERENCES "IdentityAuthDevice" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "EnumCloudDavResourceType" (
"enumValue" TEXT NOT NULL PRIMARY KEY
);
-- CreateTable
CREATE TABLE "CloudDavResource" (
"id" TEXT NOT NULL PRIMARY KEY,
"identityGroupId" INTEGER NOT NULL,
"resourceType" TEXT NOT NULL,
CONSTRAINT "CloudDavResource_identityGroupId_fkey" FOREIGN KEY ("identityGroupId") REFERENCES "IdentityGroup" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "CloudDavResource_resourceType_fkey" FOREIGN KEY ("resourceType") REFERENCES "EnumCloudDavResourceType" ("enumValue") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "CloudDavResourceNonNormalized" (
"davResourceId" TEXT NOT NULL,
"hashKey" TEXT NOT NULL,
"hashValue" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("davResourceId", "hashKey"),
CONSTRAINT "CloudDavResourceNonNormalized_davResourceId_fkey" FOREIGN KEY ("davResourceId") REFERENCES "CloudDavResource" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "IdentityUser_externalId_key" ON "IdentityUser"("externalId");
-- CreateIndex
CREATE UNIQUE INDEX "IdentityUser_username_key" ON "IdentityUser"("username");
-- CreateIndex
CREATE INDEX "IdentityAuthDevice_userId_idx" ON "IdentityAuthDevice"("userId");
-- CreateIndex
CREATE INDEX "IdentityAuthDevice_userId_deviceType_idx" ON "IdentityAuthDevice"("userId", "deviceType");
-- CreateIndex
CREATE INDEX "CloudDavResource_identityGroupId_idx" ON "CloudDavResource"("identityGroupId");

View File

@ -1,142 +0,0 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:../../../data/core.db"
}
//
// Namespace: System
//
model SystemSetting {
hashKey String @id
hashValue String
}
model SystemPostMigration {
name String @id
createdAt DateTime @default(now())
}
//
// Namespace: Identity
//
model IdentityGroup {
id Int @id @default(autoincrement())
isAdmin Boolean @default(false)
name String?
users IdentityGroupToIdentityUser[]
davResources CloudDavResource[]
}
model IdentityGroupToIdentityUser {
groupId Int
group IdentityGroup @relation(fields: [groupId], references: [id])
userId Int
user IdentityUser @relation(fields: [userId], references: [id])
@@id([groupId, userId])
}
model IdentityUser {
id Int @id @default(autoincrement())
externalId String @unique @default(uuid())
username String @unique
groups IdentityGroupToIdentityUser[]
profileHashMapPairs IdentityProfileNonNormalized[]
emails IdentityUserEmails[]
authDevices IdentityAuthDevice[]
}
model IdentityProfileNonNormalized {
userId Int
user IdentityUser @relation(fields: [userId], references: [id])
hashKey String
hashValue String
createdAt DateTime @default(now())
@@id([userId, hashKey])
}
model IdentityUserEmails {
email String @id
userId Int
user IdentityUser @relation(fields: [userId], references: [id])
verified Boolean @default(false)
default Boolean @default(false)
}
model EnumIdentityAuthDeviceType {
enumValue String @id
authDevices IdentityAuthDevice[]
}
model IdentityAuthDevice {
id Int @id @default(autoincrement())
userId Int
user IdentityUser @relation(fields: [userId], references: [id])
deviceType String
deviceTypeRelation EnumIdentityAuthDeviceType @relation(fields: [deviceType], references: [enumValue])
createdAt DateTime @default(now())
hashMapPairs IdentityAuthDeviceNonNormalized[]
@@index([userId])
@@index([userId, deviceType])
}
model IdentityAuthDeviceNonNormalized {
authDeviceId Int
davResource IdentityAuthDevice @relation(fields: [authDeviceId], references: [id])
hashKey String
hashValue String
createdAt DateTime @default(now())
@@id([authDeviceId, hashKey])
}
//
// Namespace: cloud-dav
//
model EnumCloudDavResourceType {
enumValue String @id
davResources CloudDavResource[]
}
model CloudDavResource {
id String @id @default(uuid())
identityGroupId Int
IdentityGroup IdentityGroup @relation(fields: [identityGroupId], references: [id])
resourceType String
resourceTypeRelation EnumCloudDavResourceType @relation(fields: [resourceType], references: [enumValue])
hashMapPairs CloudDavResourceNonNormalized[]
@@index([identityGroupId])
}
model CloudDavResourceNonNormalized {
davResourceId String
davResource CloudDavResource @relation(fields: [davResourceId], references: [id])
hashKey String
hashValue String
createdAt DateTime @default(now())
@@id([davResourceId, hashKey])
}

View File

@ -1,17 +0,0 @@
import { Module } from '@nestjs/common';
import { AuthModule } from './auth/auth.module';
import { ConfigModule } from './config/config.module';
import { PrismaModule } from './prisma/prisma.module';
import { GraphqlModule } from './graphql/graphql.module';
@Module({
imports: [
AuthModule,
ConfigModule,
PrismaModule,
GraphqlModule,
],
controllers: [],
providers: [],
})
export class AppModule {}

View File

@ -1,4 +0,0 @@
import { Module } from '@nestjs/common';
@Module({})
export class AuthModule {}

View File

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigService } from './config.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
providers: [ConfigService],
exports: [ConfigService],
})
export class ConfigModule {}

View File

@ -1,34 +0,0 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { normalizePairs } from '../utils';
import { PrismaService } from '../prisma/prisma.service';
import { Config, configValidator} from './config.struct';
@Injectable()
export class ConfigService {
private readonly _config: Promise<Config>;;
constructor(
private readonly prismaService: PrismaService,
) {
this._config = new Promise(async (resolve, reject) => {
const settingsHashMap = await this.prismaService.systemSetting.findMany();
const settingsObject = normalizePairs(settingsHashMap);
const { value: config, error } = configValidator.validate(settingsObject, { abortEarly: false, allowUnknown: false });
if (error) {
return reject(error);
}
return resolve(config);
});
}
async get<K extends keyof Config>(key: K): Promise<Config[K]> {
const config = await this._config;
return config[key];
}
}

View File

@ -1,15 +0,0 @@
import * as Joi from 'joi';
import { SystemSettings } from '../enumerations';
export interface Config {
[SystemSettings.Graphql.Debug]: boolean;
[SystemSettings.Graphql.IntrospectionEnabled]: boolean;
[SystemSettings.Graphql.PlaygroundEnabled]: boolean;
}
export const configValidator: Joi.ObjectSchema<Config> = Joi.object<Config, true>({
'graphql.debug.enabled': Joi.boolean().required(),
'graphql.introspection.enabled': Joi.boolean().required(),
'graphql.playground.enabled': Joi.boolean().required(),
});

View File

@ -1,13 +0,0 @@
export namespace Identity {
export namespace AuthDevice {
export enum Type {
Password = 'password',
}
export enum PasswordHashKey {
Expiry = 'expiry',
PasswordHashString = 'password_hash_string',
}
}
}

View File

@ -1,3 +0,0 @@
export * from './cloud-dav.enumerations';
export * from './identity.enumerations';
export * from './system-settings.enumerations';

Some files were not shown because too many files have changed in this diff Show More