api keycloak setup
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"arrowParens": "avoid",
|
||||
"bracketSameLine": true,
|
||||
"semi": true
|
||||
}
|
||||
@@ -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<User, "email" | "name" | "phoneNumbers">;
|
||||
|
||||
@Route('health')
|
||||
@Route("health")
|
||||
export class HealthCheckController extends Controller {
|
||||
@Get('ok')
|
||||
@SuccessResponse('200', 'OK')
|
||||
public async getOk(
|
||||
): Promise<string> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Member[]> {
|
||||
// return await this.membersService.getMembers();
|
||||
// }
|
||||
}
|
||||
@@ -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<User[]> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
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());
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Injectable } from "./injectable-decorator.js";
|
||||
export { inject } from "./injector.js";
|
||||
@@ -0,0 +1,23 @@
|
||||
import { type Token } from "./types.js";
|
||||
|
||||
const INJECTABLE_META = Symbol("injectable");
|
||||
const INJECTABLE_REGISTRY = new Set<Token>();
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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<T>(token: Token<T>): T {
|
||||
if (!currentInjector) {
|
||||
throw new Error("inject() called outside of injection context");
|
||||
}
|
||||
return currentInjector.get(token);
|
||||
}
|
||||
|
||||
export class Injector {
|
||||
private instances = new Map<Token, any>();
|
||||
private records = new Map<Token, Provider>();
|
||||
|
||||
constructor(providers: Provider[]) {
|
||||
providers.forEach(p => this.records.set(p.provide, p));
|
||||
}
|
||||
|
||||
get<T>(token: Token<T>): 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<T>(token: Token<T>): 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);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export type Token<T = any> = new (...args: any[]) => T;
|
||||
export interface Provider<T = any> {
|
||||
provide: Token<T>;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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<any> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/// Sync to Keycloak realm roles
|
||||
export enum Claim {
|
||||
MemberAdmin = "memberadmin",
|
||||
UserAdmin = "useradmin",
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export enum Topic {
|
||||
test = "test",
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Claim } from "./claim";
|
||||
|
||||
export interface User {
|
||||
readonly id?: string;
|
||||
readonly username: string;
|
||||
readonly roles: Claim[];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./keycloak-admin.js";
|
||||
@@ -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<KeycloakAdminClient>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<User[]> {
|
||||
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<RoleRepresentation> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./keycloak-user.js";
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Injectable } from "../infrastructure/di/index.js";
|
||||
|
||||
@Injectable()
|
||||
export class MembersService {
|
||||
// public async getMembers(): Promise<Member[]> {}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from "../infrastructure/di/index.js";
|
||||
|
||||
@Injectable()
|
||||
export class MessagingService {
|
||||
public async sendTestMessage(): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
if (value) {
|
||||
return await this.keycloakAdmin.addRoleToUser(uid, claim);
|
||||
} else {
|
||||
return await this.keycloakAdmin.removeRoleFromUser(uid, claim);
|
||||
}
|
||||
}
|
||||
|
||||
public async getUsers(): Promise<User[]> {
|
||||
return await this.keycloakAdmin.getUsers();
|
||||
}
|
||||
|
||||
public async setSubscriptions(token: string, topics: Topic[]) {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@
|
||||
"strictPropertyInitialization": true,
|
||||
"noImplicitThis": true,
|
||||
"alwaysStrict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"files": ["./src/index.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user