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

25
core/.eslintrc.js Normal file
View File

@@ -0,0 +1,25 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
};

5
core/.gitignore vendored Normal file
View File

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

4
core/.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

8
core/CONTRIBUTE.md Normal file
View File

@@ -0,0 +1,8 @@
# Namespacing
- Database and Graphql is namespaced by prefixed camelcase
- Typescript is namespaced by `namespace` keyword
# Adding to the enumeration types
- Update graphql enum
- Update services/core/enumerations namespaced types

73
core/README.md Normal file
View File

@@ -0,0 +1,73 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="200" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Installation
```bash
$ npm install
```
## Running the app
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Test
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](LICENSE).

9
core/nest-cli.json Normal file
View File

@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"plugins": []
}
}

9475
core/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

79
core/package.json Normal file
View File

@@ -0,0 +1,79 @@
{
"name": "@homelab-personal-cloud/core",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"generate": "graphql-codegen --config codegen.ts",
"migrate": "npx prisma db push",
"migrate:post": "nest start --entryFile prisma-post-migrations"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@prisma/client": "^5.12.1",
"bcrypt": "^5.1.1",
"express": "^4.19.2",
"joi": "17.6.4",
"pug": "^3.0.2",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"prisma": "^5.12.1",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

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");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"

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

@@ -0,0 +1,243 @@
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
twoFactorPreferred 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,65 @@
export namespace Authentication {
export namespace AccessAttempt {
export interface CreateDto {
username: string;
ip: string;
userAgent: string;
requestPath: string;
valid: boolean;
}
}
export namespace Login {
export enum ResponseStatus {
Success = 'success',
TwoFactorRequired = '2fa',
Failure = 'failure',
Timedout = 'timedout',
DeviceLocked = 'locked',
}
export interface State {
jwi: string;
exp: number;
tfa: boolean;
}
export interface RequestContext {
state: State;
username: string;
}
}
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,9 @@
export namespace CloudDav {
export namespace Resource {
export enum Type {
Contact = 'contact'
}
}
}

View File

@@ -0,0 +1,35 @@
export namespace Identity {
export interface AuthDevice {
readonly urn: string,
readonly userId: number,
readonly deviceType: AuthDevice.Type,
readonly preferred: boolean;
readonly twoFactorEligible: boolean;
readonly twoFactorPreferred: 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,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,8 @@
import { Module } from '@nestjs/common';
import { LoginModule } from './mvc/login/login.module';
@Module({
imports: [LoginModule],
})
export class HttpModule {}

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,135 @@
import { BadRequestException, Body, Controller, Get, InternalServerErrorException, Param, Post, Req, Res, UseInterceptors } from '@nestjs/common';
import { Request } from 'express';
import * as Joi from 'joi';
import { Authentication } from '../../../domain/authentication.types';
import { Identity } from '../../../domain/identity.types';
import { SecureStringUtil } from '../../../utils';
import { TooManyRequestsException } from '../../exceptions/too-many-requests.exception';
import { MVCResponse, Views } from '../mvc.types';
import { LoginContextInterceptor } from './login-context.interceptor';
import { Context } from './context.decorator';
import { StateManagerService } from './state-manager.service';
import { AccessAttemptService } from './access-attempt.service';
import { IdentityAuthDeviceDao } from '../../../persistence/identity-auth-device.dao';
@Controller({
version: '1',
path: 'auth/:realm/signin/challenge',
})
@UseInterceptors(LoginContextInterceptor)
export class ChallengeController {
constructor(
private readonly stateManager: StateManagerService,
private readonly identityAuthDeviceDao: IdentityAuthDeviceDao,
private readonly accessAttemptService: AccessAttemptService,
) {}
@Get()
async selectDevice() {
}
@Post(':device_urn')
async postDevice(
@Req() req: Request,
@Body() body: unknown,
@Param('realm') realm: string,
@Param('device_urn') deviceUrn: string,
@Context() context: Authentication.Login.RequestContext,
@Res() res: MVCResponse,
) {
const devicesForUser = await this.identityAuthDeviceDao.findByRealmAndUsername(realm, context.username);
const device = devicesForUser.find(d => d.urn === deviceUrn);
const twoFactorRequired = devicesForUser.some(d => d.twoFactorEligible);
if (!device || devicesForUser.length === 0) {
const { isLocked } = await this.accessAttemptService.recordAttempt(req, context.username, false);
if (isLocked) {
throw new TooManyRequestsException();
}
return res.render(Views.LoginPasswordChallenge, {
realm,
state: this.stateManager.updateState(context.state),
username: context.username,
user_settings: {
theme: 'dark',
},
links: {
select_device: null,
challenge_form: `/auth/${realm}/signin/challenge/${deviceUrn}`,
try_different_user: `/auth/${realm}/signin/identifier`,
},
error: 'Invalid username or password.',
});
}
if (device.deviceType === Identity.AuthDevice.Type.Password) {
const passwordDevice = device as Identity.AuthDevice.Password;
const { error, value } = Joi.object<{ password: string }, true>({
password: Joi.string().required(),
}).validate(body, { allowUnknown: true });
if (error) {
return res.render(Views.LoginPasswordChallenge, {
realm,
state: this.stateManager.updateState(context.state),
username: context.username,
user_settings: {
theme: 'dark',
},
links: {
select_device: null,
challenge_form: `/auth/${realm}/signin/challenge/${deviceUrn}`,
try_different_user: `/auth/${realm}/signin/identifier`,
},
error: 'Password is required.',
});
}
if (await SecureStringUtil.compare(value.password, passwordDevice.passwordHashString)) {
if (twoFactorRequired) {
const twoFactorDevices = devicesForUser.filter(d => d.twoFactorEligible && d.urn !== deviceUrn);
const selectedDevice = twoFactorDevices.length === 1 ? twoFactorDevices[0] : twoFactorDevices.find(d => d.twoFactorPreferred);
if (!selectedDevice) {
throw new InternalServerErrorException();
}
return res.render(Views.LoginCodeChallenge, {});
}
await this.accessAttemptService.recordAttempt(req, context.username, true);
return res.redirect('https://google.com');
}
const { isLocked } = await this.accessAttemptService.recordAttempt(req, context.username, false);
if (isLocked) {
throw new TooManyRequestsException();
}
return res.render(Views.LoginPasswordChallenge, {
realm,
state: this.stateManager.updateState(context.state),
username: context.username,
user_settings: {
theme: 'dark',
},
links: {
select_device: devicesForUser.length > 1 ? `/auth/${realm}/signin/challenge` : null,
challenge_form: `/auth/${realm}/signin/challenge/${deviceUrn}`,
try_different_user: `/auth/${realm}/signin/identifier`,
},
error: 'Invalid username or password.',
});
}
}
}

View File

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

View File

@@ -0,0 +1,87 @@
import { Controller, Get, Param, Post, Query, Res, UseInterceptors } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { MVCResponse, Views } from '../mvc.types';
import { StateManagerService } from './state-manager.service';
import { Authentication } from '../../../domain/authentication.types';
import { Identity } from '../../../domain/identity.types';
import { LoginContextInterceptor } from './login-context.interceptor';
import { IdentityAuthDeviceDao } from '../../../persistence/identity-auth-device.dao';
import { Context } from './context.decorator';
@Controller({
version: '1',
path: 'auth/:realm/signin/identifier',
})
export class IdentifierController {
constructor(
private readonly stateManager: StateManagerService,
private readonly identityAuthDeviceDao: IdentityAuthDeviceDao,
) {}
@Get()
async getLogin(
@Param('realm') realm: string,
@Res() res: MVCResponse,
) {
const state = await this.stateManager.getNewState();
return res.render(Views.LoginView, {
realm,
state,
user_settings: {
theme: 'dark',
},
links: {
identifier_form: `/auth/${realm}/signin/identifier`
}
});
}
@Post()
@UseInterceptors(LoginContextInterceptor)
async postLogin(
@Param('realm') realm: string,
@Context() context: Authentication.Login.RequestContext,
@Res() res: MVCResponse,
) {
const devicesForUser = await this.identityAuthDeviceDao.findByRealmAndUsername(realm, context.username);
if (devicesForUser.length === 0) {
return res.render(Views.LoginPasswordChallenge, {
realm,
state: this.stateManager.updateState(context.state),
username: context.username,
user_settings: {
theme: 'dark',
},
links: {
select_device: null,
challenge_form: `/auth/${realm}/signin/challenge/${randomUUID()}`,
try_different_user: `/auth/${realm}/signin/identifier`,
}
});
}
const selectedDevice = devicesForUser.length === 1 ? devicesForUser[0] : devicesForUser.find(d => d.preferred);
if (selectedDevice?.deviceType === Identity.AuthDevice.Type.Password) {
return res.render(Views.LoginPasswordChallenge, {
realm,
state: this.stateManager.updateState(context.state),
username: context.username,
user_settings: {
theme: 'dark',
},
links: {
select_device: devicesForUser.length > 1 ? `/auth/${realm}/signin/challenge` : null,
challenge_form: `/auth/${realm}/signin/challenge/${selectedDevice.urn}`,
try_different_user: `/auth/${realm}/signin/identifier`,
}
});
}
}
}

View File

@@ -0,0 +1,71 @@
import { BadRequestException, CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
import * as Joi from 'joi';
import { Observable } from 'rxjs';
import { TokenManagementService } from '../../../token-management/token-management.service';
import { AccessAttemptService } from './access-attempt.service';
import { Authentication } from '../../../domain/authentication.types';
import { DateUtil } from '../../../utils';
import { DeepPartial } from '../../../utils/deep-partial.type';
import { TooManyRequestsException } from '../../exceptions/too-many-requests.exception';
type RequestContextMutation = DeepPartial<Authentication.Login.RequestContext>
const standardBadRequestError = 'State was mutated or expired';
@Injectable()
export class LoginContextInterceptor implements NestInterceptor {
private readonly logger: Logger = new Logger(LoginContextInterceptor.name);
constructor(
private readonly tokenService: TokenManagementService,
private readonly accessAttemptService: AccessAttemptService,
) {}
async intercept(context: ExecutionContext, next: CallHandler<any>): Promise<Observable<any>> {
const request = context.switchToHttp().getRequest();
const loginContextMutable: RequestContextMutation = {};
loginContextMutable.username = request?.body?.username;
if (!loginContextMutable.username) {
this.logger.debug('Request did not provide username');
throw new BadRequestException(standardBadRequestError);
}
const isLocked = await this.accessAttemptService.checkIfLocked(request, loginContextMutable.username);
if (isLocked) {
throw new TooManyRequestsException();
}
if (!request?.body?.state) {
this.logger.debug('State was not provided in body');
throw new BadRequestException(standardBadRequestError);
}
const receipt = this.tokenService.parseToken<Authentication.Login.State>(request.body.state);
if (receipt.isToken && receipt.signatureValid) {
loginContextMutable.state = receipt.payload;
}
const { error, value: loginContext } = Joi.object<Authentication.Login.RequestContext, true>({
state: Joi.object<Authentication.Login.State, true>({
jwi: Joi.string().required(),
exp: Joi.number().required(),
tfa: Joi.boolean().required(),
}),
username: Joi.string().required(),
}).validate(loginContextMutable, { allowUnknown: false });
if (error || DateUtil.fromSecondsSinceEpoch(loginContext.state.exp).isInThePast()) {
this.logger.debug(error ? error.message : 'State is past expiration');
throw new BadRequestException(standardBadRequestError);
}
request.context = loginContext;
return next.handle();
}
}

View File

@@ -0,0 +1,30 @@
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';
@Module({
imports: [
CacheModule,
PersistenceModule,
ConfigModule,
TokenManagementModule,
],
controllers: [
IdentifierController,
ChallengeController,
],
providers: [
AccessAttemptService,
LoginContextInterceptor,
StateManagerService,
],
})
export class LoginModule {}

View File

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

View File

@@ -0,0 +1,40 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { CacheService } from '../../../cache/cache.service';
import { Authentication } from '../../../domain/authentication.types';
import { TokenManagementService } from '../../../token-management/token-management.service';
import { DateUtil } from '../../../utils';
const getStateKey = (jwi: string) => `login:state:${jwi}`;
@Injectable()
export class StateManagerService {
constructor(
private readonly tokenService: TokenManagementService,
private readonly cacheService: CacheService,
) {}
async getNewState(): Promise<string> {
const date = DateUtil.fromDate(new Date()).addNMinutes(15);
const stateObj: Authentication.Login.State = {
jwi: randomUUID(),
exp: date.seconds,
tfa: false,
}
await this.cacheService.set(getStateKey(stateObj.jwi), new Date().toISOString(), date.toDate().getTime());
return this.tokenService.createToken(stateObj);
}
updateState(state: Authentication.Login.State, dto: { tfa?: boolean } = {}): string {
return this.tokenService.createToken({ ...state, ...dto });
}
async isStateActive(jwi: string): Promise<boolean> {
return this.cacheService.exists(getStateKey(jwi));
}
}

View File

@@ -0,0 +1,26 @@
import { Response } from 'express';
import { IdentifierRenderContext, PasswordChallengeRenderContext } from './login/login.types';
export interface GlobalProps {
user_settings: {
theme: 'light' | 'dark';
}
}
export enum Views {
LoginPasswordChallenge = 'login-password-challenge',
LoginCodeChallenge = 'login-code-challenge',
LoginView = 'login-view',
NotFound = 'not-found',
}
type ViewAndContext =
[view: Views.LoginPasswordChallenge, context: PasswordChallengeRenderContext] |
[view: Views.LoginView, context: IdentifierRenderContext] |
[view: Views.LoginCodeChallenge, context: {}] |
[view: Views.NotFound, context: GlobalProps];
export interface MVCResponse extends Omit<Response, 'render'> {
render: (...args: ViewAndContext) => void;
}

View File

@@ -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;
}
}

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

@@ -0,0 +1,28 @@
import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
import { AppModule } from './app.module';
import { ConfigService } from './config/config.service';
import { SystemSettings } from './domain/system-settings.types';
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']);
}
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,79 @@
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,
twoFactorPreferred: false,
}
});
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

@@ -0,0 +1,6 @@
import { PrismaService } from '../../persistence/prisma.service';
export interface DataMigration {
name: string;
run(prisma: PrismaService): void;
}

View File

@@ -0,0 +1 @@
export * from './00-application-bootstrap-data-migration';

View File

@@ -0,0 +1,89 @@
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),
twoFactorPreferred: model.twoFactorPreferred,
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

@@ -0,0 +1,13 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}

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)
});

4
core/tsconfig.build.json Normal file
View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

26
core/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./src",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noUncheckedIndexedAccess": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": 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_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,9 @@
extends base.pug
block content
main
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")