WIP
This commit is contained in:
parent
b790a70d3a
commit
2410ef056c
|
|
@ -0,0 +1,9 @@
|
|||
type AuthOauth2ClientToAuthRealmEdge {
|
||||
data: AuthRealm
|
||||
error: AuthOauth2ClientToAuthRealmError
|
||||
}
|
||||
|
||||
type AuthOauth2ClientToAuthOauth2ScopesEdge {
|
||||
data: [AuthOauth2Scope]
|
||||
error: AuthOauth2ClientToAuthOauth2ScopesError
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
enum AuthOauth2ClientTypeEnum {
|
||||
CONFIDENTIAL
|
||||
PUBLIC
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
enum AuthOauth2ClientToAuthRealmError {
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
enum AuthOauth2ClientToAuthOauth2ScopesError {
|
||||
UNKNOWN
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
type AuthRealm {
|
||||
urn: ID!
|
||||
name: String
|
||||
createdAt: String
|
||||
}
|
||||
|
||||
type AuthOauth2Client {
|
||||
urn: ID!
|
||||
clientId: String!
|
||||
clientType: AuthOauth2ClientTypeEnum!
|
||||
clientSecret: String
|
||||
|
||||
Realm: AuthOauth2ClientToAuthRealmEdge
|
||||
Scopes: AuthOauth2ClientToAuthOauth2Scopes
|
||||
}
|
||||
|
||||
type AuthOauth2Scope {
|
||||
urn: ID!
|
||||
scope: String
|
||||
}
|
||||
|
|
@ -1,3 +1,10 @@
|
|||
enum IdentityAuthDeviceTypeEnum {
|
||||
PASSWORD
|
||||
APPLICATION_PASSWORD
|
||||
}
|
||||
|
||||
enum IdentityGroupRoleEnum {
|
||||
SYSTEM_ADMIN
|
||||
REALM_ADMIN
|
||||
STANDARD
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
type IdentityUserOutput {
|
||||
error:
|
||||
data: IdentityUser
|
||||
}
|
||||
|
||||
type Query {
|
||||
identityUsers()
|
||||
identityUser(urn: String!): IdentityUserOutput!
|
||||
myUser
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
type IdentityGroup {
|
||||
urn: ID!
|
||||
isAdmin: Boolean!
|
||||
role: IdentityGroupRoleEnum!
|
||||
name: String
|
||||
|
||||
Users: IdentityGroupToIdentityUserEdge!
|
||||
|
|
|
|||
|
|
@ -11,14 +11,16 @@
|
|||
"dependencies": {
|
||||
"@apollo/server": "^4.10.2",
|
||||
"@nestjs/apollo": "^12.1.0",
|
||||
"@nestjs/cache-manager": "^2.2.2",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/graphql": "^12.1.1",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@prisma/client": "^5.12.1",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cache-manager": "^5.5.1",
|
||||
"graphql": "^16.8.1",
|
||||
"joi": "^17.12.3",
|
||||
"joi": "17.6.4",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
|
|
@ -4315,6 +4317,17 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/cache-manager": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-2.2.2.tgz",
|
||||
"integrity": "sha512-+n7rpU1QABeW2WV17Dl1vZCG3vWjJU1MaamWgZvbGxYE9EeCM0lVLfw3z7acgDTNwOy+K68xuQPoIMxD0bhjlA==",
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^9.0.0 || ^10.0.0",
|
||||
"@nestjs/core": "^9.0.0 || ^10.0.0",
|
||||
"cache-manager": "<=5",
|
||||
"rxjs": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/cli": {
|
||||
"version": "10.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.3.2.tgz",
|
||||
|
|
@ -6393,6 +6406,30 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cache-manager": {
|
||||
"version": "5.5.1",
|
||||
"resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-5.5.1.tgz",
|
||||
"integrity": "sha512-QYZFOjZTTennYdN3NNCKh+yq452+wQ4ChyL40jkEyghIgg5Ugwb4YO8ARIIF1fvTBkgDLlLTYFaxZVaPGmQ92A==",
|
||||
"dependencies": {
|
||||
"eventemitter3": "^5.0.1",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lru-cache": "^10.2.0",
|
||||
"promise-coalesce": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/cache-manager/node_modules/eventemitter3": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
||||
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="
|
||||
},
|
||||
"node_modules/cache-manager/node_modules/lru-cache": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz",
|
||||
"integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==",
|
||||
"engines": {
|
||||
"node": "14 || >=16.14"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
|
||||
|
|
@ -9868,14 +9905,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/joi": {
|
||||
"version": "17.12.3",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-17.12.3.tgz",
|
||||
"integrity": "sha512-2RRziagf555owrm9IRVtdKynOBeITiDpuZqIpgwqXShPncPKNiRQoiGsl/T8SQdq+8ugRzH2LqY67irr2y/d+g==",
|
||||
"version": "17.6.4",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-17.6.4.tgz",
|
||||
"integrity": "sha512-tPzkTJHZQjSFCc842QpdVpOZ9LI2txApboNUbW70qgnRB14Lzl+oWQOPdF2N4yqyiY14wBGe8lc7f/2hZxbGmw==",
|
||||
"dependencies": {
|
||||
"@hapi/hoek": "^9.3.0",
|
||||
"@hapi/topo": "^5.1.0",
|
||||
"@sideway/address": "^4.1.5",
|
||||
"@sideway/formula": "^3.0.1",
|
||||
"@hapi/hoek": "^9.0.0",
|
||||
"@hapi/topo": "^5.0.0",
|
||||
"@sideway/address": "^4.1.3",
|
||||
"@sideway/formula": "^3.0.0",
|
||||
"@sideway/pinpoint": "^2.0.0"
|
||||
}
|
||||
},
|
||||
|
|
@ -10137,6 +10174,11 @@
|
|||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
},
|
||||
"node_modules/lodash.clonedeep": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ=="
|
||||
},
|
||||
"node_modules/lodash.memoize": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
||||
|
|
@ -11201,6 +11243,14 @@
|
|||
"asap": "~2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/promise-coalesce": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/promise-coalesce/-/promise-coalesce-1.1.2.tgz",
|
||||
"integrity": "sha512-zLaJ9b8hnC564fnJH6NFSOGZYYdzrAJn2JUUIwzoQb32fG2QAakpDNM+CZo1km6keXkRXRM+hml1BFAPVnPkxg==",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/prompts": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
||||
|
|
|
|||
|
|
@ -25,14 +25,16 @@
|
|||
"dependencies": {
|
||||
"@apollo/server": "^4.10.2",
|
||||
"@nestjs/apollo": "^12.1.0",
|
||||
"@nestjs/cache-manager": "^2.2.2",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/graphql": "^12.1.1",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@prisma/client": "^5.12.1",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cache-manager": "^5.5.1",
|
||||
"graphql": "^16.8.1",
|
||||
"joi": "^17.12.3",
|
||||
"joi": "17.6.4",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -20,13 +20,102 @@ model SystemPostMigration {
|
|||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
//
|
||||
// Namespace: Auth
|
||||
//
|
||||
model AuthRealm {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
oauth2Clients AuthOauth2Client[]
|
||||
groups IdentityGroup[]
|
||||
profileAttributeNames IdentityProfileAttributeName[]
|
||||
roles AuthRole[]
|
||||
}
|
||||
|
||||
model AuthOauth2Client {
|
||||
id Int @id @default(autoincrement())
|
||||
|
||||
realmId Int
|
||||
realm AuthRealm @relation(fields: [realmId], references: [id])
|
||||
|
||||
clientId String
|
||||
clientSecret String?
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
//
|
||||
// Namespace: Identity
|
||||
//
|
||||
model EnumIdentityGroupRole {
|
||||
enumValue String @id
|
||||
|
||||
groups IdentityGroup[]
|
||||
}
|
||||
|
||||
model IdentityGroup {
|
||||
id Int @id @default(autoincrement())
|
||||
isAdmin Boolean @default(false)
|
||||
name String?
|
||||
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[]
|
||||
|
|
@ -35,8 +124,11 @@ model IdentityGroup {
|
|||
model IdentityGroupToIdentityUser {
|
||||
groupId Int
|
||||
group IdentityGroup @relation(fields: [groupId], references: [id])
|
||||
userId Int
|
||||
user IdentityUser @relation(fields: [userId], references: [id])
|
||||
|
||||
userId Int
|
||||
user IdentityUser @relation(fields: [userId], references: [id])
|
||||
|
||||
userIsGroupAdmin Boolean @default(false)
|
||||
|
||||
@@id([groupId, userId])
|
||||
}
|
||||
|
|
@ -52,15 +144,29 @@ model IdentityUser {
|
|||
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])
|
||||
|
||||
hashKey String
|
||||
attributeNameId Int
|
||||
attributeName IdentityProfileAttributeName @relation(fields: [attributeNameId], references: [id])
|
||||
|
||||
hashValue String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@id([userId, hashKey])
|
||||
@@id([userId, attributeNameId])
|
||||
}
|
||||
|
||||
model IdentityUserEmails {
|
||||
|
|
@ -98,7 +204,7 @@ model IdentityAuthDevice {
|
|||
|
||||
model IdentityAuthDeviceNonNormalized {
|
||||
authDeviceId Int
|
||||
davResource IdentityAuthDevice @relation(fields: [authDeviceId], references: [id])
|
||||
authDevice IdentityAuthDevice @relation(fields: [authDeviceId], references: [id])
|
||||
|
||||
hashKey String
|
||||
hashValue String
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
|
||||
import { AuthorizationServerModule } from './authorization-server/authorization-server.module';
|
||||
import { CacheModule } from './cache/cache.module';
|
||||
import { ConfigModule } from './config/config.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { GraphqlModule } from './graphql/graphql.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthDomainModule } from './auth-domain/auth-domain.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
AuthorizationServerModule,
|
||||
CacheModule,
|
||||
ConfigModule,
|
||||
PrismaModule,
|
||||
GraphqlModule,
|
||||
PrismaModule,
|
||||
AuthDomainModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [],
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
|
||||
@Module({})
|
||||
export class AuthModule {}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { OauthController } from './oauth/oauth.controller';
|
||||
import { IdentityModule } from '../identity-domain/identity.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
IdentityModule,
|
||||
],
|
||||
controllers: [OauthController]
|
||||
})
|
||||
export class AuthorizationServerModule {}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export interface DeviceHandler {}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { DeviceHandler } from './device-handler.interface';
|
||||
|
||||
@Injectable()
|
||||
export class PasswordHandler implements DeviceHandler {
|
||||
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { BadRequestException, Body, Controller, Post } from '@nestjs/common';
|
||||
import * as Joi from 'joi';
|
||||
import { LoginFirstContact, supportedDevices } from './login.interface';
|
||||
import { Identity } from '../../identity-domain/identity.interface';
|
||||
import { TokenManagementService } from '../token-management/token-management.service';
|
||||
import { IdentityAuthDeviceService } from '../../identity-domain/identity-auth-device.service';
|
||||
|
||||
@Controller({
|
||||
version: '1',
|
||||
path: 'auth/:realm',
|
||||
})
|
||||
export class LoginController {
|
||||
|
||||
constructor(
|
||||
private readonly identityAuthDeviceService: IdentityAuthDeviceService,
|
||||
private readonly tokenManagementService: TokenManagementService,
|
||||
) {}
|
||||
|
||||
@Post('login')
|
||||
async login(
|
||||
@Body() body: unknown,
|
||||
): Promise<LoginFirstContact.Response> {
|
||||
|
||||
const { value: request, error } = Joi.object<LoginFirstContact.Request, true>({
|
||||
state: Joi.string().required(),
|
||||
username: Joi.string().required(),
|
||||
}).validate(body, { allowUnknown: false, abortEarly: false });
|
||||
|
||||
if (error) {
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
|
||||
const authDevices = await this.identityAuthDeviceService.findByUsername(request.username);
|
||||
|
||||
return {
|
||||
state: request.state,
|
||||
availableDevices: authDevices.filter(dev => supportedDevices.includes(dev.deviceType as Identity.AuthDevice.Type)),
|
||||
continuation: await this.tokenManagementService.generateContinuationToken(),
|
||||
}
|
||||
}
|
||||
|
||||
@Post('login/authenticate')
|
||||
async loginAuthenticate(
|
||||
|
||||
) {
|
||||
// parseLoginRequestBody
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
async logout() {
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import { Auth, Identity } from '../../enumerations';
|
||||
import * as Joi from 'joi';
|
||||
import { declare } from '../../utils';
|
||||
|
||||
interface ILoginRequestMethod {
|
||||
device: Identity.AuthDevice.Type;
|
||||
}
|
||||
|
||||
export namespace LoginFirstContact {
|
||||
export interface Request {
|
||||
state: string;
|
||||
username: string;
|
||||
}
|
||||
export interface Response {
|
||||
state: string;
|
||||
continuation: string;
|
||||
availableDevices: Identity.AuthDevice.Type[];
|
||||
}
|
||||
}
|
||||
|
||||
export namespace LoginPassword {
|
||||
export interface Request extends ILoginRequestMethod {
|
||||
device: Identity.AuthDevice.Type.Password;
|
||||
password: string;
|
||||
state: string;
|
||||
continuation?: string;
|
||||
}
|
||||
|
||||
export type Response = Success | TwoFactorRequired | Failure | Timedout | DeviceLocked;
|
||||
|
||||
type Success = {
|
||||
status: Auth.Login.ResponseStatus.Success;
|
||||
state: string;
|
||||
}
|
||||
|
||||
type TwoFactorRequired = {
|
||||
status: Auth.Login.ResponseStatus.TwoFactorRequired;
|
||||
state: string;
|
||||
continuation: string;
|
||||
availableDevices: Identity.AuthDevice.Type[];
|
||||
}
|
||||
|
||||
type Failure = {
|
||||
status: Auth.Login.ResponseStatus.Failure;
|
||||
state: string;
|
||||
continuation: string;
|
||||
}
|
||||
|
||||
type Timedout = {
|
||||
status: Auth.Login.ResponseStatus.Timedout;
|
||||
state: string;
|
||||
}
|
||||
|
||||
type DeviceLocked = {
|
||||
status: Auth.Login.ResponseStatus.DeviceLocked;
|
||||
state: string;
|
||||
}
|
||||
}
|
||||
|
||||
type LoginMethodRequests = LoginPassword.Request;
|
||||
|
||||
export const supportedDevices = [
|
||||
Identity.AuthDevice.Type.Password,
|
||||
]
|
||||
|
||||
const standardJoiValidationOptions: Joi.ValidationOptions = {
|
||||
abortEarly: false,
|
||||
allowUnknown: false,
|
||||
}
|
||||
|
||||
const loginPasswordValidator = Joi.object<LoginPassword.Request, true>({
|
||||
device: Joi.string().required().allow(Identity.AuthDevice.Type.Password),
|
||||
password: Joi.string().required(),
|
||||
state: Joi.string().required(),
|
||||
continuation: Joi.string(),
|
||||
}).options(standardJoiValidationOptions);
|
||||
|
||||
export const parseLoginRequestBody = (body: unknown): [null, LoginMethodRequests] | [Error, null] => {
|
||||
|
||||
const { error: error1, value: simpleRequest } = Joi.object<ILoginRequestMethod, true>({
|
||||
device: Joi.string().required().allow(...supportedDevices),
|
||||
}).validate(body);
|
||||
|
||||
if (error1) {
|
||||
return [error1, null];
|
||||
}
|
||||
|
||||
const { error: error2, value: request } = declare<Joi.ValidationResult, Identity.AuthDevice.Type>()
|
||||
.when(simpleRequest.device)
|
||||
.matches(Identity.AuthDevice.Type.Password).then(() => loginPasswordValidator.validate(body))
|
||||
.resolveOrThrow();
|
||||
|
||||
if (error2) {
|
||||
return [error2, null];
|
||||
}
|
||||
|
||||
return [null, request];
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class LoginService {
|
||||
|
||||
}
|
||||
|
|
@ -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 { Auth } from '../../enumerations/auth.enumerations';
|
||||
|
||||
interface ErrorResponse {
|
||||
state?: string;
|
||||
error: Auth.Oauth2.Error;
|
||||
error_description?: string;
|
||||
error_uri?: string;
|
||||
}
|
||||
|
||||
namespace AuthorizationCode {
|
||||
interface AuthorizationRequest {
|
||||
response_type: Auth.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: Auth.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: Auth.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: Auth.Oauth2.GrantType.ClientCredentials;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
interface AccessTokenResponse {
|
||||
access_token: string;
|
||||
token_type: 'bearer';
|
||||
expires_in: number;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { CacheService } from '../../cache/cache.symbols';
|
||||
import { ConfigService } from '../../config/config.service';
|
||||
import { Identity, SystemSettings } from '../../enumerations';
|
||||
|
||||
@Injectable()
|
||||
export class TokenManagementService {
|
||||
|
||||
private readonly signingSecret: string;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {}
|
||||
|
||||
async generateContinuationToken(devicesUsed: Identity.AuthDevice.Type[]) {
|
||||
|
||||
}
|
||||
|
||||
private async signToken() {
|
||||
const signingSecret = await this.configService.get(SystemSettings.Auth.TokenManagement.SigningSecret);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Identity } from '../../enumerations';
|
||||
|
||||
export namespace TokenManagement {
|
||||
interface GenerateContinuationTokenRequest {
|
||||
deviceUsed: Identity.AuthDevice.Type;
|
||||
identityUserUrn: string;
|
||||
previousContinuationToken?: string;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { CacheModule as NestCacheModule } from '@nestjs/cache-manager';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CacheService } from './cache.symbols';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
NestCacheModule.register(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: CacheService,
|
||||
useFactory: (cache) => cache,
|
||||
inject: [CacheService],
|
||||
}
|
||||
],
|
||||
exports: [CacheService],
|
||||
})
|
||||
export class CacheModule {}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { Cache } from 'cache-manager';
|
||||
|
||||
export type CacheService = Cache;
|
||||
export const CacheService = CACHE_MANAGER;
|
||||
|
|
@ -3,13 +3,19 @@ import * as Joi from 'joi';
|
|||
import { SystemSettings } from '../enumerations';
|
||||
|
||||
export interface Config {
|
||||
[SystemSettings.Auth.Oauth2.Enabled]: boolean;
|
||||
[SystemSettings.Auth.Oauth2.EncryptionSecret]: string;
|
||||
[SystemSettings.Auth.TokenManagement.SigningSecret]: string;
|
||||
[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(),
|
||||
[SystemSettings.Auth.Oauth2.Enabled]: Joi.boolean().required(),
|
||||
[SystemSettings.Auth.Oauth2.EncryptionSecret]: Joi.string().required(),
|
||||
[SystemSettings.Auth.TokenManagement.SigningSecret]: Joi.string().required(),
|
||||
[SystemSettings.Graphql.Debug]: Joi.boolean().required(),
|
||||
[SystemSettings.Graphql.IntrospectionEnabled]: Joi.boolean().required(),
|
||||
[SystemSettings.Graphql.PlaygroundEnabled]: Joi.boolean().required(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AuthDomainService {}
|
||||
|
|
@ -6,6 +6,8 @@ import { PrismaService } from './prisma.service';
|
|||
providers: [
|
||||
PrismaService,
|
||||
],
|
||||
exports: [PrismaService],
|
||||
exports: [
|
||||
|
||||
],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
export class DomainModule {}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { Identity } from './identity.interface';
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class IdentityAuthDeviceService {
|
||||
|
||||
constructor(
|
||||
private readonly prismaService: PrismaService,
|
||||
) {}
|
||||
|
||||
async findByUsername(username: string): Promise<Identity.AuthDevice[]> {
|
||||
|
||||
const devices = await this.prismaService.identityAuthDevice.findMany({
|
||||
include: {
|
||||
hashMapPairs: true,
|
||||
},
|
||||
where: {
|
||||
user: {
|
||||
username,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return devices.map(d => Identity.AuthDevice.modelToEntity(d, d.hashMapPairs));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class IdentityService {
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { IdentityAuthDevice, IdentityAuthDeviceNonNormalized } from '@prisma/client';
|
||||
import { normalizePairs } from '../../utils';
|
||||
|
||||
export namespace Identity {
|
||||
|
||||
export interface AuthDevice {
|
||||
urn: string;
|
||||
userId: number;
|
||||
deviceType: AuthDevice.Type;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export namespace AuthDevice {
|
||||
export enum Type {
|
||||
Password = 'password',
|
||||
ApplicationPassword = 'application_password',
|
||||
}
|
||||
|
||||
export enum PasswordHashKey {
|
||||
Expiry = 'expiry',
|
||||
PasswordHashString = 'password_hash_string',
|
||||
Locked = 'locked',
|
||||
}
|
||||
|
||||
export interface Password extends AuthDevice {
|
||||
expiry: Date;
|
||||
passwordHashString: string;
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
export function modelToEntity(model: IdentityAuthDevice, hashMapPairs: IdentityAuthDeviceNonNormalized[]): AuthDevice {
|
||||
|
||||
const map = normalizePairs(hashMapPairs);
|
||||
|
||||
const entity: AuthDevice = {
|
||||
urn: `urn:identity:auth-device:${model.id}`,
|
||||
userId: model.userId,
|
||||
deviceType: model.deviceType as Type,
|
||||
createdAt: new Date(model.createdAt),
|
||||
}
|
||||
|
||||
if (model.deviceType === AuthDevice.Type.Password) {
|
||||
const passwordEntity: Password = {
|
||||
...entity,
|
||||
expiry: new Date(map[PasswordHashKey.Expiry] as string),
|
||||
passwordHashString: map[PasswordHashKey.PasswordHashString] as string,
|
||||
locked: map[PasswordHashKey.Locked] as boolean,
|
||||
}
|
||||
return passwordEntity;
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Group {
|
||||
export enum Role {
|
||||
SystemAdmin = 'SYSTEM_ADMIN',
|
||||
RealmAdmin = 'REALM_ADMIN',
|
||||
Standard = 'STANDARD',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { IdentityAuthDeviceService } from './identity-auth-device.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [
|
||||
IdentityAuthDeviceService,
|
||||
],
|
||||
exports: [
|
||||
IdentityAuthDeviceService,
|
||||
],
|
||||
})
|
||||
export class IdentityModule {}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { _00ApplicationBootstrapDataMigration } from './data-migrations';
|
||||
import { Identity } from './identity-domain/identity.interface';
|
||||
import { CloudDav } from '../enumerations';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
await this.ensureEnumIntegrity();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
|
||||
async ensureEnumIntegrity() {
|
||||
|
||||
const prisma = this;
|
||||
|
||||
const enumsRegistered: [string, string[]][] = [
|
||||
['enumIdentityGroupRole', Object.values<string>(Identity.Group.Role)],
|
||||
['enumIdentityAuthDeviceType', Object.values<string>(Identity.AuthDevice.Type)],
|
||||
['enumCloudDavResourceType', Object.values<string>(CloudDav.Resource.Type)],
|
||||
];
|
||||
|
||||
for (const [dbRunner, known] of enumsRegistered) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const result = await tx[dbRunner].findMany();
|
||||
const existing = result.map(e => e.enumValue);
|
||||
const missing = known.filter(k => !existing.includes(k));
|
||||
await tx[dbRunner].createMany({
|
||||
data: missing.map(enumValue => ({ enumValue })),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
export namespace Auth {
|
||||
|
||||
export namespace Login {
|
||||
export enum ResponseStatus {
|
||||
Success = 'success',
|
||||
TwoFactorRequired = '2fa',
|
||||
Failure = 'failure',
|
||||
Timedout = 'timedout',
|
||||
DeviceLocked = 'locked',
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 +1,3 @@
|
|||
export * from './auth.enumerations';
|
||||
export * from './cloud-dav.enumerations';
|
||||
export * from './identity.enumerations';
|
||||
export * from './system-settings.enumerations';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,23 @@
|
|||
export namespace SystemSettings {
|
||||
export namespace Auth {
|
||||
export enum Oauth2 {
|
||||
Enabled = 'auth.oauth2.enabled',
|
||||
EncryptionSecret = 'auth.oauth2.encryption_secret',
|
||||
}
|
||||
export enum TokenManagement {
|
||||
SigningSecret = 'auth.token-management.signing_secret',
|
||||
}
|
||||
}
|
||||
|
||||
export enum Graphql {
|
||||
Debug = 'graphql.debug.enabled',
|
||||
IntrospectionEnabled = 'graphql.introspection.enabled',
|
||||
PlaygroundEnabled = 'graphql.playground.enabled',
|
||||
}
|
||||
|
||||
export namespace Dav {
|
||||
export enum Contacts {
|
||||
Enabled = 'dav.contacts.enabled',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { _00ApplicationBootstrapDataMigration } from './data-migrations';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +1,4 @@
|
|||
export * from './normalize-hash-set';
|
||||
export * from './secure-string';
|
||||
export * from './secure-string';
|
||||
export * from './snake-to-camel';
|
||||
export * from './when-clause';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
// https://stackoverflow.com/questions/40710628/how-to-convert-snake-case-to-camelcase
|
||||
export const snakeToCamel = str =>
|
||||
str.toLowerCase().replace(/([-_][a-z])/g, group =>
|
||||
group
|
||||
.toUpperCase()
|
||||
.replace('-', '')
|
||||
.replace('_', '')
|
||||
);
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
type Literal = string | number;
|
||||
type Resolved<T> = T | (() => T);
|
||||
type Then<T, K extends Literal> = (result: Resolved<T>) => WhenClosure<T, K>;
|
||||
|
||||
const defaultErrorMessage = 'Runtime exception: failed to handle all scenarios of when clause';
|
||||
|
||||
class WhenClosure<T, K extends Literal> {
|
||||
|
||||
private readonly stack: {[key: Literal]: Resolved<T>} = {};
|
||||
|
||||
constructor(private readonly expression: Literal) {}
|
||||
|
||||
matches(literal: K): { then: Then<T, K> } {
|
||||
return {
|
||||
then: (result: Resolved<T>) => {
|
||||
this.stack[literal] = result;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else(result: Resolved<T>): T {
|
||||
const resolved = this.expression in this.stack ? this.stack[this.expression] : result;
|
||||
if (resolved instanceof Function) {
|
||||
return resolved();
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
resolveOrThrow(errorMessage = defaultErrorMessage): T {
|
||||
if (this.expression in this.stack) {
|
||||
const resolved = this.stack[this.expression];
|
||||
if (resolved instanceof Function) {
|
||||
return resolved();
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
export const declare = <T, K extends Literal = Literal>() => ({
|
||||
when: (expression: K) => new WhenClosure<T, K>(expression)
|
||||
});
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import * as request from 'supertest';
|
||||
import { AppModule } from './../src/app.module';
|
||||
|
||||
describe('AppController (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.expect(200)
|
||||
.expect('Hello World!');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue