diff --git a/packages/api/.prettierignore b/packages/api/.prettierignore new file mode 100644 index 0000000..e69de29 diff --git a/packages/api/.prettierrc b/packages/api/.prettierrc new file mode 100644 index 0000000..34299a5 --- /dev/null +++ b/packages/api/.prettierrc @@ -0,0 +1,6 @@ +{ + "tabWidth": 4, + "arrowParens": "avoid", + "bracketSameLine": true, + "semi": true +} diff --git a/packages/api/src/controllers/healthCheckController.ts b/packages/api/src/controllers/healthCheckController.ts index d45d21d..7240f91 100644 --- a/packages/api/src/controllers/healthCheckController.ts +++ b/packages/api/src/controllers/healthCheckController.ts @@ -1,4 +1,7 @@ -import { Controller, Get, Route, SuccessResponse } from 'tsoa'; +import { Controller, Get, Post, Route, Security, SuccessResponse } from "tsoa"; +import { inject } from "../infrastructure/di/index.js"; +import { KC_SECURITY_NAME } from "../services/keycloak/user/index.js"; +import { MessagingService } from "../services/messagingService.js"; export interface User { email: string; @@ -8,13 +11,21 @@ export interface User { export type UserCreationParams = Pick; -@Route('health') +@Route("health") export class HealthCheckController extends Controller { - @Get('ok') - @SuccessResponse('200', 'OK') - public async getOk( - ): Promise { - this.setStatus(200); - return 'Ok'; + private readonly messaging = inject(MessagingService); + + @Get("ok") + @SuccessResponse("200", "OK") + public async getOk() {} + + @Get("auth") + @Security(KC_SECURITY_NAME) + @SuccessResponse("200", "OK") + public async getAuth() {} + + @Post("testmessage") + public async sendTestMessage() { + await this.messaging.sendTestMessage(); } } diff --git a/packages/api/src/controllers/membersController.ts b/packages/api/src/controllers/membersController.ts new file mode 100644 index 0000000..d633d1c --- /dev/null +++ b/packages/api/src/controllers/membersController.ts @@ -0,0 +1,13 @@ +import { Controller, Route } from "@tsoa/runtime"; +import { inject } from "../infrastructure/di/index.js"; +import { MembersService } from "../services/membersService.js"; + +@Route("members") +export class MembersContoller extends Controller { + private readonly membersService = inject(MembersService); + // @Security(KC_SECURITY_NAME) + // @Get() + // public async getMembers(): Promise { + // return await this.membersService.getMembers(); + // } +} diff --git a/packages/api/src/controllers/usersController.ts b/packages/api/src/controllers/usersController.ts new file mode 100644 index 0000000..bdc442e --- /dev/null +++ b/packages/api/src/controllers/usersController.ts @@ -0,0 +1,47 @@ +import { + Body, + BodyProp, + Controller, + Get, + Path, + Put, + Query, + Route, + Security, +} from "@tsoa/runtime"; +import { inject } from "../infrastructure/di"; +import { Claim } from "../models/claim"; +import { Topic } from "../models/topic"; +import { User } from "../models/user"; +import { KC_SECURITY_NAME } from "../services/keycloak/user/index.js"; +import { UserService } from "../services/userService"; + +@Route("users") +export class UsersController extends Controller { + private readonly userService = inject(UserService); + + @Security(KC_SECURITY_NAME, [Claim.UserAdmin]) + @Get() + public async getUsers(): Promise { + return await this.userService.getUsers(); + } + + @Security(KC_SECURITY_NAME, [Claim.UserAdmin]) + @Put("/claims/{uid}") + public async setClaims( + @Path() uid: string, + @BodyProp() claim: Claim, + @Query() value: boolean, + ): Promise { + await this.userService.setClaim(uid, claim, value); + } + + @Security(KC_SECURITY_NAME) + @Put("/subscriptions/{token}") + public async setSubscriptions( + @Body() topics: Topic[], + @Path() token: string, + ) { + await this.userService.setSubscriptions(token, topics); + } +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 0840c4d..95f8d80 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,14 +1,19 @@ -import express from 'express'; -import { RegisterRoutes } from './generated/routes.js'; +import express from "express"; +import { RegisterRoutes } from "./generated/routes.js"; +import { sessionHandler } from "./infrastructure/session.js"; +import { keycloak } from "./services/keycloak/user/index.js"; const app = express(); const port = 3000; +app.use(sessionHandler); +app.use(keycloak.middleware()); + app.use(express.urlencoded({ extended: true })); app.use(express.json()); RegisterRoutes(app); app.listen(port, () => { - console.log(`Server is running at http://localhost:${port}`); + console.log(`Server is running at http://localhost:${port}`); }); diff --git a/packages/api/src/infrastructure/database.ts b/packages/api/src/infrastructure/database.ts new file mode 100644 index 0000000..599228f --- /dev/null +++ b/packages/api/src/infrastructure/database.ts @@ -0,0 +1,16 @@ +import { Pool } from "pg"; + +const dbUser = process.env["TKD_DB_USER"]; +const dbPassword = process.env["TKD_DB_PASSWORD"]; + +if (dbUser === undefined || dbPassword === undefined) { + throw new Error("missing db configuration (user/password)"); +} + +export const DB_POOL = new Pool({ + user: dbUser, + password: dbPassword, + host: "localhost", + port: 5432, + database: "taekwondo", +}); diff --git a/packages/api/src/infrastructure/di/index.ts b/packages/api/src/infrastructure/di/index.ts new file mode 100644 index 0000000..43d79e7 --- /dev/null +++ b/packages/api/src/infrastructure/di/index.ts @@ -0,0 +1,2 @@ +export { Injectable } from "./injectable-decorator.js"; +export { inject } from "./injector.js"; diff --git a/packages/api/src/infrastructure/di/injectable-decorator.ts b/packages/api/src/infrastructure/di/injectable-decorator.ts new file mode 100644 index 0000000..523f0ee --- /dev/null +++ b/packages/api/src/infrastructure/di/injectable-decorator.ts @@ -0,0 +1,23 @@ +import { type Token } from "./types.js"; + +const INJECTABLE_META = Symbol("injectable"); +const INJECTABLE_REGISTRY = new Set(); + +export function Injectable(): ClassDecorator { + return function (target: any) { + Reflect.defineProperty(target, INJECTABLE_META, { + value: true, + writable: false, + }); + + INJECTABLE_REGISTRY.add(target); + }; +} + +export function isInjectable(targert: any) { + return !!targert[INJECTABLE_META]; +} + +export function getAllInjectables(): Token[] { + return Array.from(INJECTABLE_REGISTRY); +} diff --git a/packages/api/src/infrastructure/di/injector.ts b/packages/api/src/infrastructure/di/injector.ts new file mode 100644 index 0000000..8edd0a3 --- /dev/null +++ b/packages/api/src/infrastructure/di/injector.ts @@ -0,0 +1,56 @@ +import { getAllInjectables, isInjectable } from "./injectable-decorator.js"; +import { type Provider, type Token } from "./types.js"; + +const currentInjector: Injector = createGlobalInjector(); + +export function inject(token: Token): T { + if (!currentInjector) { + throw new Error("inject() called outside of injection context"); + } + return currentInjector.get(token); +} + +export class Injector { + private instances = new Map(); + private records = new Map(); + + constructor(providers: Provider[]) { + providers.forEach(p => this.records.set(p.provide, p)); + } + + get(token: Token): T { + if (this.instances.has(token)) { + return this.instances.get(token); + } + + const provider = this.records.get(token); + if (!provider) { + throw new Error("No provider for token"); + } + + let value = this.instantiate(token); + this.instances.set(token, value); + return value; + } + + private instantiate(token: Token): T { + return new token(); + } +} + +export function createInjector(injectables: Token[]) { + const providers: Provider[] = injectables.map(injectable => { + if (!isInjectable(injectable)) { + throw new Error(`${injectable.name} is not injectable`); + } + return { provide: injectable, useClass: injectable }; + }); + + return new Injector(providers); +} + +export function createGlobalInjector() { + const injectables = getAllInjectables(); + + return createInjector(injectables); +} diff --git a/packages/api/src/infrastructure/di/types.ts b/packages/api/src/infrastructure/di/types.ts new file mode 100644 index 0000000..7faeebd --- /dev/null +++ b/packages/api/src/infrastructure/di/types.ts @@ -0,0 +1,4 @@ +export type Token = new (...args: any[]) => T; +export interface Provider { + provide: Token; +} diff --git a/packages/api/src/infrastructure/session.ts b/packages/api/src/infrastructure/session.ts new file mode 100644 index 0000000..ea9bbbb --- /dev/null +++ b/packages/api/src/infrastructure/session.ts @@ -0,0 +1,29 @@ +import pgSession from "connect-pg-simple"; +import { type RequestHandler } from "express"; +import session from "express-session"; +import { DB_POOL } from "./database.js"; + +const cookieSecret = process.env["TKD_COOKIE_SECRET"]; + +if (cookieSecret === undefined) { + throw new Error("TKD_COOKIE_SECRET missing"); +} +const pgSessionFunc = pgSession(session); +export const sessionStore = new pgSessionFunc({ + pool: DB_POOL, + tableName: "session", +}); +const sessionConfig = { + store: sessionStore, + name: "SID", + secret: cookieSecret, + resave: false, + saveUninitialized: true, + cookie: { + maxAge: 1000 * 60 * 60 * 24 * 7, + sameSite: true, + secure: true, //TODO false for development + }, +}; + +export const sessionHandler: RequestHandler = session(sessionConfig); diff --git a/packages/api/src/middleware/authentication.ts b/packages/api/src/middleware/authentication.ts index 63c24b2..ed5fe40 100644 --- a/packages/api/src/middleware/authentication.ts +++ b/packages/api/src/middleware/authentication.ts @@ -1,10 +1,47 @@ -import * as express from 'express'; +import { type Request, type RequestHandler } from "express"; + +import { KC_SECURITY_NAME, keycloak } from "../services/keycloak/user/index.js"; + +function promisifyMiddleware( + request: Request, + middleware: RequestHandler, + response?: Response, +): Promise { + return new Promise((resolve, reject) => { + middleware(request, response || ({} as any), (result: any) => { + result instanceof Error ? reject(result) : resolve(result); + }); + }); +} + +async function doKeycloakAuth( + request: Request, + roles?: string[], + response?: Response, +): Promise { + if (roles) { + return await promisifyMiddleware( + request, + keycloak.enforcer(roles?.map(role => `realm:${role}`)), + ); + } else { + return await promisifyMiddleware(request, keycloak.protect(), response); + } +} export async function expressAuthentication( - request: express.Request, + request: Request, securityName: string, - scopes?: string[] + roles?: string[], + response?: Response, ): Promise { - console.dir({ request, securityName, scopes }); - return {}; + console.dir({ request, securityName, roles }); + switch (securityName) { + case KC_SECURITY_NAME: + return doKeycloakAuth(request, roles, response); + case undefined: + return Promise.resolve(); + default: + return Promise.reject("Unknown security name"); + } } diff --git a/packages/api/src/models/claim.ts b/packages/api/src/models/claim.ts new file mode 100644 index 0000000..94e4711 --- /dev/null +++ b/packages/api/src/models/claim.ts @@ -0,0 +1,5 @@ +/// Sync to Keycloak realm roles +export enum Claim { + MemberAdmin = "memberadmin", + UserAdmin = "useradmin", +} diff --git a/packages/api/src/models/member.ts b/packages/api/src/models/member.ts new file mode 100644 index 0000000..d7e4bac --- /dev/null +++ b/packages/api/src/models/member.ts @@ -0,0 +1,17 @@ +export interface Member { + id?: string | undefined; + vorname?: string | undefined; + nachname?: string | undefined; + geburtsdatum?: string | undefined; + geburtsort?: string | undefined; + geschlecht?: string | undefined; + passnummer?: string | undefined; + uvertrag?: boolean | undefined; + verein?: boolean | undefined; + graduierung?: string | undefined; + letztePruefung?: string | undefined; + kontakt?: string | undefined; + kuendigungzum?: string | undefined; + loeschenam?: string | undefined; + level?: string | undefined; +} diff --git a/packages/api/src/models/topic.ts b/packages/api/src/models/topic.ts new file mode 100644 index 0000000..61c8015 --- /dev/null +++ b/packages/api/src/models/topic.ts @@ -0,0 +1,3 @@ +export enum Topic { + test = "test", +} diff --git a/packages/api/src/models/user.ts b/packages/api/src/models/user.ts new file mode 100644 index 0000000..9c72263 --- /dev/null +++ b/packages/api/src/models/user.ts @@ -0,0 +1,7 @@ +import { Claim } from "./claim"; + +export interface User { + readonly id?: string; + readonly username: string; + readonly roles: Claim[]; +} diff --git a/packages/api/src/services/keycloak/admin/index.ts b/packages/api/src/services/keycloak/admin/index.ts new file mode 100644 index 0000000..580effa --- /dev/null +++ b/packages/api/src/services/keycloak/admin/index.ts @@ -0,0 +1 @@ +export * from "./keycloak-admin.js"; diff --git a/packages/api/src/services/keycloak/admin/keycloak-admin.ts b/packages/api/src/services/keycloak/admin/keycloak-admin.ts new file mode 100644 index 0000000..9954121 --- /dev/null +++ b/packages/api/src/services/keycloak/admin/keycloak-admin.ts @@ -0,0 +1,74 @@ +import RoleRepresentation from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation"; +import { Injectable } from "../../../infrastructure/di/injectable-decorator"; +import kcClientConfig from "./nodejs-api.json" with { type: "json" }; + +import KeycloakAdminClient from "@keycloak/keycloak-admin-client"; +import { Claim } from "../../../models/claim"; +import { User } from "../../../models/user"; + +@Injectable() +export class KeycloakAdmin { + private readonly kcAdminClient: Promise; + constructor() { + this.kcAdminClient = new Promise(async resolve => { + const client = new KeycloakAdminClient({ + baseUrl: kcClientConfig.baseUrl, + realmName: "taekwondo", + }); + + await client.auth({ + ...kcClientConfig, + grantType: "client_credentials", + }); + resolve(client); + }); + } + + public async addRoleToUser(uid: string, roleName: string): Promise { + const client = await this.kcAdminClient; + const role = await this.getRole(roleName); + if (!role.id || !role.name) { + throw new Error(`Role ${roleName} is missing id or name`); + } + + await client.users.addRealmRoleMappings({ + id: uid, + roles: [{ id: role.id, name: role.name }], + }); + } + + public async removeRoleFromUser(uid: string, claim: Claim): Promise { + const client = await this.kcAdminClient; + const role = await this.getRole(claim); + if (!role.id || !role.name) { + throw new Error(`Role ${claim} is missing id or name`); + } + + await client.users.delRealmRoleMappings({ + id: uid, + roles: [{ id: role.id, name: role.name }], + }); + } + + public async getUsers(): Promise { + const client = await this.kcAdminClient; + const kcUsers = await client.users.find(); + return kcUsers.map(kcUser => ({ + id: kcUser.id, + username: kcUser.username || "", + roles: (kcUser.realmRoles || []).map(role => role as Claim), + })); + } + + private async getRole(name: string): Promise { + const client = await this.kcAdminClient; + const role = await client.roles.findOneByName({ + name: name, + realm: "taekwondo", + }); + if (!role) { + throw new Error(`Role ${name} not found`); + } + return role; + } +} diff --git a/packages/api/src/services/keycloak/admin/nodejs-api.json b/packages/api/src/services/keycloak/admin/nodejs-api.json new file mode 100644 index 0000000..c4f5b2f --- /dev/null +++ b/packages/api/src/services/keycloak/admin/nodejs-api.json @@ -0,0 +1,60 @@ +{ + "clientId": "nodejs-api", + "name": "NodejsApi", + "description": "", + "rootUrl": "https://tkd-api.toni714.de", + "adminUrl": "https://tkd-api.toni714.de", + "baseUrl": "https://tkd-api.toni714.de", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "btdeoaq2mgtoa7SpCEL1phLYcw2gGhGp", + "redirectUris": ["https://tkd-api.toni714.de/*"], + "webOrigins": ["https://tkd-api.toni714.de"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "client.secret.creation.time": "1762463686", + "backchannel.logout.session.required": "true", + "standard.token.exchange.enabled": "true", + "oauth2.device.authorization.grant.enabled": "false", + "pkce.code.challenge.method": "S256", + "backchannel.logout.revoke.offline.tokens": "false", + "dpop.bound.access.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "service_account", + "web-origins", + "acr", + "roles", + "profile", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "organization", + "offline_access", + "microprofile-jwt" + ], + "access": { + "view": true, + "configure": true, + "manage": true + } +} diff --git a/packages/api/src/services/keycloak/user/index.ts b/packages/api/src/services/keycloak/user/index.ts new file mode 100644 index 0000000..e314cd5 --- /dev/null +++ b/packages/api/src/services/keycloak/user/index.ts @@ -0,0 +1 @@ +export * from "./keycloak-user.js"; diff --git a/packages/api/src/services/keycloak/user/keycloak-user.ts b/packages/api/src/services/keycloak/user/keycloak-user.ts new file mode 100644 index 0000000..078988f --- /dev/null +++ b/packages/api/src/services/keycloak/user/keycloak-user.ts @@ -0,0 +1,11 @@ +import KeycloakConnect from "keycloak-connect"; +import { sessionStore } from "../../../infrastructure/session.js"; +import keycloakConfig from "./keycloak.json" with { type: "json" }; + +export const KC_SECURITY_NAME = "bearerAuth"; + +export const keycloak = new KeycloakConnect( + { store: sessionStore }, + keycloakConfig, +); +export const kcUserConfig = keycloakConfig; diff --git a/packages/api/src/services/keycloak/user/keycloak.json b/packages/api/src/services/keycloak/user/keycloak.json new file mode 100644 index 0000000..50bae1e --- /dev/null +++ b/packages/api/src/services/keycloak/user/keycloak.json @@ -0,0 +1,10 @@ +{ + "realm": "taekwondo", + "auth-server-url": "https://keycloak.toni714.de:8443", + "ssl-required": "external", + "resource": "nodejs-api", + "credentials": { + "secret": "btdeoaq2mgtoa7SpCEL1phLYcw2gGhGp" + }, + "confidential-port": 0 +} diff --git a/packages/api/src/services/membersService.ts b/packages/api/src/services/membersService.ts new file mode 100644 index 0000000..5f2ee09 --- /dev/null +++ b/packages/api/src/services/membersService.ts @@ -0,0 +1,6 @@ +import { Injectable } from "../infrastructure/di/index.js"; + +@Injectable() +export class MembersService { + // public async getMembers(): Promise {} +} diff --git a/packages/api/src/services/messagingService.ts b/packages/api/src/services/messagingService.ts new file mode 100644 index 0000000..4297065 --- /dev/null +++ b/packages/api/src/services/messagingService.ts @@ -0,0 +1,8 @@ +import { Injectable } from "../infrastructure/di/index.js"; + +@Injectable() +export class MessagingService { + public async sendTestMessage(): Promise { + throw new Error("Method not implemented."); + } +} diff --git a/packages/api/src/services/userService.ts b/packages/api/src/services/userService.ts new file mode 100644 index 0000000..95afce5 --- /dev/null +++ b/packages/api/src/services/userService.ts @@ -0,0 +1,28 @@ +import { inject } from "../infrastructure/di/index.js"; +import { Claim } from "../models/claim"; +import { Topic } from "../models/topic.js"; +import { User } from "../models/user.js"; +import { KeycloakAdmin } from "./keycloak/admin/index.js"; + +export class UserService { + private readonly keycloakAdmin = inject(KeycloakAdmin); + public async setClaim( + uid: string, + claim: Claim, + value: boolean, + ): Promise { + if (value) { + return await this.keycloakAdmin.addRoleToUser(uid, claim); + } else { + return await this.keycloakAdmin.removeRoleFromUser(uid, claim); + } + } + + public async getUsers(): Promise { + return await this.keycloakAdmin.getUsers(); + } + + public async setSubscriptions(token: string, topics: Topic[]) { + throw new Error("Method not implemented."); + } +} diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json index d484179..e2903f9 100644 --- a/packages/api/tsconfig.json +++ b/packages/api/tsconfig.json @@ -1,60 +1,60 @@ { - // Visit https://aka.ms/tsconfig to read more about this file - "compilerOptions": { - // File Layout - "rootDir": "./src", - "outDir": "./dist", + // Visit https://aka.ms/tsconfig to read more about this file + "compilerOptions": { + // File Layout + "rootDir": "./src", + "outDir": "./dist", - // Environment Settings - // See also https://aka.ms/tsconfig/module - "module": "nodenext", - "target": "esnext", - // "types": [], - // For nodejs: - "lib": ["esnext"], - "types": ["node"], - // and npm install -D @types/node + // Environment Settings + // See also https://aka.ms/tsconfig/module + "module": "nodenext", + "target": "esnext", + // "types": [], + // For nodejs: + "lib": ["esnext"], + "types": ["node"], + // and npm install -D @types/node - // Other Outputs - "sourceMap": true, - "declaration": true, - "declarationMap": true, + // Other Outputs + "sourceMap": true, + "declaration": true, + "declarationMap": true, - // Stricter Typechecking Options - "noUncheckedIndexedAccess": true, - "exactOptionalPropertyTypes": true, + // Stricter Typechecking Options + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, - // Style Options - "noImplicitReturns": true, - "noImplicitOverride": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "noPropertyAccessFromIndexSignature": true, + // Style Options + "noImplicitReturns": true, + "noImplicitOverride": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true, - // Recommended Options - "strict": true, - "jsx": "react-jsx", - "verbatimModuleSyntax": true, - "isolatedModules": true, - "noUncheckedSideEffectImports": true, - "moduleDetection": "force", - "skipLibCheck": true, + // Recommended Options + "strict": true, + "jsx": "react-jsx", + "verbatimModuleSyntax": true, + "isolatedModules": true, + "noUncheckedSideEffectImports": true, + "moduleDetection": "force", + "skipLibCheck": true, - // Custom Options - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "useDefineForClassFields": false, - "moduleResolution": "nodenext", - "incremental": true, - "noImplicitAny": true, - "strictNullChecks": true, - "strictFunctionTypes": true, - "strictBindCallApply": true, - "strictPropertyInitialization": true, - "noImplicitThis": true, - "alwaysStrict": true, - "forceConsistentCasingInFileNames": true, - }, - "files": ["./src/index.ts"] + // Custom Options + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "useDefineForClassFields": false, + "moduleResolution": "nodenext", + "incremental": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "alwaysStrict": true, + "forceConsistentCasingInFileNames": true + }, + "files": ["./src/index.ts"] }