client invites

This commit is contained in:
Evert Prants 2024-06-02 20:08:56 +03:00
parent 243fe982b1
commit dc9be2016b
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
14 changed files with 1390 additions and 44 deletions

View File

@ -0,0 +1,3 @@
ALTER TABLE `user_token` MODIFY COLUMN `type` enum('generic','activation','deactivation','password','login','gdpr','totp','public_key','invite','recovery') NOT NULL;--> statement-breakpoint
ALTER TABLE `privilege` ADD `automatic` tinyint DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE `user_token` ADD `metadata` text;

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,13 @@
"when": 1717232859846, "when": 1717232859846,
"tag": "0001_redundant_layla_miller", "tag": "0001_redundant_layla_miller",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1717344670405,
"tag": "0002_whole_vivisector",
"breakpoints": true
} }
] ]
} }

View File

@ -105,6 +105,12 @@
"account": "Change user account settings", "account": "Change user account settings",
"openid": "Get an ID token JWT (OpenID Connect)" "openid": "Get an ID token JWT (OpenID Connect)"
}, },
"managers": {
"title": "Application members",
"hint": "These users can edit this application just like you can, except that they cannot regenerate the secret or delete the application. Please note that they must have an active account on {{siteName}}, but not necessarily with the provided address.",
"add": "Invite a new member",
"invite": "Invite"
},
"errors": { "errors": {
"noRedirect": "At least one Redirect URI is required for you to be able to use this application!", "noRedirect": "At least one Redirect URI is required for you to be able to use this application!",
"forbidden": "This action is forbidden for this user.", "forbidden": "This action is forbidden for this user.",
@ -116,6 +122,8 @@
"invalidUrl": "Invalid URL provided.", "invalidUrl": "Invalid URL provided.",
"invalidPrivilegeId": "Invalid privilege ID for deletion.", "invalidPrivilegeId": "Invalid privilege ID for deletion.",
"invalidPrivilege": "Invalid privilege provided.", "invalidPrivilege": "Invalid privilege provided.",
"invalidEmail": "Invalid email address.",
"emailExists": "This email address is already added.",
"noFile": "Please upload a file first." "noFile": "Please upload a file first."
} }
} }

View File

@ -5,4 +5,13 @@ export class ApiUtils {
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}); });
} }
static redirect(url: string, status = 302): Response {
return new Response(null, {
status,
headers: {
Location: url
}
});
}
} }

View File

@ -145,7 +145,8 @@ export type NewOAuth2Token = typeof oauth2Token.$inferInsert;
export const privilege = mysqlTable('privilege', { export const privilege = mysqlTable('privilege', {
id: int('id').autoincrement().notNull(), id: int('id').autoincrement().notNull(),
name: text('name').notNull(), name: text('name').notNull(),
clientId: int('clientId').references(() => oauth2Client.id, { onDelete: 'cascade' }) clientId: int('clientId').references(() => oauth2Client.id, { onDelete: 'cascade' }),
automatic: tinyint('automatic').default(0).notNull()
}); });
export type Privilege = typeof privilege.$inferSelect; export type Privilege = typeof privilege.$inferSelect;
@ -235,11 +236,13 @@ export const userToken = mysqlTable('user_token', {
'gdpr', 'gdpr',
'totp', 'totp',
'public_key', 'public_key',
'invite',
'recovery' 'recovery'
]).notNull(), ]).notNull(),
expires_at: timestamp('expires_at', { mode: 'date' }), expires_at: timestamp('expires_at', { mode: 'date' }),
userId: int('userId').references(() => user.id, { onDelete: 'cascade' }), userId: int('userId').references(() => user.id, { onDelete: 'cascade' }),
nonce: text('nonce'), nonce: text('nonce'),
metadata: text('metadata'),
created_at: datetime('created_at', { mode: 'date', fsp: 6 }) created_at: datetime('created_at', { mode: 'date', fsp: 6 })
.default(sql`current_timestamp(6)`) .default(sql`current_timestamp(6)`)
.notNull() .notNull()

