This commit is contained in:
Evert Prants 2024-06-07 17:24:27 +03:00
parent a365c96733
commit 6febe18daa
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
16 changed files with 139 additions and 130 deletions

View File

@ -1 +1,3 @@
export const allowedImages = ['image/png', 'image/jpg', 'image/jpeg']; export const allowedImages = ['image/png', 'image/jpg', 'image/jpeg'];
export const OAUTH2_MAX_REDIRECTS = 5;
export const OAUTH2_MAX_URLS = 1;

View File

@ -118,6 +118,7 @@
"invalidTitle": "Name must be between 3 and 32 characters long.", "invalidTitle": "Name must be between 3 and 32 characters long.",
"invalidDescription": "Description must be at most 1000 characters long.", "invalidDescription": "Description must be at most 1000 characters long.",
"deleteActivated": "Cannot delete an active application. Please deactivate it first.", "deleteActivated": "Cannot delete an active application. Please deactivate it first.",
"illegalUrl": "You cannot add another URL of this type.",
"invalidUrlId": "Invalid URL ID for deletion.", "invalidUrlId": "Invalid URL ID for deletion.",
"invalidUrlType": "Invalid URL type provided.", "invalidUrlType": "Invalid URL type provided.",
"invalidUrl": "Invalid URL provided.", "invalidUrl": "Invalid URL provided.",

View File

@ -14,7 +14,7 @@ export class ApiUtils {
const jsonBody = await request.json(); const jsonBody = await request.json();
return jsonBody; return jsonBody;
} catch { } catch {
// Try next... return {};
} }
} }
@ -22,9 +22,7 @@ export class ApiUtils {
const formBody = await request.formData(); const formBody = await request.formData();
return Object.fromEntries(formBody); return Object.fromEntries(formBody);
} catch (err) { } catch (err) {
// Skip...
}
return {}; return {};
} }
}
} }

View File

@ -11,8 +11,7 @@ In order to change your password, please click on the following link.
Change your password: ${url} Change your password: ${url}
If you did not request a password change on ${PUBLIC_SITE_NAME}, you can safely ignore this email. If you did not request a password change on ${PUBLIC_SITE_NAME}, you can safely ignore this email.`,
`,
html: /* html */ ` html: /* html */ `
<h1>${PUBLIC_SITE_NAME}</h1> <h1>${PUBLIC_SITE_NAME}</h1>
@ -22,6 +21,5 @@ If you did not request a password change on ${PUBLIC_SITE_NAME}, you can safely
<p>Change your password: <a href="${url}" target="_blank">${url}</a></p> <p>Change your password: <a href="${url}" target="_blank">${url}</a></p>
<p>If you did not request a password change on ${PUBLIC_SITE_NAME}, you can safely ignore this email.</p> <p>If you did not request a password change on ${PUBLIC_SITE_NAME}, you can safely ignore this email.</p>`
`
}); });

View File

@ -9,8 +9,7 @@ Please click on the following link to create an account on ${PUBLIC_SITE_NAME}.
Create your account here: ${url} Create your account here: ${url}
This email was sent to you because you have requested an account on ${PUBLIC_SITE_NAME}. If you did not request this, you may safely ignore this email. This email was sent to you because you have requested an account on ${PUBLIC_SITE_NAME}. If you did not request this, you may safely ignore this email.`,
`,
html: /* html */ ` html: /* html */ `
<h1>${PUBLIC_SITE_NAME}</h1> <h1>${PUBLIC_SITE_NAME}</h1>
@ -18,6 +17,5 @@ This email was sent to you because you have requested an account on ${PUBLIC_SIT
<p>Create your account here: <a href="${url}" target="_blank">${url}</a></p> <p>Create your account here: <a href="${url}" target="_blank">${url}</a></p>
<p>This email was sent to you because you have requested an account on ${PUBLIC_SITE_NAME}. If you did not request this, you may safely ignore this email.</p> <p>This email was sent to you because you have requested an account on ${PUBLIC_SITE_NAME}. If you did not request this, you may safely ignore this email.</p>`
`
}); });

View File

@ -15,8 +15,7 @@ Please use the following link to accept the invitation.
Accept invitation: ${url} 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. 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 */ ` html: /* html */ `
<h1>${PUBLIC_SITE_NAME}</h1> <h1>${PUBLIC_SITE_NAME}</h1>
@ -26,6 +25,5 @@ This email was sent to you because someone invited you to contribute to an appli
<p>Accept invitation: <a href="${url}" target="_blank">${url}</a></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> <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

@ -11,8 +11,7 @@ In order to proceed with logging in, please click on the following link to activ
Activate your account: ${url} Activate your account: ${url}
This email was sent to you because you have created an account on ${PUBLIC_SITE_NAME}. If you did not create an account, you may contact us or just let the account expire. This email was sent to you because you have created an account on ${PUBLIC_SITE_NAME}. If you did not create an account, you may contact us or just let the account expire.`,
`,
html: /* html */ ` html: /* html */ `
<h1>${PUBLIC_SITE_NAME}</h1> <h1>${PUBLIC_SITE_NAME}</h1>
@ -22,6 +21,5 @@ This email was sent to you because you have created an account on ${PUBLIC_SITE_
<p>Activate your account: <a href="${url}" target="_blank">${url}</a></p> <p>Activate your account: <a href="${url}" target="_blank">${url}</a></p>
<p>This email was sent to you because you have created an account on ${PUBLIC_SITE_NAME}. If you did not create an account, you may contact us or just let the account expire.</p> <p>This email was sent to you because you have created an account on ${PUBLIC_SITE_NAME}. If you did not create an account, you may contact us or just let the account expire.</p>`
`
}); });

View File

