183 lines
4.7 KiB
TypeScript
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
|
|
};
|
|
};
|