password reset
This commit is contained in:
parent
1cdd511ac9
commit
fcfe39e78a
19
.env.example
Normal file
19
.env.example
Normal file
@ -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
|
47
README.md
47
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`.
|
||||
|
32
package-lock.json
generated
32
package-lock.json
generated
@ -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",
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
19
src/lib/components/ButtonRow.svelte
Normal file
19
src/lib/components/ButtonRow.svelte
Normal file
@ -0,0 +1,19 @@
|
||||
<div class="button-row">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.button-row > :global(*):not(:first-child)::before {
|
||||
content: '·';
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
}
|
||||
</style>
|
@ -1,3 +1,8 @@
|
||||
<script>
|
||||
import { t } from '$lib/i18n';
|
||||
import Button from './Button.svelte';
|
||||
</script>
|
||||
|
||||
<form action="/account?/logout" method="POST">
|
||||
<button type="submit" class="btn btn-link">Log out</button>
|
||||
<Button type="submit" variant="link">{$t('account.logout')}</Button>
|
||||
</form>
|
||||
|
@ -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",
|
||||
|
@ -5,6 +5,7 @@ import { TimeOTP } from './users/totp';
|
||||
|
||||
export interface ChallengeBody<T> {
|
||||
aud: string;
|
||||
exp: number;
|
||||
data: T;
|
||||
}
|
||||
|
||||
@ -13,8 +14,11 @@ export class Challenge {
|
||||
challenge: TChallenge,
|
||||
recipient: string
|
||||
): Promise<string> {
|
||||
// 5-minute expiration on all challenges.
|
||||
const expiration = Date.now() + 5 * 60 * 1000;
|
||||
const body = <ChallengeBody<TChallenge>>{
|
||||
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<TRes> = await CryptoUtils.decryptChallenge(challenge);
|
||||
if (aud !== recipient) {
|
||||
const { aud, data, exp }: ChallengeBody<TRes> = await CryptoUtils.decryptChallenge(challenge);
|
||||
if (exp < Date.now() || aud !== recipient) {
|
||||
throw new Error('Invalid challenge');
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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'
|
||||
|
@ -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<ActivateChallenge>({ 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<ActivateRequest>(['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<ActivateChallenge>(
|
||||
{ secret: secretHash },
|
||||
currentUser.uuid
|
||||
);
|
||||
return { challenge: issued, action: 'deactivate' };
|
||||
}
|
||||
|
||||
const decoded = await CryptoUtils.decryptChallenge<ChallengeBody<ActivateChallenge>>(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' };
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { PUBLIC_SITE_NAME } from '$env/static/public';
|
||||
import Alert from '$lib/components/Alert.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import ColumnView from '$lib/components/ColumnView.svelte';
|
||||
import MainContainer from '$lib/components/MainContainer.svelte';
|
||||
import FormControl from '$lib/components/form/FormControl.svelte';
|
||||
import FormSection from '$lib/components/form/FormSection.svelte';
|
||||
@ -12,47 +14,54 @@
|
||||
export let form: ActionData;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('account.otp.title')} - {PUBLIC_SITE_NAME}</title>
|
||||
</svelte:head>
|
||||
|
||||
<MainContainer>
|
||||
<h1>{PUBLIC_SITE_NAME}</h1>
|
||||
<h2>{$t('account.otp.title')}</h2>
|
||||
|
||||
{#if form?.success}
|
||||
<Alert type="success">{$t('account.otp.activated')}</Alert>
|
||||
<br />
|
||||
<a href="/account">{$t('account.otp.return')}</a>
|
||||
{:else if form?.challenge}
|
||||
<form action="?/activate" method="POST">
|
||||
<FormWrapper>
|
||||
<FormSection title={$t('account.otp.scan')}>
|
||||
<div class="qr-wrapper">
|
||||
<img src={form?.qr} alt={form?.uri} />
|
||||
</div>
|
||||
<ColumnView>
|
||||
{#if form?.success}
|
||||
<Alert type="success">{$t(`account.otp.${form?.action || 'activate'}d`)}</Alert>
|
||||
<a href="/account">{$t('account.otp.return')}</a>
|
||||
{:else if form?.challenge}
|
||||
<form action="?/{form?.action || 'activate'}" method="POST">
|
||||
<FormWrapper>
|
||||
<input value={form.challenge} type="hidden" name="challenge" />
|
||||
</FormSection>
|
||||
<FormControl>
|
||||
<label for="form-otpCode">{$t('account.otp.code')}</label>
|
||||
<input type="text" name="otpCode" id="form-otpCode" autocomplete="off" />
|
||||
</FormControl>
|
||||
<Button type="submit" variant="primary">{$t('account.submit')}</Button>
|
||||
</FormWrapper>
|
||||
</form>
|
||||
{:else if form?.invalid}
|
||||
<Alert type="error">{$t('account.errors.otpFailed')}</Alert>
|
||||
<br />
|
||||
<form action="?/activate" method="POST">
|
||||
<Button type="submit" variant="link">{$t('account.otp.retry')}</Button>
|
||||
</form>
|
||||
{:else if data?.otpEnabled}
|
||||
<Alert type="success">{$t('account.otp.enabled')}</Alert>
|
||||
<br />
|
||||
<form action="?/deactivate" method="POST">
|
||||
<Button type="submit" variant="link">{$t('account.otp.deactivate')}</Button>
|
||||
</form>
|
||||
{:else}
|
||||
<Alert type="default">{$t('account.otp.disabled')}</Alert>
|
||||
<br />
|
||||
<form action="?/activate" method="POST">
|
||||
<Button type="submit" variant="link">{$t('account.otp.activate')}</Button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if form?.action !== 'deactivate'}
|
||||
<FormSection title={$t('account.otp.scan')}>
|
||||
<div class="qr-wrapper">
|
||||
<img src={form?.qr} alt={form?.uri} />
|
||||
</div>
|
||||
</FormSection>
|
||||
{/if}
|
||||
|
||||
<FormControl>
|
||||
<label for="form-otpCode">{$t('account.otp.code')}</label>
|
||||
<input type="text" name="otpCode" id="form-otpCode" autocomplete="off" />
|
||||
</FormControl>
|
||||
|
||||
<Button type="submit" variant="primary">{$t('account.submit')}</Button>
|
||||
</FormWrapper>
|
||||
</form>
|
||||
{:else if form?.invalid}
|
||||
<Alert type="error">{$t('account.errors.otpFailed')}</Alert>
|
||||
<form action="?/{form?.action || 'activate'}" method="POST">
|
||||
<Button type="submit" variant="link">{$t('account.otp.retry')}</Button>
|
||||
</form>
|
||||
{:else if data?.otpEnabled}
|
||||
<Alert type="success">{$t('account.otp.enabled')}</Alert>
|
||||
<form action="?/deactivate" method="POST">
|
||||
<Button type="submit" variant="link">{$t('account.otp.deactivate')}</Button>
|
||||
</form>
|
||||
{:else}
|
||||
<Alert type="default">{$t('account.otp.disabled')}</Alert>
|
||||
<form action="?/activate" method="POST">
|
||||
<Button type="submit" variant="link">{$t('account.otp.activate')}</Button>
|
||||
</form>
|
||||
{/if}
|
||||
</ColumnView>
|
||||
</MainContainer>
|
||||
|
@ -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)
|
||||
|
@ -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 @@
|
||||
/>
|
||||
</FormControl>
|
||||
{/if}
|
||||
<Button type="submit" variant="primary">{$t('account.login.submit')}</Button>
|
||||
<div><a href="/register">{$t('account.register.title')}</a></div>
|
||||
<ButtonRow>
|
||||
<Button type="submit" variant="primary">{$t('account.login.submit')}</Button>
|
||||
<div><a href="/register">{$t('account.register.title')}</a></div>
|
||||
<div><a href="/login/password">{$t('account.forgotPassword')}</a></div>
|
||||
</ButtonRow>
|
||||
</FormWrapper>
|
||||
</form>
|
||||
|
||||
|
118
src/routes/login/password/+page.server.ts
Normal file
118
src/routes/login/password/+page.server.ts
Normal file
@ -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<PasswordRequest>(
|
||||
['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 };
|
||||
};
|
101
src/routes/login/password/+page.svelte
Normal file
101
src/routes/login/password/+page.svelte
Normal file
@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { page } from '$app/stores';
|
||||
import { PUBLIC_SITE_NAME } from '$env/static/public';
|
||||
import Alert from '$lib/components/Alert.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import ColumnView from '$lib/components/ColumnView.svelte';
|
||||
import SideContainer from '$lib/components/SideContainer.svelte';
|
||||
import FormControl from '$lib/components/form/FormControl.svelte';
|
||||
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import type { ActionData, PageData, SubmitFunction } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
export let form: ActionData;
|
||||
|
||||
let internalErrors: string[] = [];
|
||||
let submitted = false;
|
||||
$: errors = [...internalErrors, ...(form?.errors?.length ? form.errors : [])];
|
||||
$: actionUrl = data.setter
|
||||
? `?/setPassword&token=${$page.url.searchParams.get('token')}`
|
||||
: '?/sendEmail';
|
||||
$: pageTitle = data.setter ? 'setNewPassword' : 'resetPassword';
|
||||
|
||||
const enhanceFn: SubmitFunction = ({ formData, cancel }) => {
|
||||
internalErrors.length = 0;
|
||||
|
||||
const pwd = formData.get('newPassword') as string;
|
||||
const repeat = formData.get('repeatPassword') as string;
|
||||
if (pwd && pwd !== repeat) {
|
||||
internalErrors.push('passwordMismatch');
|
||||
return cancel();
|
||||
}
|
||||
|
||||
submitted = true;
|
||||
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
submitted = false;
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t(`account.${pageTitle}`)} - {PUBLIC_SITE_NAME}</title>
|
||||
</svelte:head>
|
||||
|
||||
<SideContainer>
|
||||
<h1>{PUBLIC_SITE_NAME}</h1>
|
||||
<h2>{$t(`account.${pageTitle}`)}</h2>
|
||||
<ColumnView>
|
||||
{#if form?.success}
|
||||
<Alert type="success"
|
||||
>{$t(
|
||||
`account.${form.success === 'set' ? 'passwordSetSuccess' : 'passwordResetSucces'}`
|
||||
)}</Alert
|
||||
>
|
||||
<a href="/login">{$t('account.login.title')}</a>
|
||||
{:else}
|
||||
<form action={actionUrl} method="POST" use:enhance={enhanceFn}>
|
||||
<FormWrapper>
|
||||
{#if errors.length}
|
||||
{#each errors as error}
|
||||
<Alert type="error">{$t(`account.errors.${error}`)}</Alert>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if data.setter}
|
||||
<FormControl>
|
||||
<label for="password-newPassword">{$t('account.newPassword')}</label>
|
||||
<input
|
||||
type="password"
|
||||
name="newPassword"
|
||||
id="password-newPassword"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<span>{$t('account.passwordHint')}</span>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<label for="password-repeatPassword">{$t('account.repeatPassword')}</label>
|
||||
<input
|
||||
type="password"
|
||||
name="repeatPassword"
|
||||
id="password-repeatPassword"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</FormControl>
|
||||
{:else}
|
||||
<FormControl>
|
||||
<label for="password-email">{$t('account.email')}</label>
|
||||
<input type="email" name="email" id="password-email" autocomplete="email" />
|
||||
</FormControl>
|
||||
{/if}
|
||||
<Button type="submit" variant="primary" disabled={submitted}
|
||||
>{$t('account.submit')}</Button
|
||||
>
|
||||
</FormWrapper>
|
||||
</form>
|
||||
{/if}
|
||||
</ColumnView>
|
||||
</SideContainer>
|
@ -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, '/');
|
||||
|
Loading…
Reference in New Issue
Block a user