From d11403a07314afe752f084514d45f27e74314b92 Mon Sep 17 00:00:00 2001 From: Evert Prants Date: Sun, 2 Jun 2024 12:42:45 +0300 Subject: [PATCH] oauth2 client adminning --- .../components/admin/AdminClientCard.svelte | 101 +++++ src/lib/components/avatar/AvatarModal.svelte | 3 +- src/lib/components/form/FormControl.svelte | 4 + src/lib/i18n/en/admin.json | 83 +++- src/lib/i18n/en/common.json | 3 +- src/lib/i18n/index.ts | 3 +- src/lib/server/oauth2/model/client.ts | 184 +++++++- src/lib/server/upload.ts | 76 +++- src/lib/server/users/index.ts | 9 +- src/lib/validators.ts | 1 + .../api/avatar/{[slug] => [uuid]}/+server.ts | 5 +- .../api/avatar/client/[uuid]/+server.ts | 23 + src/routes/api/user/+server.ts | 6 +- src/routes/oauth2/authorize/+page.svelte | 3 +- src/routes/ssoadmin/oauth2/+page.server.ts | 44 ++ src/routes/ssoadmin/oauth2/+page.svelte | 33 ++ .../ssoadmin/oauth2/[uuid]/+page.server.ts | 402 ++++++++++++++++++ .../ssoadmin/oauth2/[uuid]/+page.svelte | 333 +++++++++++++++ .../users/{[slug] => [uuid]}/+page.server.ts | 11 +- .../users/{[slug] => [uuid]}/+page.svelte | 0 20 files changed, 1295 insertions(+), 32 deletions(-) create mode 100644 src/lib/components/admin/AdminClientCard.svelte rename src/routes/api/avatar/{[slug] => [uuid]}/+server.ts (80%) create mode 100644 src/routes/api/avatar/client/[uuid]/+server.ts create mode 100644 src/routes/ssoadmin/oauth2/+page.server.ts create mode 100644 src/routes/ssoadmin/oauth2/+page.svelte create mode 100644 src/routes/ssoadmin/oauth2/[uuid]/+page.server.ts create mode 100644 src/routes/ssoadmin/oauth2/[uuid]/+page.svelte rename src/routes/ssoadmin/users/{[slug] => [uuid]}/+page.server.ts (92%) rename src/routes/ssoadmin/users/{[slug] => [uuid]}/+page.svelte (100%) diff --git a/src/lib/components/admin/AdminClientCard.svelte b/src/lib/components/admin/AdminClientCard.svelte new file mode 100644 index 0000000..01050f8 --- /dev/null +++ b/src/lib/components/admin/AdminClientCard.svelte @@ -0,0 +1,101 @@ + + +
+
+ {client.title} +
+ +
+

{client.title}

