From 9004721331ab9760636933fae24001268d97a434 Mon Sep 17 00:00:00 2001 From: "DESKTOP-1R2VSQH\\Lenovo ThinkPad E490" Date: Wed, 29 May 2024 14:01:53 +0700 Subject: [PATCH] UserController --- src/controllers/UserController.ts | 249 ++++++++++++++ src/keycloak/index.ts | 542 ++++++++++++++++++++++++++++++ 2 files changed, 791 insertions(+) create mode 100644 src/controllers/UserController.ts create mode 100644 src/keycloak/index.ts diff --git a/src/controllers/UserController.ts b/src/controllers/UserController.ts new file mode 100644 index 00000000..ccfa676d --- /dev/null +++ b/src/controllers/UserController.ts @@ -0,0 +1,249 @@ +import { + Body, + Controller, + Delete, + Get, + Path, + Post, + Put, + Query, + Request, + Route, + Security, + Tags, +} from "tsoa"; +import { + addUserGroup, + addUserRoles, + createGroup, + createUser, + deleteGroup, + deleteUser, + editUser, + getGroups, + getRoles, + getUser, + getUserGroups, + getUserList, + removeUserGroup, + removeUserRoles, +} from "../keycloak"; +// import * as io from "../lib/websocket"; +// import elasticsearch from "../elasticsearch"; +// import { StorageFolder } from "../interfaces/storage-fs"; + +// if (!process.env.MINIO_BUCKET) throw Error("Default MinIO bucket must be specified."); +// if (!process.env.ELASTICSEARCH_INDEX) throw Error("Default ElasticSearch index must be specified."); + +// const DEFAULT_INDEX = process.env.ELASTICSEARCH_INDEX; + +function stripLeadingSlash(str: string) { + return str.replace(/^\//, ""); +} + +@Route("keycloak") +@Tags("Single-Sign On") +@Security("bearerAuth") +export class KeycloakController extends Controller { + @Get("user/{id}") + async getUser(@Path() id: string) { + return await getUser(id); + } + + @Post("user") + @Security("bearerAuth", ["system", "admin"]) + async createUser( + @Request() request: { user: { sub: string; preferred_username: string } }, + @Body() + body: { + username: string; + password: string; + firstName?: string; + lastName?: string; + email?: string; + }, + ) { + const userId = await createUser(body.username, body.password, { + firstName: body.firstName, + lastName: body.lastName, + email: body.email, + requiredActions: ["UPDATE_PASSWORD"], + }); + + if (typeof userId !== "string") { + throw new Error("ไม่สามารถติดต่อกับระบบจัดการผู้ใช้งานได้"); + } + + const now = new Date().toISOString(); + const folderData: any = { + pathname: stripLeadingSlash(`${body.username.trim()}/`), + path: "", + name: body.username.trim(), + hidden: false, + permissionGroup: [], + permissionUser: [], + permissionOther: { + create: false, + read: false, + update: false, + delete: false, + perm: false, + }, + favourite: false, + color: "default", + type: "folder", + owner: body.username, + ownerId: userId, + createdAt: now, + createdBy: request.user.preferred_username, + createdByUserId: request.user.sub, + updatedAt: now, + updatedBy: request.user.preferred_username, + updatedByUserId: request.user.sub, + }; + + // await elasticsearch.index({ + // index: DEFAULT_INDEX!, + // document: folderData, + // refresh: "wait_for", + // }); + + // io.getInstance()?.emit("FolderCreate", folderData); + + return userId; + } + + @Put("user/{userId}") + async editUser( + @Path() userId: string, + @Body() + body: { + username?: string; + password?: string; + firstName?: string; + lastName?: string; + email?: string; + }, + ) { + return await editUser(userId, body); + } + + @Delete("user/{userId}") + @Security("bearerAuth", ["system", "admin"]) + async deleteUser(@Path() userId: string) { + return await deleteUser(userId).then(async (v) => { + if (!v) throw new Error("ไม่สามารถติดต่อกับระบบจัดการผู้ใช้งานได้"); + // await elasticsearch.deleteByQuery({ + // index: DEFAULT_INDEX, + // query: { + // bool: { + // must: [ + // { prefix: { pathname: stripLeadingSlash(`${userId}/`) } }, + // { match: { type: "folder" } }, + // ], + // }, + // }, + // }); + // delete file that is not uploaded + // await elasticsearch.deleteByQuery({ + // index: DEFAULT_INDEX, + // query: { + // bool: { + // must: [ + // { prefix: { pathname: stripLeadingSlash(`${userId}/`) } }, + // { match: { upload: false } }, + // ], + // }, + // }, + // }); + + // io.getInstance()?.emit("FolderDelete", { pathname: userId + "/" }); + }); + } + + @Get("role") + async getRole() { + const role = await getRoles(); + if (Array.isArray(role)) + return role.filter( + (a) => + !["uma_authorization", "offline_access", "default-roles"].some((b) => a.name.includes(b)), + ); + throw new Error("Failed. Cannot get role."); + } + + @Post("{userId}/role") + async addRole(@Path() userId: string, @Body() body: { role: string[] }) { + const list = await getRoles(); + + if (!Array.isArray(list)) throw new Error("Failed. Cannot get role(s) data from the server."); + + const result = await addUserRoles( + userId, + list.filter((v) => body.role.includes(v.id)), + ); + + if (!result) throw new Error("Failed. Cannot set user's role."); + } + + @Delete("{userId}/role/{roleId}") + async deleteRole(@Path() userId: string, @Path() roleId: string) { + const list = await getRoles(); + + if (!Array.isArray(list)) throw new Error("Failed. Cannot get role(s) data from the server."); + + const result = await removeUserRoles( + userId, + list.filter((v) => roleId === v.id), + ); + if (!result) throw new Error("Failed. Cannot remove user's role."); + } + + @Get("user") + async getUserList(@Query() search = "") { + const result = await getUserList(search); + + if (Array.isArray(result)) { + return result; + } + throw new Error("Failed. Cannot get user list."); + } + + @Get("group") + async getGroup() { + const group = await getGroups(); + if (Array.isArray(group)) return group; + throw new Error("Failed. Cannot get group."); + } + + @Post("group") + async createGroup(@Body() body: { name: string }) { + const result = await createGroup(body.name); + if (!result) throw new Error("Failed. Cannot create group."); + } + + @Delete("group/{groupId}") + async deleteGroup(@Path() groupId: string) { + const result = await deleteGroup(groupId); + if (!result) throw new Error("Failed. Cannot delete group."); + } + + @Get("user/{userId}/group") + async getUserGroup(@Path() userId: string) { + const result = await getUserGroups(userId); + if (!result) throw new Error("Failed. Cannot list group to user."); + return result; + } + + @Post("user/{userId}/group/{groupId}") + async addUserGroup(@Path() userId: string, @Path() groupId: string) { + const result = await addUserGroup(userId, groupId); + if (!result) throw new Error("Failed. Cannot assign group to user."); + } + + @Delete("user/{userId}/group/{groupId}") + async removeUserGroup(@Path() userId: string, @Path() groupId: string) { + const result = await removeUserGroup(userId, groupId); + if (!result) throw new Error("Failed. Cannot remove group to user."); + } +} diff --git a/src/keycloak/index.ts b/src/keycloak/index.ts new file mode 100644 index 00000000..da953e9f --- /dev/null +++ b/src/keycloak/index.ts @@ -0,0 +1,542 @@ +import { DecodedJwt, createDecoder } from "fast-jwt"; + +const KC_URL = process.env.KC_URL; +const KC_REALM = process.env.KC_REALM; +const KC_CLIENT_ID = process.env.KC_SERVICE_ACCOUNT_CLIENT_ID; +const KC_SECRET = process.env.KC_SERVICE_ACCOUNT_SECRET; + +console.log(process.env.KC_URL); + +let token: string | null = null; +let decoded: DecodedJwt | null = null; + +const jwtDecode = createDecoder({ complete: true }); + +/** + * Check if token is expired or will expire in 30 seconds + * @returns true if expire or can't get exp, false otherwise + */ +export function isTokenExpired(token: string, beforeExpire: number = 30) { + decoded = jwtDecode(token); + + if (decoded && decoded.payload.exp) { + return Date.now() / 1000 >= decoded.payload.exp - beforeExpire; + } + + return true; +} + +/** + * Get token from keycloak if needed + */ +export async function getToken() { + if (!KC_CLIENT_ID || !KC_SECRET) { + throw new Error("KC_CLIENT_ID and KC_SECRET are required to used this feature."); + } + + if (token && !isTokenExpired(token)) return token; + + const body = new URLSearchParams(); + + body.append("client_id", KC_CLIENT_ID); + body.append("client_secret", KC_SECRET); + body.append("grant_type", "client_credentials"); + + const res = await fetch(`${KC_URL}/realms/${KC_REALM}/protocol/openid-connect/token`, { + method: "POST", + body: body, + }).catch((e) => console.error(e)); + + if (!res) { + throw new Error("Cannot get token from keycloak."); + } + + const data = (await res.json()) as any; + + if (data && data.access_token) { + token = data.access_token; + } + + return token; +} + +/** + * Create keycloak user by given username and password with roles + * + * Client must have permission to manage realm's user + * + * @returns user uuid or true if success, false otherwise. + */ +export async function createUser(username: string, password: string, opts?: Record) { + const res = await fetch(`${KC_URL}/admin/realms/${KC_REALM}/users`, { + // prettier-ignore + headers: { + "authorization": `Bearer ${await getToken()}`, + "content-type": `application/json`, + }, + method: "POST", + body: JSON.stringify({ + enabled: true, + credentials: [{ type: "password", value: password }], + username, + ...opts, + }), + }).catch((e) => console.log("Keycloak Error: ", e)); + + if (!res) return false; + if (!res.ok) { + return Boolean(console.error("Keycloak Error Response: ", await res.json())); + } + + const path = res.headers.get("Location"); + const id = path?.split("/").at(-1); + return id || true; +} + +/** + * Get keycloak user by uuid + * + * Client must have permission to manage realm's user + * + * @returns user if success, false otherwise. + */ +export async function getUser(userId: string) { + const res = await fetch(`${KC_URL}/admin/realms/${KC_REALM}/users/${userId}`, { + // prettier-ignore + headers: { + "authorization": `Bearer ${await getToken()}`, + "content-type": `application/json`, + }, + }).catch((e) => console.log("Keycloak Error: ", e)); + if (!res) return false; + if (!res.ok) { + return Boolean(console.error("Keycloak Error Response: ", await res.json())); + } + return await res.json(); +} + +/** + * Get keycloak user list + * + * Client must have permission to manage realm's user + * + * @returns user list if success, false otherwise. + */ +export async function getUserList(search = "") { + const res = await fetch( + `${KC_URL}/admin/realms/${KC_REALM}/users`.concat(!!search ? `?search=${search}` : ""), + { + // prettier-ignore + headers: { + "authorization": `Bearer ${await getToken()}`, + "content-type": `application/json`, + }, + }, + ).catch((e) => console.log("Keycloak Error: ", e)); + + if (!res) return false; + if (!res.ok) { + return Boolean(console.error("Keycloak Error Response: ", await res.json())); + } + + console.log(res.json); + + return ((await res.json()) as any[]).map((v: Record) => ({ + id: v.id, + username: v.username, + firstName: v.firstName, + lastName: v.lastName, + email: v.email, + })); +} + +/** + * Update keycloak user by uuid + * + * Client must have permission to manage realm's user + * + * @returns user uuid or true if success, false otherwise. + */ +export async function editUser(userId: string, opts: Record) { + const { password, ...rest } = opts; + + const res = await fetch(`${KC_URL}/admin/realms/${KC_REALM}/users/${userId}`, { + // prettier-ignore + headers: { + "authorization": `Bearer ${await getToken()}`, + "content-type": `application/json`, + }, + method: "PUT", + body: JSON.stringify({ + enabled: true, + credentials: (password && [{ type: "password", value: opts?.password }]) || undefined, + ...rest, + }), + }).catch((e) => console.log("Keycloak Error: ", e)); + + if (!res) return false; + if (!res.ok) { + return Boolean(console.error("Keycloak Error Response: ", await res.json())); + } + + const path = res.headers.get("Location"); + const id = path?.split("/").at(-1); + return id || true; +} + +/** + * Delete keycloak user by uuid + * + * Client must have permission to manage realm's user + * + * @returns user true if success, false otherwise. + */ +export async function deleteUser(userId: string) { + const res = await fetch(`${KC_URL}/admin/realms/${KC_REALM}/users/${userId}`, { + // prettier-ignore + headers: { + "authorization": `Bearer ${await getToken()}`, + "content-type": `application/json`, + }, + method: "DELETE", + }).catch((e) => console.log("Keycloak Error: ", e)); + + if (!res) return false; + if (!res.ok) { + return Boolean(console.error("Keycloak Error Response: ", await res.json())); + } + return true; +} + +/** + * Get roles list or specific role data + * + * Client must have permission to get realms roles + * + * @returns role's info (array if not specify name) if success, null if not found, false otherwise. + */ +export async function getRoles(name?: string) { + const res = await fetch( + `${KC_URL}/admin/realms/${KC_REALM}/roles`.concat((name && `/${name}`) || ""), + { + // prettier-ignore + headers: { + "authorization": `Bearer ${await getToken()}`, + }, + }, + ).catch((e) => console.log(e)); + + if (!res) return false; + if (!res.ok && res.status !== 404) { + return Boolean(console.error("Keycloak Error Response: ", await res.json())); + } + + if (res.status === 404) { + return null; + } + + const data = (await res.json()) as any; + + if (Array.isArray(data)) { + return data.map((v: Record) => ({ id: v.id, name: v.name })); + } + + return { + id: data.id, + name: data.name, + }; +} + +/** + * Get roles list of user + * + * Client must have permission to get realms roles + * + * @returns role's info (array if not specify name) if success, null if not found, false otherwise. + */ +export async function getUserRoles(userId: string) { + const res = await fetch( + `${KC_URL}/admin/realms/${KC_REALM}/users/${userId}/role-mappings/realm`, + { + // prettier-ignore + headers: { + "authorization": `Bearer ${await getToken()}`, + }, + }, + ).catch((e) => console.log(e)); + + if (!res) return false; + if (!res.ok && res.status !== 404) { + return Boolean(console.error("Keycloak Error Response: ", await res.json())); + } + + if (res.status === 404) { + return null; + } + + const data = (await res.json()) as any; + + if (Array.isArray(data)) { + return data.map((v: Record) => ({ id: v.id, name: v.name })); + } + + return { + id: data.id, + name: data.name, + }; +} + +/** + * Assign role to user + * + * Client must have permission to manage realm's user roles + * + * @returns true if success, false otherwise. + */ +export async function addUserRoles(userId: string, roles: { id: string; name: string }[]) { + const res = await fetch( + `${KC_URL}/admin/realms/${KC_REALM}/users/${userId}/role-mappings/realm`, + { + // prettier-ignore + headers: { + "authorization": `Bearer ${await getToken()}`, + "content-type": `application/json`, + }, + method: "POST", + body: JSON.stringify(roles), + }, + ).catch((e) => console.log(e)); + + if (!res) return false; + if (!res.ok) { + return Boolean(console.error("Keycloak Error Response: ", await res.json())); + } + return true; +} + +/** + * Remove role from user + * + * Client must have permission to manage realm's user roles + * + * @returns true if success, false otherwise. + */ +export async function removeUserRoles(userId: string, roles: { id: string; name: string }[]) { + const res = await fetch( + `${KC_URL}/admin/realms/${KC_REALM}/users/${userId}/role-mappings/realm`, + { + // prettier-ignore + headers: { + "authorization": `Bearer ${await getToken()}`, + "content-type": `application/json`, + }, + method: "DELETE", + body: JSON.stringify(roles), + }, + ).catch((e) => console.log(e)); + + if (!res) return false; + if (!res.ok) { + return Boolean(console.error("Keycloak Error Response: ", await res.json())); + } + return true; +} + +/** + * Get group list or specific group data + * + * Client must have permission to manage realms group + * + * @returns group's info (array if not specify name) if success, null if not found, false otherwise. + */ +export async function getGroups(id?: string) { + const res = await fetch( + `${KC_URL}/admin/realms/${KC_REALM}/groups`.concat((id && `/${id}`) || ""), + { + // prettier-ignore + headers: { + "authorization": `Bearer ${await getToken()}`, + }, + }, + ).catch((e) => console.log(e)); + + if (!res) return false; + if (!res.ok && res.status !== 404) { + return Boolean(console.error("Keycloak Error Response: ", await res.json())); + } + + if (res.status === 404) { + return null; + } + + const data = (await res.json()) as any; + + if (Array.isArray(data)) { + return data.map((v: Record) => ({ id: v.id, name: v.name })); + } + + return { + id: data.id, + name: data.name, + }; +} + +/** + * Create group + * + * Client must have permission to manage realms group + * + * @returns true if success, false otherwise. + */ +export async function createGroup(name: string) { + const res = await fetch(`${KC_URL}/admin/realms/${KC_REALM}/groups`, { + // prettier-ignore + headers: { + "authorization": `Bearer ${await getToken()}`, + "content-type": `application/json`, + }, + method: "POST", + body: JSON.stringify({ name }), + }).catch((e) => console.log(e)); + + if (!res) return false; + if (!res.ok && res.status !== 404) { + return Boolean(console.error("Keycloak Error Response: ", await res.json())); + } + + return true; +} + +/** + * Edit group + * + * Client must have permission to manage realms group + * + * @returns true if success, false otherwise. + */ +export async function editGroup(id: string, name: string) { + const res = await fetch(`${KC_URL}/admin/realms/${KC_REALM}/groups/${id}`, { + // prettier-ignore + headers: { + "authorization": `Bearer ${await getToken()}`, + "content-type": `application/json`, + }, + method: "PUT", + body: JSON.stringify({ name }), + }).catch((e) => console.log(e)); + + if (!res) return false; + if (!res.ok && res.status !== 404) { + return Boolean(console.error("Keycloak Error Response: ", await res.json())); + } + + return true; +} + +/** + * Delete group + * + * Client must have permission to manage realms group + * + * @returns true if success, false otherwise. + */ +export async function deleteGroup(id: string) { + const res = await fetch(`${KC_URL}/admin/realms/${KC_REALM}/groups/${id}`, { + // prettier-ignore + headers: { + "authorization": `Bearer ${await getToken()}`, + }, + method: "DELETE", + }).catch((e) => console.log(e)); + + if (!res) return false; + if (!res.ok && res.status !== 404) { + return Boolean(console.error("Keycloak Error Response: ", await res.json())); + } + + return true; +} + +/** + * Get group list or specific group data + * + * Client must have permission to manage realms group + * + * @returns group's info (array if not specify name) if success, null if not found, false otherwise. + */ +export async function getUserGroups(userId?: string) { + const res = await fetch(`${KC_URL}/admin/realms/${KC_REALM}/users/${userId}/groups`, { + // prettier-ignore + headers: { + "authorization": `Bearer ${await getToken()}`, + }, + }).catch((e) => console.log(e)); + + if (!res) return false; + if (!res.ok && res.status !== 404) { + return Boolean(console.error("Keycloak Error Response: ", await res.json())); + } + + if (res.status === 404) { + return null; + } + + const data = (await res.json()) as any; + + if (Array.isArray(data)) { + return data.map((v: Record) => ({ id: v.id, name: v.name })); + } + + return { + id: data.id, + name: data.name, + }; +} + +/** + * Add group to user + * + * Client must have permission to manage user group + * + * @returns true if success, false otherwise. + */ +export async function addUserGroup(userId: string, groupId: string) { + const res = await fetch(`${KC_URL}/admin/realms/${KC_REALM}/users/${userId}/groups/${groupId}`, { + // prettier-ignore + headers: { + "authorization": `Bearer ${await getToken()}`, + }, + method: "PUT", + }).catch((e) => console.log(e)); + + if (!res) return false; + if (!res.ok && res.status !== 404) { + return Boolean(console.error("Keycloak Error Response: ", await res.json())); + } + + return true; +} + +/** + * Delete group from user + * + * Client must have permission to manage user group + * + * @returns true if success, false otherwise. + */ +export async function removeUserGroup(userId: string, groupId: string) { + const res = await fetch(`${KC_URL}/admin/realms/${KC_REALM}/users/${userId}/groups/${groupId}`, { + // prettier-ignore + headers: { + "authorization": `Bearer ${await getToken()}`, + }, + method: "PUT", + }).catch((e) => console.log(e)); + + if (!res) return false; + if (!res.ok && res.status !== 404) { + return Boolean(console.error("Keycloak Error Response: ", await res.json())); + } + + return true; +}