diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..f3dbace
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,19 @@
+PUBLIC_URL=http://localhost:5173
+PUBLIC_SITE_NAME=Amanita SSO
+DATABASE_HOST=localhost
+DATABASE_DB=icyauth
+DATABASE_USER=icyauth
+DATABASE_PASS=icyauth
+SESSION_SECRET=32 char key
+CHALLENGE_SECRET=64 char key
+JWT_ALGORITHM=RS256
+JWT_EXPIRATION=8h
+JWT_ISSUER=http://localhost:5173
+EMAIL_ENABLED=true
+EMAIL_FROM=no-reply@icynet.eu
+EMAIL_SMTP_HOST=mail.icynet.eu
+EMAIL_SMTP_PORT=587
+EMAIL_SMTP_SECURE=false
+EMAIL_SMTP_USER=
+EMAIL_SMTP_PASS=
+REGISTRATIONS=true
diff --git a/README.md b/README.md
index 5ce6766..59dde4a 100644
--- a/README.md
+++ b/README.md
@@ -1,38 +1,15 @@
-# create-svelte
+# sso-core
-Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte).
+This is a SvelteKit-powered authentication service.
-## Creating a project
+## Set up
-If you're seeing this, you've probably already done this step. Congrats!
-
-```bash
-# create a new project in the current directory
-npm create svelte@latest
-
-# create a new project in my-app
-npm create svelte@latest my-app
-```
-
-## Developing
-
-Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
-
-```bash
-npm run dev
-
-# or start the server and open the app in a new browser tab
-npm run dev -- --open
-```
-
-## Building
-
-To create a production version of your app:
-
-```bash
-npm run build
-```
-
-You can preview the production build with `npm run preview`.
-
-> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
+1. Install dependenices - `npm install`.
+2. Configure the environment - `cp .env.example .env`.
+3. Generate secrets and stuff:
+ 1. Session secret - `node -e 'console.log(require("crypto").randomBytes(16).toString("hex"))'`.
+ 2. Challenge secret - `node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))'`.
+ 3. Generate JWT keys in the `private` directory - `openssl genpkey -out jwt.private.pem -algorithm RSA -pkeyopt rsa_keygen_bits:2048`.
+ 4. Also make the public key - `openssl rsa -in jwt.private.pem -pubout -outform PEM -out jwt.public.pem`.
+4. Build the application - `npm run build`.
+5. Run the application - `node -r dotenv/config build`.
diff --git a/package-lock.json b/package-lock.json
index f3f18db..6be304d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,7 @@
"@sveltejs/adapter-node": "^5.0.1",
"bcryptjs": "^2.4.3",
"cropperjs": "^1.6.2",
+ "dotenv": "^16.4.5",
"drizzle-orm": "^0.30.10",
"jose": "^5.3.0",
"mime-types": "^2.1.35",
@@ -20,6 +21,7 @@
"qrcode": "^1.5.3",
"svelte-kit-cookie-session": "^4.0.0",
"sveltekit-i18n": "^2.4.2",
+ "sveltekit-rate-limiter": "^0.5.1",
"uuid": "^9.0.1"
},
"devDependencies": {
@@ -947,6 +949,14 @@
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
"dev": true
},
+ "node_modules/@isaacs/ttlcache": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz",
+ "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
@@ -2273,6 +2283,17 @@
"node": ">=6.0.0"
}
},
+ "node_modules/dotenv": {
+ "version": "16.4.5",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",
+ "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
"node_modules/dreamopt": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/dreamopt/-/dreamopt-0.8.0.tgz",
@@ -5030,6 +5051,17 @@
"svelte": ">=3.49.0"
}
},
+ "node_modules/sveltekit-rate-limiter": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/sveltekit-rate-limiter/-/sveltekit-rate-limiter-0.5.1.tgz",
+ "integrity": "sha512-Q2C7mT9PdoL6v3VXgxngyXiEg2i3Dp0iVjVvKi722lroTM7oHxAJsmj66607BiSw8mdQk1Me6nhE6uRXrkDVIg==",
+ "dependencies": {
+ "@isaacs/ttlcache": "^1.4.1"
+ },
+ "peerDependencies": {
+ "@sveltejs/kit": "1.x || 2.x"
+ }
+ },
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
diff --git a/package.json b/package.json
index 8cfd4bc..564349b 100644
--- a/package.json
+++ b/package.json
@@ -41,6 +41,7 @@
"@sveltejs/adapter-node": "^5.0.1",
"bcryptjs": "^2.4.3",
"cropperjs": "^1.6.2",
+ "dotenv": "^16.4.5",
"drizzle-orm": "^0.30.10",
"jose": "^5.3.0",
"mime-types": "^2.1.35",
@@ -50,6 +51,7 @@
"qrcode": "^1.5.3",
"svelte-kit-cookie-session": "^4.0.0",
"sveltekit-i18n": "^2.4.2",
+ "sveltekit-rate-limiter": "^0.5.1",
"uuid": "^9.0.1"
}
}
diff --git a/src/lib/components/ButtonRow.svelte b/src/lib/components/ButtonRow.svelte
new file mode 100644
index 0000000..612f4c3
--- /dev/null
+++ b/src/lib/components/ButtonRow.svelte
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/src/lib/components/LogoutButton.svelte b/src/lib/components/LogoutButton.svelte
index d679617..e580ca7 100644
--- a/src/lib/components/LogoutButton.svelte
+++ b/src/lib/components/LogoutButton.svelte
@@ -1,3 +1,8 @@
+
+
diff --git a/src/lib/i18n/en/account.json b/src/lib/i18n/en/account.json
index ed8c56e..c0a09ce 100644
--- a/src/lib/i18n/en/account.json
+++ b/src/lib/i18n/en/account.json
@@ -14,10 +14,16 @@
"currentPassword": "Current password",
"newPassword": "New password",
"repeatNewPassword": "Repeat new password",
+ "forgotPassword": "Forgot your password?",
+ "resetPassword": "Reset your password",
+ "setNewPassword": "Set a new password",
"passwordHint": "At least 8 characters, a capital letter and a number required.",
"submit": "Submit",
"changeSuccess": "Account settings changed successfully!",
"activateSuccess": "Your account has been activated successfully! You may now log in.",
+ "passwordSetSuccess": "Your new password has been set successfully! You may now log in.",
+ "passwordResetSucces": "If there is an account with that email address, we have sent a password reset email to it.",
+ "logout": "Log out",
"avatar": {
"title": "Profile avatar",
"change": "Change avatar",
@@ -51,6 +57,7 @@
"invalidEmail": "The email address is invalid.",
"passwordRequired": "The password is required.",
"passwordMismatch": "The passwords do not match!",
+ "passwordSame": "Your new password cannot be your current password.",
"invalidPassword": "The provided password is invalid.",
"invalidDisplayName": "The provided display name is invalid.",
"otpFailed": "The code you entered was invalid. Please note that you will be given a new QR code for subsequent retries.",
@@ -63,6 +70,7 @@
"enabled": "Two-factor authentication is enabled",
"disabled": "Your account does not have two-factor authentication enabled.",
"activated": "Two-factor authentication has been activated successfully!",
+ "deactivated": "Two-factor authentication has been deactivated successfully.",
"scan": "Scan this QR code with the authenticator app of your choice",
"code": "Enter the code displayed on the authenticator app",
"return": "Return to account management",
diff --git a/src/lib/server/challenge.ts b/src/lib/server/challenge.ts
index 3c8fb7f..29393fe 100644
--- a/src/lib/server/challenge.ts
+++ b/src/lib/server/challenge.ts
@@ -5,6 +5,7 @@ import { TimeOTP } from './users/totp';
export interface ChallengeBody {
aud: string;
+ exp: number;
data: T;
}
@@ -13,8 +14,11 @@ export class Challenge {
challenge: TChallenge,
recipient: string
): Promise {
+ // 5-minute expiration on all challenges.
+ const expiration = Date.now() + 5 * 60 * 1000;
const body = >{
aud: recipient,
+ exp: expiration,
data: challenge
};
return CryptoUtils.encryptChallenge(body);
@@ -26,8 +30,8 @@ export class Challenge {
code: string,
secret: string
) {
- const { aud, data }: ChallengeBody = await CryptoUtils.decryptChallenge(challenge);
- if (aud !== recipient) {
+ const { aud, data, exp }: ChallengeBody = await CryptoUtils.decryptChallenge(challenge);
+ if (exp < Date.now() || aud !== recipient) {
throw new Error('Invalid challenge');
}
diff --git a/src/lib/server/crypto-utils.ts b/src/lib/server/crypto-utils.ts
index 3314ab7..31d1786 100644
--- a/src/lib/server/crypto-utils.ts
+++ b/src/lib/server/crypto-utils.ts
@@ -62,7 +62,11 @@ export class CryptoUtils {
}
static safeCompare(token: string, token2: string) {
- return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(token2));
+ try {
+ return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(token2));
+ } catch {
+ return false;
+ }
}
static sha256hash(input: string) {
diff --git a/src/lib/server/users/tokens.ts b/src/lib/server/users/tokens.ts
index 1abbfd2..0e6f530 100644
--- a/src/lib/server/users/tokens.ts
+++ b/src/lib/server/users/tokens.ts
@@ -1,4 +1,4 @@
-import { eq } from 'drizzle-orm';
+import { and, eq, gt, isNull, or } from 'drizzle-orm';
import { CryptoUtils } from '../crypto-utils';
import { db, userToken, type UserToken } from '../drizzle';
@@ -25,4 +25,19 @@ export class UserTokens {
const removeBy = typeof token === 'string' ? token : token.token;
await db.delete(userToken).where(eq(userToken.token, removeBy));
}
+
+ static async getByToken(token: string, type: (typeof userToken.$inferSelect)['type']) {
+ const [returned] = await db
+ .select()
+ .from(userToken)
+ .where(
+ and(
+ eq(userToken.token, token),
+ eq(userToken.type, type),
+ or(isNull(userToken.expires_at), gt(userToken.expires_at, new Date()))
+ )
+ )
+ .limit(1);
+ return returned;
+ }
}
diff --git a/src/routes/account/+page.server.ts b/src/routes/account/+page.server.ts
index 749e16c..5e5fe1e 100644
--- a/src/routes/account/+page.server.ts
+++ b/src/routes/account/+page.server.ts
@@ -155,8 +155,7 @@ export const actions = {
}
const formData = Object.fromEntries(await request.formData());
-
- if (!(formData.file as File).name || (formData.file as File).name === 'undefined') {
+ if (!(formData.file as File)?.name || (formData.file as File).name === 'undefined') {
return fail(400, {
error: true,
message: 'You must provide a file to upload'
diff --git a/src/routes/account/two-factor/+page.server.ts b/src/routes/account/two-factor/+page.server.ts
index 1ea3816..32f454e 100644
--- a/src/routes/account/two-factor/+page.server.ts
+++ b/src/routes/account/two-factor/+page.server.ts
@@ -2,7 +2,7 @@ import { Challenge, type ChallengeBody } from '$lib/server/challenge.js';
import { Changesets } from '$lib/server/changesets.js';
import { CryptoUtils } from '$lib/server/crypto-utils';
import type { User } from '$lib/server/drizzle';
-import { Users } from '$lib/server/users';
+import { UserTokens, Users } from '$lib/server/users';
import { TimeOTP } from '$lib/server/users/totp.js';
import { fail, redirect } from '@sveltejs/kit';
import * as QRCode from 'qrcode';
@@ -12,6 +12,7 @@ interface ActivateChallenge {
}
interface ActivateRequest {
+ action: 'deactivate' | 'activate';
challenge: string;
otpCode?: string;
}
@@ -21,7 +22,7 @@ const issueActivateChallenge = async (subject: User) => {
const challenge = await Challenge.issueChallenge({ secret }, subject.uuid);
const uri = TimeOTP.getUri(secret, subject.username);
const qr = await QRCode.toDataURL(uri);
- return { challenge, qr, uri };
+ return { challenge, qr, uri, action: 'activate' };
};
export const actions = {
@@ -43,25 +44,63 @@ export const actions = {
if (
!otpCode ||
+ decoded?.exp < Date.now() ||
decoded?.aud !== currentUser.uuid ||
!decoded?.data?.secret ||
!TimeOTP.validate(decoded.data.secret, otpCode)
) {
- return fail(400, { invalid: true });
+ return fail(400, { invalid: true, action: 'activate' });
}
await TimeOTP.saveUserOtp(currentUser, decoded.data.secret);
// TODO: audit log
- return { success: true };
+ return { success: true, action: 'activate' };
},
- deactivate: async ({ locals }) => {
+ deactivate: async ({ request, locals }) => {
const currentUser = await Users.getBySession(locals.session.data?.user);
if (!currentUser) {
await locals.session.destroy();
return redirect(303, '/login');
}
+
+ const body = await request.formData();
+ const { challenge, otpCode } = Changesets.take(['challenge', 'otpCode'], body);
+
+ const userOtp = await TimeOTP.getUserOtp(currentUser);
+ if (!userOtp) {
+ // Invalid request, no OTP activated...
+ return redirect(303, '/account');
+ }
+
+ const secretHash = CryptoUtils.sha256hash(userOtp.token).toString('hex');
+
+ if (!challenge) {
+ const issued = await Challenge.issueChallenge(
+ { secret: secretHash },
+ currentUser.uuid
+ );
+ return { challenge: issued, action: 'deactivate' };
+ }
+
+ const decoded = await CryptoUtils.decryptChallenge>(challenge);
+
+ if (
+ !otpCode ||
+ decoded?.exp < Date.now() ||
+ decoded?.aud !== currentUser.uuid ||
+ decoded?.data?.secret !== secretHash ||
+ !TimeOTP.validate(userOtp.token, otpCode)
+ ) {
+ return fail(400, { invalid: true, action: 'deactivate' });
+ }
+
+ await UserTokens.remove(userOtp);
+
+ // TODO: audit log
+
+ return { success: true, action: 'deactivate' };
}
};
diff --git a/src/routes/account/two-factor/+page.svelte b/src/routes/account/two-factor/+page.svelte
index 8b5a717..3272439 100644
--- a/src/routes/account/two-factor/+page.svelte
+++ b/src/routes/account/two-factor/+page.svelte
@@ -1,6 +1,8 @@
+
+ {$t('account.otp.title')} - {PUBLIC_SITE_NAME}
+
+
{PUBLIC_SITE_NAME}
{$t('account.otp.title')}
- {#if form?.success}
- {$t('account.otp.activated')}
-
- {$t('account.otp.return')}
- {:else if form?.challenge}
-
- {:else if form?.invalid}
- {$t('account.errors.otpFailed')}
-
-
- {:else if data?.otpEnabled}
- {$t('account.otp.enabled')}
-
-
- {:else}
- {$t('account.otp.disabled')}
-
-
- {/if}
+
+ {#if form?.action !== 'deactivate'}
+
+
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+ {:else if form?.invalid}
+ {$t('account.errors.otpFailed')}
+
+ {:else if data?.otpEnabled}
+ {$t('account.otp.enabled')}
+
+ {:else}
+ {$t('account.otp.disabled')}
+
+ {/if}
+
diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts
index 2fb348b..91003b7 100644
--- a/src/routes/login/+page.server.ts
+++ b/src/routes/login/+page.server.ts
@@ -2,7 +2,8 @@ import { Challenge } from '$lib/server/challenge.js';
import { Changesets } from '$lib/server/changesets.js';
import { Users } from '$lib/server/users/index.js';
import { TimeOTP } from '$lib/server/users/totp.js';
-import { fail, redirect, type Actions } from '@sveltejs/kit';
+import { error, fail, redirect, type Actions } from '@sveltejs/kit';
+import { RateLimiter } from 'sveltekit-rate-limiter/server';
interface LoginParams {
email: string;
@@ -15,8 +16,15 @@ interface LoginChallenge {
email: string;
}
+const limiter = new RateLimiter({
+ IP: [6, '45s']
+});
+
export const actions = {
- default: async ({ request, locals, url }) => {
+ default: async (event) => {
+ const { request, locals, url } = event;
+ if (await limiter.isLimited(event)) throw error(429);
+
// Redirect
const redirectUrl = url.searchParams.has('redirectTo')
? (url.searchParams.get('redirectTo') as string)
diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte
index 8000bc0..609e8b3 100644
--- a/src/routes/login/+page.svelte
+++ b/src/routes/login/+page.svelte
@@ -9,6 +9,7 @@
import FormSection from '$lib/components/form/FormSection.svelte';
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
import { t } from '$lib/i18n';
+ import ButtonRow from '$lib/components/ButtonRow.svelte';
export let data: PageData;
export let form: ActionData;
@@ -61,8 +62,11 @@
/>
{/if}
-
-
+
+
+
+
+
diff --git a/src/routes/login/password/+page.server.ts b/src/routes/login/password/+page.server.ts
new file mode 100644
index 0000000..6dc3bdf
--- /dev/null
+++ b/src/routes/login/password/+page.server.ts
@@ -0,0 +1,118 @@
+import { Changesets } from '$lib/server/changesets.js';
+import { Users } from '$lib/server/users/index.js';
+import { UserTokens } from '$lib/server/users/tokens.js';
+import { emailRegex, passwordRegex } from '$lib/validators.js';
+import { error, redirect } from '@sveltejs/kit';
+import { RateLimiter } from 'sveltekit-rate-limiter/server';
+
+interface PasswordRequest {
+ newPassword: string;
+ repeatPassword: string;
+}
+
+const failDelay = () =>
+ new Promise((resolve) => setTimeout(resolve, 2000 + (Math.random() * 2000 - 1000)));
+
+const limiter = new RateLimiter({
+ IP: [3, '45s']
+});
+
+export const actions = {
+ sendEmail: async (event) => {
+ const { locals, request } = event;
+ if (await limiter.isLimited(event)) throw error(429);
+ if (locals.session.data?.user) {
+ return redirect(303, '/');
+ }
+
+ const body = await request.formData();
+ const { email } = Changesets.take<{ email: string }>(['email'], body);
+
+ if (!email || !emailRegex.test(email)) {
+ return { errors: ['invalidEmail'] };
+ }
+
+ const user = await Users.getByLogin(email);
+ if (!user || user.activated === 0) {
+ await failDelay();
+ return { success: true };
+ }
+
+ try {
+ await Users.sendPasswordEmail(user);
+ } catch {
+ // Ignore errors
+ await failDelay();
+ }
+
+ return { success: 'sent' };
+ },
+ setPassword: async ({ locals, request, url }) => {
+ if (locals.session.data?.user) {
+ return redirect(303, '/');
+ }
+
+ const token = url.searchParams.get('token');
+ if (!token) {
+ return {
+ errors: ['invalidPasswordToken']
+ };
+ }
+
+ const exists = await UserTokens.getByToken(token, 'password');
+ if (!exists?.userId) {
+ return {
+ errors: ['invalidPasswordToken']
+ };
+ }
+
+ const user = await Users.getById(exists.userId as number);
+ if (!user?.id || user.activated === 0) {
+ return {
+ errors: ['invalidPasswordToken']
+ };
+ }
+
+ const body = await request.formData();
+ const { newPassword, repeatPassword } = Changesets.take(
+ ['newPassword', 'repeatPassword'],
+ body
+ );
+
+ if (!newPassword || !passwordRegex.test(newPassword)) {
+ return { errors: ['invalidPassword'] };
+ }
+
+ if (newPassword !== repeatPassword) {
+ return { errors: ['passwordMismatch'] };
+ }
+
+ if (await Users.validatePassword(user, newPassword)) {
+ return { errors: ['passwordSame'] };
+ }
+
+ const hashed = await Users.hashPassword(newPassword);
+ await Users.update(user, { password: hashed });
+ await UserTokens.remove(exists);
+
+ return { success: 'set' };
+ }
+};
+
+export const load = async ({ locals, url }) => {
+ if (locals.session.data?.user) {
+ return redirect(301, '/');
+ }
+
+ const token = url.searchParams.get('token');
+ if (token) {
+ const exists = await UserTokens.getByToken(token, 'password');
+ if (!exists) {
+ return redirect(301, '/login/password');
+ }
+
+ return { setter: true };
+ }
+
+ return { setter: false };
+};
diff --git a/src/routes/login/password/+page.svelte b/src/routes/login/password/+page.svelte
new file mode 100644
index 0000000..c9e74a4
--- /dev/null
+++ b/src/routes/login/password/+page.svelte
@@ -0,0 +1,101 @@
+
+
+
+ {$t(`account.${pageTitle}`)} - {PUBLIC_SITE_NAME}
+
+
+
+ {PUBLIC_SITE_NAME}
+ {$t(`account.${pageTitle}`)}
+
+ {#if form?.success}
+ {$t(
+ `account.${form.success === 'set' ? 'passwordSetSuccess' : 'passwordResetSucces'}`
+ )}
+ {$t('account.login.title')}
+ {:else}
+
+ {/if}
+
+
diff --git a/src/routes/register/+page.server.ts b/src/routes/register/+page.server.ts
index 54b7bc2..10ac81e 100644
--- a/src/routes/register/+page.server.ts
+++ b/src/routes/register/+page.server.ts
@@ -2,7 +2,8 @@ import { REGISTRATIONS } from '$env/static/private';
import { Changesets } from '$lib/server/changesets.js';
import { Users } from '$lib/server/users/index.js';
import { emailRegex, passwordRegex, usernameRegex } from '$lib/validators.js';
-import { fail, redirect } from '@sveltejs/kit';
+import { error, fail, redirect } from '@sveltejs/kit';
+import { RateLimiter } from 'sveltekit-rate-limiter/server';
interface RegisterData {
username: string;
@@ -13,8 +14,15 @@ interface RegisterData {
const fields: (keyof RegisterData)[] = ['username', 'displayName', 'email', 'password'];
+const limiter = new RateLimiter({
+ IP: [6, 'm']
+});
+
export const actions = {
- default: async ({ request, locals }) => {
+ default: async (event) => {
+ const { request, locals } = event;
+ if (await limiter.isLimited(event)) throw error(429);
+
// Logged in users cannot make more accounts
if (locals.session.data?.user || REGISTRATIONS === 'false') {
return redirect(303, '/');