+ {client.description} + +
+
{$t('admin.oauth2.clientId')}
+
{client.client_id}
+ +
{$t('admin.oauth2.scopes')}
+
{client.scope}
+ +
{$t('admin.oauth2.grants')}
+
{client.grants}
+ +
{$t('admin.oauth2.activated')}
+
{$t(`common.bool.${Boolean(client.activated)}`)}
+ +
{$t('admin.oauth2.verified')}
+
{$t(`common.bool.${Boolean(client.verified)}`)}
+ +
{$t('admin.oauth2.created')}
+
{formatDate(client.created_at)}
+ +
{$t('admin.oauth2.owner')}
+
+ {client.ownerInfo?.uuid} ({client.ownerInfo?.name}) + {#if client.isOwner} - {$t('admin.oauth2.ownerMe')}{/if} +
+ +
{$t('admin.oauth2.urls.title')}
+
+ {#each client.urls as url} + {$t(`admin.oauth2.urls.types.${url.type}`)} <{url.url}> + {/each} +
+
+
+
+ + diff --git a/src/lib/components/avatar/AvatarModal.svelte b/src/lib/components/avatar/AvatarModal.svelte index 6a87d52..f5ed925 100644 --- a/src/lib/components/avatar/AvatarModal.svelte +++ b/src/lib/components/avatar/AvatarModal.svelte @@ -10,6 +10,7 @@ import { allowedImages } from '$lib/constants'; export let show: Writable; + export let url: string = '/account'; let cropper: Cropper; let image: HTMLImageElement; @@ -75,7 +76,7 @@ const data = new FormData(); data.append('file', resultBlob); - await fetch(`/account?/avatar`, { + await fetch(`${url}?/avatar`, { method: 'POST', body: data, credentials: 'include', diff --git a/src/lib/components/form/FormControl.svelte b/src/lib/components/form/FormControl.svelte index fa4943c..5653294 100644 --- a/src/lib/components/form/FormControl.svelte +++ b/src/lib/components/form/FormControl.svelte @@ -3,6 +3,8 @@ diff --git a/src/routes/ssoadmin/oauth2/[uuid]/+page.server.ts b/src/routes/ssoadmin/oauth2/[uuid]/+page.server.ts new file mode 100644 index 0000000..38d17a3 --- /dev/null +++ b/src/routes/ssoadmin/oauth2/[uuid]/+page.server.ts @@ -0,0 +1,402 @@ +import { AdminUtils } from '$lib/server/admin-utils'; +import { Changesets } from '$lib/server/changesets.js'; +import { CryptoUtils } from '$lib/server/crypto-utils.js'; +import type { OAuth2Client, User } from '$lib/server/drizzle'; +import { OAuth2ClientURLType, OAuth2Clients } from '$lib/server/oauth2'; +import { Uploads } from '$lib/server/upload.js'; +import { Users } from '$lib/server/users'; +import { UsersAdmin } from '$lib/server/users/admin'; +import { hasPrivileges } from '$lib/utils'; +import { privilegeRegex } from '$lib/validators.js'; +import { error, fail, redirect } from '@sveltejs/kit'; + +interface AddUrlRequest { + type: OAuth2ClientURLType; + url: string; +} + +interface UpdateRequest { + title: string; + description: string; + activated?: string; + verified?: string; +} + +interface AddPrivilegeRequest { + name: string; +} + +export const actions = { + update: async ({ locals, request, params: { uuid } }) => { + const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [ + ['admin:oauth2', 'self:oauth2'] + ]); + + const { + list: [details] + } = await OAuth2Clients.getClientByAdminUser(currentUser as User, { + clientId: uuid, + listAll: false, + omitSecret: false + }); + + if (!details) { + return error(404, 'Client not found'); + } + + const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']); + + const body = await request.formData(); + const { title, description, activated, verified } = Changesets.take( + ['title', 'description', 'activated', 'verified'], + body + ); + + if (!!verified && !fullPrivileges) { + return fail(403, { errors: ['forbidden'] }); + } + + const actuallyVerified = fullPrivileges ? Number(!!verified) : undefined; + const actuallyActivated = Number(!!activated); + + if (title && (title.length < 3 || title.length > 32)) { + return fail(403, { errors: ['invalidTitle'] }); + } + + if (description && description.length > 1000) { + return fail(403, { errors: ['invalidDescription'] }); + } + + await OAuth2Clients.update(details as OAuth2Client, { + title, + description, + verified: actuallyVerified, + activated: actuallyActivated + }); + + return { errors: [] }; + }, + delete: async ({ locals, params: { uuid } }) => { + const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [ + ['admin:oauth2', 'self:oauth2'] + ]); + + const { + list: [details] + } = await OAuth2Clients.getClientByAdminUser(currentUser as User, { + clientId: uuid, + listAll: false, + omitSecret: false + }); + + if (!details) { + return error(404, 'Client not found'); + } + + if (details.activated === 1) { + return fail(400, { errors: ['deleteActivated'] }); + } + + const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']); + if (details.ownerId !== currentUser.id && !fullPrivileges) { + return fail(403, { errors: ['forbidden'] }); + } + + await OAuth2Clients.deleteClient(details as OAuth2Client); + + return redirect(303, '/ssoadmin/oauth2'); + }, + regenerate: async ({ locals, params: { uuid } }) => { + const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [ + ['admin:oauth2', 'self:oauth2'] + ]); + + const { + list: [details] + } = await OAuth2Clients.getClientByAdminUser(currentUser as User, { + clientId: uuid, + listAll: false, + omitSecret: false + }); + + if (!details) { + return error(404, 'Client not found'); + } + + const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']); + if (!fullPrivileges && !details.isOwner) { + return fail(403, { errors: ['forbidden'] }); + } + + await OAuth2Clients.update(details as OAuth2Client, { + client_secret: CryptoUtils.generateSecret() + }); + + return { errors: [] }; + }, + removeUrl: async ({ locals, url, params: { uuid } }) => { + const { currentUser } = await UsersAdmin.getActionUser(locals, [ + ['admin:oauth2', 'self:oauth2'] + ]); + + const { + list: [details] + } = await OAuth2Clients.getClientByAdminUser(currentUser as User, { + clientId: uuid, + listAll: false, + omitSecret: false + }); + + if (!details) { + return error(404, 'Client not found'); + } + + const id = Number(url.searchParams.get('id')); + if (isNaN(id)) { + return fail(400, { errors: ['invalidUrlId'] }); + } + + await OAuth2Clients.deleteUrl(details as OAuth2Client, id); + + return { errors: [] }; + }, + addUrl: async ({ locals, request, params: { uuid } }) => { + const { currentUser } = await UsersAdmin.getActionUser(locals, [ + ['admin:oauth2', 'self:oauth2'] + ]); + + const { + list: [details] + } = await OAuth2Clients.getClientByAdminUser(currentUser as User, { + clientId: uuid, + listAll: false, + omitSecret: false + }); + + if (!details) { + return error(404, 'Client not found'); + } + + const body = await request.formData(); + const { type, url } = Changesets.take(['type', 'url'], body); + if (!type || !OAuth2Clients.availableUrlTypes.includes(type)) { + return fail(400, { errors: ['invalidUrlType'] }); + } + + if (!url) { + return fail(400, { errors: ['invalidUrl'] }); + } + + await OAuth2Clients.addUrl(details as OAuth2Client, type, url); + + return { errors: [] }; + }, + removePrivilege: async ({ locals, url, params: { uuid } }) => { + const { currentUser } = await UsersAdmin.getActionUser(locals, [ + ['admin:oauth2', 'self:oauth2'] + ]); + + const { + list: [details] + } = await OAuth2Clients.getClientByAdminUser(currentUser as User, { + clientId: uuid, + listAll: false, + omitSecret: false + }); + + if (!details) { + return error(404, 'Client not found'); + } + + const id = Number(url.searchParams.get('id')); + if (isNaN(id)) { + return fail(400, { errors: ['invalidPrivilegeId'] }); + } + + await OAuth2Clients.deletePrivilege(details as OAuth2Client, id); + + return { errors: [] }; + }, + addPrivilege: async ({ locals, request, params: { uuid } }) => { + const { currentUser } = await UsersAdmin.getActionUser(locals, [ + ['admin:oauth2', 'self:oauth2'] + ]); + + const { + list: [details] + } = await OAuth2Clients.getClientByAdminUser(currentUser as User, { + clientId: uuid, + listAll: false, + omitSecret: false + }); + + if (!details) { + return error(404, 'Client not found'); + } + + const body = await request.formData(); + const { name } = Changesets.take(['name'], body); + + if (!name || !privilegeRegex.test(name)) { + return fail(400, { errors: ['invalidPrivilege'] }); + } + + await OAuth2Clients.addPrivilege(details as OAuth2Client, name); + + return { errors: [] }; + }, + grants: async ({ locals, request, params: { uuid } }) => { + const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [ + ['admin:oauth2', 'self:oauth2'] + ]); + + const { + list: [details] + } = await OAuth2Clients.getClientByAdminUser(currentUser as User, { + clientId: uuid, + listAll: false, + omitSecret: false + }); + + if (!details) { + return error(404, 'Client not found'); + } + + const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']); + const allowedGrants = fullPrivileges + ? OAuth2Clients.availableGrantTypes + : OAuth2Clients.userSetGrants; + + const body = await request.formData(); + const values = Array.from(body.keys()); + values.unshift('authorization_code'); + const deduplicatedAllowedGrants = values.filter( + (value, index, array) => allowedGrants.includes(value) && array.indexOf(value) === index + ); + + await OAuth2Clients.update(details as OAuth2Client, { + grants: deduplicatedAllowedGrants.join(' ') + }); + + return { errors: [] }; + }, + scopes: async ({ locals, request, params: { uuid } }) => { + const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [ + ['admin:oauth2', 'self:oauth2'] + ]); + + const { + list: [details] + } = await OAuth2Clients.getClientByAdminUser(currentUser as User, { + clientId: uuid, + listAll: false, + omitSecret: false + }); + + if (!details) { + return error(404, 'Client not found'); + } + + const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']); + const allowedScopes = fullPrivileges + ? OAuth2Clients.availableScopes + : OAuth2Clients.userSetScopes; + + const body = await request.formData(); + const values = Array.from(body.keys()); + values.unshift('profile'); + const deduplicatedAllowedScopes = values.filter( + (value, index, array) => allowedScopes.includes(value) && array.indexOf(value) === index + ); + + await OAuth2Clients.update(details as OAuth2Client, { + scope: OAuth2Clients.joinScope(deduplicatedAllowedScopes) + }); + + return { errors: [] }; + }, + avatar: async ({ request, locals, params: { uuid } }) => { + const { currentUser } = await UsersAdmin.getActionUser(locals, [ + ['admin:oauth2', 'self:oauth2'] + ]); + + const { + list: [details] + } = await OAuth2Clients.getClientByAdminUser(currentUser as User, { + clientId: uuid, + listAll: false, + omitSecret: false + }); + + if (!details) { + return error(404, 'Client not found'); + } + + const formData = Object.fromEntries(await request.formData()); + if (!(formData.file as File)?.name || (formData.file as File).name === 'undefined') { + return fail(400, { + errors: ['noFile'] + }); + } + + const { file } = formData as { file: File }; + + await Uploads.saveClientAvatar(details as OAuth2Client, currentUser, file); + + return { errors: [] }; + }, + removeAvatar: async ({ locals, params: { uuid } }) => { + const { currentUser } = await UsersAdmin.getActionUser(locals, [ + ['admin:oauth2', 'self:oauth2'] + ]); + + const { + list: [details] + } = await OAuth2Clients.getClientByAdminUser(currentUser as User, { + clientId: uuid, + listAll: false, + omitSecret: false + }); + + if (!details) { + return error(404, 'Client not found'); + } + + await Uploads.removeClientAvatar(details as OAuth2Client); + + return { errors: [] }; + } +}; + +export const load = async ({ params: { uuid }, parent }) => { + const { user } = await parent(); + const currentUser = await Users.getBySession(user); + AdminUtils.checkPrivileges(user, [['admin:oauth2', 'self:oauth2']]); + + const fullPrivileges = hasPrivileges(user.privileges, ['admin:oauth2']); + + const { + list: [details] + } = await OAuth2Clients.getClientByAdminUser(currentUser as User, { + clientId: uuid, + listAll: false, + omitSecret: false + }); + + if (!details) { + return error(404, 'Client not found'); + } + + const privileges = await Users.getAvailablePrivileges(details.id); + + return { + availableUrls: OAuth2Clients.availableUrlTypes, + availablePrivileges: privileges, + availableGrants: fullPrivileges + ? OAuth2Clients.availableGrantTypes + : OAuth2Clients.userSetGrants, + availableScopes: fullPrivileges ? OAuth2Clients.availableScopes : OAuth2Clients.userSetScopes, + fullPrivileges, + details + }; +}; diff --git a/src/routes/ssoadmin/oauth2/[uuid]/+page.svelte b/src/routes/ssoadmin/oauth2/[uuid]/+page.svelte new file mode 100644 index 0000000..b07d8a2 --- /dev/null +++ b/src/routes/ssoadmin/oauth2/[uuid]/+page.svelte @@ -0,0 +1,333 @@ + + +

{$t('admin.oauth2.title')} / {data.details.title}

+ + + + + + +
+ +
+ {#if data.details.pictureId} +
+ +
+ {/if} +
+
+ +
+ + + + + + + + + + + + + + + + {#if secret} + + {:else} + + {/if} + + + + +