View File

@ -1,3 +1,4 @@
export * from './forgot-password.email'; export * from './forgot-password.email';
export * from './invitation.email'; export * from './invitation.email';
export * from './oauth2-invitation.email';
export * from './registration.email'; export * from './registration.email';

View File

@ -0,0 +1,31 @@
import { PUBLIC_SITE_NAME } from '$env/static/public';
import type { EmailTemplate } from '../template.interface';
export const OAuth2InvitationEmail = (
inviter: string,
clientName: string,
url: string
): EmailTemplate => ({
text: `
${PUBLIC_SITE_NAME}
${inviter} has invited you to edit the "${clientName}" application on ${PUBLIC_SITE_NAME}.
Please click on the following link to accept the invitation.
Accept invitation: ${url}
This email was sent to you because someone invited you to contribute to an application on ${PUBLIC_SITE_NAME}. If you believe that this was sent in error, you may safely ignore this email.
`,
html: /* html */ `
<h1>${PUBLIC_SITE_NAME}</h1>
<p>${inviter} has invited you to edit the "${clientName}" application on ${PUBLIC_SITE_NAME}.
<p><b>Please click on the following link to accept the invitation ${PUBLIC_SITE_NAME}.</b></p>
<p>Accept invitation: <a href="${url}" target="_blank">${url}</a></p>
<p>This email was sent to you because someone invited you to contribute to an application on ${PUBLIC_SITE_NAME}. If you believe that this was sent in error, you may safely ignore this email.</p>
`
});

View File

