Compare commits
4 Commits
main
...
features/a
| Author | SHA1 | Date |
|---|---|---|
|
|
b26e963bd6 | |
|
|
8db8ce9f47 | |
|
|
59540510b7 | |
|
|
2410ef056c |
|
|
@ -1,5 +1,5 @@
|
||||||
node_modules
|
node_modules
|
||||||
.env
|
.env
|
||||||
__generated__
|
.generated
|
||||||
graphql.schema.json
|
graphql.schema.json
|
||||||
dist
|
dist
|
||||||
|
|
@ -4,6 +4,6 @@
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true,
|
"deleteOutDir": true,
|
||||||
"plugins": ["@nestjs/graphql"]
|
"plugins": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -23,24 +23,18 @@
|
||||||
"migrate:post": "nest start --entryFile prisma-post-migrations"
|
"migrate:post": "nest start --entryFile prisma-post-migrations"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/server": "^4.10.2",
|
|
||||||
"@nestjs/apollo": "^12.1.0",
|
|
||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "^10.0.0",
|
||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/core": "^10.0.0",
|
||||||
"@nestjs/graphql": "^12.1.1",
|
|
||||||
"@nestjs/platform-express": "^10.0.0",
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
"@prisma/client": "^5.12.1",
|
"@prisma/client": "^5.12.1",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"graphql": "^16.8.1",
|
"express": "^4.19.2",
|
||||||
"joi": "^17.12.3",
|
"joi": "17.6.4",
|
||||||
|
"pug": "^3.0.2",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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/cli": "^10.0.0",
|
||||||
"@nestjs/schematics": "^10.0.0",
|
"@nestjs/schematics": "^10.0.0",
|
||||||
"@nestjs/testing": "^10.0.0",
|
"@nestjs/testing": "^10.0.0",
|
||||||
|
|
@ -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");
|
||||||
|
|
@ -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])
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export type LoadedConfig = Record<string, string>;
|
||||||
|
export const LoadedConfig = Symbol.for('LOADED_CONFIG');
|
||||||
|
|
@ -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(),
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
export namespace Authentication {
|
||||||
|
|
||||||
|
export namespace AccessAttempt {
|
||||||
|
export interface CreateDto {
|
||||||
|
username: string;
|
||||||
|
ip: string;
|
||||||
|
userAgent: string;
|
||||||
|
requestPath: string;
|
||||||
|
valid: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace Oauth2 {
|
||||||
|
export enum TokenClaims {
|
||||||
|
Issuer = 'iss',
|
||||||
|
Expiry = 'exp',
|
||||||
|
TokenId = 'jwi',
|
||||||
|
IssuedAt = 'iat',
|
||||||
|
}
|
||||||
|
export enum AuthorizationGrant {
|
||||||
|
AuthorizationCode = 'authorization_code',
|
||||||
|
Implicit = 'implicit',
|
||||||
|
ResourceOwnerPasswordCredentials = 'resource_owner_password_credentials',
|
||||||
|
ClientCredentials = 'client_credentials',
|
||||||
|
}
|
||||||
|
export enum GrantType {
|
||||||
|
RefreshToken = 'refresh_token',
|
||||||
|
Password = 'password',
|
||||||
|
}
|
||||||
|
export enum ResponseType {
|
||||||
|
Code = 'code',
|
||||||
|
Token = 'token',
|
||||||
|
}
|
||||||
|
export enum Error {
|
||||||
|
InvalidRequest = 'invalid_request',
|
||||||
|
UnauthorizedClient = 'unauthorized_client',
|
||||||
|
AccessDenied = 'access_denied',
|
||||||
|
UnsupportedResponseType = 'unsupported_response_type',
|
||||||
|
InvalidScope = 'invalid_scope',
|
||||||
|
ServerError = 'server_error',
|
||||||
|
TemporarilyUnavailable = 'temporarily_unavailable',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
export namespace Identity {
|
||||||
|
|
||||||
|
export interface AuthDevice {
|
||||||
|
readonly urn: string,
|
||||||
|
readonly userId: number,
|
||||||
|
readonly deviceType: AuthDevice.Type,
|
||||||
|
readonly preferred: boolean;
|
||||||
|
readonly twoFactorEligible: boolean;
|
||||||
|
readonly createdAt: Date,
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace AuthDevice {
|
||||||
|
export enum Type {
|
||||||
|
Password = 'password',
|
||||||
|
ApplicationPassword = 'application_password',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasswordAttributes {
|
||||||
|
readonly expiry: Date;
|
||||||
|
readonly passwordHashString: string;
|
||||||
|
readonly locked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Password extends AuthDevice, PasswordAttributes {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace Group {
|
||||||
|
export enum Role {
|
||||||
|
SystemAdmin = 'SYSTEM_ADMIN',
|
||||||
|
RealmAdmin = 'REALM_ADMIN',
|
||||||
|
Standard = 'STANDARD',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
|
||||||
|
export enum RegisteredCookies {
|
||||||
|
Theme = 'mvc:theme',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Cookies = createParamDecorator((data: string, ctx: ExecutionContext) => {
|
||||||
|
const request = ctx.switchToHttp().getRequest();
|
||||||
|
return data ? request.cookies?.[data] : request.cookies;
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { ExecutionContext, createParamDecorator } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { Request, UserAgentSettings as UserAgentSettingsType } from '../request.type';
|
||||||
|
|
||||||
|
export const UserAgentSettings = createParamDecorator((data: keyof UserAgentSettingsType, ctx: ExecutionContext) => {
|
||||||
|
const request = ctx.switchToHttp().getRequest<Request>();
|
||||||
|
return data ? request.userAgentSettings[data] : request.cookies;
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
|
||||||
|
import { Response } from 'express';
|
||||||
|
|
||||||
|
import { FoundException } from '../exceptions/found.exception';
|
||||||
|
import { SeeOtherException } from '../exceptions/see-other.exception';
|
||||||
|
|
||||||
|
@Catch(FoundException, SeeOtherException)
|
||||||
|
export class RedirectExceptionFilter implements ExceptionFilter {
|
||||||
|
catch(exception: FoundException | SeeOtherException, host: ArgumentsHost) {
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
const response = ctx.getResponse<Response>();
|
||||||
|
|
||||||
|
if (exception.trueRedirect) {
|
||||||
|
response.setHeader('hx-redirect', exception.redirectUri);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.redirect(exception.exposedStatus, exception.redirectUri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/302
|
||||||
|
// Use for GET or HEAD responses
|
||||||
|
export class FoundException extends HttpException {
|
||||||
|
|
||||||
|
readonly exposedStatus = HttpStatus.FOUND;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly redirectUri: string,
|
||||||
|
readonly trueRedirect: boolean = false,
|
||||||
|
) {
|
||||||
|
super(`<a href="${redirectUri}">Found.</a>`, HttpStatus.FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/303
|
||||||
|
// User for POST or PUT responses
|
||||||
|
export class SeeOtherException extends HttpException {
|
||||||
|
|
||||||
|
readonly exposedStatus = HttpStatus.SEE_OTHER;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly redirectUri: string,
|
||||||
|
readonly trueRedirect: boolean = false,
|
||||||
|
) {
|
||||||
|
super(`<a href="${redirectUri}">See other.</a>`, HttpStatus.SEE_OTHER);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
|
||||||
|
export class TooManyRequestsException extends HttpException {
|
||||||
|
constructor() {
|
||||||
|
super('Too Many Requests', HttpStatus.TOO_MANY_REQUESTS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { MvcModule } from './mvc/mvc.module';
|
||||||
|
import { RestModule } from './rest/rest.module';
|
||||||
|
import { UserAgentSettingsInterceptor } from './interceptors/user-agent-settings.interceptor';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [MvcModule, RestModule],
|
||||||
|
providers: [UserAgentSettingsInterceptor],
|
||||||
|
})
|
||||||
|
export class HttpModule {}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
||||||
|
import { Observable, map } from 'rxjs';
|
||||||
|
|
||||||
|
import { Request } from '../request.type';
|
||||||
|
import { RegisteredCookies } from '../decorators/cookies.decorator';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UserAgentSettingsInterceptor implements NestInterceptor {
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> | Promise<Observable<any>> {
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
|
||||||
|
return next.handle().pipe(
|
||||||
|
map(data => ({
|
||||||
|
...data,
|
||||||
|
user_agent_settings: {
|
||||||
|
theme: request.cookies[RegisteredCookies.Theme] ?? 'light',
|
||||||
|
links: {
|
||||||
|
toggle_theme: '/v1/user-agent/toggle-theme',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
import { Body, Controller, Get, InternalServerErrorException, Param, Post, Query, Render, Req, Res, UseInterceptors } from '@nestjs/common';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
import * as Joi from 'joi';
|
||||||
|
|
||||||
|
import { Identity } from '../../../domain/identity.types';
|
||||||
|
import { SecureStringUtil } from '../../../utils';
|
||||||
|
import { TooManyRequestsException } from '../../exceptions/too-many-requests.exception';
|
||||||
|
import { Views } from '../mvc.types';
|
||||||
|
import { LoginContextInterceptor } from './login-context.interceptor';
|
||||||
|
import { Context } from './context.decorator';
|
||||||
|
import { StateManagerService } from './state-manager.service';
|
||||||
|
import { AccessAttemptService } from './access-attempt.service';
|
||||||
|
import { ChallengeRenderContext, RequestContext } from './login.types';
|
||||||
|
import { DeviceCalculatorService } from './device-calculator.service';
|
||||||
|
import { FoundException } from '../../exceptions/found.exception';
|
||||||
|
import { SeeOtherException } from '../../exceptions/see-other.exception';
|
||||||
|
|
||||||
|
@Controller({
|
||||||
|
version: '1',
|
||||||
|
path: 'auth/:realm/signin/challenge',
|
||||||
|
})
|
||||||
|
@UseInterceptors(LoginContextInterceptor)
|
||||||
|
export class ChallengeController {
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly stateManager: StateManagerService,
|
||||||
|
private readonly deviceCalcService: DeviceCalculatorService,
|
||||||
|
private readonly accessAttemptService: AccessAttemptService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async selectDevice() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':device_urn')
|
||||||
|
@Render(Views.LoginChallenge)
|
||||||
|
async getDevice(
|
||||||
|
@Param('realm') realm: string,
|
||||||
|
@Param('device_urn') deviceUrn: string,
|
||||||
|
@Context() context: RequestContext,
|
||||||
|
@Res() res: Response,
|
||||||
|
): Promise<ChallengeRenderContext> {
|
||||||
|
|
||||||
|
const deviceInfo = await this.deviceCalcService.considerDevicesForUser(realm, context.username);
|
||||||
|
const device = deviceInfo.primaryDevices.find(d => d.urn === deviceUrn);
|
||||||
|
|
||||||
|
const refreshedState = this.stateManager.updateState(context.state);
|
||||||
|
const redirectQueryParams = new URLSearchParams();
|
||||||
|
redirectQueryParams.append('username', context.username);
|
||||||
|
redirectQueryParams.append('state', refreshedState);
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
throw new FoundException(`/v1/auth/${realm}/signin/identifier`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: this.stateManager.updateState(context.state),
|
||||||
|
username: context.username,
|
||||||
|
device,
|
||||||
|
links: {
|
||||||
|
select_device: deviceInfo.primaryDevices.length > 1 ? `/v1/auth/${realm}/signin/challenge?${redirectQueryParams.toString()}` : null,
|
||||||
|
challenge_form: `/v1/auth/${realm}/signin/challenge/${deviceUrn}`,
|
||||||
|
try_different_user: `/v1/auth/${realm}/signin/identifier`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':device_urn')
|
||||||
|
@Render(Views.LoginChallenge)
|
||||||
|
async postDevice(
|
||||||
|
@Req() req: Request,
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Param('realm') realm: string,
|
||||||
|
@Param('device_urn') deviceUrn: string,
|
||||||
|
@Context() context: RequestContext,
|
||||||
|
@Res() res: Response,
|
||||||
|
): Promise<ChallengeRenderContext> {
|
||||||
|
|
||||||
|
const deviceInfo = await this.deviceCalcService.considerDevicesForUser(realm, context.username);
|
||||||
|
const device = deviceInfo.primaryDevices.find(d => d.urn === deviceUrn);
|
||||||
|
|
||||||
|
const refreshedState = this.stateManager.updateState(context.state);
|
||||||
|
const redirectQueryParams = new URLSearchParams();
|
||||||
|
redirectQueryParams.append('username', context.username);
|
||||||
|
redirectQueryParams.append('state', refreshedState);
|
||||||
|
|
||||||
|
const selectDeviceUrl = deviceInfo.primaryDevices.length > 1 ? `/v1/auth/${realm}/signin/challenge?${redirectQueryParams.toString()}` : null;
|
||||||
|
|
||||||
|
if (!device || deviceInfo.primaryDevices.length === 0) {
|
||||||
|
const { isLocked } = await this.accessAttemptService.recordAttempt(req, context.username, false);
|
||||||
|
|
||||||
|
if (isLocked) {
|
||||||
|
throw new TooManyRequestsException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: refreshedState,
|
||||||
|
username: context.username,
|
||||||
|
device: null,
|
||||||
|
links: {
|
||||||
|
select_device: null,
|
||||||
|
challenge_form: `/v1/auth/${realm}/signin/challenge/${deviceUrn}`,
|
||||||
|
try_different_user: `/v1/auth/${realm}/signin/identifier`,
|
||||||
|
},
|
||||||
|
error: 'Invalid username or password.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (device.deviceType === Identity.AuthDevice.Type.Password) {
|
||||||
|
|
||||||
|
const passwordDevice = device as Identity.AuthDevice.Password;
|
||||||
|
|
||||||
|
const { error, value } = Joi.object<{ password: string }, true>({
|
||||||
|
password: Joi.string().required(),
|
||||||
|
}).validate(body, { allowUnknown: true });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return {
|
||||||
|
state: this.stateManager.updateState(context.state),
|
||||||
|
username: context.username,
|
||||||
|
device,
|
||||||
|
links: {
|
||||||
|
select_device: selectDeviceUrl,
|
||||||
|
challenge_form: `/v1/auth/${realm}/signin/challenge/${deviceUrn}`,
|
||||||
|
try_different_user: `/v1/auth/${realm}/signin/identifier`,
|
||||||
|
},
|
||||||
|
error: 'Password is required.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await SecureStringUtil.compare(value.password, passwordDevice.passwordHashString)) {
|
||||||
|
|
||||||
|
if (deviceInfo.hasSecondaryDevice) {
|
||||||
|
|
||||||
|
if (!deviceInfo.preferredSecondaryDevice) {
|
||||||
|
throw new SeeOtherException(`/v1/auth/${realm}/signing/2fa/challenge?${redirectQueryParams.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new SeeOtherException(`/v1/auth/${realm}/signing/2fa/challenge/${deviceInfo.preferredSecondaryDevice.urn}?${redirectQueryParams.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.accessAttemptService.recordAttempt(req, context.username, true);
|
||||||
|
throw new SeeOtherException(context.state.htu ?? 'https://google.com', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isLocked } = await this.accessAttemptService.recordAttempt(req, context.username, false);
|
||||||
|
|
||||||
|
if (isLocked) {
|
||||||
|
throw new TooManyRequestsException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: refreshedState,
|
||||||
|
username: context.username,
|
||||||
|
device,
|
||||||
|
links: {
|
||||||
|
select_device: selectDeviceUrl,
|
||||||
|
challenge_form: `/v1/auth/${realm}/signin/challenge/${deviceUrn}`,
|
||||||
|
try_different_user: `/v1/auth/${realm}/signin/identifier`,
|
||||||
|
},
|
||||||
|
error: 'Invalid username or password.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new SeeOtherException(`/v1/auth/${realm}/signin/error`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { LoginRequest, RequestContext } from './login.types';
|
||||||
|
|
||||||
|
export const Context = createParamDecorator(
|
||||||
|
(data: unknown, ctx: ExecutionContext): RequestContext | null => {
|
||||||
|
const request = ctx.switchToHttp().getRequest<LoginRequest>();
|
||||||
|
return request.context!;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { Identity } from '../../../domain/identity.types';
|
||||||
|
import { IdentityAuthDeviceDao } from '../../../persistence/identity-auth-device.dao';
|
||||||
|
|
||||||
|
interface DeviceResults {
|
||||||
|
hasSecondaryDevice: boolean;
|
||||||
|
preferredPrimaryDevice: Identity.AuthDevice | null;
|
||||||
|
preferredSecondaryDevice: Identity.AuthDevice | null;
|
||||||
|
primaryDevices: Identity.AuthDevice[];
|
||||||
|
secondaryDevices: Identity.AuthDevice[]
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DeviceCalculatorService {
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly identityAuthDeviceDao: IdentityAuthDeviceDao,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async considerDevicesForUser(realm: string, username: string): Promise<DeviceResults> {
|
||||||
|
const devicesForUser = await this.identityAuthDeviceDao.findByRealmAndUsername(realm, username);
|
||||||
|
|
||||||
|
const primaryDevices = devicesForUser.filter(d => !d.twoFactorEligible);
|
||||||
|
const secondaryDevices = devicesForUser.filter(d => d.twoFactorEligible);
|
||||||
|
const hasSecondaryDevice = devicesForUser.some(d => d.twoFactorEligible);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasSecondaryDevice,
|
||||||
|
preferredPrimaryDevice: primaryDevices.find(d => d.preferred) ?? null,
|
||||||
|
preferredSecondaryDevice: secondaryDevices.find(d => d.preferred) ?? null,
|
||||||
|
primaryDevices,
|
||||||
|
secondaryDevices,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Controller } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Controller({
|
||||||
|
version: '1',
|
||||||
|
path: 'auth/:realm/signin/challenge',
|
||||||
|
})
|
||||||
|
export class ErrorController {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { Controller, Get, Param, Post, Render, Res, UseInterceptors } from '@nestjs/common';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import { Views } from '../mvc.types';
|
||||||
|
import { StateManagerService } from './state-manager.service';
|
||||||
|
import { Identity } from '../../../domain/identity.types';
|
||||||
|
import { LoginContextInterceptor } from './login-context.interceptor';
|
||||||
|
import { Context } from './context.decorator';
|
||||||
|
import { IdentifierRenderContext, RequestContext } from './login.types';
|
||||||
|
import { DeviceCalculatorService } from './device-calculator.service';
|
||||||
|
import { SeeOtherException } from '../../exceptions/see-other.exception';
|
||||||
|
|
||||||
|
@Controller({
|
||||||
|
version: '1',
|
||||||
|
path: 'auth/:realm/signin/identifier',
|
||||||
|
})
|
||||||
|
export class IdentifierController {
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly stateManager: StateManagerService,
|
||||||
|
private readonly deviceCalcService: DeviceCalculatorService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Render(Views.LoginIdentifier)
|
||||||
|
async getLogin(
|
||||||
|
@Param('realm') realm: string,
|
||||||
|
): Promise<IdentifierRenderContext> {
|
||||||
|
|
||||||
|
const state = await this.stateManager.getNewState();
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
links: {
|
||||||
|
identifier_form: `/v1/auth/${realm}/signin/identifier`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@Render(Views.LoginIdentifier)
|
||||||
|
@UseInterceptors(LoginContextInterceptor)
|
||||||
|
async postLogin(
|
||||||
|
@Param('realm') realm: string,
|
||||||
|
@Context() context: RequestContext,
|
||||||
|
): Promise<IdentifierRenderContext> {
|
||||||
|
|
||||||
|
const deviceInfo = await this.deviceCalcService.considerDevicesForUser(realm, context.username);
|
||||||
|
const refreshedState = this.stateManager.updateState(context.state);
|
||||||
|
|
||||||
|
const redirectQueryParams = new URLSearchParams();
|
||||||
|
redirectQueryParams.append('username', context.username);
|
||||||
|
redirectQueryParams.append('state', refreshedState);
|
||||||
|
|
||||||
|
|
||||||
|
if (deviceInfo.primaryDevices.length === 0) {
|
||||||
|
throw new SeeOtherException(`/v1/auth/${realm}/signin/challenge/${randomUUID()}?${redirectQueryParams.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceInfo.preferredPrimaryDevice?.deviceType === Identity.AuthDevice.Type.Password) {
|
||||||
|
throw new SeeOtherException(`/v1/auth/${realm}/signin/challenge/${deviceInfo.preferredPrimaryDevice.urn}?${redirectQueryParams.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: refreshedState,
|
||||||
|
links: {
|
||||||
|
identifier_form: `/v1/auth/${realm}/signin/identifier`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { BadRequestException, CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
|
||||||
|
import * as Joi from 'joi';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import { TokenManagementService } from '../../../token-management/token-management.service';
|
||||||
|
import { AccessAttemptService } from './access-attempt.service';
|
||||||
|
import { DateUtil } from '../../../utils';
|
||||||
|
import { DeepPartial } from '../../../utils/deep-partial.type';
|
||||||
|
import { TooManyRequestsException } from '../../exceptions/too-many-requests.exception';
|
||||||
|
import { LoginRequest, RequestContext, State } from './login.types';
|
||||||
|
|
||||||
|
type RequestContextMutation = DeepPartial<RequestContext>
|
||||||
|
|
||||||
|
const standardBadRequestError = 'State was mutated or expired';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LoginContextInterceptor implements NestInterceptor {
|
||||||
|
|
||||||
|
private readonly logger: Logger = new Logger(LoginContextInterceptor.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly tokenService: TokenManagementService,
|
||||||
|
private readonly accessAttemptService: AccessAttemptService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async intercept(context: ExecutionContext, next: CallHandler<any>): Promise<Observable<any>> {
|
||||||
|
const request = context.switchToHttp().getRequest<LoginRequest>();
|
||||||
|
const loginContextMutable: RequestContextMutation = {};
|
||||||
|
|
||||||
|
loginContextMutable.username = request.method === 'POST' ? request?.body?.username : request?.query?.username;
|
||||||
|
|
||||||
|
if (!loginContextMutable.username) {
|
||||||
|
this.logger.debug('Request did not provide username');
|
||||||
|
throw new BadRequestException(standardBadRequestError);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLocked = await this.accessAttemptService.checkIfLocked(request, loginContextMutable.username);
|
||||||
|
|
||||||
|
if (isLocked) {
|
||||||
|
throw new TooManyRequestsException();
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = request.method === 'POST' ? request?.body?.state : request?.query?.state;
|
||||||
|
|
||||||
|
if (!state) {
|
||||||
|
this.logger.debug('State was not provided in body');
|
||||||
|
throw new BadRequestException(standardBadRequestError);
|
||||||
|
}
|
||||||
|
|
||||||
|
const receipt = this.tokenService.parseToken<State>(state);
|
||||||
|
|
||||||
|
if (receipt.isToken && receipt.signatureValid) {
|
||||||
|
loginContextMutable.state = receipt.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error, value: loginContext } = Joi.object<RequestContext, true>({
|
||||||
|
state: Joi.object<State, true>({
|
||||||
|
jwi: Joi.string().required(),
|
||||||
|
exp: Joi.number().required(),
|
||||||
|
tfa: Joi.boolean().required(),
|
||||||
|
htu: Joi.string(),
|
||||||
|
}),
|
||||||
|
username: Joi.string().required(),
|
||||||
|
}).validate(loginContextMutable, { allowUnknown: false });
|
||||||
|
|
||||||
|
if (error || DateUtil.fromSecondsSinceEpoch(loginContext.state.exp).isInThePast()) {
|
||||||
|
this.logger.debug(error ? error.message : 'State is past expiration');
|
||||||
|
throw new BadRequestException(standardBadRequestError);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.context = loginContext;
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { CacheModule } from '../../../cache/cache.module';
|
||||||
|
import { PersistenceModule } from '../../../persistence/persistence.module';
|
||||||
|
import { AccessAttemptService } from './access-attempt.service';
|
||||||
|
import { LoginContextInterceptor } from './login-context.interceptor';
|
||||||
|
import { ConfigModule } from '../../../config/config.module';
|
||||||
|
import { TokenManagementModule } from '../../../token-management/token-management.module';
|
||||||
|
import { StateManagerService } from './state-manager.service';
|
||||||
|
import { IdentifierController } from './identifier.controller';
|
||||||
|
import { ChallengeController } from './challenge.controller';
|
||||||
|
import { DeviceCalculatorService } from './device-calculator.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
CacheModule,
|
||||||
|
PersistenceModule,
|
||||||
|
ConfigModule,
|
||||||
|
TokenManagementModule,
|
||||||
|
],
|
||||||
|
controllers: [
|
||||||
|
IdentifierController,
|
||||||
|
ChallengeController,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
AccessAttemptService,
|
||||||
|
LoginContextInterceptor,
|
||||||
|
StateManagerService,
|
||||||
|
DeviceCalculatorService,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class LoginModule {}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
|
||||||
|
import { Request as ExpressRequest, Response as ExpressResponse } from 'express';
|
||||||
|
import { Identity } from '../../../domain/identity.types';
|
||||||
|
import { Views } from '../mvc.types';
|
||||||
|
|
||||||
|
export interface LoginRequest extends ExpressRequest {
|
||||||
|
context?: RequestContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IdentifierRenderContext {
|
||||||
|
state: string;
|
||||||
|
links: {
|
||||||
|
identifier_form: string;
|
||||||
|
}
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChallengeRenderContext {
|
||||||
|
state: string;
|
||||||
|
username: string;
|
||||||
|
device: Identity.AuthDevice | null;
|
||||||
|
links: {
|
||||||
|
select_device: string | null;
|
||||||
|
challenge_form: string;
|
||||||
|
try_different_user: string;
|
||||||
|
},
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface State {
|
||||||
|
jwi: string;
|
||||||
|
exp: number;
|
||||||
|
tfa: boolean;
|
||||||
|
htu?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestContext {
|
||||||
|
state: State;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ViewContextPairs =
|
||||||
|
[view: Views.LoginChallenge, ctx: ChallengeRenderContext] |
|
||||||
|
[view: Views.LoginIdentifier, ctx: IdentifierRenderContext] ;
|
||||||
|
|
||||||
|
export interface LoginResponse extends Omit<ExpressResponse, 'render'> {
|
||||||
|
render(...args: ViewContextPairs): Promise<void>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import { CacheService } from '../../../cache/cache.service';
|
||||||
|
import { TokenManagementService } from '../../../token-management/token-management.service';
|
||||||
|
import { DateUtil } from '../../../utils';
|
||||||
|
import { State } from './login.types';
|
||||||
|
|
||||||
|
const getStateKey = (jwi: string) => `login:state:${jwi}`;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StateManagerService {
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly tokenService: TokenManagementService,
|
||||||
|
private readonly cacheService: CacheService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getNewState(): Promise<string> {
|
||||||
|
const date = DateUtil.fromDate(new Date()).addNMinutes(15);
|
||||||
|
|
||||||
|
const stateObj: State = {
|
||||||
|
jwi: randomUUID(),
|
||||||
|
exp: date.seconds,
|
||||||
|
tfa: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.cacheService.set(getStateKey(stateObj.jwi), new Date().toISOString(), date.toDate().getTime());
|
||||||
|
|
||||||
|
return this.tokenService.createToken(stateObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateState(state: State, dto: { tfa?: boolean } = {}): string {
|
||||||
|
return this.tokenService.createToken({ ...state, ...dto });
|
||||||
|
}
|
||||||
|
|
||||||
|
async isStateActive(jwi: string): Promise<boolean> {
|
||||||
|
return this.cacheService.exists(getStateKey(jwi));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { LoginModule } from './login/login.module';
|
||||||
|
import { UserAgentController } from './user-agent.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
LoginModule,
|
||||||
|
],
|
||||||
|
controllers: [
|
||||||
|
UserAgentController,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class MvcModule {}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export enum Views {
|
||||||
|
LoginChallenge = 'login-challenge',
|
||||||
|
LoginIdentifier = 'login-identifier',
|
||||||
|
NotFound = 'not-found',
|
||||||
|
UserAgentThemeSwitch = 'user-agent-theme-switch',
|
||||||
|
}
|
||||||
|
|
@ -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() {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { Controller, Put, Render, Req, Res } from '@nestjs/common';
|
||||||
|
import { Cookies, RegisteredCookies } from '../decorators/cookies.decorator';
|
||||||
|
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { Views } from './mvc.types';
|
||||||
|
|
||||||
|
@Controller({
|
||||||
|
version: '1',
|
||||||
|
path: 'user-agent',
|
||||||
|
})
|
||||||
|
export class UserAgentController {
|
||||||
|
|
||||||
|
@Put('toggle-theme')
|
||||||
|
@Render(Views.UserAgentThemeSwitch)
|
||||||
|
async toggleTheme(
|
||||||
|
@Req() req: Request,
|
||||||
|
@Res() res: Response,
|
||||||
|
@Cookies(RegisteredCookies.Theme) theme?: string,
|
||||||
|
) {
|
||||||
|
|
||||||
|
if (theme === 'dark') {
|
||||||
|
req.cookies[RegisteredCookies.Theme] = 'light';
|
||||||
|
res.cookie(RegisteredCookies.Theme, 'light');
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
req.cookies[RegisteredCookies.Theme] = 'dark';
|
||||||
|
res.cookie(RegisteredCookies.Theme, 'dark');
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Request as ExpressRequest } from 'express';
|
||||||
|
|
||||||
|
export interface UserAgentSettings {
|
||||||
|
theme: 'light' | 'dark';
|
||||||
|
links: {
|
||||||
|
toggle_theme: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Request extends ExpressRequest {
|
||||||
|
userAgentSettings: UserAgentSettings;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class RestModule {}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { Logger, VERSION_NEUTRAL, VersioningType } from '@nestjs/common';
|
||||||
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import * as cookieParser from 'cookie-parser';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { ConfigService } from './config/config.service';
|
||||||
|
import { SystemSettings } from './domain/system-settings.types';
|
||||||
|
import { UserAgentSettingsInterceptor } from './http/interceptors/user-agent-settings.interceptor';
|
||||||
|
import { RedirectExceptionFilter } from './http/exception-filters/redirect.exception-filter';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||||
|
const configService: ConfigService = app.get(ConfigService);
|
||||||
|
|
||||||
|
const logger: Logger = new Logger();
|
||||||
|
app.useLogger(logger);
|
||||||
|
|
||||||
|
app.set('view engine', 'pug');
|
||||||
|
app.setBaseViewsDir(join(__dirname, '../views'));
|
||||||
|
|
||||||
|
if (configService.get(SystemSettings.Auth.AccessAttempts.CheckForwardedFor)) {
|
||||||
|
app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal']);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
app.enableVersioning({
|
||||||
|
type: VersioningType.URI,
|
||||||
|
defaultVersion: VERSION_NEUTRAL,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userAgentSettingsInterceptor = app.get(UserAgentSettingsInterceptor);
|
||||||
|
app.useGlobalInterceptors(userAgentSettingsInterceptor);
|
||||||
|
|
||||||
|
app.useGlobalFilters(new RedirectExceptionFilter());
|
||||||
|
|
||||||
|
await app.listen(3000, () => {
|
||||||
|
logger.log('Listening on port 3000', 'main.ts');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
bootstrap();
|
||||||
|
|
@ -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,78 @@
|
||||||
|
import * as crypto from 'node:crypto';
|
||||||
|
|
||||||
|
import { SecureStringUtil } from '../../utils';
|
||||||
|
import { PrismaService } from '../../persistence/prisma.service';
|
||||||
|
import { DataMigration } from './data-migration.interface';
|
||||||
|
import { Identity } from '../../domain/identity.types';
|
||||||
|
import { SystemSettings } from '../../domain/system-settings.types';
|
||||||
|
|
||||||
|
export class _00ApplicationBootstrapDataMigration implements DataMigration {
|
||||||
|
|
||||||
|
readonly name = '00-application-bootstrap-data-migration';
|
||||||
|
|
||||||
|
async run(prisma: PrismaService) {
|
||||||
|
await prisma.$queryRaw`PRAGMA journal_mode=WAL;`;
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
|
||||||
|
const realm = await tx.authRealm.create({
|
||||||
|
data: {
|
||||||
|
name: 'main',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminGroup = await tx.identityGroup.create({
|
||||||
|
data: {
|
||||||
|
role: Identity.Group.Role.RealmAdmin,
|
||||||
|
realmId: realm.id,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminUser = await tx.identityUser.create({
|
||||||
|
data: {
|
||||||
|
username: 'admin',
|
||||||
|
realmId: realm.id,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.identityGroupToIdentityUser.create({
|
||||||
|
data: {
|
||||||
|
userId: adminUser.id,
|
||||||
|
groupId: adminGroup.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const passwordDevice: Identity.AuthDevice.PasswordAttributes = {
|
||||||
|
expiry: new Date(0),
|
||||||
|
passwordHashString: await SecureStringUtil.generateNewHash('ThisIsExpired123!'),
|
||||||
|
locked: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.identityAuthDevice.create({
|
||||||
|
data: {
|
||||||
|
userId: adminUser.id,
|
||||||
|
deviceType: Identity.AuthDevice.Type.Password,
|
||||||
|
attributes: JSON.stringify(passwordDevice),
|
||||||
|
preferred: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const startingConfig: SystemSettings.Config = {
|
||||||
|
[SystemSettings.Auth.AccessAttempts.CheckForwardedFor]: true,
|
||||||
|
[SystemSettings.Auth.AccessAttempts.MaxAttempts]: 5,
|
||||||
|
[SystemSettings.Auth.AccessAttempts.Timeout]: 15 * 60 * 1000,
|
||||||
|
[SystemSettings.Auth.Oauth2.Enabled]: true,
|
||||||
|
[SystemSettings.Auth.Oauth2.EncryptionSecret]: crypto.randomBytes(16).toString('hex'),
|
||||||
|
[SystemSettings.Auth.TokenManagement.SigningSecret]: crypto.randomBytes(16).toString('hex'),
|
||||||
|
}
|
||||||
|
|
||||||
|
const paired = Object.entries(startingConfig)
|
||||||
|
.map(([hashKey, hashValue]) => ({ hashKey, hashValue: hashValue.toString(), hashValueType: typeof hashValue }));
|
||||||
|
|
||||||
|
await tx.systemSetting.createMany({
|
||||||
|
data: paired,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { PrismaService } from '../prisma.service';
|
import { PrismaService } from '../../persistence/prisma.service';
|
||||||
|
|
||||||
export interface DataMigration {
|
export interface DataMigration {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { IdentityAuthDevice } from '@prisma/client';
|
||||||
|
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
import { Identity } from '../domain/identity.types';
|
||||||
|
import { safeJsonParse } from '../utils';
|
||||||
|
import * as Joi from 'joi';
|
||||||
|
|
||||||
|
|
||||||
|
const eligibleForTwoFactor: Identity.AuthDevice.Type[] = [
|
||||||
|
|
||||||
|
]
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class IdentityAuthDeviceDao {
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findByRealmAndUsername(realm: string, username: string): Promise<Identity.AuthDevice[]> {
|
||||||
|
|
||||||
|
const devices = await this.prismaService.identityAuthDevice.findMany({
|
||||||
|
where: {
|
||||||
|
user: {
|
||||||
|
username: username.toLowerCase(),
|
||||||
|
realm: {
|
||||||
|
name: realm.toLowerCase(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return devices.map(d => IdentityAuthDeviceDao.modelToEntity(d));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOneByUrn(urn: string): Promise<Identity.AuthDevice | null> {
|
||||||
|
const device = await this.prismaService.identityAuthDevice.findFirst({
|
||||||
|
where: {
|
||||||
|
id: urn.replace('urn:identity:auth-device:', ''),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return IdentityAuthDeviceDao.modelToEntity(device);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static modelToEntity(model: IdentityAuthDevice, skipAttributes = false): Identity.AuthDevice {
|
||||||
|
|
||||||
|
const entity: Identity.AuthDevice = {
|
||||||
|
urn: `urn:identity:auth-device:${model.id}`,
|
||||||
|
userId: model.userId,
|
||||||
|
deviceType: model.deviceType as Identity.AuthDevice.Type,
|
||||||
|
preferred: model.preferred,
|
||||||
|
twoFactorEligible: eligibleForTwoFactor.includes(model.deviceType as Identity.AuthDevice.Type),
|
||||||
|
createdAt: new Date(model.createdAt),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipAttributes || !model.attributes) {
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.deviceType === Identity.AuthDevice.Type.Password) {
|
||||||
|
|
||||||
|
const [_, attributes] = safeJsonParse<Identity.AuthDevice.PasswordAttributes>(model.attributes);
|
||||||
|
|
||||||
|
const { error, value } = Joi.object<Identity.AuthDevice.PasswordAttributes, true>({
|
||||||
|
expiry: Joi.date().required(),
|
||||||
|
passwordHashString: Joi.string().required(),
|
||||||
|
locked: Joi.boolean().required(),
|
||||||
|
}).validate(attributes);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...entity,
|
||||||
|
...value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
import { _00ApplicationBootstrapDataMigration } from './data-migrations';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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<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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export type DeepPartial<T> = {
|
||||||
|
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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('$');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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('_', '')
|
||||||
|
);
|
||||||
|
|
@ -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)
|
||||||
|
});
|
||||||
|
|
@ -9,13 +9,18 @@
|
||||||
"target": "ES2021",
|
"target": "ES2021",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"baseUrl": "./",
|
"baseUrl": "./src",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strictNullChecks": false,
|
"strictNullChecks": true,
|
||||||
"noImplicitAny": false,
|
"strictFunctionTypes": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
"strictBindCallApply": false,
|
"strictBindCallApply": false,
|
||||||
"forceConsistentCasingInFileNames": false,
|
"forceConsistentCasingInFileNames": false,
|
||||||
"noFallthroughCasesInSwitch": false
|
"noFallthroughCasesInSwitch": false,
|
||||||
}
|
},
|
||||||
|
"exclude": [
|
||||||
|
"codegen.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
doctype html
|
||||||
|
html(data-theme=user_agent_settings.theme)
|
||||||
|
head
|
||||||
|
link(rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css")
|
||||||
|
script(src="https://unpkg.com/htmx.org@1.9.12" integrity="sha384-ujb1lZYygJmzgSwoxRggbCHcjc0rB2XoQrxeTUQyRjrOnlCoYta87iKBWq3EsdM2" crossorigin="anonymous")
|
||||||
|
body
|
||||||
|
block content
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
extends base.pug
|
||||||
|
|
||||||
|
block content
|
||||||
|
main
|
||||||
|
include user-agent-theme-switch.pug
|
||||||
|
form(hx-post=links.identifier_form hx-swap="outerHTML")
|
||||||
|
fieldset
|
||||||
|
input(type="hidden" name="state" value=state)
|
||||||
|
input(type="text" name="username" placeholder="Username")
|
||||||
|
input(type="submit" name="submit" value="Submit")
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
- var isChecked = user_agent_settings.theme === 'dark'
|
||||||
|
div#themeToggleContainer
|
||||||
|
input(name="terms" type="checkbox" role="switch" hx-put=user_agent_settings.links.toggle_theme hx-swap="innerHTML" hx-select='div#themeToggleContainer' checked=isChecked)
|
||||||
|
span Toggle Light/Dark Mode
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
type CloudDavContact {
|
|
||||||
urn: ID!
|
|
||||||
identityGroupUrn: String!
|
|
||||||
|
|
||||||
firstName: String
|
|
||||||
lastName: String
|
|
||||||
company: String
|
|
||||||
phones: [String]
|
|
||||||
addresses: [CloudDavContactAddress]
|
|
||||||
dates: [CloudDavContactLabeledString]
|
|
||||||
urls: [CloudDavContactLabeledString]
|
|
||||||
notes: String
|
|
||||||
}
|
|
||||||
|
|
||||||
type CloudDavContactAddress {
|
|
||||||
urn: ID!
|
|
||||||
type: String
|
|
||||||
street1: String
|
|
||||||
street2: String
|
|
||||||
city: String
|
|
||||||
state: String
|
|
||||||
postalCode: String
|
|
||||||
country: String
|
|
||||||
}
|
|
||||||
|
|
||||||
type CloudDavContactLabeledString {
|
|
||||||
context: String!
|
|
||||||
value: String!
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
type IdentityGroupToIdentityUserEdge {
|
|
||||||
data: [IdentityUser]
|
|
||||||
error: IdentityGroupToIdentityUserError
|
|
||||||
}
|
|
||||||
|
|
||||||
type IdentityUserToIdentityGroupEdge {
|
|
||||||
data: [IdentityGroup]
|
|
||||||
error: IdentityUserToIdentityGroupError
|
|
||||||
}
|
|
||||||
|
|
||||||
type IdentityUserToIdentityProfileEdge {
|
|
||||||
data: IdentityProfile
|
|
||||||
error: IdentityUserToIdentityProfileError
|
|
||||||
}
|
|
||||||
|
|
||||||
type IdentityUserToIdentityEmailEdge {
|
|
||||||
data: [IdentityEmail]
|
|
||||||
error: IdentityUserToIdentityEmailError
|
|
||||||
}
|
|
||||||
|
|
||||||
type IdentityUserToIdentityAuthDeviceEdge {
|
|
||||||
data: [IdentityAuthDevice]
|
|
||||||
error: IdentityUserToIdentityAuthDeviceError
|
|
||||||
}
|
|
||||||
|
|
||||||
type IdentityGroupToCloudDavContactEdge {
|
|
||||||
data: [CloudDavContact]
|
|
||||||
error: IdentityGroupToCloudDavContactError
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
enum IdentityAuthDeviceTypeEnum {
|
|
||||||
PASSWORD
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
enum IdentityGroupToIdentityUserError {
|
|
||||||
UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
enum IdentityUserToIdentityGroupError {
|
|
||||||
UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
enum IdentityUserToIdentityProfileError {
|
|
||||||
UNKNOWN
|
|
||||||
NOT_FOUND
|
|
||||||
}
|
|
||||||
|
|
||||||
enum IdentityUserToIdentityEmailError {
|
|
||||||
UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
enum IdentityUserToIdentityAuthDeviceError {
|
|
||||||
UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
enum IdentityGroupToCloudDavContactError {
|
|
||||||
UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
type IdentityGroup {
|
|
||||||
urn: ID!
|
|
||||||
isAdmin: Boolean!
|
|
||||||
name: String
|
|
||||||
|
|
||||||
Users: IdentityGroupToIdentityUserEdge!
|
|
||||||
Contacts: IdentityGroupToCloudDavContactEdge!
|
|
||||||
}
|
|
||||||
|
|
||||||
type IdentityUser {
|
|
||||||
urn: ID!
|
|
||||||
externalId: String!
|
|
||||||
username: String!
|
|
||||||
|
|
||||||
Groups: IdentityUserToIdentityGroupEdge!
|
|
||||||
Profile: IdentityUserToIdentityProfileEdge!
|
|
||||||
Emails: IdentityUserToIdentityEmailEdge!
|
|
||||||
AuthDevices: IdentityUserToIdentityAuthDeviceEdge!
|
|
||||||
}
|
|
||||||
|
|
||||||
type IdentityProfile {
|
|
||||||
urn: ID!
|
|
||||||
firstName: String
|
|
||||||
lastName: String
|
|
||||||
}
|
|
||||||
|
|
||||||
type IdentityEmail {
|
|
||||||
urn: ID!
|
|
||||||
email: String!
|
|
||||||
userUrn: String!
|
|
||||||
verified: Boolean!
|
|
||||||
default: Boolean!
|
|
||||||
}
|
|
||||||
|
|
||||||
type IdentityAuthDevice {
|
|
||||||
urn: ID!
|
|
||||||
userUrn: String!
|
|
||||||
deviceType: IdentityAuthDeviceTypeEnum!
|
|
||||||
|
|
||||||
IdentityAuthDevicePassword: IdentityAuthDevicePassword
|
|
||||||
}
|
|
||||||
|
|
||||||
type IdentityAuthDevicePassword {
|
|
||||||
urn: ID!
|
|
||||||
expiry: String!
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
enum SystemSettingHashValueTypeEnum {
|
|
||||||
BOOLEAN
|
|
||||||
STRING
|
|
||||||
NUMBER
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
enum SystemSettingsQueryOutputError {
|
|
||||||
UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
enum UpdateSystemSettingOutputError {
|
|
||||||
UNKNOWN
|
|
||||||
NOT_FOUND
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
input UpdateSystemSettingInput {
|
|
||||||
urn: ID!
|
|
||||||
hashValue: String!
|
|
||||||
}
|
|
||||||
|
|
||||||
type UpdateSystemSettingOutput {
|
|
||||||
error: UpdateSystemSettingOutputError
|
|
||||||
}
|
|
||||||
|
|
||||||
type Mutation {
|
|
||||||
updateSystemSetting(input: UpdateSystemSettingInput!): UpdateSystemSettingOutput!
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
type SystemSettingsQueryOutput {
|
|
||||||
data: [SystemSetting]
|
|
||||||
error: SystemSettingsQueryOutputError
|
|
||||||
}
|
|
||||||
|
|
||||||
type Query {
|
|
||||||
systemSettings: SystemSettingsQueryOutput!
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
type SystemSetting {
|
|
||||||
urn: ID!
|
|
||||||
hashKey: String!
|
|
||||||
hashValueType: SystemSettingHashValueTypeEnum!
|
|
||||||
hashValue: String!
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
{
|
||||||
|
"name": "homelab-personal-cloud",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"cookie-parser": "^1.4.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cookie-parser": "^1.4.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/body-parser": {
|
||||||
|
"version": "1.19.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
|
||||||
|
"integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/connect": "*",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/connect": {
|
||||||
|
"version": "3.4.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||||
|
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/cookie-parser": {
|
||||||
|
"version": "1.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||||
|
"integrity": "sha512-Fvuyi354Z+uayxzIGCwYTayFKocfV7TuDYZClCdIP9ckhvAu/ixDtCB6qx2TT0FKjPLf1f3P/J1rgf6lPs64mw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/express": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/express": {
|
||||||
|
"version": "4.17.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz",
|
||||||
|
"integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/body-parser": "*",
|
||||||
|
"@types/express-serve-static-core": "^4.17.33",
|
||||||
|
"@types/qs": "*",
|
||||||
|
"@types/serve-static": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/express-serve-static-core": {
|
||||||
|
"version": "4.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.0.tgz",
|
||||||
|
"integrity": "sha512-bGyep3JqPCRry1wq+O5n7oiBgGWmeIJXPjXXCo8EK0u8duZGSYar7cGqd3ML2JUsLGeB7fmc06KYo9fLGWqPvQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"@types/qs": "*",
|
||||||
|
"@types/range-parser": "*",
|
||||||
|
"@types/send": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/http-errors": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/@types/mime": {
|
||||||
|
"version": "1.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||||
|
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "20.12.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz",
|
||||||
|
"integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~5.26.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/qs": {
|
||||||
|
"version": "6.9.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz",
|
||||||
|
"integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/@types/range-parser": {
|
||||||
|
"version": "1.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||||
|
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/@types/send": {
|
||||||
|
"version": "0.17.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz",
|
||||||
|
"integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/mime": "^1",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/serve-static": {
|
||||||
|
"version": "1.15.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz",
|
||||||
|
"integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/http-errors": "*",
|
||||||
|
"@types/node": "*",
|
||||||
|
"@types/send": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "0.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz",
|
||||||
|
"integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-parser": {
|
||||||
|
"version": "1.4.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz",
|
||||||
|
"integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "0.4.1",
|
||||||
|
"cookie-signature": "1.0.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-signature": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "5.26.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||||
|
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||||
|
"dev": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"cookie-parser": "^1.4.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cookie-parser": "^1.4.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
|
|
||||||
import type { CodegenConfig } from '@graphql-codegen/cli';
|
|
||||||
|
|
||||||
const config: CodegenConfig = {
|
|
||||||
overwrite: true,
|
|
||||||
schema: "../../graphql",
|
|
||||||
generates: {
|
|
||||||
"src/__generated__/graphql.ts": {
|
|
||||||
plugins: ["typescript", "typescript-resolvers"]
|
|
||||||
},
|
|
||||||
"./graphql.schema.json": {
|
|
||||||
plugins: ["introspection"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "SystemSetting" (
|
|
||||||
"hashKey" TEXT NOT NULL PRIMARY KEY,
|
|
||||||
"hashValue" TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "SystemPostMigration" (
|
|
||||||
"name" TEXT NOT NULL PRIMARY KEY,
|
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "IdentityGroup" (
|
|
||||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
|
||||||
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"name" TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "IdentityGroupToIdentityUser" (
|
|
||||||
"groupId" INTEGER NOT NULL,
|
|
||||||
"userId" INTEGER NOT NULL,
|
|
||||||
|
|
||||||
PRIMARY KEY ("groupId", "userId"),
|
|
||||||
CONSTRAINT "IdentityGroupToIdentityUser_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "IdentityGroup" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
|
||||||
CONSTRAINT "IdentityGroupToIdentityUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "IdentityUser" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "IdentityUser" (
|
|
||||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
|
||||||
"externalId" TEXT NOT NULL,
|
|
||||||
"username" TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "IdentityProfileNonNormalized" (
|
|
||||||
"userId" INTEGER NOT NULL,
|
|
||||||
"hashKey" TEXT NOT NULL,
|
|
||||||
"hashValue" TEXT NOT NULL,
|
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
PRIMARY KEY ("userId", "hashKey"),
|
|
||||||
CONSTRAINT "IdentityProfileNonNormalized_userId_fkey" FOREIGN KEY ("userId") REFERENCES "IdentityUser" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "IdentityUserEmails" (
|
|
||||||
"email" TEXT NOT NULL PRIMARY KEY,
|
|
||||||
"userId" INTEGER NOT NULL,
|
|
||||||
"verified" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"default" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
CONSTRAINT "IdentityUserEmails_userId_fkey" FOREIGN KEY ("userId") REFERENCES "IdentityUser" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "EnumIdentityAuthDeviceType" (
|
|
||||||
"enumValue" TEXT NOT NULL PRIMARY KEY
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "IdentityAuthDevice" (
|
|
||||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
|
||||||
"userId" INTEGER NOT NULL,
|
|
||||||
"deviceType" TEXT NOT NULL,
|
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT "IdentityAuthDevice_userId_fkey" FOREIGN KEY ("userId") REFERENCES "IdentityUser" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
|
||||||
CONSTRAINT "IdentityAuthDevice_deviceType_fkey" FOREIGN KEY ("deviceType") REFERENCES "EnumIdentityAuthDeviceType" ("enumValue") ON DELETE RESTRICT ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "IdentityAuthDeviceNonNormalized" (
|
|
||||||
"authDeviceId" INTEGER NOT NULL,
|
|
||||||
"hashKey" TEXT NOT NULL,
|
|
||||||
"hashValue" TEXT NOT NULL,
|
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
PRIMARY KEY ("authDeviceId", "hashKey"),
|
|
||||||
CONSTRAINT "IdentityAuthDeviceNonNormalized_authDeviceId_fkey" FOREIGN KEY ("authDeviceId") REFERENCES "IdentityAuthDevice" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "EnumCloudDavResourceType" (
|
|
||||||
"enumValue" TEXT NOT NULL PRIMARY KEY
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "CloudDavResource" (
|
|
||||||
"id" TEXT NOT NULL PRIMARY KEY,
|
|
||||||
"identityGroupId" INTEGER NOT NULL,
|
|
||||||
"resourceType" TEXT NOT NULL,
|
|
||||||
CONSTRAINT "CloudDavResource_identityGroupId_fkey" FOREIGN KEY ("identityGroupId") REFERENCES "IdentityGroup" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
|
||||||
CONSTRAINT "CloudDavResource_resourceType_fkey" FOREIGN KEY ("resourceType") REFERENCES "EnumCloudDavResourceType" ("enumValue") ON DELETE RESTRICT ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "CloudDavResourceNonNormalized" (
|
|
||||||
"davResourceId" TEXT NOT NULL,
|
|
||||||
"hashKey" TEXT NOT NULL,
|
|
||||||
"hashValue" TEXT NOT NULL,
|
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
PRIMARY KEY ("davResourceId", "hashKey"),
|
|
||||||
CONSTRAINT "CloudDavResourceNonNormalized_davResourceId_fkey" FOREIGN KEY ("davResourceId") REFERENCES "CloudDavResource" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "IdentityUser_externalId_key" ON "IdentityUser"("externalId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "IdentityUser_username_key" ON "IdentityUser"("username");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "IdentityAuthDevice_userId_idx" ON "IdentityAuthDevice"("userId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "IdentityAuthDevice_userId_deviceType_idx" ON "IdentityAuthDevice"("userId", "deviceType");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "CloudDavResource_identityGroupId_idx" ON "CloudDavResource"("identityGroupId");
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
generator client {
|
|
||||||
provider = "prisma-client-js"
|
|
||||||
}
|
|
||||||
|
|
||||||
datasource db {
|
|
||||||
provider = "sqlite"
|
|
||||||
url = "file:../../../data/core.db"
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Namespace: System
|
|
||||||
//
|
|
||||||
model SystemSetting {
|
|
||||||
hashKey String @id
|
|
||||||
hashValue String
|
|
||||||
}
|
|
||||||
|
|
||||||
model SystemPostMigration {
|
|
||||||
name String @id
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Namespace: Identity
|
|
||||||
//
|
|
||||||
model IdentityGroup {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
isAdmin Boolean @default(false)
|
|
||||||
name String?
|
|
||||||
|
|
||||||
users IdentityGroupToIdentityUser[]
|
|
||||||
davResources CloudDavResource[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model IdentityGroupToIdentityUser {
|
|
||||||
groupId Int
|
|
||||||
group IdentityGroup @relation(fields: [groupId], references: [id])
|
|
||||||
userId Int
|
|
||||||
user IdentityUser @relation(fields: [userId], references: [id])
|
|
||||||
|
|
||||||
@@id([groupId, userId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model IdentityUser {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
externalId String @unique @default(uuid())
|
|
||||||
username String @unique
|
|
||||||
|
|
||||||
groups IdentityGroupToIdentityUser[]
|
|
||||||
profileHashMapPairs IdentityProfileNonNormalized[]
|
|
||||||
emails IdentityUserEmails[]
|
|
||||||
authDevices IdentityAuthDevice[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model IdentityProfileNonNormalized {
|
|
||||||
userId Int
|
|
||||||
user IdentityUser @relation(fields: [userId], references: [id])
|
|
||||||
|
|
||||||
hashKey String
|
|
||||||
hashValue String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
@@id([userId, hashKey])
|
|
||||||
}
|
|
||||||
|
|
||||||
model IdentityUserEmails {
|
|
||||||
email String @id
|
|
||||||
|
|
||||||
userId Int
|
|
||||||
user IdentityUser @relation(fields: [userId], references: [id])
|
|
||||||
|
|
||||||
verified Boolean @default(false)
|
|
||||||
default Boolean @default(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
model EnumIdentityAuthDeviceType {
|
|
||||||
enumValue String @id
|
|
||||||
|
|
||||||
authDevices IdentityAuthDevice[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model IdentityAuthDevice {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
|
|
||||||
userId Int
|
|
||||||
user IdentityUser @relation(fields: [userId], references: [id])
|
|
||||||
|
|
||||||
deviceType String
|
|
||||||
deviceTypeRelation EnumIdentityAuthDeviceType @relation(fields: [deviceType], references: [enumValue])
|
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
hashMapPairs IdentityAuthDeviceNonNormalized[]
|
|
||||||
|
|
||||||
@@index([userId])
|
|
||||||
@@index([userId, deviceType])
|
|
||||||
}
|
|
||||||
|
|
||||||
model IdentityAuthDeviceNonNormalized {
|
|
||||||
authDeviceId Int
|
|
||||||
davResource IdentityAuthDevice @relation(fields: [authDeviceId], references: [id])
|
|
||||||
|
|
||||||
hashKey String
|
|
||||||
hashValue String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
@@id([authDeviceId, hashKey])
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Namespace: cloud-dav
|
|
||||||
//
|
|
||||||
model EnumCloudDavResourceType {
|
|
||||||
enumValue String @id
|
|
||||||
|
|
||||||
davResources CloudDavResource[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model CloudDavResource {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
|
|
||||||
identityGroupId Int
|
|
||||||
IdentityGroup IdentityGroup @relation(fields: [identityGroupId], references: [id])
|
|
||||||
|
|
||||||
resourceType String
|
|
||||||
resourceTypeRelation EnumCloudDavResourceType @relation(fields: [resourceType], references: [enumValue])
|
|
||||||
|
|
||||||
hashMapPairs CloudDavResourceNonNormalized[]
|
|
||||||
|
|
||||||
@@index([identityGroupId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model CloudDavResourceNonNormalized {
|
|
||||||
davResourceId String
|
|
||||||
davResource CloudDavResource @relation(fields: [davResourceId], references: [id])
|
|
||||||
|
|
||||||
hashKey String
|
|
||||||
hashValue String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
@@id([davResourceId, hashKey])
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { AuthModule } from './auth/auth.module';
|
|
||||||
import { ConfigModule } from './config/config.module';
|
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
|
||||||
import { GraphqlModule } from './graphql/graphql.module';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
AuthModule,
|
|
||||||
ConfigModule,
|
|
||||||
PrismaModule,
|
|
||||||
GraphqlModule,
|
|
||||||
],
|
|
||||||
controllers: [],
|
|
||||||
providers: [],
|
|
||||||
})
|
|
||||||
export class AppModule {}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
|
|
||||||
@Module({})
|
|
||||||
export class AuthModule {}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { ConfigService } from './config.service';
|
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [PrismaModule],
|
|
||||||
providers: [ConfigService],
|
|
||||||
exports: [ConfigService],
|
|
||||||
})
|
|
||||||
export class ConfigModule {}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { normalizePairs } from '../utils';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
import { Config, configValidator} from './config.struct';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ConfigService {
|
|
||||||
|
|
||||||
private readonly _config: Promise<Config>;;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly prismaService: PrismaService,
|
|
||||||
) {
|
|
||||||
|
|
||||||
this._config = new Promise(async (resolve, reject) => {
|
|
||||||
|
|
||||||
const settingsHashMap = await this.prismaService.systemSetting.findMany();
|
|
||||||
const settingsObject = normalizePairs(settingsHashMap);
|
|
||||||
const { value: config, error } = configValidator.validate(settingsObject, { abortEarly: false, allowUnknown: false });
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return reject(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolve(config);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async get<K extends keyof Config>(key: K): Promise<Config[K]> {
|
|
||||||
const config = await this._config;
|
|
||||||
return config[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import * as Joi from 'joi';
|
|
||||||
|
|
||||||
import { SystemSettings } from '../enumerations';
|
|
||||||
|
|
||||||
export interface Config {
|
|
||||||
[SystemSettings.Graphql.Debug]: boolean;
|
|
||||||
[SystemSettings.Graphql.IntrospectionEnabled]: boolean;
|
|
||||||
[SystemSettings.Graphql.PlaygroundEnabled]: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const configValidator: Joi.ObjectSchema<Config> = Joi.object<Config, true>({
|
|
||||||
'graphql.debug.enabled': Joi.boolean().required(),
|
|
||||||
'graphql.introspection.enabled': Joi.boolean().required(),
|
|
||||||
'graphql.playground.enabled': Joi.boolean().required(),
|
|
||||||
});
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
export namespace Identity {
|
|
||||||
|
|
||||||
export namespace AuthDevice {
|
|
||||||
export enum Type {
|
|
||||||
Password = 'password',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum PasswordHashKey {
|
|
||||||
Expiry = 'expiry',
|
|
||||||
PasswordHashString = 'password_hash_string',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export * from './cloud-dav.enumerations';
|
|
||||||
export * from './identity.enumerations';
|
|
||||||
export * from './system-settings.enumerations';
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue