sso-core/src/routes/login/+page.server.ts
2025-03-08 10:02:06 +02:00

183 lines
4.7 KiB
TypeScript

import { env } from '$env/dynamic/public';
import { Audit } from '$lib/server/audit/audit.js';
import { AuditAction } from '$lib/server/audit/types.js';
import { Challenge } from '$lib/server/challenge.js';
import { Changesets } from '$lib/server/changesets.js';
import { OAuth2Clients } from '$lib/server/oauth2/index.js';
import { Users } from '$lib/server/users/index.js';
import { TimeOTP } from '$lib/server/users/totp.js';
import type { OAuth2ClientInfo } from '$lib/types';
import { error, fail, redirect, type Actions } from '@sveltejs/kit';
import { RateLimiter } from 'sveltekit-rate-limiter/server';
interface LoginChallenge {
email: string;
}
const rainbowTableLimiter = new RateLimiter({
IP: [3, '10s']
});
const limiter = new RateLimiter({
IP: [8, 'm']
});
export const actions = {
default: async (event) => {
const { request, locals, url } = event;
if (await limiter.isLimited(event)) {
await Audit.insertRequest(AuditAction.THROTTLE, event, undefined, 'login');
throw error(429);
}
// Redirect
const redirectUrl = url.searchParams.has('redirectTo')
? (url.searchParams.get('redirectTo') as string)
: '/';
// Already logged in
if (locals.session.data?.user) {
return redirect(303, redirectUrl);
}
const body = await request.formData();
const { email, password, challenge, otpCode } = Changesets.only(
['email', 'password', 'challenge', 'otpCode'],
body
);
if (!email) {
return fail(400, { incorrect: true });
}
if (!password && (!challenge || !otpCode)) {
return fail(400, { incorrect: true });
}
// Find existing active user
const loginUser = await Users.getByLogin(email);
if (!loginUser) {
if (await rainbowTableLimiter.isLimited(event)) {
await Audit.insertRequest(
AuditAction.THROTTLE,
event,
undefined,
`rainbow table\nemail=${email}`
);
throw error(429);
}
return fail(400, { email, incorrect: true });
}
// Check OTP challenge
if (challenge && otpCode) {
const userOtp = await TimeOTP.getUserOtp(loginUser);
if (!userOtp) {
return fail(400, { email, incorrect: true });
}
try {
const valid = await Challenge.verifyChallenge<LoginChallenge>(
challenge,
loginUser.uuid,
otpCode,
userOtp.token
);
if (email !== valid.email) {
return fail(400, { email, incorrect: true });
}
} catch {
return fail(400, { email, incorrect: true });
}
} else {
// Compare user password
if (!password || !(await Users.validatePassword(loginUser, password))) {
if (await rainbowTableLimiter.isLimited(event)) {
await Audit.insertRequest(
AuditAction.THROTTLE,
event,
loginUser,
`password attempts\nemail=${email}`
);
throw error(429);
}
return fail(400, { email, incorrect: true });
}
}
// Issue two-factor challenge
if (!challenge) {
const isOtp = await TimeOTP.isUserOtp(loginUser);
if (isOtp) {
const issued = await Challenge.issueChallenge<LoginChallenge>({ email }, loginUser.uuid);
return { otpRequired: issued, email };
}
}
// Create session data for user
const sessionUser = await Users.toSession(loginUser);
await locals.session.set({ user: sessionUser });
await Audit.insertRequest(AuditAction.LOGIN, event, loginUser, sessionUser.sid);
return redirect(303, redirectUrl);
}
} as Actions;
export const load = async ({ locals, url, ...event }) => {
let clientInfo: OAuth2ClientInfo | undefined;
if (url.searchParams.has('redirectTo')) {
// Check that the redirect URL is a local path
if (!url.searchParams.get('redirectTo')?.startsWith('/')) {
return redirect(301, '/login');
}
// Redirect if already logged in
if (locals.session.data?.user) {
return redirect(301, url.searchParams.get('redirectTo') || '/');
}
// Take client ID from the redirect if applicable
const parsedParams = new URL(url.searchParams.get('redirectTo') as string, env.PUBLIC_URL);
const clientId = parsedParams.searchParams.get('client_id') as string;
if (clientId) {
const client = await OAuth2Clients.fetchById(clientId);
if (!client) {
return redirect(301, '/login');
}
// Displays client info on the login page to make the redirect less confusing
clientInfo = await OAuth2Clients.authorizeClientInfo(client, []);
}
}
// Activation routine
if (url.searchParams.has('activate')) {
const activationInfo = await Users.getActivationToken(
url.searchParams.get('activate') as string
);
if (!activationInfo?.user) {
return {
activated: false
};
}
await Users.activateUserBy(activationInfo.token, activationInfo.user);
await Audit.insertRequest(AuditAction.USER_UPDATE, event, activationInfo?.user, 'activate');
return {
activated: true
};
}
return {
activated: null,
clientInfo
};
};