@ -20,7 +20,7 @@ import { OAuth2Users } from '../model/user';
import { OAuth2Response } from '../response'; import { OAuth2Response } from '../response';
export class OAuth2AuthorizationController { export class OAuth2AuthorizationController {
static prehandle = async (url: URL, locals: App.Locals) => { private static prehandle = async (url: URL, locals: App.Locals) => {
if (!url.searchParams.has('redirect_uri')) { if (!url.searchParams.has('redirect_uri')) {
throw new InvalidRequest('redirect_uri field is mandatory for authorization endpoint'); throw new InvalidRequest('redirect_uri field is mandatory for authorization endpoint');
} }
@ -52,8 +52,8 @@ export class OAuth2AuthorizationController {
// Support multiple types // Support multiple types
const responseTypes = responseType.split(' '); const responseTypes = responseType.split(' ');
let grantTypes: string[] = []; let grantTypes: string[] = [];
for (const i in responseTypes) { for (const responseType of responseTypes) {
switch (responseTypes[i]) { switch (responseType) {
case 'code': case 'code':
grantTypes.push('authorization_code'); grantTypes.push('authorization_code');
break; break;
@ -61,10 +61,8 @@ export class OAuth2AuthorizationController {
grantTypes.push('implicit'); grantTypes.push('implicit');
break; break;
case 'id_token': case 'id_token':
grantTypes.push('id_token');
break;
case 'none': case 'none':
grantTypes.push(responseTypes[i]); grantTypes.push(responseType);
break; break;
default: default:
throw new UnsupportedResponseType('Unknown response_type parameter passed'); throw new UnsupportedResponseType('Unknown response_type parameter passed');
@ -95,7 +93,7 @@ export class OAuth2AuthorizationController {
// The client needs to support all grant types // The client needs to support all grant types
for (const grantType of grantTypes) { for (const grantType of grantTypes) {
if (!OAuth2Clients.checkGrantType(client, grantType) && grantType !== 'none') { if (grantType !== 'none' && !OAuth2Clients.checkGrantType(client, grantType)) {
throw new UnauthorizedClient('This client does not support grant type ' + grantType); throw new UnauthorizedClient('This client does not support grant type ' + grantType);
} }
} }
@ -127,7 +125,7 @@ export class OAuth2AuthorizationController {
}; };
}; };
static posthandle = async ( private static posthandle = async (
url: URL, url: URL,
{ {
client, client,
@ -141,9 +139,9 @@ export class OAuth2AuthorizationController {
}: Awaited<ReturnType<typeof OAuth2AuthorizationController.prehandle>> }: Awaited<ReturnType<typeof OAuth2AuthorizationController.prehandle>>
) => { ) => {
let resObj: Record<string, string | number> = {}; let resObj: Record<string, string | number> = {};
for (const i in grantTypes) { for (const grantType of grantTypes) {
let data = null; let data = null;
switch (grantTypes[i]) { switch (grantType) {
case 'authorization_code': case 'authorization_code':
data = await OAuth2Codes.create( data = await OAuth2Codes.create(
user.id, user.id,

View File

@ -215,6 +215,11 @@ export class OAuth2Clients {
) { ) {
const filterText = `%${filters?.filter?.toLowerCase()}%`; const filterText = `%${filters?.filter?.toLowerCase()}%`;
const limit = filters?.limit || 20; const limit = filters?.limit || 20;
// LEFT JOINs below will include more rows than we allow with LIMIT,
// so we need to do a subquery with the limiting first.
// The LEFT JOIN in the subquery only contributes to the WHERE clause
// and will not affect the returned row count.
const allowedClients = DB.drizzle const allowedClients = DB.drizzle
.select({ id: oauth2Client.id }) .select({ id: oauth2Client.id })
.from(oauth2Client) .from(oauth2Client)
@ -404,7 +409,7 @@ export class OAuth2Clients {
static async sendManagerInvitationEmail(client: OAuth2Client, actor: User, email: string) { static async sendManagerInvitationEmail(client: OAuth2Client, actor: User, email: string) {
const token = await UserTokens.create( const token = await UserTokens.create(
'invite', 'invite',
new Date(Date.now() + 3600 * 1000), new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
actor.id, actor.id,
undefined, undefined,
`clientmanager=${client.client_id}` `clientmanager=${client.client_id}`
@ -413,7 +418,7 @@ export class OAuth2Clients {
const content = OAuth2InvitationEmail( const content = OAuth2InvitationEmail(
actor.display_name, actor.display_name,
client.title, client.title,
`${PUBLIC_URL}/ssoadmin/oauth2/invite?${params.toString()}` `${PUBLIC_URL}/account/accept-invite?${params.toString()}`
); );
// TODO: logging // TODO: logging

View File

@ -15,14 +15,65 @@ export interface OAuth2TokenResponse {
expires_in?: number; expires_in?: number;
token_type?: string; token_type?: string;
state?: string; state?: string;
active?: boolean; }
export interface OAuth2IntrospectResponse {
active: boolean;
scope?: string; scope?: string;
username?: string;
client_id?: string; client_id?: string;
exp?: number; exp?: number;
} }
export type OAuth2ResponseType = OAuth2TokenResponse | OAuth2IntrospectResponse;
export class OAuth2Response { export class OAuth2Response {
static createResponse(code: number, data: unknown) { static error(url: URL, err: OAuth2Error, redirectUri?: string) {
if (!(err instanceof OAuth2Error)) {
throw err;
}
OAuth2Response.doErrorRedirect(url, err, redirectUri);
return OAuth2Response.createErrorResponse(err);
}
static errorPlain(url: URL, err: OAuth2Error, redirectUri?: string) {
if (!(err instanceof OAuth2Error)) {
throw err;
}
OAuth2Response.doErrorRedirect(url, err, redirectUri);
return {
error: err.code,
error_description: err.message
};
}
static response(
url: URL,
obj: OAuth2ResponseType,
redirectUri?: string,
fragment: boolean = false
) {
OAuth2Response.doResponseRedirect(url, obj, redirectUri, fragment);
return OAuth2Response.createResponse(200, obj);
}
static responsePlain(
url: URL,
obj: OAuth2ResponseType,
redirectUri?: string,
fragment: boolean = false
) {
OAuth2Response.doResponseRedirect(url, obj, redirectUri, fragment);
return obj;
}
private static createResponse(code: number, data: unknown) {
const isJson = typeof data === 'object'; const isJson = typeof data === 'object';
const body = isJson ? JSON.stringify(data) : (data as string); const body = isJson ? JSON.stringify(data) : (data as string);
return new Response(body, { return new Response(body, {
@ -33,104 +84,44 @@ export class OAuth2Response {
}); });
} }
static createErrorResponse(err: OAuth2Error) { private static createErrorResponse(err: OAuth2Error) {
return OAuth2Response.createResponse(err.status, { return OAuth2Response.createResponse(err.status, {
error: err.code, error: err.code,
error_description: err.message error_description: err.message
}); });
} }
static redirect(redirectUri: string) { private static doResponseRedirect(
return redirect(302, redirectUri);
}
static error(url: URL, err: OAuth2Error, redirectUri?: string) {
if (!(err instanceof OAuth2Error)) {
throw err;
}
if (redirectUri) {
const obj: ErrorResponseData = {
error: err.code,
error_description: err.message
};
if (url.searchParams.has('state')) {
obj.state = url.searchParams.get('state') as string;
}
redirectUri += '?' + new URLSearchParams(obj as Record<string, string>).toString();
return redirect(302, redirectUri);
}
return OAuth2Response.createErrorResponse(err);
}
static errorPlain(url: URL, err: OAuth2Error, redirectUri?: string) {
if (!(err instanceof OAuth2Error)) {
throw err;
}
if (redirectUri) {
const obj: ErrorResponseData = {
error: err.code,
error_description: err.message
};
if (url.searchParams.has('state')) {
obj.state = url.searchParams.get('state') as string;
}
redirectUri += '?' + new URLSearchParams(obj as Record<string, string>).toString();
return redirect(302, redirectUri);
}
return {
error: err.code,
error_description: err.message
};
}
static response(
url: URL, url: URL,
obj: OAuth2TokenResponse, obj: OAuth2ResponseType,
redirectUri?: string, redirectUri?: string,
fragment: boolean = false fragment: boolean = false
) { ) {
if (redirectUri) { if (!redirectUri) return;
redirectUri += fragment ? '#' : redirectUri.indexOf('?') === -1 ? '?' : '&';
if (url.searchParams.has('state')) {
obj.state = url.searchParams.get('state') as string;
}
redirectUri += new URLSearchParams(obj as Record<string, string>).toString();
return redirect(302, redirectUri);
}
return OAuth2Response.createResponse(200, obj);
}
static responsePlain(
url: URL,
obj: OAuth2TokenResponse,
redirectUri?: string,
fragment: boolean = false
) {
if (redirectUri) {
const searchJoinChar = redirectUri.includes('?') ? '&' : '?'; const searchJoinChar = redirectUri.includes('?') ? '&' : '?';
redirectUri += fragment ? '#' : searchJoinChar; redirectUri += fragment ? '#' : searchJoinChar;
if (url.searchParams.has('state')) { if (url.searchParams.has('state')) {
obj.state = url.searchParams.get('state') as string; (obj as OAuth2TokenResponse).state = url.searchParams.get('state') as string;
} }
redirectUri += new URLSearchParams(obj as Record<string, string>).toString(); redirectUri += new URLSearchParams(obj as Record<string, string>).toString();
return redirect(302, redirectUri); return redirect(302, redirectUri);
} }
return obj; private static doErrorRedirect(url: URL, err: OAuth2Error, redirectUri?: string) {
if (!redirectUri) return;
const obj: ErrorResponseData = {
error: err.code,
error_description: err.message
};
if (url.searchParams.has('state')) {
obj.state = url.searchParams.get('state') as string;
}
redirectUri += '?' + new URLSearchParams(obj as Record<string, string>).toString();
return redirect(302, redirectUri);
} }
} }

View File

@ -75,6 +75,8 @@ export class UsersAdmin {
.from(user) .from(user)
.where(searchExpression); .where(searchExpression);
// LEFT JOINs below will include more rows than we allow with LIMIT,
// so we need to do a subquery with the limiting first.
const baseQuery = DB.drizzle const baseQuery = DB.drizzle
.select({ id: user.id }) .select({ id: user.id })
.from(user) .from(user)

View File

@ -229,7 +229,7 @@ export class Users {
static async sendRegistrationEmail(user: User) { static async sendRegistrationEmail(user: User) {
const token = await UserTokens.create( const token = await UserTokens.create(
'activation', 'activation',
new Date(Date.now() + 3600 * 1000), new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
user.id user.id
); );
@ -254,7 +254,11 @@ export class Users {
* @param user User * @param user User
*/ */
static async sendPasswordEmail(user: User) { static async sendPasswordEmail(user: User) {
const token = await UserTokens.create('password', new Date(Date.now() + 3600 * 1000), user.id); const token = await UserTokens.create(
'password',
new Date(Date.now() + 60 * 60 * 1000),
user.id
);
const params = new URLSearchParams({ token: token.token }); const params = new URLSearchParams({ token: token.token });
const content = ForgotPasswordEmail( const content = ForgotPasswordEmail(
user.username, user.username,
@ -280,7 +284,7 @@ export class Users {
static async sendInvitationEmail(email: string) { static async sendInvitationEmail(email: string) {
const token = await UserTokens.create( const token = await UserTokens.create(
'invite', 'invite',
new Date(Date.now() + 3600 * 1000), new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
undefined, undefined,
undefined, undefined,
`register=${email}` `register=${email}`

View File

@ -3,9 +3,8 @@ import { OAuth2Clients } from '$lib/server/oauth2/index.js';
import { UserTokens, Users } from '$lib/server/users'; import { UserTokens, Users } from '$lib/server/users';
export const GET = async ({ locals, url }) => { export const GET = async ({ locals, url }) => {
const userInfo = locals.session.data?.user; const currentUser = await Users.getBySession(locals.session.data?.user);
const currentUser = await Users.getBySession(userInfo); if (!currentUser) {
if (!userInfo || !currentUser) {
await locals.session.destroy(); await locals.session.destroy();
return ApiUtils.redirect(`/login?redirectTo=${encodeURIComponent(url.pathname)}`); return ApiUtils.redirect(`/login?redirectTo=${encodeURIComponent(url.pathname)}`);
} }

View File

@ -10,6 +10,8 @@ interface PasswordRequest {
repeatPassword: string; repeatPassword: string;
} }
// Sending an email asynchronously has a similar amount of delay,
// so lets fake it. TODO: offload email sending somewhere else.
const failDelay = () => const failDelay = () =>
new Promise((resolve) => setTimeout(resolve, 2000 + (Math.random() * 2000 - 1000))); new Promise((resolve) => setTimeout(resolve, 2000 + (Math.random() * 2000 - 1000)));
@ -35,7 +37,7 @@ export const actions = {
const user = await Users.getByLogin(email); const user = await Users.getByLogin(email);
if (!user || user.activated === 0) { if (!user || user.activated === 0) {
await failDelay(); await failDelay();
return { success: true }; return { success: 'sent' };
} }
try { try {

View File

@ -1,8 +1,13 @@
import { OAUTH2_MAX_REDIRECTS, OAUTH2_MAX_URLS } from '$lib/constants.js';
import { AdminUtils } from '$lib/server/admin-utils'; import { AdminUtils } from '$lib/server/admin-utils';
import { Changesets } from '$lib/server/changesets.js'; import { Changesets } from '$lib/server/changesets.js';
import { CryptoUtils } from '$lib/server/crypto-utils.js'; import { CryptoUtils } from '$lib/server/crypto-utils.js';
import type { OAuth2Client, User } from '$lib/server/drizzle'; import type { OAuth2Client, User } from '$lib/server/drizzle';
import { OAuth2ClientURLType, OAuth2Clients } from '$lib/server/oauth2'; import {
OAuth2ClientURLType,
OAuth2Clients,
type OAuth2ClientAdminListItem
} from '$lib/server/oauth2';
import { Uploads } from '$lib/server/upload.js'; import { Uploads } from '$lib/server/upload.js';
import { Users } from '$lib/server/users'; import { Users } from '$lib/server/users';
import { hasPrivileges } from '$lib/utils'; import { hasPrivileges } from '$lib/utils';
@ -182,6 +187,15 @@ export const actions = {
return fail(400, { errors: ['invalidUrl'] }); return fail(400, { errors: ['invalidUrl'] });
} }
// Do not allow adding multiple URLs of the same type, except for redirects.
const existingURLs = (details as OAuth2ClientAdminListItem).urls;
const existingOfType = existingURLs.filter((entry) => entry.type === type).length;
const maxOfType =
type === OAuth2ClientURLType.REDIRECT_URI ? OAUTH2_MAX_REDIRECTS : OAUTH2_MAX_URLS;
if (maxOfType < existingOfType) {
return fail(400, { errors: ['illegalUrl'] });
}
await OAuth2Clients.addUrl(details, type, url); await OAuth2Clients.addUrl(details, type, url);
return { errors: [] }; return { errors: [] };

View File

@ -9,13 +9,14 @@
import FormControl from '$lib/components/form/FormControl.svelte'; import FormControl from '$lib/components/form/FormControl.svelte';
import FormSection from '$lib/components/form/FormSection.svelte'; import FormSection from '$lib/components/form/FormSection.svelte';
import FormWrapper from '$lib/components/form/FormWrapper.svelte'; import FormWrapper from '$lib/components/form/FormWrapper.svelte';
import FormErrors from '$lib/components/form/FormErrors.svelte';
import ActionButton from '$lib/components/ActionButton.svelte';
import type { ActionData, PageData } from './$types';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { page } from '$app/stores'; import { page } from '$app/stores';
import type { ActionData, PageData } from './$types';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import FormErrors from '$lib/components/form/FormErrors.svelte';
import { PUBLIC_SITE_NAME, PUBLIC_URL } from '$env/static/public'; import { PUBLIC_SITE_NAME, PUBLIC_URL } from '$env/static/public';
import ActionButton from '$lib/components/ActionButton.svelte'; import { OAUTH2_MAX_REDIRECTS, OAUTH2_MAX_URLS } from '$lib/constants';
export let data: PageData; export let data: PageData;
export let form: ActionData; export let form: ActionData;
@ -31,9 +32,9 @@
// Can have up to five redirect URIs, only one of other types // Can have up to five redirect URIs, only one of other types
const countOfType = data.details.urls.filter(({ type: subType }) => type === subType).length; const countOfType = data.details.urls.filter(({ type: subType }) => type === subType).length;
if (type === 'redirect_uri') { if (type === 'redirect_uri') {
return countOfType < 5; return countOfType < OAUTH2_MAX_REDIRECTS;
} }
return !countOfType; return countOfType < OAUTH2_MAX_URLS;
}); });
$: splitScopes = data.details.scope?.split(' ') || []; $: splitScopes = data.details.scope?.split(' ') || [];
$: splitGrants = data.details.grants?.split(' ') || []; $: splitGrants = data.details.grants?.split(' ') || [];