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: [6, '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( 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({ 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 }; };