WIP2
This commit is contained in:
25
monolithic-backend/.eslintrc.js
Normal file
25
monolithic-backend/.eslintrc.js
Normal 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
monolithic-backend/.gitignore
vendored
Normal file
5
monolithic-backend/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.env
|
||||
.generated
|
||||
graphql.schema.json
|
||||
dist
|
||||
4
monolithic-backend/.prettierrc
Normal file
4
monolithic-backend/.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
8
monolithic-backend/CONTRIBUTE.md
Normal file
8
monolithic-backend/CONTRIBUTE.md
Normal 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
monolithic-backend/README.md
Normal file
73
monolithic-backend/README.md
Normal 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>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](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).
|
||||
17
monolithic-backend/codegen.ts
Normal file
17
monolithic-backend/codegen.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
|
||||
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;
|
||||
9
monolithic-backend/nest-cli.json
Normal file
9
monolithic-backend/nest-cli.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"plugins": ["@nestjs/graphql"]
|
||||
}
|
||||
}
|
||||
13730
monolithic-backend/package-lock.json
generated
Normal file
13730
monolithic-backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
86
monolithic-backend/package.json
Normal file
86
monolithic-backend/package.json
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"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": {
|
||||
"@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.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",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
-- 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,
|
||||
"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");
|
||||
3
monolithic-backend/prisma/migrations/migration_lock.toml
Normal file
3
monolithic-backend/prisma/migrations/migration_lock.toml
Normal 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"
|
||||
242
monolithic-backend/prisma/schema.prisma
Normal file
242
monolithic-backend/prisma/schema.prisma
Normal 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])
|
||||
}
|
||||
20
monolithic-backend/src/app.module.ts
Normal file
20
monolithic-backend/src/app.module.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CacheModule } from './cache/cache.module';
|
||||
import { ConfigModule } from './config/config.module';
|
||||
import { PersistenceModule } from './persistence/persistence.module';
|
||||
import { GraphqlServerModule } from './graphql-server/graphql-server.module';
|
||||
import { HttpModule } from './http/http.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule,
|
||||
CacheModule,
|
||||
ConfigModule,
|
||||
GraphqlServerModule,
|
||||
PersistenceModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [],
|
||||
})
|
||||
export class AppModule {}
|
||||
17
monolithic-backend/src/cache/cache.module.ts
vendored
Normal file
17
monolithic-backend/src/cache/cache.module.ts
vendored
Normal 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
monolithic-backend/src/cache/cache.service.ts
vendored
Normal file
32
monolithic-backend/src/cache/cache.service.ts
vendored
Normal 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
monolithic-backend/src/cache/cache.symbols.ts
vendored
Normal file
8
monolithic-backend/src/cache/cache.symbols.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface CacheDriver {
|
||||
get(key: string): Promise<string>;
|
||||
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');
|
||||
42
monolithic-backend/src/cache/in-memory.driver.ts
vendored
Normal file
42
monolithic-backend/src/cache/in-memory.driver.ts
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
import { DateUtil } from '../utils';
|
||||
import { CacheDriver } from './cache.symbols';
|
||||
|
||||
export class InMemoryDriver implements CacheDriver {
|
||||
|
||||
private readonly cache: Record<string, { val: string, ttl?: DateUtil }> = {};
|
||||
|
||||
get(key: string): Promise<string> {
|
||||
|
||||
if (!this.cache[key] || !this.cache[key].ttl?.isInTheFuture()) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return Promise.resolve(this.cache[key].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)
|
||||
);
|
||||
}
|
||||
|
||||
expire(key: string, seconds: number): Promise<number> {
|
||||
const dateUtil = DateUtil.fromDate(new Date()).addNSeconds(seconds);
|
||||
this.cache[key].ttl = dateUtil;
|
||||
return Promise.resolve(1);
|
||||
}
|
||||
|
||||
del(key: string): Promise<number> {
|
||||
delete this.cache[key];
|
||||
return Promise.resolve(1);
|
||||
}
|
||||
}
|
||||
36
monolithic-backend/src/config/config.module.ts
Normal file
36
monolithic-backend/src/config/config.module.ts
Normal 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 {}
|
||||
28
monolithic-backend/src/config/config.service.ts
Normal file
28
monolithic-backend/src/config/config.service.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
2
monolithic-backend/src/config/config.symbol.ts
Normal file
2
monolithic-backend/src/config/config.symbol.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type LoadedConfig = Record<string, string>;
|
||||
export const LoadedConfig = Symbol.for('LOADED_CONFIG');
|
||||
15
monolithic-backend/src/config/config.validator.ts
Normal file
15
monolithic-backend/src/config/config.validator.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
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(),
|
||||
[SystemSettings.Graphql.Debug]: Joi.boolean().required(),
|
||||
[SystemSettings.Graphql.IntrospectionEnabled]: Joi.boolean().required(),
|
||||
[SystemSettings.Graphql.PlaygroundEnabled]: Joi.boolean().required(),
|
||||
});
|
||||
68
monolithic-backend/src/domain/authentication.types.ts
Normal file
68
monolithic-backend/src/domain/authentication.types.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
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 {
|
||||
isComplete: boolean;
|
||||
rawState?: Object;
|
||||
state?: State;
|
||||
username?: string;
|
||||
isLocked?: 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',
|
||||
}
|
||||
}
|
||||
}
|
||||
9
monolithic-backend/src/domain/cloud-dav.types.ts
Normal file
9
monolithic-backend/src/domain/cloud-dav.types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export namespace CloudDav {
|
||||
|
||||
export namespace Resource {
|
||||
export enum Type {
|
||||
Contact = 'contact'
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
39
monolithic-backend/src/domain/identity.types.ts
Normal file
39
monolithic-backend/src/domain/identity.types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export namespace Identity {
|
||||
|
||||
export interface AuthDevice {
|
||||
readonly urn: string,
|
||||
readonly userId: number,
|
||||
readonly deviceType: AuthDevice.Type,
|
||||
readonly preferred: boolean;
|
||||
readonly createdAt: Date,
|
||||
}
|
||||
|
||||
export namespace AuthDevice {
|
||||
export enum Type {
|
||||
Password = 'password',
|
||||
ApplicationPassword = 'application_password',
|
||||
}
|
||||
|
||||
export enum PasswordHashKey {
|
||||
Expiry = 'expiry',
|
||||
PasswordHashString = 'password_hash_string',
|
||||
Locked = 'locked',
|
||||
}
|
||||
|
||||
export interface PasswordAttributes {
|
||||
readonly expiry: Date;
|
||||
readonly passwordHashString: string;
|
||||
readonly locked: boolean;
|
||||
}
|
||||
|
||||
export type Password = AuthDevice | PasswordAttributes;
|
||||
}
|
||||
|
||||
export namespace Group {
|
||||
export enum Role {
|
||||
SystemAdmin = 'SYSTEM_ADMIN',
|
||||
RealmAdmin = 'REALM_ADMIN',
|
||||
Standard = 'STANDARD',
|
||||
}
|
||||
}
|
||||
}
|
||||
18
monolithic-backend/src/domain/serializable.type.ts
Normal file
18
monolithic-backend/src/domain/serializable.type.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
43
monolithic-backend/src/domain/system-settings.types.ts
Normal file
43
monolithic-backend/src/domain/system-settings.types.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
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;
|
||||
[Graphql.Debug]: boolean;
|
||||
[Graphql.IntrospectionEnabled]: boolean;
|
||||
[Graphql.PlaygroundEnabled]: boolean;
|
||||
}
|
||||
|
||||
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 enum Graphql {
|
||||
Debug = 'graphql.debug.enabled',
|
||||
IntrospectionEnabled = 'graphql.introspection.enabled',
|
||||
PlaygroundEnabled = 'graphql.playground.enabled',
|
||||
}
|
||||
|
||||
export namespace Dav {
|
||||
export enum Contacts {
|
||||
Enabled = 'dav.contacts.enabled',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GraphQLModule } from '@nestjs/graphql';
|
||||
|
||||
import { ConfigService } from '../config/config.service';
|
||||
import { SystemSettingsResolver } from './system-settings.resolver';
|
||||
import { ConfigModule } from '../config/config.module';
|
||||
import { SystemSettings } from '../domain/system-settings.types';
|
||||
import { PersistenceModule } from '../persistence/persistence.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
PersistenceModule,
|
||||
GraphQLModule.forRootAsync<ApolloDriverConfig>({
|
||||
driver: ApolloDriver,
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
debug: await configService.get(SystemSettings.Graphql.Debug),
|
||||
playground: await configService.get(SystemSettings.Graphql.PlaygroundEnabled),
|
||||
introspection: await configService.get(SystemSettings.Graphql.IntrospectionEnabled),
|
||||
typePaths: ['../graphql/**/*.graphql'],
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
imports: [ConfigModule],
|
||||
})
|
||||
],
|
||||
providers: [
|
||||
SystemSettingsResolver,
|
||||
]
|
||||
})
|
||||
export class GraphqlServerModule {}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { SystemSettingsQueryOutput, SystemSettingsQueryOutputError, UpdateSystemSettingInput, UpdateSystemSettingOutput, UpdateSystemSettingOutputError, SystemSettingHashValueTypeEnum } from '../.generated/graphql';
|
||||
import { SystemSettingsDao } from '../persistence/system-settings.dao';
|
||||
|
||||
const getUrn = (hashKey: string): string => `urn:system-settings:${hashKey}`;
|
||||
const parseUrn = (urn: string): string => urn.split(':').pop();
|
||||
|
||||
const t2g: Record<'string' | 'boolean' | 'number', SystemSettingHashValueTypeEnum> = {
|
||||
string: SystemSettingHashValueTypeEnum.String,
|
||||
boolean: SystemSettingHashValueTypeEnum.Boolean,
|
||||
number: SystemSettingHashValueTypeEnum.Number,
|
||||
}
|
||||
|
||||
const g2t: Record<SystemSettingHashValueTypeEnum, 'string' | 'boolean' | 'number'> = {
|
||||
[SystemSettingHashValueTypeEnum.String]: 'string',
|
||||
[SystemSettingHashValueTypeEnum.Boolean]: 'boolean',
|
||||
[SystemSettingHashValueTypeEnum.Number]: 'number',
|
||||
}
|
||||
|
||||
@Resolver('SystemSettings')
|
||||
export class SystemSettingsResolver {
|
||||
|
||||
private readonly logger: Logger = new Logger(SystemSettingsResolver.name);
|
||||
|
||||
constructor(
|
||||
private readonly systemSettingsDao: SystemSettingsDao,
|
||||
) {}
|
||||
|
||||
@Query()
|
||||
async systemSettings(): Promise<SystemSettingsQueryOutput> {
|
||||
try {
|
||||
|
||||
const { valueMap, typeMap } = await this.systemSettingsDao.getSettings();
|
||||
|
||||
return {
|
||||
data: Object.entries(valueMap).map(([hashKey, hashValue]) => ({
|
||||
urn: getUrn(hashKey),
|
||||
hashKey,
|
||||
hashValue: hashValue.toString(),
|
||||
hashValueType: t2g[typeMap[hashKey]],
|
||||
})),
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error(e.message);
|
||||
return {
|
||||
error: SystemSettingsQueryOutputError.Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Mutation()
|
||||
async updateSystemSetting(
|
||||
@Args('input') { urn, hashValue, hashValueType }: UpdateSystemSettingInput
|
||||
): Promise<UpdateSystemSettingOutput> {
|
||||
try {
|
||||
|
||||
const hashKey = parseUrn(urn);
|
||||
const exists = await this.systemSettingsDao.hashKeyExists(hashKey);
|
||||
|
||||
if (!exists) {
|
||||
return {
|
||||
error: UpdateSystemSettingOutputError.NotFound,
|
||||
}
|
||||
}
|
||||
|
||||
await this.systemSettingsDao.setSettingWithType(hashKey, g2t[hashValueType], hashValue);
|
||||
|
||||
return {};
|
||||
|
||||
} catch (e) {
|
||||
this.logger.error(e.message);
|
||||
return {
|
||||
error: UpdateSystemSettingOutputError.Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
|
||||
export class TooManyRequestsException extends HttpException {
|
||||
constructor() {
|
||||
super('Too Many Requests', HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
}
|
||||
8
monolithic-backend/src/http/http.module.ts
Normal file
8
monolithic-backend/src/http/http.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { LoginModule } from './mvc/login/login.module';
|
||||
|
||||
@Module({
|
||||
imports: [LoginModule],
|
||||
})
|
||||
export class HttpModule {}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Injectable } 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 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 } = 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) {
|
||||
await this.cacheService.set(ipAddressKey, (ipAttempts + 1).toString(), this.lockTimeout);
|
||||
await this.cacheService.set(usernameKey, (usernameAttempts + 1).toString(), this.lockTimeout);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async recordAttempt(request: Request, username: string, loginSucceeded: boolean): Promise<void> {
|
||||
|
||||
const isLocked = await this.checkIfLocked(request, username);
|
||||
|
||||
if (isLocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
42
monolithic-backend/src/http/mvc/login/context.decorator.ts
Normal file
42
monolithic-backend/src/http/mvc/login/context.decorator.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import * as Joi from 'joi';
|
||||
|
||||
import { DateUtil } from '../../../utils';
|
||||
import { Authentication } from '../../../domain/authentication.types';
|
||||
|
||||
export const Context = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext): Authentication.Login.RequestContext => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const loginContext: Authentication.Login.RequestContext = request?.context;
|
||||
|
||||
if (!loginContext.username) {
|
||||
return loginContext;
|
||||
}
|
||||
|
||||
const state = loginContext?.rawState;
|
||||
|
||||
if (!state) {
|
||||
return loginContext;
|
||||
}
|
||||
|
||||
const { error, value } = Joi.object<Authentication.Login.State, true>({
|
||||
jwi: Joi.string().required(),
|
||||
exp: Joi.number().required(),
|
||||
tfa: Joi.boolean().required(),
|
||||
}).validate(state, { allowUnknown: false });
|
||||
|
||||
if (error) {
|
||||
return loginContext;
|
||||
}
|
||||
|
||||
if (DateUtil.fromSecondsSinceEpoch(value?.exp).isInThePast()) {
|
||||
return loginContext;
|
||||
}
|
||||
|
||||
return {
|
||||
...loginContext,
|
||||
isComplete: true,
|
||||
state: value,
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { TokenManagementService } from '../../../token-management/token-management.service';
|
||||
import { AccessAttemptService } from './access-attempt.service';
|
||||
import { Authentication } from '../../../domain/authentication.types';
|
||||
|
||||
@Injectable()
|
||||
export class LoginContextInterceptor implements NestInterceptor {
|
||||
|
||||
constructor(
|
||||
private readonly tokenService: TokenManagementService,
|
||||
private readonly accessAttemptService: AccessAttemptService,
|
||||
) {}
|
||||
|
||||
async intercept(context: ExecutionContext, next: CallHandler<any>): Promise<Observable<any>> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const loginContext: Authentication.Login.RequestContext = { isComplete: false };
|
||||
|
||||
if (!request?.body?.state) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const receipt = this.tokenService.parseToken(request.body.state);
|
||||
|
||||
if (receipt.isToken && receipt.signatureValid) {
|
||||
loginContext.rawState = receipt.payload;
|
||||
}
|
||||
|
||||
loginContext.username = request?.body?.username;
|
||||
|
||||
if (loginContext.username) {
|
||||
loginContext.isLocked = await this.accessAttemptService.checkIfLocked(request, loginContext.username);
|
||||
}
|
||||
|
||||
request.context = loginContext;
|
||||
return next.handle();
|
||||
}
|
||||
}
|
||||
130
monolithic-backend/src/http/mvc/login/login.controller.ts
Normal file
130
monolithic-backend/src/http/mvc/login/login.controller.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { BadRequestException, Controller, Get, Param, Post, Query, Render, Res, UseInterceptors } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Authentication } from '../../../domain/authentication.types';
|
||||
import { TokenManagementService } from '../../../token-management/token-management.service';
|
||||
import { DateUtil } from '../../../utils';
|
||||
import { TooManyRequestsException } from '../../exceptions/too-many-requests.exception';
|
||||
import { AccessAttemptService } from './access-attempt.service';
|
||||
import { Context } from './context.decorator';
|
||||
import { LoginContextInterceptor } from './login-context.interceptor';
|
||||
import { LoginStages } from './login.types';
|
||||
import { IdentityAuthDeviceDao } from '../../../persistence/identity-auth-device.dao';
|
||||
import { Response } from 'express';
|
||||
import { CacheService } from '../../../cache/cache.service';
|
||||
import { Identity } from '../../../domain/identity.types';
|
||||
|
||||
const getStateKey = (jwi: string) => `login:state:${jwi}`;
|
||||
|
||||
const challengeToTemplate = {
|
||||
[Identity.AuthDevice.Type.Password]: 'login-password-challenge',
|
||||
}
|
||||
|
||||
@Controller({
|
||||
version: '1',
|
||||
path: 'auth/:realm/signin',
|
||||
})
|
||||
@UseInterceptors(LoginContextInterceptor)
|
||||
export class LoginController {
|
||||
|
||||
constructor(
|
||||
private readonly tokenService: TokenManagementService,
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly accessAttemptService: AccessAttemptService,
|
||||
private readonly identityAuthDeviceDao: IdentityAuthDeviceDao,
|
||||
) {}
|
||||
|
||||
@Get('identifier')
|
||||
@Render('login-view')
|
||||
async getLogin(
|
||||
@Param('realm') realm: string,
|
||||
@Query('error') error?: string,
|
||||
): Promise<LoginStages.IdentifierView.RenderContext> {
|
||||
|
||||
const date = DateUtil.fromDate(new Date()).addNMinutes(15);
|
||||
|
||||
const stateObj: Authentication.Login.State = {
|
||||
jwi: randomUUID(),
|
||||
exp: date.seconds,
|
||||
tfa: false,
|
||||
}
|
||||
|
||||
await this.cacheService.set(getStateKey(stateObj.jwi), new Date().toISOString(), date.toDate().getTime());
|
||||
|
||||
return {
|
||||
realm,
|
||||
state: this.tokenService.createToken(stateObj),
|
||||
user_settings: {
|
||||
theme: 'dark',
|
||||
},
|
||||
links: {
|
||||
identifier_form: `/auth/${realm}/signin/identifier`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Post('identifier')
|
||||
async postLogin(
|
||||
@Param('realm') realm: string,
|
||||
@Context() context: Authentication.Login.RequestContext,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
|
||||
if (!context.isComplete) {
|
||||
throw new BadRequestException();
|
||||
}
|
||||
|
||||
if (context.isLocked) {
|
||||
throw new TooManyRequestsException();
|
||||
}
|
||||
|
||||
const devicesForUser = await this.identityAuthDeviceDao.findByRealmAndUsername(realm, context.username);
|
||||
const selectedDevice = devicesForUser.length === 1 ? devicesForUser[0] : devicesForUser.find(d => d.preferred);
|
||||
|
||||
if (selectedDevice) {
|
||||
const renderContext: LoginStages.ChallengeView.RenderContext = {
|
||||
realm,
|
||||
state: this.tokenService.createToken(context.state),
|
||||
username: context.username,
|
||||
user_settings: {
|
||||
theme: 'dark',
|
||||
},
|
||||
links: {
|
||||
select_device: devicesForUser.length > 1 ? `/auth/${realm}/signin/challenge` : null,
|
||||
challenge_form: `/auth/${realm}/signin/challenge/${selectedDevice.urn}`,
|
||||
}
|
||||
}
|
||||
return res.render(challengeToTemplate[selectedDevice.deviceType], renderContext);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('challenge')
|
||||
async selectDevice() {
|
||||
|
||||
}
|
||||
|
||||
@Post('challenge/:device_urn')
|
||||
async postDevice(
|
||||
@Param('realm') realm: string,
|
||||
@Context() context: Authentication.Login.RequestContext,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
|
||||
|
||||
}
|
||||
|
||||
@Get('2fa/challenge')
|
||||
async select2faDevice() {
|
||||
|
||||
}
|
||||
|
||||
@Post('2fa/challenge/:device_urn')
|
||||
async post2faDevice() {
|
||||
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
async logout() {
|
||||
|
||||
}
|
||||
}
|
||||
26
monolithic-backend/src/http/mvc/login/login.module.ts
Normal file
26
monolithic-backend/src/http/mvc/login/login.module.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CacheModule } from '../../../cache/cache.module';
|
||||
import { PersistenceModule } from '../../../persistence/persistence.module';
|
||||
import { LoginController } from './login.controller';
|
||||
import { AccessAttemptService } from './access-attempt.service';
|
||||
import { LoginContextInterceptor } from './login-context.interceptor';
|
||||
import { ConfigModule } from '../../../config/config.module';
|
||||
import { TokenManagementModule } from '../../../token-management/token-management.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
CacheModule,
|
||||
PersistenceModule,
|
||||
ConfigModule,
|
||||
TokenManagementModule,
|
||||
],
|
||||
controllers: [
|
||||
LoginController,
|
||||
],
|
||||
providers: [
|
||||
AccessAttemptService,
|
||||
LoginContextInterceptor,
|
||||
],
|
||||
})
|
||||
export class LoginModule {}
|
||||
70
monolithic-backend/src/http/mvc/login/login.types.ts
Normal file
70
monolithic-backend/src/http/mvc/login/login.types.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Identity } from '../../../domain/identity.types';
|
||||
import { Authentication } from '../../../domain/authentication.types';
|
||||
import { GlobalProps } from '../mvc.types';
|
||||
|
||||
export namespace LoginStages {
|
||||
|
||||
export enum Error {
|
||||
INVALID,
|
||||
}
|
||||
|
||||
export const errorDescriptionMap: Record<Error, string> = {
|
||||
[Error.INVALID]: 'Invalid username'
|
||||
}
|
||||
|
||||
export namespace IdentifierView {
|
||||
export interface RenderContext extends GlobalProps {
|
||||
state: string;
|
||||
realm: string;
|
||||
links: {
|
||||
identifier_form: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace ChallengeView {
|
||||
export interface RenderContext extends GlobalProps {
|
||||
state: string;
|
||||
realm: string;
|
||||
username: string;
|
||||
links: {
|
||||
select_device: string | null;
|
||||
challenge_form: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace $7PostLoginContext_Result {
|
||||
|
||||
export type Context = Success | TwoFactorRequired | Failure | Timedout | DeviceLocked;
|
||||
|
||||
type Success = {
|
||||
status: Authentication.Login.ResponseStatus.Success;
|
||||
state: string;
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
type TwoFactorRequired = {
|
||||
status: Authentication.Login.ResponseStatus.TwoFactorRequired;
|
||||
state: string;
|
||||
availableDevices: Identity.AuthDevice.Type[];
|
||||
}
|
||||
|
||||
type Failure = {
|
||||
status: Authentication.Login.ResponseStatus.Failure;
|
||||
state: string;
|
||||
}
|
||||
|
||||
type Timedout = {
|
||||
status: Authentication.Login.ResponseStatus.Timedout;
|
||||
}
|
||||
|
||||
type DeviceLocked = {
|
||||
status: Authentication.Login.ResponseStatus.DeviceLocked;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const supportedDevices = [
|
||||
Identity.AuthDevice.Type.Password,
|
||||
]
|
||||
5
monolithic-backend/src/http/mvc/mvc.types.ts
Normal file
5
monolithic-backend/src/http/mvc/mvc.types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface GlobalProps {
|
||||
user_settings: {
|
||||
theme: 'light' | 'dark';
|
||||
}
|
||||
}
|
||||
48
monolithic-backend/src/http/mvc/oauth/oauth.controller.ts
Normal file
48
monolithic-backend/src/http/mvc/oauth/oauth.controller.ts
Normal 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() {
|
||||
|
||||
}
|
||||
}
|
||||
70
monolithic-backend/src/http/mvc/oauth/oauth.interface.ts
Normal file
70
monolithic-backend/src/http/mvc/oauth/oauth.interface.ts
Normal 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
monolithic-backend/src/main.ts
Normal file
28
monolithic-backend/src/main.ts
Normal 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();
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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'),
|
||||
[SystemSettings.Graphql.Debug]: false,
|
||||
[SystemSettings.Graphql.IntrospectionEnabled]: true,
|
||||
[SystemSettings.Graphql.PlaygroundEnabled]: true,
|
||||
}
|
||||
|
||||
const paired = Object.entries(startingConfig)
|
||||
.map(([hashKey, hashValue]) => ({ hashKey, hashValue: hashValue.toString(), hashValueType: typeof hashValue }));
|
||||
|
||||
await tx.systemSetting.createMany({
|
||||
data: paired,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PrismaService } from '../../persistence/prisma.service';
|
||||
|
||||
export interface DataMigration {
|
||||
name: string;
|
||||
run(prisma: PrismaService): void;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './00-application-bootstrap-data-migration';
|
||||
@@ -0,0 +1,73 @@
|
||||
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';
|
||||
|
||||
|
||||
@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, true));
|
||||
}
|
||||
|
||||
async findOneByUrn(urn: string): Promise<Identity.AuthDevice> {
|
||||
const device = await this.prismaService.identityAuthDevice.findFirst({
|
||||
where: {
|
||||
id: urn.replace('urn:identity:auth-device:', ''),
|
||||
},
|
||||
});
|
||||
|
||||
return IdentityAuthDeviceDao.modelToEntity(device);
|
||||
}
|
||||
|
||||
private static modelToEntity(model: IdentityAuthDevice, skipAttributes = false): Identity.AuthDevice {
|
||||
|
||||
const [_, attributes] = safeJsonParse(model.attributes);
|
||||
|
||||
const entity: Identity.AuthDevice = {
|
||||
urn: `urn:identity:auth-device:${model.id}`,
|
||||
userId: model.userId,
|
||||
deviceType: model.deviceType as Identity.AuthDevice.Type,
|
||||
preferred: model.preferred,
|
||||
createdAt: new Date(model.createdAt),
|
||||
}
|
||||
|
||||
if (skipAttributes) {
|
||||
return entity;
|
||||
}
|
||||
|
||||
if (model.deviceType === Identity.AuthDevice.Type.Password) {
|
||||
const passwordEntity: Identity.AuthDevice.PasswordAttributes = {
|
||||
expiry: new Date(attributes[Identity.AuthDevice.PasswordHashKey.Expiry] as string),
|
||||
passwordHashString: attributes[Identity.AuthDevice.PasswordHashKey.PasswordHashString] as string,
|
||||
locked: attributes[Identity.AuthDevice.PasswordHashKey.Locked] as boolean,
|
||||
}
|
||||
|
||||
return {
|
||||
...entity,
|
||||
...passwordEntity,
|
||||
}
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
21
monolithic-backend/src/persistence/persistence.module.ts
Normal file
21
monolithic-backend/src/persistence/persistence.module.ts
Normal 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 {}
|
||||
13
monolithic-backend/src/persistence/prisma.service.ts
Normal file
13
monolithic-backend/src/persistence/prisma.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
61
monolithic-backend/src/persistence/system-settings.dao.ts
Normal file
61
monolithic-backend/src/persistence/system-settings.dao.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from './prisma.service';
|
||||
import { SystemSettings } from '../domain/system-settings.types';
|
||||
|
||||
type Result = {
|
||||
typeMap: Record<string, 'string' | 'boolean' | 'number'>;
|
||||
valueMap: Record<string, string>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SystemSettingsDao {
|
||||
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
) {}
|
||||
|
||||
async getSettings(): Promise<Result> {
|
||||
const settings = await this.prismaService.systemSetting.findMany();
|
||||
const typeMap = {}
|
||||
const valueMap = {}
|
||||
|
||||
for (const { hashKey, hashValue, hashValueType } of settings) {
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
53
monolithic-backend/src/prisma-post-migrations.ts
Normal file
53
monolithic-backend/src/prisma-post-migrations.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { NestFactory, repl } 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) => {
|
||||
const result = await tx[dbRunner].findMany();
|
||||
const existing = result.map(e => e.enumValue);
|
||||
const missing = known.filter(k => !existing.includes(k));
|
||||
await tx[dbRunner].createMany({
|
||||
data: missing.map(enumValue => ({ enumValue })),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -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 {}
|
||||
@@ -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 = {
|
||||
isToken: true;
|
||||
signatureValid: boolean;
|
||||
payload: Object;
|
||||
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(token: string): Receipt {
|
||||
|
||||
const parts = token.split('.');
|
||||
|
||||
if (parts.length !== 3) {
|
||||
return { isToken: false }
|
||||
}
|
||||
|
||||
const [encodedHeader, encodedPayload, signature] = parts;
|
||||
|
||||
const headerJson = Buffer.from(encodedHeader, 'base64url').toString('utf-8');
|
||||
const payloadJson = Buffer.from(encodedPayload, 'base64url').toString('utf-8');
|
||||
|
||||
const [err1, headerObj] = safeJsonParse(headerJson);
|
||||
const [err2, payloadObj] = safeJsonParse(payloadJson);
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
91
monolithic-backend/src/utils/date-util.ts
Normal file
91
monolithic-backend/src/utils/date-util.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
export class DateUtil {
|
||||
|
||||
private readonly original: Date | null;
|
||||
|
||||
constructor(
|
||||
private _date: Date | null,
|
||||
) {
|
||||
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 | null): Date | null {
|
||||
return date ? new Date(date) : null;
|
||||
}
|
||||
}
|
||||
5
monolithic-backend/src/utils/index.ts
Normal file
5
monolithic-backend/src/utils/index.ts
Normal 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';
|
||||
7
monolithic-backend/src/utils/safe-json-parse.ts
Normal file
7
monolithic-backend/src/utils/safe-json-parse.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const safeJsonParse = (json: string): [Error, null] | [null, Object] => {
|
||||
try {
|
||||
return [null, JSON.parse(json)];
|
||||
} catch (err) {
|
||||
return [err, null];
|
||||
}
|
||||
}
|
||||
136
monolithic-backend/src/utils/secure-string.ts
Normal file
136
monolithic-backend/src/utils/secure-string.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
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,
|
||||
}
|
||||
|
||||
type HashingFunction = (rawString: string, workFactor: number, salt: string) => Promise<string>;
|
||||
|
||||
const algImplementationMap: Record<NodeCryptoAlgs, HashingFunction> = {
|
||||
[NodeCryptoAlgs.md5]: async function (rawString: string, workFactor: number, salt: string): Promise<string> {
|
||||
throw new Error('Function not implemented.');
|
||||
},
|
||||
[NodeCryptoAlgs.bcrypt]: async function (rawString: string, _workFactor: number, salt: string): Promise<string> {
|
||||
return bcrypt.hash(rawString, salt);
|
||||
},
|
||||
[NodeCryptoAlgs.sha256]: async function (rawString: string, workFactor: number, salt: string): Promise<string> {
|
||||
throw new Error('Function not implemented.');
|
||||
},
|
||||
[NodeCryptoAlgs.sha512]: async function (rawString: string, workFactor: number, salt: string): Promise<string> {
|
||||
throw new Error('Function 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 workFactor = this.defaultWorkFactor;
|
||||
const salt = await bcrypt.genSalt(workFactor);
|
||||
return algImplementationMap[alg](rawString, workFactor, salt);
|
||||
}
|
||||
|
||||
static async generateHash(rawString: string, alg: NodeCryptoAlgs, workFactor: number, salt: string): Promise<string> {
|
||||
return algImplementationMap[alg](rawString, workFactor, salt);
|
||||
}
|
||||
|
||||
static async compare(rawString: string, hashMetaSerialized: string): Promise<boolean> {
|
||||
|
||||
const { alg, salt, workFactor, hash: hashA} = HashMeta.deserialize(hashMetaSerialized);
|
||||
const hashB = await this.generateHash(rawString, alg, workFactor, salt);
|
||||
return hashA === hashB;
|
||||
}
|
||||
}
|
||||
|
||||
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 [_, alg, workFactor, salthash] = hashMeta.split('$');
|
||||
|
||||
const saltStringLength = saltStringLengthDictionary[alg];
|
||||
const salt = salthash.substring(0, saltStringLength);
|
||||
const hash = salthash.substring(saltStringLength);
|
||||
|
||||
return new HashMeta(
|
||||
hashDictionary[alg],
|
||||
+workFactor,
|
||||
salt,
|
||||
hash
|
||||
);
|
||||
}
|
||||
|
||||
serialize(): string {
|
||||
return [
|
||||
'',
|
||||
inversedHashDictionary[this.alg],
|
||||
this.workFactor,
|
||||
`${this.salt}${this.hash}`
|
||||
].join('$');
|
||||
}
|
||||
}
|
||||
8
monolithic-backend/src/utils/snake-to-camel.ts
Normal file
8
monolithic-backend/src/utils/snake-to-camel.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// https://stackoverflow.com/questions/40710628/how-to-convert-snake-case-to-camelcase
|
||||
export const snakeToCamel = str =>
|
||||
str.toLowerCase().replace(/([-_][a-z])/g, group =>
|
||||
group
|
||||
.toUpperCase()
|
||||
.replace('-', '')
|
||||
.replace('_', '')
|
||||
);
|
||||
45
monolithic-backend/src/utils/when-clause.ts
Normal file
45
monolithic-backend/src/utils/when-clause.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
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;
|
||||
}
|
||||
|
||||
resolveOrThrow(errorMessage = defaultErrorMessage): T {
|
||||
if (this.expression in this.stack) {
|
||||
const resolved = this.stack[this.expression];
|
||||
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
monolithic-backend/tsconfig.build.json
Normal file
4
monolithic-backend/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
24
monolithic-backend/tsconfig.json
Normal file
24
monolithic-backend/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"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": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
},
|
||||
"exclude": [
|
||||
"codegen.ts"
|
||||
]
|
||||
}
|
||||
7
monolithic-backend/views/base.pug
Normal file
7
monolithic-backend/views/base.pug
Normal 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
|
||||
9
monolithic-backend/views/login-password-challenge.pug
Normal file
9
monolithic-backend/views/login-password-challenge.pug
Normal file
@@ -0,0 +1,9 @@
|
||||
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")
|
||||
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
|
||||
9
monolithic-backend/views/login-view.pug
Normal file
9
monolithic-backend/views/login-view.pug
Normal 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")
|
||||
Reference in New Issue
Block a user