@ -1,3 +1,4 @@
import { PUBLIC_URL, PUBLIC_SITE_NAME } from '$env/static/public';
import { CryptoUtils } from '$lib/server/crypto-utils'; import { CryptoUtils } from '$lib/server/crypto-utils';
import { import {
db, db,
@ -12,7 +13,9 @@ import {
type OAuth2ClientUrl, type OAuth2ClientUrl,
type User type User
} from '$lib/server/drizzle'; } from '$lib/server/drizzle';
import { Emails, OAuth2InvitationEmail } from '$lib/server/email';
import { Uploads } from '$lib/server/upload'; import { Uploads } from '$lib/server/upload';
import { UserTokens } from '$lib/server/users';
import type { PaginationMeta } from '$lib/types'; import type { PaginationMeta } from '$lib/types';
import { and, count, eq, like, or, sql } from 'drizzle-orm'; import { and, count, eq, like, or, sql } from 'drizzle-orm';
@ -38,6 +41,7 @@ export interface OAuth2AuthorizedUser {
} }
export interface OAuth2ManagerUser { export interface OAuth2ManagerUser {
id: number;
email: string; email: string;
} }
@ -369,6 +373,57 @@ export class OAuth2Clients {
await db.update(oauth2Client).set(body).where(eq(oauth2Client.id, client.id)); await db.update(oauth2Client).set(body).where(eq(oauth2Client.id, client.id));
} }
static async getManagers(client: OAuth2Client) {
return await db
.select({ id: oauth2ClientManager.id, email: user.email })
.from(oauth2ClientManager)
.innerJoin(user, eq(user.id, oauth2ClientManager.userId))
.where(eq(oauth2ClientManager.clientId, client.id));
}
static async addManager(client: OAuth2Client, actor: User, subject: User) {
await db.insert(oauth2ClientManager).values({
clientId: client.id,
userId: subject.id,
issuerId: actor.id
});
}
static async removeManager(client: OAuth2Client, managerId: number) {
await db
.delete(oauth2ClientManager)
.where(
and(eq(oauth2ClientManager.clientId, client.id), eq(oauth2ClientManager.id, managerId))
);
}
static async sendManagerInvitationEmail(client: OAuth2Client, actor: User, email: string) {
const token = await UserTokens.create(
'invite',
new Date(Date.now() + 3600 * 1000),
actor.id,
undefined,
`clientmanager=${client.client_id}`
);
const params = new URLSearchParams({ token: token.token });
const content = OAuth2InvitationEmail(
actor.display_name,
client.title,
`${PUBLIC_URL}/ssoadmin/oauth2/invite?${params.toString()}`
);
// TODO: logging
try {
await Emails.getSender().sendTemplate(
email,
`You have been invited to manage "${client.title}" on ${PUBLIC_SITE_NAME}`,
content
);
} catch {
await UserTokens.remove(token);
}
}
static async authorizeClientInfo(client: OAuth2Client, scope: string[]) { static async authorizeClientInfo(client: OAuth2Client, scope: string[]) {
const links = await OAuth2Clients.getClientUrls(client); const links = await OAuth2Clients.getClientUrls(client);
const filteredLinks = links const filteredLinks = links

View File

@ -194,10 +194,11 @@ export class Users {
static async sendInvitationEmail(email: string) { static async sendInvitationEmail(email: string) {
const token = await UserTokens.create( const token = await UserTokens.create(
'login', 'invite',
new Date(Date.now() + 3600 * 1000), new Date(Date.now() + 3600 * 1000),
undefined, undefined,
email undefined,
`register=${email}`
); );
const params = new URLSearchParams({ token: token.token }); const params = new URLSearchParams({ token: token.token });
const content = InvitationEmail(`${PUBLIC_URL}/register?${params.toString()}`); const content = InvitationEmail(`${PUBLIC_URL}/register?${params.toString()}`);

View File

@ -7,7 +7,8 @@ export class UserTokens {
type: (typeof userToken.$inferInsert)['type'], type: (typeof userToken.$inferInsert)['type'],
expires: Date, expires: Date,
userId?: number, userId?: number,
nonce?: string nonce?: string,
metadata?: string
) { ) {
const token = CryptoUtils.generateString(64); const token = CryptoUtils.generateString(64);
const obj = <typeof userToken.$inferInsert>{ const obj = <typeof userToken.$inferInsert>{
@ -15,7 +16,8 @@ export class UserTokens {
token, token,
userId, userId,
expires_at: expires, expires_at: expires,
nonce nonce,
metadata
}; };
const [retval] = await db.insert(userToken).values(obj); const [retval] = await db.insert(userToken).values(obj);
return { id: retval.insertId, ...obj } as UserToken; return { id: retval.insertId, ...obj } as UserToken;

View File

@ -7,7 +7,7 @@ import { Uploads } from '$lib/server/upload.js';
import { Users } from '$lib/server/users'; import { Users } from '$lib/server/users';
import { UsersAdmin } from '$lib/server/users/admin'; import { UsersAdmin } from '$lib/server/users/admin';
import { hasPrivileges } from '$lib/utils'; import { hasPrivileges } from '$lib/utils';
import { privilegeRegex } from '$lib/validators.js'; import { emailRegex, privilegeRegex } from '$lib/validators.js';
import { error, fail, redirect } from '@sveltejs/kit'; import { error, fail, redirect } from '@sveltejs/kit';
interface AddUrlRequest { interface AddUrlRequest {
@ -26,6 +26,10 @@ interface AddPrivilegeRequest {
name: string; name: string;
} }
interface InviteRequest {
email: string;
}
export const actions = { export const actions = {
update: async ({ locals, request, params: { uuid } }) => { update: async ({ locals, request, params: { uuid } }) => {
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [ const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [
@ -380,6 +384,69 @@ export const actions = {
await Uploads.removeClientAvatar(details as OAuth2Client); await Uploads.removeClientAvatar(details as OAuth2Client);
return { errors: [] };
},
invite: async ({ locals, request, params: { uuid } }) => {
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [
['admin:oauth2', 'self:oauth2']
]);
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
const {
list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid,
listAll: fullPrivileges,
omitSecret: false
});
if (!details) {
return error(404, 'Client not found');
}
const body = await request.formData();
const { email } = Changesets.take<InviteRequest>(['email'], body);
if (!email || !emailRegex.test(email)) {
return fail(400, { errors: ['invalidEmail'] });
}
const managers = await OAuth2Clients.getManagers(details as OAuth2Client);
if (managers.some((entry) => entry.email.toLowerCase() === email.toLowerCase())) {
return fail(400, { errors: ['emailExists'] });
}
await OAuth2Clients.sendManagerInvitationEmail(details as OAuth2Client, currentUser, email);
return { errors: [] };
},
removeManager: async ({ locals, url, params: { uuid } }) => {
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [
['admin:oauth2', 'self:oauth2']
]);
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
const {
list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid,
listAll: fullPrivileges,
omitSecret: false
});
if (!details) {
return error(404, 'Client not found');
}
const id = Number(url.searchParams.get('id'));
if (isNaN(id)) {
return fail(400, { errors: ['invalidManagerId'] });
}
await OAuth2Clients.removeManager(details as OAuth2Client, id);
return { errors: [] }; return { errors: [] };
} }
}; };
@ -405,9 +472,11 @@ export const load = async ({ params: { uuid }, parent }) => {
const privileges = await Users.getAvailablePrivileges(details.id); const privileges = await Users.getAvailablePrivileges(details.id);
const users = await OAuth2Clients.getAuthorizedUsers(details as OAuth2Client); const users = await OAuth2Clients.getAuthorizedUsers(details as OAuth2Client);
const managers = await OAuth2Clients.getManagers(details as OAuth2Client);
return { return {
users, users,
managers,
availableUrls: OAuth2Clients.availableUrlTypes, availableUrls: OAuth2Clients.availableUrlTypes,
availablePrivileges: privileges, availablePrivileges: privileges,
availableGrants: fullPrivileges availableGrants: fullPrivileges

View File

@ -23,6 +23,7 @@
let secret = false; let secret = false;
let addingUrl = false; let addingUrl = false;
let addingPrivilege = false; let addingPrivilege = false;
let addingManager = false;
$: noRedirects = !data.details.urls.some(({ type }) => type === 'redirect_uri'); $: noRedirects = !data.details.urls.some(({ type }) => type === 'redirect_uri');
$: availableUrls = data.availableUrls.filter((type) => { $: availableUrls = data.availableUrls.filter((type) => {
@ -290,44 +291,97 @@
<Button type="submit" variant="primary">{$t('common.submit')}</Button> <Button type="submit" variant="primary">{$t('common.submit')}</Button>
</form> </form>
<h2>{$t('admin.oauth2.apis.title')}</h2> <SplitView>
<ul> {#if data.fullPrivileges || data.details.isOwner}
<li> <ColumnView>
{$t('admin.oauth2.apis.authorize')} - <h2>{$t('admin.oauth2.managers.title')}</h2>
<code <p>{$t('admin.oauth2.managers.hint', { siteName: PUBLIC_SITE_NAME })}</p>
><a href={`/oauth2/authorize`} data-sveltekit-preload-data="off"
>{PUBLIC_URL}/oauth2/authorize</a <div class="addremove">
></code {#each data.managers as user}
> <div class="addremove-item">
</li> <b>{user.email}</b>
<li> <form action="?/removeManager&id={user.id}" method="POST">
{$t('admin.oauth2.apis.token')} - <Button type="submit" variant="link">{$t('common.remove')}</Button>
<code </form>
><a href={`/oauth2/token`} data-sveltekit-preload-data="off">{PUBLIC_URL}/oauth2/token</a </div>
></code {/each}
> </div>
</li>
<li> {#if addingManager}
{$t('admin.oauth2.apis.introspect')} - <form action="?/invite" method="POST">
<code <FormWrapper>
><a href={`/oauth2/introspect`} data-sveltekit-preload-data="off" <FormSection title={$t('admin.oauth2.managers.add')}>
>{PUBLIC_URL}/oauth2/introspect</a <FormControl>
></code <label for="invite-email">{$t('admin.users.email')}</label>
> <input type="email" name="email" id="invite-email" />
</li> </FormControl>
<li> <ButtonRow>
{$t('admin.oauth2.apis.userinfo')} - <Button type="submit" variant="primary"
<code><a href={`/api/user`} data-sveltekit-preload-data="off">{PUBLIC_URL}/api/user</a></code> >{$t('admin.oauth2.managers.invite')}</Button
</li> >
<li> <Button variant="link" on:click={() => (addingManager = false)}
{$t('admin.oauth2.apis.openid')} - >{$t('common.cancel')}</Button
<code >
><a href={`/.well-known/openid-configuration`} data-sveltekit-preload-data="off" </ButtonRow>
>{PUBLIC_URL}/.well-known/openid-configuration</a </FormSection>
></code </FormWrapper>
> </form>
</li> {:else}
</ul> <div>
<Button variant="link" on:click={() => (addingManager = true)}
>+ {$t('admin.oauth2.managers.add')}</Button
>
</div>
{/if}
</ColumnView>
{/if}
<ColumnView>
<h2>{$t('admin.oauth2.apis.title')}</h2>
<ul>
<li>
{$t('admin.oauth2.apis.authorize')} -
<code
><a href={`/oauth2/authorize`} data-sveltekit-preload-data="off"
>{PUBLIC_URL}/oauth2/authorize</a
></code
>
</li>
<li>
{$t('admin.oauth2.apis.token')} -
<code
><a href={`/oauth2/token`} data-sveltekit-preload-data="off"
>{PUBLIC_URL}/oauth2/token</a
></code
>
</li>
<li>
{$t('admin.oauth2.apis.introspect')} -
<code
><a href={`/oauth2/introspect`} data-sveltekit-preload-data="off"
>{PUBLIC_URL}/oauth2/introspect</a
></code
>
</li>
<li>
{$t('admin.oauth2.apis.userinfo')} -
<code
><a href={`/api/user`} data-sveltekit-preload-data="off">{PUBLIC_URL}/api/user</a></code
>
</li>
<li>
{$t('admin.oauth2.apis.openid')} -
<code
><a href={`/.well-known/openid-configuration`} data-sveltekit-preload-data="off"
>{PUBLIC_URL}/.well-known/openid-configuration</a
></code
>
</li>
</ul>
</ColumnView>
</SplitView>
<h2>{$t('admin.oauth2.authorizations')}</h2> <h2>{$t('admin.oauth2.authorizations')}</h2>
<p>{$t('admin.oauth2.authorizationsHint')}</p> <p>{$t('admin.oauth2.authorizationsHint')}</p>

View File

@ -0,0 +1,39 @@
import { ApiUtils } from '$lib/server/api-utils.js';
import { OAuth2Clients } from '$lib/server/oauth2/index.js';
import { UserTokens, Users } from '$lib/server/users';
export const GET = async ({ locals, url }) => {
const userInfo = locals.session.data?.user;
const currentUser = await Users.getBySession(userInfo);
if (!userInfo || !currentUser) {
await locals.session.destroy();
return ApiUtils.redirect(`/login?redirectTo=${encodeURIComponent(url.pathname)}`);
}
const token = url.searchParams.get('token');
if (!token) {
return ApiUtils.redirect('/');
}
const fetch = await UserTokens.getByToken(token, 'invite');
if (!fetch?.userId || !fetch.metadata?.startsWith('clientmanager=')) {
return ApiUtils.redirect('/');
}
const inviter = await Users.getById(fetch.userId);
if (!inviter) {
return ApiUtils.redirect('/');
}
const [, clientId] = fetch.metadata.split('=');
const client = await OAuth2Clients.fetchById(clientId);
if (!client) {
return ApiUtils.redirect('/');
}
await OAuth2Clients.addManager(client, inviter, currentUser);
await UserTokens.remove(fetch);
console.log('?');
return ApiUtils.redirect(`/ssoadmin/oauth2/${client.client_id}`);
};