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!
|
1. Install dependenices - `npm install`.
|
||||||
|
2. Configure the environment - `cp .env.example .env`.
|
||||||
```bash
|
3. Generate secrets and stuff:
|
||||||
# create a new project in the current directory
|
1. Session secret - `node -e 'console.log(require("crypto").randomBytes(16).toString("hex"))'`.
|
||||||
npm create svelte@latest
|
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`.
|
||||||
# create a new project in my-app
|
4. Also make the public key - `openssl rsa -in jwt.private.pem -pubout -outform PEM -out jwt.public.pem`.
|
||||||
npm create svelte@latest my-app
|
4. Build the application - `npm run build`.
|
||||||
```
|
5. Run the application - `node -r dotenv/config build`.
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
32
package-lock.json
generated
32
package-lock.json
generated
@ -11,6 +11,7 @@
|
|||||||
"@sveltejs/adapter-node": "^5.0.1",
|
"@sveltejs/adapter-node": "^5.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cropperjs": "^1.6.2",
|
"cropperjs": "^1.6.2",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
"drizzle-orm": "^0.30.10",
|
"drizzle-orm": "^0.30.10",
|
||||||
"jose": "^5.3.0",
|
"jose": "^5.3.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@ -20,6 +21,7 @@
|
|||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
"svelte-kit-cookie-session": "^4.0.0",
|
"svelte-kit-cookie-session": "^4.0.0",
|
||||||
"sveltekit-i18n": "^2.4.2",
|
"sveltekit-i18n": "^2.4.2",
|
||||||
|
"sveltekit-rate-limiter": "^0.5.1",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -947,6 +949,14 @@
|
|||||||
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
|
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
|
||||||
@ -2273,6 +2283,17 @@
|
|||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/dreamopt": {
|
||||||
"version": "0.8.0",
|
"version": "0.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/dreamopt/-/dreamopt-0.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/dreamopt/-/dreamopt-0.8.0.tgz",
|
||||||
@ -5030,6 +5051,17 @@
|
|||||||
"svelte": ">=3.49.0"
|
"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": {
|
"node_modules/text-table": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
||||||
|
@ -41,6 +41,7 @@
|
|||||||
"@sveltejs/adapter-node": "^5.0.1",
|
"@sveltejs/adapter-node": "^5.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cropperjs": "^1.6.2",
|
"cropperjs": "^1.6.2",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
"drizzle-orm": "^0.30.10",
|
"drizzle-orm": "^0.30.10",
|
||||||
"jose": "^5.3.0",
|
"jose": "^5.3.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@ -50,6 +51,7 @@
|
|||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
"svelte-kit-cookie-session": "^4.0.0",
|
"svelte-kit-cookie-session": "^4.0.0",
|
||||||
"sveltekit-i18n": "^2.4.2",
|
"sveltekit-i18n": "^2.4.2",
|
||||||
|
"sveltekit-rate-limiter": "^0.5.1",
|
||||||
"uuid": "^9.0.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">
|
<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>
|
</form>
|
||||||
|
@ -14,10 +14,16 @@
|
|||||||
"currentPassword": "Current password",
|
"currentPassword": "Current password",
|
||||||
"newPassword": "New password",
|
"newPassword": "New password",
|
||||||
"repeatNewPassword": "Repeat 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.",
|
"passwordHint": "At least 8 characters, a capital letter and a number required.",
|
||||||
"submit": "Submit",
|
"submit": "Submit",
|
||||||
"changeSuccess": "Account settings changed successfully!",
|
"changeSuccess": "Account settings changed successfully!",
|
||||||
"activateSuccess": "Your account has been activated successfully! You may now log in.",
|
"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": {
|
"avatar": {
|
||||||
"title": "Profile avatar",
|
"title": "Profile avatar",
|
||||||
"change": "Change avatar",
|
"change": "Change avatar",
|
||||||
@ -51,6 +57,7 @@
|
|||||||
"invalidEmail": "The email address is invalid.",
|
"invalidEmail": "The email address is invalid.",
|
||||||
"passwordRequired": "The password is required.",
|
"passwordRequired": "The password is required.",
|
||||||
"passwordMismatch": "The passwords do not match!",
|
"passwordMismatch": "The passwords do not match!",
|
||||||
|
"passwordSame": "Your new password cannot be your current password.",
|
||||||
"invalidPassword": "The provided password is invalid.",
|
"invalidPassword": "The provided password is invalid.",
|
||||||
"invalidDisplayName": "The provided display name 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.",
|
"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",
|
"enabled": "Two-factor authentication is enabled",
|
||||||
"disabled": "Your account does not have two-factor authentication enabled.",
|
"disabled": "Your account does not have two-factor authentication enabled.",
|
||||||
"activated": "Two-factor authentication has been activated successfully!",
|
"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",
|
"scan": "Scan this QR code with the authenticator app of your choice",
|
||||||
"code": "Enter the code displayed on the authenticator app",
|
"code": "Enter the code displayed on the authenticator app",
|
||||||
"return": "Return to account management",
|
"return": "Return to account management",
|
||||||
|
@ -5,6 +5,7 @@ import { TimeOTP } from './users/totp';
|
|||||||
|
|
||||||
export interface ChallengeBody<T> {
|
export interface ChallengeBody<T> {
|
||||||
aud: string;
|
aud: string;
|
||||||
|
exp: number;
|
||||||
data: T;
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -13,8 +14,11 @@ export class Challenge {
|
|||||||
challenge: TChallenge,
|
challenge: TChallenge,
|
||||||
recipient: string
|
recipient: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
// 5-minute expiration on all challenges.
|
||||||
|
const expiration = Date.now() + 5 * 60 * 1000;
|
||||||
const body = <ChallengeBody<TChallenge>>{
|
const body = <ChallengeBody<TChallenge>>{
|
||||||
aud: recipient,
|
aud: recipient,
|
||||||
|
exp: expiration,
|
||||||
data: challenge
|
data: challenge
|
||||||
};
|
};
|
||||||
return CryptoUtils.encryptChallenge(body);
|
return CryptoUtils.encryptChallenge(body);
|
||||||
@ -26,8 +30,8 @@ export class Challenge {
|
|||||||
code: string,
|
code: string,
|
||||||
secret: string
|
secret: string
|
||||||
) {
|
) {
|
||||||
const { aud, data }: ChallengeBody<TRes> = await CryptoUtils.decryptChallenge(challenge);
|
const { aud, data, exp }: ChallengeBody<TRes> = await CryptoUtils.decryptChallenge(challenge);
|
||||||
if (aud !== recipient) {
|
if (exp < Date.now() || aud !== recipient) {
|
||||||
throw new Error('Invalid challenge');
|
throw new Error('Invalid challenge');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -62,7 +62,11 @@ export class CryptoUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static safeCompare(token: string, token2: string) {
|
static safeCompare(token: string, token2: string) {
|
||||||
|
try {
|
||||||
return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(token2));
|
return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(token2));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static sha256hash(input: string) {
|
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 { CryptoUtils } from '../crypto-utils';
|
||||||
import { db, userToken, type UserToken } from '../drizzle';
|
import { db, userToken, type UserToken } from '../drizzle';
|
||||||
|
|
||||||
@ -25,4 +25,19 @@ export class UserTokens {
|
|||||||
const removeBy = typeof token === 'string' ? token : token.token;
|
const removeBy = typeof token === 'string' ? token : token.token;
|
||||||
await db.delete(userToken).where(eq(userToken.token, removeBy));
|
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());
|
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, {
|
return fail(400, {
|
||||||
error: true,
|
error: true,
|
||||||
message: 'You must provide a file to upload'
|
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 { Changesets } from '$lib/server/changesets.js';
|
||||||
import { CryptoUtils } from '$lib/server/crypto-utils';
|
import { CryptoUtils } from '$lib/server/crypto-utils';
|
||||||
import type { User } from '$lib/server/drizzle';
|
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 { TimeOTP } from '$lib/server/users/totp.js';
|
||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import * as QRCode from 'qrcode';
|
import * as QRCode from 'qrcode';
|
||||||
@ -12,6 +12,7 @@ interface ActivateChallenge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ActivateRequest {
|
interface ActivateRequest {
|
||||||
|
action: 'deactivate' | 'activate';
|
||||||
challenge: string;
|
challenge: string;
|
||||||
otpCode?: string;
|
otpCode?: string;
|
||||||
}
|
}
|
||||||
@ -21,7 +22,7 @@ const issueActivateChallenge = async (subject: User) => {
|
|||||||
const challenge = await Challenge.issueChallenge<ActivateChallenge>({ secret }, subject.uuid);
|
const challenge = await Challenge.issueChallenge<ActivateChallenge>({ secret }, subject.uuid);
|
||||||
const uri = TimeOTP.getUri(secret, subject.username);
|
const uri = TimeOTP.getUri(secret, subject.username);
|
||||||
const qr = await QRCode.toDataURL(uri);
|
const qr = await QRCode.toDataURL(uri);
|
||||||
return { challenge, qr, uri };
|
return { challenge, qr, uri, action: 'activate' };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
@ -43,25 +44,63 @@ export const actions = {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
!otpCode ||
|
!otpCode ||
|
||||||
|
decoded?.exp < Date.now() ||
|
||||||
decoded?.aud !== currentUser.uuid ||
|
decoded?.aud !== currentUser.uuid ||
|
||||||
!decoded?.data?.secret ||
|
!decoded?.data?.secret ||
|
||||||
!TimeOTP.validate(decoded.data.secret, otpCode)
|
!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);
|
await TimeOTP.saveUserOtp(currentUser, decoded.data.secret);
|
||||||
|
|
||||||
// TODO: audit log
|
// 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);
|
const currentUser = await Users.getBySession(locals.session.data?.user);
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
await locals.session.destroy();
|
await locals.session.destroy();
|
||||||
return redirect(303, '/login');
|
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">
|
<script lang="ts">
|
||||||
|
import { PUBLIC_SITE_NAME } from '$env/static/public';
|
||||||
import Alert from '$lib/components/Alert.svelte';
|
import Alert from '$lib/components/Alert.svelte';
|
||||||
import Button from '$lib/components/Button.svelte';
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
import ColumnView from '$lib/components/ColumnView.svelte';
|
||||||
import MainContainer from '$lib/components/MainContainer.svelte';
|
import MainContainer from '$lib/components/MainContainer.svelte';
|
||||||
import FormControl from '$lib/components/form/FormControl.svelte';
|
import FormControl from '$lib/components/form/FormControl.svelte';
|
||||||
import FormSection from '$lib/components/form/FormSection.svelte';
|
import FormSection from '$lib/components/form/FormSection.svelte';
|
||||||
@ -12,47 +14,54 @@
|
|||||||
export let form: ActionData;
|
export let form: ActionData;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{$t('account.otp.title')} - {PUBLIC_SITE_NAME}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<MainContainer>
|
<MainContainer>
|
||||||
<h1>{PUBLIC_SITE_NAME}</h1>
|
<h1>{PUBLIC_SITE_NAME}</h1>
|
||||||
<h2>{$t('account.otp.title')}</h2>
|
<h2>{$t('account.otp.title')}</h2>
|
||||||
|
|
||||||
|
<ColumnView>
|
||||||
{#if form?.success}
|
{#if form?.success}
|
||||||
<Alert type="success">{$t('account.otp.activated')}</Alert>
|
<Alert type="success">{$t(`account.otp.${form?.action || 'activate'}d`)}</Alert>
|
||||||
<br />
|
|
||||||
<a href="/account">{$t('account.otp.return')}</a>
|
<a href="/account">{$t('account.otp.return')}</a>
|
||||||
{:else if form?.challenge}
|
{:else if form?.challenge}
|
||||||
<form action="?/activate" method="POST">
|
<form action="?/{form?.action || 'activate'}" method="POST">
|
||||||
<FormWrapper>
|
<FormWrapper>
|
||||||
|
<input value={form.challenge} type="hidden" name="challenge" />
|
||||||
|
|
||||||
|
{#if form?.action !== 'deactivate'}
|
||||||
<FormSection title={$t('account.otp.scan')}>
|
<FormSection title={$t('account.otp.scan')}>
|
||||||
<div class="qr-wrapper">
|
<div class="qr-wrapper">
|
||||||
<img src={form?.qr} alt={form?.uri} />
|
<img src={form?.qr} alt={form?.uri} />
|
||||||
</div>
|
</div>
|
||||||
<input value={form.challenge} type="hidden" name="challenge" />
|
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<label for="form-otpCode">{$t('account.otp.code')}</label>
|
<label for="form-otpCode">{$t('account.otp.code')}</label>
|
||||||
<input type="text" name="otpCode" id="form-otpCode" autocomplete="off" />
|
<input type="text" name="otpCode" id="form-otpCode" autocomplete="off" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<Button type="submit" variant="primary">{$t('account.submit')}</Button>
|
<Button type="submit" variant="primary">{$t('account.submit')}</Button>
|
||||||
</FormWrapper>
|
</FormWrapper>
|
||||||
</form>
|
</form>
|
||||||
{:else if form?.invalid}
|
{:else if form?.invalid}
|
||||||
<Alert type="error">{$t('account.errors.otpFailed')}</Alert>
|
<Alert type="error">{$t('account.errors.otpFailed')}</Alert>
|
||||||
<br />
|
<form action="?/{form?.action || 'activate'}" method="POST">
|
||||||
<form action="?/activate" method="POST">
|
|
||||||
<Button type="submit" variant="link">{$t('account.otp.retry')}</Button>
|
<Button type="submit" variant="link">{$t('account.otp.retry')}</Button>
|
||||||
</form>
|
</form>
|
||||||
{:else if data?.otpEnabled}
|
{:else if data?.otpEnabled}
|
||||||
<Alert type="success">{$t('account.otp.enabled')}</Alert>
|
<Alert type="success">{$t('account.otp.enabled')}</Alert>
|
||||||
<br />
|
|
||||||
<form action="?/deactivate" method="POST">
|
<form action="?/deactivate" method="POST">
|
||||||
<Button type="submit" variant="link">{$t('account.otp.deactivate')}</Button>
|
<Button type="submit" variant="link">{$t('account.otp.deactivate')}</Button>
|
||||||
</form>
|
</form>
|
||||||
{:else}
|
{:else}
|
||||||
<Alert type="default">{$t('account.otp.disabled')}</Alert>
|
<Alert type="default">{$t('account.otp.disabled')}</Alert>
|
||||||
<br />
|
|
||||||
<form action="?/activate" method="POST">
|
<form action="?/activate" method="POST">
|
||||||
<Button type="submit" variant="link">{$t('account.otp.activate')}</Button>
|
<Button type="submit" variant="link">{$t('account.otp.activate')}</Button>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
</ColumnView>
|
||||||
</MainContainer>
|
</MainContainer>
|
||||||
|
@ -2,7 +2,8 @@ import { Challenge } from '$lib/server/challenge.js';
|
|||||||
import { Changesets } from '$lib/server/changesets.js';
|
import { Changesets } from '$lib/server/changesets.js';
|
||||||
import { Users } from '$lib/server/users/index.js';
|
import { Users } from '$lib/server/users/index.js';
|
||||||
import { TimeOTP } from '$lib/server/users/totp.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 {
|
interface LoginParams {
|
||||||
email: string;
|
email: string;
|
||||||
@ -15,8 +16,15 @@ interface LoginChallenge {
|
|||||||
email: string;
|
email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const limiter = new RateLimiter({
|
||||||
|
IP: [6, '45s']
|
||||||
|
});
|
||||||
|
|
||||||
export const actions = {
|
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
|
// Redirect
|
||||||
const redirectUrl = url.searchParams.has('redirectTo')
|
const redirectUrl = url.searchParams.has('redirectTo')
|
||||||
? (url.searchParams.get('redirectTo') as string)
|
? (url.searchParams.get('redirectTo') as string)
|
||||||
|
@ -9,6 +9,7 @@
|
|||||||
import FormSection from '$lib/components/form/FormSection.svelte';
|
import FormSection from '$lib/components/form/FormSection.svelte';
|
||||||
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
|
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
|
import ButtonRow from '$lib/components/ButtonRow.svelte';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
export let form: ActionData;
|
export let form: ActionData;
|
||||||
@ -61,8 +62,11 @@
|
|||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
{/if}
|
{/if}
|
||||||
|
<ButtonRow>
|
||||||
<Button type="submit" variant="primary">{$t('account.login.submit')}</Button>
|
<Button type="submit" variant="primary">{$t('account.login.submit')}</Button>
|
||||||
<div><a href="/register">{$t('account.register.title')}</a></div>
|
<div><a href="/register">{$t('account.register.title')}</a></div>
|
||||||
|
<div><a href="/login/password">{$t('account.forgotPassword')}</a></div>
|
||||||
|
</ButtonRow>
|
||||||
</FormWrapper>
|
</FormWrapper>
|
||||||
</form>
|
</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 { Changesets } from '$lib/server/changesets.js';
|
||||||
import { Users } from '$lib/server/users/index.js';
|
import { Users } from '$lib/server/users/index.js';
|
||||||
import { emailRegex, passwordRegex, usernameRegex } from '$lib/validators.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 {
|
interface RegisterData {
|
||||||
username: string;
|
username: string;
|
||||||
@ -13,8 +14,15 @@ interface RegisterData {
|
|||||||
|
|
||||||
const fields: (keyof RegisterData)[] = ['username', 'displayName', 'email', 'password'];
|
const fields: (keyof RegisterData)[] = ['username', 'displayName', 'email', 'password'];
|
||||||
|
|
||||||
|
const limiter = new RateLimiter({
|
||||||
|
IP: [6, 'm']
|
||||||
|
});
|
||||||
|
|
||||||
export const actions = {
|
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
|
// Logged in users cannot make more accounts
|
||||||
if (locals.session.data?.user || REGISTRATIONS === 'false') {
|
if (locals.session.data?.user || REGISTRATIONS === 'false') {
|
||||||
return redirect(303, '/');
|
return redirect(303, '/');
|
||||||
|
Loading…
Reference in New Issue
Block a user