From d258880ac4d231dd120a9bf33d8f9c3027f6d4a5 Mon Sep 17 00:00:00 2001 From: Evert Prants Date: Sat, 1 Jun 2024 18:50:36 +0300 Subject: [PATCH] user management mostly done --- src/app.d.ts | 5 + src/lib/components/admin/AdminHeader.svelte | 6 + .../admin/AdminPrivilegesSelect.svelte | 99 +++++++++++ src/lib/components/admin/AdminSidebar.svelte | 8 + src/lib/components/admin/AdminUserCard.svelte | 81 +++++++++ src/lib/components/form/FormControl.svelte | 10 ++ src/lib/i18n/en/account.json | 1 + src/lib/i18n/en/admin.json | 17 +- src/lib/i18n/en/common.json | 4 +- src/lib/server/oauth2/model/tokens.ts | 4 + src/lib/server/users/admin.ts | 73 ++++++-- src/lib/server/users/index.ts | 50 +++++- src/lib/server/users/tokens.ts | 6 +- src/lib/server/users/totp.ts | 4 +- src/routes/ssoadmin/+layout.server.ts | 1 + src/routes/ssoadmin/+layout.svelte | 6 + src/routes/ssoadmin/users/+page.svelte | 85 +-------- .../ssoadmin/users/[slug]/+page.server.ts | 161 ++++++++++++++++++ src/routes/ssoadmin/users/[slug]/+page.svelte | 103 +++++++++++ 19 files changed, 622 insertions(+), 102 deletions(-) create mode 100644 src/lib/components/admin/AdminPrivilegesSelect.svelte create mode 100644 src/lib/components/admin/AdminUserCard.svelte create mode 100644 src/routes/ssoadmin/users/[slug]/+page.server.ts create mode 100644 src/routes/ssoadmin/users/[slug]/+page.svelte diff --git a/src/app.d.ts b/src/app.d.ts index 7cd1d1f..78fe65a 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -9,6 +9,11 @@ type SessionData = { // See https://kit.svelte.dev/docs/types#app // for information about these interfaces declare global { + type PartialK = Partial>> & + Omit extends infer O + ? { [P in keyof O]: O[P] } + : never; + namespace App { // interface Error {} diff --git a/src/lib/components/admin/AdminHeader.svelte b/src/lib/components/admin/AdminHeader.svelte index d5e9100..1870b79 100644 --- a/src/lib/components/admin/AdminHeader.svelte +++ b/src/lib/components/admin/AdminHeader.svelte @@ -29,6 +29,7 @@ padding: 4px 8px 4px 4px; background-color: #004edf; border-radius: 40px; + color: #fff; & .admin-user-avatar { width: 32px; @@ -40,5 +41,10 @@ a { text-decoration: none; + color: #fff; + + &:visited { + color: #fff; + } } diff --git a/src/lib/components/admin/AdminPrivilegesSelect.svelte b/src/lib/components/admin/AdminPrivilegesSelect.svelte new file mode 100644 index 0000000..c6d40ea --- /dev/null +++ b/src/lib/components/admin/AdminPrivilegesSelect.svelte @@ -0,0 +1,99 @@ + + + + + +
+ + + + + + + + + + + + + + + + + + +
+
+ + diff --git a/src/lib/components/admin/AdminSidebar.svelte b/src/lib/components/admin/AdminSidebar.svelte index 20bdbf8..b4a3287 100644 --- a/src/lib/components/admin/AdminSidebar.svelte +++ b/src/lib/components/admin/AdminSidebar.svelte @@ -67,6 +67,14 @@ display: block; padding: 8px 16px; text-decoration: none; + + &.active { + background-color: #c7c7c7; + } + + &:hover { + background-color: #e4e4e4; + } } } } diff --git a/src/lib/components/admin/AdminUserCard.svelte b/src/lib/components/admin/AdminUserCard.svelte new file mode 100644 index 0000000..b3fe984 --- /dev/null +++ b/src/lib/components/admin/AdminUserCard.svelte @@ -0,0 +1,81 @@ + + +
+
+ {user.display_name} +
+ + +
+ + diff --git a/src/lib/components/form/FormControl.svelte b/src/lib/components/form/FormControl.svelte index 3e0365e..fa4943c 100644 --- a/src/lib/components/form/FormControl.svelte +++ b/src/lib/components/form/FormControl.svelte @@ -55,4 +55,14 @@ margin-left: 4px; font-weight: 700; } + + .form-control:has(input[type='checkbox']) { + flex-direction: row; + gap: 1rem; + align-items: center; + } + + .form-control:has(input[type='checkbox']) > :global(label) { + margin: 0; + } diff --git a/src/lib/i18n/en/account.json b/src/lib/i18n/en/account.json index 1eb39ac..7876a6a 100644 --- a/src/lib/i18n/en/account.json +++ b/src/lib/i18n/en/account.json @@ -71,6 +71,7 @@ "title": "Two-factor authentication", "enabled": "Two-factor authentication is enabled.", "disabled": "Your account does not have two-factor authentication enabled.", + "unavailable": "Two-factor authentication is not set up.", "activated": "Two-factor authentication has been activated successfully!", "deactivated": "Two-factor authentication has been deactivated successfully.", "scan": "Scan this QR code with the authenticator app of your choice", diff --git a/src/lib/i18n/en/admin.json b/src/lib/i18n/en/admin.json index a00a54f..636b940 100644 --- a/src/lib/i18n/en/admin.json +++ b/src/lib/i18n/en/admin.json @@ -10,7 +10,22 @@ "uuid": "UUID", "email": "Email", "privileges": "Privileges", + "actions": "Account actions", "activated": "Activated", - "registered": "Registered" + "registered": "Registered", + "deactivate": "Deactivate account", + "deactivateOtp": "Remove two-factor authentication", + "deleteInfo": "Delete account information", + "deleteInfoHint": "All personalized information will be deleted. The account will remain in the database as an UUID stub. This action is irreversible.", + "passwordEmail": "Send password email", + "activationEmail": "Send activation email", + "errors": { + "invalidUuid": "Invalid user or impossible action", + "invalidEmailType": "Invalid email type", + "unauthorized": "Unauthorized changes", + "lockout": "You cannot lock yourself out!", + "invalidDisplayName": "Invalid display name", + "invalidEmail": "Invalid email address" + } } } diff --git a/src/lib/i18n/en/common.json b/src/lib/i18n/en/common.json index 8eb8e18..fd9c47f 100644 --- a/src/lib/i18n/en/common.json +++ b/src/lib/i18n/en/common.json @@ -13,5 +13,7 @@ "bool": { "true": "Yes", "false": "No" - } + }, + "available": "Available", + "current": "Current" } diff --git a/src/lib/server/oauth2/model/tokens.ts b/src/lib/server/oauth2/model/tokens.ts index 269344d..c5b36fd 100644 --- a/src/lib/server/oauth2/model/tokens.ts +++ b/src/lib/server/oauth2/model/tokens.ts @@ -99,6 +99,10 @@ export class OAuth2Tokens { ); } + static async wipeUserTokens(user: User) { + await db.delete(oauth2Token).where(eq(oauth2Token.userId, user.id)); + } + static async wipeExpiredTokens() { await db.execute(sql`DELETE FROM ${oauth2Token} WHERE ${oauth2Token.expires_at} < NOW()`); } diff --git a/src/lib/server/users/admin.ts b/src/lib/server/users/admin.ts index 33e721d..3acfcaf 100644 --- a/src/lib/server/users/admin.ts +++ b/src/lib/server/users/admin.ts @@ -8,12 +8,43 @@ import { type User } from '../drizzle'; import type { Paginated, PaginationMeta } from '$lib/types'; +import type { RequiredPrivileges } from '$lib/utils'; +import { Users } from '.'; +import { error } from '@sveltejs/kit'; +import { AdminUtils } from '../admin-utils'; export interface AdminUserListItem extends Omit { privileges: Privilege[]; } export class UsersAdmin { + static mergeUserResponse( + junkList: { + user: User; + user_privileges_privilege?: typeof userPrivilegesPrivilege.$inferSelect | null; + privilege?: Privilege | null; + }[] + ) { + return junkList.reduce((accum, dbe) => { + let user = accum.find((entry) => entry.id === dbe.user.id); + if (!user) { + user = { ...dbe.user, password: undefined, privileges: [] } as AdminUserListItem; + accum.push(user); + } + + // Individual privilege + if (dbe.user_privileges_privilege && dbe.privilege) { + if ( + !user.privileges.some((priv) => priv.id === dbe.user_privileges_privilege?.privilegeId) + ) { + user.privileges.push(dbe.privilege); + } + } + + return accum; + }, []); + } + static async getAllUsers({ filter, offset = 0, @@ -53,27 +84,35 @@ export class UsersAdmin { pageCount: Math.ceil(rowCount / limit) }; - const list = junkList.reduce((accum, dbe) => { - let user = accum.find((entry) => entry.id === dbe.user.id); - if (!user) { - user = { ...dbe.user, password: undefined, privileges: [] } as AdminUserListItem; - accum.push(user); - } - - if (dbe.user_privileges_privilege && dbe.privilege) { - if ( - !user.privileges.some((priv) => priv.id === dbe.user_privileges_privilege?.privilegeId) - ) { - user.privileges.push(dbe.privilege); - } - } - - return accum; - }, []); + const list = UsersAdmin.mergeUserResponse(junkList); return >{ list, meta }; } + + static async getUserDetails(uuid: string) { + const junkList = await db + .select() + .from(user) + .leftJoin(userPrivilegesPrivilege, eq(userPrivilegesPrivilege.userId, user.id)) + .leftJoin(privilege, eq(userPrivilegesPrivilege.privilegeId, privilege.id)) + .where(eq(user.uuid, uuid)); + const [userInfo] = UsersAdmin.mergeUserResponse(junkList); + return userInfo; + } + + static async getActionUser(locals: App.Locals, privileges: RequiredPrivileges) { + const userSession = locals.session.data?.user; + const currentUser = await Users.getBySession(userSession); + if (!userSession || !currentUser) { + return error(403); + } + + userSession.privileges = await Users.getUserPrivileges(currentUser); + AdminUtils.checkPrivileges(userSession, privileges); + + return { currentUser, userSession }; + } } diff --git a/src/lib/server/users/index.ts b/src/lib/server/users/index.ts index 7c164af..0bbcd41 100644 --- a/src/lib/server/users/index.ts +++ b/src/lib/server/users/index.ts @@ -1,5 +1,5 @@ import bcrypt from 'bcryptjs'; -import { and, eq, or, sql } from 'drizzle-orm'; +import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm'; import { db, privilege, user, userPrivilegesPrivilege, type User } from '../drizzle'; import type { UserSession } from './types'; import { redirect } from '@sveltejs/kit'; @@ -19,11 +19,11 @@ export class Users { return result; } - static async getByUuid(uuid: string): Promise { + static async getByUuid(uuid: string, activatedCheck = true): Promise { const [result] = await db .select() .from(user) - .where(and(eq(user.uuid, uuid), eq(user.activated, 1))) + .where(and(eq(user.uuid, uuid), activatedCheck ? eq(user.activated, 1) : undefined)) .limit(1); return result; } @@ -214,6 +214,13 @@ export class Users { } } + static async getAvailablePrivileges(clientId?: number) { + return await db + .select() + .from(privilege) + .where(clientId ? eq(privilege.clientId, clientId) : isNull(privilege.clientId)); + } + static async getUserPrivileges(subject: User) { const list = await db .select({ @@ -229,6 +236,43 @@ export class Users { ); } + static async setUserPrivileges(subject: User, privilegeIds: number[]) { + const current = await db + .select({ + privilegeId: userPrivilegesPrivilege.privilegeId + }) + .from(userPrivilegesPrivilege) + .where(eq(userPrivilegesPrivilege.userId, subject.id)); + + const toRemoveIds = current.reduce( + (list, { privilegeId }) => + !privilegeIds.includes(privilegeId) ? [...list, privilegeId] : list, + [] + ); + + if (toRemoveIds.length) { + await db + .delete(userPrivilegesPrivilege) + .where( + and( + eq(userPrivilegesPrivilege.userId, subject.id), + inArray(userPrivilegesPrivilege.privilegeId, toRemoveIds) + ) + ); + } + + const toInsertIds = privilegeIds.reduce( + (list, id) => (!current.some(({ privilegeId }) => privilegeId === id) ? [...list, id] : list), + [] + ); + + if (toInsertIds.length) { + await db + .insert(userPrivilegesPrivilege) + .values(toInsertIds.map((privilegeId) => ({ userId: subject.id, privilegeId }))); + } + } + static anonymizeEmail(email: string) { const [name, domain] = email.split('@'); const namePart = `${name.charAt(0)}${''.padStart(name.length - 2, '*')}${name.charAt(name.length - 1)}`; diff --git a/src/lib/server/users/tokens.ts b/src/lib/server/users/tokens.ts index 5f9366f..4bc4fed 100644 --- a/src/lib/server/users/tokens.ts +++ b/src/lib/server/users/tokens.ts @@ -1,6 +1,6 @@ import { and, eq, gt, isNull, or, sql } from 'drizzle-orm'; import { CryptoUtils } from '../crypto-utils'; -import { db, userToken, type UserToken } from '../drizzle'; +import { db, userToken, type User, type UserToken } from '../drizzle'; export class UserTokens { static async create( @@ -41,6 +41,10 @@ export class UserTokens { return returned; } + static async wipeUserTokens(user: User) { + await db.delete(userToken).where(eq(userToken.userId, user.id)); + } + static async wipeExpiredTokens() { await db.execute(sql`DELETE FROM ${userToken} WHERE ${userToken.expires_at} < NOW()`); } diff --git a/src/lib/server/users/totp.ts b/src/lib/server/users/totp.ts index 1cd5bda..ec84d61 100644 --- a/src/lib/server/users/totp.ts +++ b/src/lib/server/users/totp.ts @@ -20,7 +20,7 @@ export class TimeOTP { return totp.generateSecret(); } - public static async isUserOtp(subject: User) { + public static async isUserOtp(subject: PartialK) { const tokens = await db .select({ id: userToken.id }) .from(userToken) @@ -31,7 +31,7 @@ export class TimeOTP { or(isNull(userToken.expires_at), gt(userToken.expires_at, new Date())) ) ); - return tokens?.length; + return !!tokens?.length; } public static async getUserOtp(subject: User) { diff --git a/src/routes/ssoadmin/+layout.server.ts b/src/routes/ssoadmin/+layout.server.ts index 7a28440..7b8a8fd 100644 --- a/src/routes/ssoadmin/+layout.server.ts +++ b/src/routes/ssoadmin/+layout.server.ts @@ -16,6 +16,7 @@ export const load = async ({ url, locals }) => { } return { + renderrt: Date.now(), user: { ...userInfo, privileges diff --git a/src/routes/ssoadmin/+layout.svelte b/src/routes/ssoadmin/+layout.svelte index f355336..8b9c2ce 100644 --- a/src/routes/ssoadmin/+layout.svelte +++ b/src/routes/ssoadmin/+layout.svelte @@ -18,6 +18,12 @@ diff --git a/src/routes/ssoadmin/users/[slug]/+page.server.ts b/src/routes/ssoadmin/users/[slug]/+page.server.ts new file mode 100644 index 0000000..411ed44 --- /dev/null +++ b/src/routes/ssoadmin/users/[slug]/+page.server.ts @@ -0,0 +1,161 @@ +import { AdminUtils } from '$lib/server/admin-utils'; +import { Changesets } from '$lib/server/changesets.js'; +import { OAuth2Tokens } from '$lib/server/oauth2/index.js'; +import { Uploads } from '$lib/server/upload.js'; +import { UsersAdmin } from '$lib/server/users/admin.js'; +import { UserTokens, Users } from '$lib/server/users/index.js'; +import { TimeOTP } from '$lib/server/users/totp.js'; +import { hasPrivileges } from '$lib/utils.js'; +import { emailRegex } from '$lib/validators.js'; +import { error, fail } from '@sveltejs/kit'; + +interface UpdateRequest { + displayName?: string; + email?: string; + activated?: boolean; + privileges?: string; +} + +export const actions = { + removeOtp: async () => {}, + removeAvatar: async ({ locals, params: { slug: uuid } }) => { + await UsersAdmin.getActionUser(locals, ['admin', 'admin:user']); + + const targetUser = await Users.getByUuid(uuid, false); + if (!targetUser) { + return fail(404, { errors: ['invalidUuid'] }); + } + + await Uploads.removeAvatar(targetUser); + + return { errors: [] }; + }, + deleteInfo: async ({ locals, params: { slug: uuid } }) => { + await UsersAdmin.getActionUser(locals, ['admin', 'admin:user']); + + const targetUser = await Users.getByUuid(uuid, false); + if (!targetUser || !!targetUser.activated) { + return fail(404, { errors: ['invalidUuid'] }); + } + + // TODO: audit log + + const [stubName] = uuid.split('-'); + + // Nuke EVERYTHING + await UserTokens.wipeUserTokens(targetUser); + await OAuth2Tokens.wipeUserTokens(targetUser); + await Uploads.removeAvatar(targetUser); + await Users.update(targetUser, { + username: `stub${stubName}`, + display_name: `Stub ${stubName}`, + email: `${stubName}@uuid-stub.target`, + activated: 0, + updated_at: new Date() + }); + + return { errors: [] }; + }, + email: async ({ locals, params: { slug: uuid }, url }) => { + await UsersAdmin.getActionUser(locals, ['admin', 'admin:user']); + + const type = url.searchParams.get('type') as 'password' | 'activate'; + if (!type) { + return fail(403, { errors: ['invalidEmailType'] }); + } + + const targetUser = await Users.getByUuid(uuid, false); + if (!targetUser) { + return fail(404, { errors: ['invalidUuid'] }); + } + + switch (type) { + case 'password': + await Users.sendPasswordEmail(targetUser); + break; + case 'activate': + await Users.sendRegistrationEmail(targetUser); + break; + default: + return fail(403, { errors: ['invalidEmailType'] }); + } + + // TODO: audit log + + return { errors: [] }; + }, + update: async ({ locals, params: { slug: uuid }, request }) => { + const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [ + 'admin', + 'admin:user' + ]); + const body = await request.formData(); + + const { displayName, email, activated, privileges } = Changesets.take( + ['displayName', 'email', 'activated', 'privileges'], + body + ); + + if (!!privileges && !hasPrivileges(userSession.privileges || [], ['admin:user:privilege'])) { + return fail(403, { errors: ['unauthorized'] }); + } + + const targetUser = await Users.getByUuid(uuid, false); + if (!targetUser) { + return fail(404, { errors: ['invalidUuid'] }); + } + + if (currentUser.id === targetUser.id && !activated) { + return fail(400, { errors: ['lockout'] }); + } + + if (privileges) { + // TODO: check NaNs + const newPrivilegeIds = privileges?.split(',').map(Number) || []; + await Users.setUserPrivileges(targetUser, newPrivilegeIds); + } + + if (displayName && (displayName.length < 3 || displayName.length > 32)) { + return fail(400, { errors: ['invalidDisplayName'] }); + } + + if (email && !emailRegex.test(email)) { + return fail(400, { errors: ['invalidEmail'] }); + } + + await Users.update(targetUser, { + display_name: displayName, + email, + activated: Number(!!activated), + updated_at: new Date() + }); + + // TODO: audit log + + return { errors: [] }; + } +}; + +export const load = async ({ parent, params }) => { + const { user } = await parent(); + AdminUtils.checkPrivileges(user, ['admin:user']); + + const uuid = params.slug; + const userInfo = await UsersAdmin.getUserDetails(uuid); + if (!userInfo) { + error(404, 'User not found'); + } + + const privilegeRight = hasPrivileges(user.privileges, ['admin:user:privilege']); + const otpEnabled = await TimeOTP.isUserOtp(userInfo); + const privileges = privilegeRight ? await Users.getAvailablePrivileges() : []; + + return { + privilegeRight, + privileges, + details: { + ...userInfo, + otpEnabled + } + }; +}; diff --git a/src/routes/ssoadmin/users/[slug]/+page.svelte b/src/routes/ssoadmin/users/[slug]/+page.svelte new file mode 100644 index 0000000..f1f223d --- /dev/null +++ b/src/routes/ssoadmin/users/[slug]/+page.svelte @@ -0,0 +1,103 @@ + + +

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

+ + + + + + {#if data.details.pictureId} +
+ +
+ {/if} +
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + {#if data.privilegeRight} + + {/if} + + + + +
+
+ + +

{$t('account.otp.title')}

+

{$t(`account.otp.${data.details.otpEnabled ? 'enabled' : 'unavailable'}`)}

+ {#if data.details.otpEnabled} +
+ +
+ {/if} + +

{$t('admin.users.actions')}

+ {#if data.details.activated} +
+ +
+ {:else} +
+ +
+
+ + - {$t('admin.users.deleteInfoHint')} +
+ {/if} +
+
+ +