register and activate

This commit is contained in:
Evert Prants 2024-05-18 19:07:56 +03:00
parent d47b68736d
commit 1cdd511ac9
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
29 changed files with 859 additions and 154 deletions

19
package-lock.json generated
View File

@ -15,6 +15,7 @@
"jose": "^5.3.0", "jose": "^5.3.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"mysql2": "^3.9.7", "mysql2": "^3.9.7",
"nodemailer": "^6.9.13",
"otplib": "^12.0.1", "otplib": "^12.0.1",
"qrcode": "^1.5.3", "qrcode": "^1.5.3",
"svelte-kit-cookie-session": "^4.0.0", "svelte-kit-cookie-session": "^4.0.0",
@ -29,6 +30,7 @@
"@types/eslint": "^8.56.0", "@types/eslint": "^8.56.0",
"@types/mime-types": "^2.1.4", "@types/mime-types": "^2.1.4",
"@types/node": "^20.12.12", "@types/node": "^20.12.12",
"@types/nodemailer": "^6.4.15",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/eslint-plugin": "^7.0.0",
@ -1559,6 +1561,15 @@
"undici-types": "~5.26.4" "undici-types": "~5.26.4"
} }
}, },
"node_modules/@types/nodemailer": {
"version": "6.4.15",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.15.tgz",
"integrity": "sha512-0EBJxawVNjPkng1zm2vopRctuWVCxk34JcIlRuXSf54habUWdz1FB7wHDqOqvDa8Mtpt0Q3LTXQkAs2LNyK5jQ==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pug": { "node_modules/@types/pug": {
"version": "2.0.10", "version": "2.0.10",
"resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz", "resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz",
@ -4108,6 +4119,14 @@
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
"dev": true "dev": true
}, },
"node_modules/nodemailer": {
"version": "6.9.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.13.tgz",
"integrity": "sha512-7o38Yogx6krdoBf3jCAqnIN4oSQFx+fMa0I7dK1D+me9kBxx12D+/33wSb+fhOCtIxvYJ+4x4IMEhmhCKfAiOA==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-path": { "node_modules/normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",

View File

@ -19,6 +19,7 @@
"@types/eslint": "^8.56.0", "@types/eslint": "^8.56.0",
"@types/mime-types": "^2.1.4", "@types/mime-types": "^2.1.4",
"@types/node": "^20.12.12", "@types/node": "^20.12.12",
"@types/nodemailer": "^6.4.15",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/eslint-plugin": "^7.0.0",
@ -44,6 +45,7 @@
"jose": "^5.3.0", "jose": "^5.3.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"mysql2": "^3.9.7", "mysql2": "^3.9.7",
"nodemailer": "^6.9.13",
"otplib": "^12.0.1", "otplib": "^12.0.1",
"qrcode": "^1.5.3", "qrcode": "^1.5.3",
"svelte-kit-cookie-session": "^4.0.0", "svelte-kit-cookie-session": "^4.0.0",

View File

@ -11,10 +11,12 @@
--in-normalized-background: #000; --in-normalized-background: #000;
--in-input-background: #fff; --in-input-background: #fff;
--in-input-background-disabled: #c2c2c2; --in-input-background-disabled: #c2c2c2;
--in-input-required-color: #ff0000;
--in-input-color: #000; --in-input-color: #000;
--in-input-color-disabled: #414141; --in-input-color-disabled: #414141;
--in-input-border-color: #ddd; --in-input-border-color: #ddd;
--in-input-border-color-disabled: #a0a0a0; --in-input-border-color-disabled: #a0a0a0;
--in-input-border-color-invalid: #ff0000;
--in-alert-color: #006597; --in-alert-color: #006597;
--in-error-color: #b52e2e; --in-error-color: #b52e2e;

View File

@ -3,10 +3,13 @@
export let type: 'button' | 'submit' = 'button'; export let type: 'button' | 'submit' = 'button';
export let variant: 'default' | 'primary' | 'link' = 'default'; export let variant: 'default' | 'primary' | 'link' = 'default';
export let disabled = false;
const dispath = createEventDispatcher(); const dispath = createEventDispatcher();
</script> </script>
<button {type} class="btn btn-{variant}" on:click={(e) => dispath('click', e)}><slot /></button> <button {type} class="btn btn-{variant}" on:click={(e) => dispath('click', e)} {disabled}
><slot /></button
>
<style> <style>
.btn { .btn {

View File

@ -26,6 +26,10 @@
color: var(--in-input-color-disabled); color: var(--in-input-color-disabled);
cursor: not-allowed; cursor: not-allowed;
} }
&:user-invalid {
border-color: var(--in-input-border-color-invalid);
}
} }
} }
@ -44,4 +48,11 @@
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
} }
.form-control > :global(label):has(+ input[required])::after {
content: '*';
color: var(--in-input-required-color);
margin-left: 4px;
font-weight: 700;
}
</style> </style>

View File

@ -1,9 +1,13 @@
<script lang="ts"> <script lang="ts">
import { t } from '$lib/i18n';
export let title: string = ''; export let title: string = '';
export let required = false;
</script> </script>
<div class="form-section"> <div class="form-section">
{#if title}<div class="form-subtitle">{title}</div>{/if} {#if title}<div class="form-subtitle">{title}</div>{/if}
{#if required}<span class="form-required">{$t('common.required')}</span>{/if}
<slot /> <slot />
</div> </div>
@ -20,4 +24,15 @@
font-size: 1.2rem; font-size: 1.2rem;
font-weight: 700; font-weight: 700;
} }
.form-required {
font-size: 0.9rem;
&::before {
content: '*';
color: var(--in-input-required-color);
margin-right: 6px;
font-weight: 700;
}
}
</style> </style>

View File

@ -1,17 +1,23 @@
{ {
"title": "Manage your account", "title": "Manage your account",
"username": "Username", "username": "Username",
"usernameHint": "Only the English alphabet, numbers and _-. are allowed.",
"displayName": "Display Name", "displayName": "Display Name",
"displayNameHint": "Must be between 3 and 32 characters.",
"changeEmail": "Change email address", "changeEmail": "Change email address",
"currentEmail": "Current email address", "currentEmail": "Current email address",
"email": "Email address",
"newEmail": "New email address", "newEmail": "New email address",
"password": "Password",
"repeatPassword": "Repeat password",
"changePassword": "Change password", "changePassword": "Change password",
"currentPassword": "Current password", "currentPassword": "Current password",
"newPassword": "New password", "newPassword": "New password",
"repeatPassword": "Repeat new password", "repeatNewPassword": "Repeat 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.",
"avatar": { "avatar": {
"title": "Profile avatar", "title": "Profile avatar",
"change": "Change avatar", "change": "Change avatar",
@ -30,16 +36,27 @@
"otp": "Two-factor authentication is enabled", "otp": "Two-factor authentication is enabled",
"otpCode": "Enter the code displayed on the authenticator app" "otpCode": "Enter the code displayed on the authenticator app"
}, },
"register": {
"title": "Create a new account",
"disabled": "Registrations are currently disabled by the administrator! Sorry.",
"userCreated": "User account has been created successfully! You may now log in.",
"emailSent": "User account has been created successfully, and an activation email has been sent to your email address.",
"submit": "Create account"
},
"errors": { "errors": {
"invalidLogin": "Invalid email or password!", "invalidLogin": "Invalid email or password!",
"invalidRequest": "Invalid request! Please try again.", "invalidRequest": "Invalid request! Please try again.",
"emailRequired": "Email address is required.", "emailRequired": "Email address is required.",
"invalidUsername": "The username entered is invalid or not allowed",
"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!",
"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.",
"required": "Please fill in the required fields",
"existingRegistration": "The username or email address is already in use.",
"activationFailed": "Your account could not be activated - the URL might have been used or expired already."
}, },
"otp": { "otp": {
"title": "Two-factor authentication", "title": "Two-factor authentication",

View File

@ -1,10 +1,10 @@
{ {
"siteName": "Icy Network", "description": "{{siteName}} is a Single-Sign-On service used by other applications.",
"description": "Icy Network is a Single-Sign-On service used by other applications.",
"cookieDisclaimer": "The website may use temporary cookies for storing your login session and ensuring your security. This web service is&nbsp;<a href=\"https://git.icynet.eu/IcyNetwork/icynet-auth-server\" target=\"_blank\">completely open source</a> and can be audited by anyone.", "cookieDisclaimer": "The website may use temporary cookies for storing your login session and ensuring your security. This web service is&nbsp;<a href=\"https://git.icynet.eu/IcyNetwork/icynet-auth-server\" target=\"_blank\">completely open source</a> and can be audited by anyone.",
"submit": "Submit", "submit": "Submit",
"cancel": "Cancel", "cancel": "Cancel",
"manage": "Manage", "manage": "Manage",
"back": "Go back", "back": "Go back",
"home": "Home page" "home": "Home page",
"required": "Required fields"
} }

View File

@ -11,8 +11,7 @@
"email": "Your email address", "email": "Your email address",
"picture": "Your profile picture", "picture": "Your profile picture",
"account": "Changing your password or other account settings", "account": "Changing your password or other account settings",
"management": "Manage Icy Network on your behalf", "management": "Commit administrative actions to the extent of your user privileges"
"admin": "Commit administrative actions to the extent of your user privileges"
}, },
"link": { "link": {
"website": "Website", "website": "Website",

View File

@ -1,6 +1,10 @@
import i18n from 'sveltekit-i18n'; import i18n, { type Config } from 'sveltekit-i18n';
const config = { interface Params {
siteName: string;
}
const config: Config<Params> = {
loaders: [ loaders: [
{ {
locale: 'en', locale: 'en',

View File

@ -1,129 +0,0 @@
import { relations } from 'drizzle-orm/relations';
import {
user,
auditLog,
document,
oauth2Client,
upload,
oauth2ClientAuthorization,
oauth2ClientUrl,
oauth2Token,
userPrivilegesPrivilege,
privilege,
userToken
} from '../schema';
export const auditLogRelations = relations(auditLog, ({ one }) => ({
user: one(user, {
fields: [auditLog.actorId],
references: [user.id]
})
}));
export const userRelations = relations(user, ({ one, many }) => ({
audit_logs: many(auditLog),
documents: many(document),
o_auth2_clients: many(oauth2Client),
o_auth2_client_authorizations: many(oauth2ClientAuthorization),
o_auth2_tokens: many(oauth2Token),
uploads: many(upload, {
relationName: 'upload_uploaderId_user_id'
}),
upload: one(upload, {
fields: [user.pictureId],
references: [upload.id],
relationName: 'user_pictureId_upload_id'
}),
user_privileges_privileges: many(userPrivilegesPrivilege),
user_tokens: many(userToken)
}));
export const documentRelations = relations(document, ({ one }) => ({
user: one(user, {
fields: [document.authorId],
references: [user.id]
})
}));
export const oauth2ClientRelations = relations(oauth2Client, ({ one, many }) => ({
user: one(user, {
fields: [oauth2Client.ownerId],
references: [user.id]
}),
upload: one(upload, {
fields: [oauth2Client.pictureId],
references: [upload.id]
}),
o_auth2_client_authorizations: many(oauth2ClientAuthorization),
o_auth2_client_urls: many(oauth2ClientUrl),
o_auth2_tokens: many(oauth2Token)
}));
export const uploadRelations = relations(upload, ({ one, many }) => ({
o_auth2_clients: many(oauth2Client),
user: one(user, {
fields: [upload.uploaderId],
references: [user.id],
relationName: 'upload_uploaderId_user_id'
}),
users: many(user, {
relationName: 'user_pictureId_upload_id'
})
}));
export const oauth2ClientAuthorizationRelations = relations(
oauth2ClientAuthorization,
({ one }) => ({
user: one(user, {
fields: [oauth2ClientAuthorization.userId],
references: [user.id]
}),
o_auth2_client: one(oauth2Client, {
fields: [oauth2ClientAuthorization.clientId],
references: [oauth2Client.id]
})
})
);
export const oauth2ClientUrlRelations = relations(oauth2ClientUrl, ({ one }) => ({
o_auth2_client: one(oauth2Client, {
fields: [oauth2ClientUrl.clientId],
references: [oauth2Client.id]
})
}));
export const oauth2TokenRelations = relations(oauth2Token, ({ one }) => ({
o_auth2_client: one(oauth2Client, {
fields: [oauth2Token.clientId],
references: [oauth2Client.id]
}),
user: one(user, {
fields: [oauth2Token.userId],
references: [user.id]
})
}));
export const userPrivilegesPrivilegeRelations = relations(
userPrivilegesPrivilege,
({ one }) => ({
user: one(user, {
fields: [userPrivilegesPrivilege.userId],
references: [user.id]
}),
privilege: one(privilege, {
fields: [userPrivilegesPrivilege.privilegeId],
references: [privilege.id]
})
})
);
export const privilegeRelations = relations(privilege, ({ many }) => ({
user_privileges_privileges: many(userPrivilegesPrivilege)
}));
export const userTokenRelations = relations(userToken, ({ one }) => ({
user: one(user, {
fields: [userToken.userId],
references: [user.id]
})
}));

View File

@ -1,4 +1,4 @@
import { sql } from 'drizzle-orm'; import { relations, sql } from 'drizzle-orm';
import { import {
mysqlTable, mysqlTable,
int, int,
@ -225,3 +225,117 @@ export const userToken = mysqlTable('user_token', {
.default(sql`current_timestamp(6)`) .default(sql`current_timestamp(6)`)
.notNull() .notNull()
}); });
export type UserToken = typeof userToken.$inferSelect;
export const auditLogRelations = relations(auditLog, ({ one }) => ({
user: one(user, {
fields: [auditLog.actorId],
references: [user.id]
})
}));
export const userRelations = relations(user, ({ one, many }) => ({
audit_logs: many(auditLog),
documents: many(document),
o_auth2_clients: many(oauth2Client),
o_auth2_client_authorizations: many(oauth2ClientAuthorization),
o_auth2_tokens: many(oauth2Token),
uploads: many(upload, {
relationName: 'upload_uploaderId_user_id'
}),
upload: one(upload, {
fields: [user.pictureId],
references: [upload.id],
relationName: 'user_pictureId_upload_id'
}),
user_privileges_privileges: many(userPrivilegesPrivilege),
user_tokens: many(userToken)
}));
export const documentRelations = relations(document, ({ one }) => ({
user: one(user, {
fields: [document.authorId],
references: [user.id]
})
}));
export const oauth2ClientRelations = relations(oauth2Client, ({ one, many }) => ({
user: one(user, {
fields: [oauth2Client.ownerId],
references: [user.id]
}),
upload: one(upload, {
fields: [oauth2Client.pictureId],
references: [upload.id]
}),
o_auth2_client_authorizations: many(oauth2ClientAuthorization),
o_auth2_client_urls: many(oauth2ClientUrl),
o_auth2_tokens: many(oauth2Token)
}));
export const uploadRelations = relations(upload, ({ one, many }) => ({
o_auth2_clients: many(oauth2Client),
user: one(user, {
fields: [upload.uploaderId],
references: [user.id],
relationName: 'upload_uploaderId_user_id'
}),
users: many(user, {
relationName: 'user_pictureId_upload_id'
})
}));
export const oauth2ClientAuthorizationRelations = relations(
oauth2ClientAuthorization,
({ one }) => ({
user: one(user, {
fields: [oauth2ClientAuthorization.userId],
references: [user.id]
}),
o_auth2_client: one(oauth2Client, {
fields: [oauth2ClientAuthorization.clientId],
references: [oauth2Client.id]
})
})
);
export const oauth2ClientUrlRelations = relations(oauth2ClientUrl, ({ one }) => ({
o_auth2_client: one(oauth2Client, {
fields: [oauth2ClientUrl.clientId],
references: [oauth2Client.id]
})
}));
export const oauth2TokenRelations = relations(oauth2Token, ({ one }) => ({
o_auth2_client: one(oauth2Client, {
fields: [oauth2Token.clientId],
references: [oauth2Client.id]
}),
user: one(user, {
fields: [oauth2Token.userId],
references: [user.id]
})
}));
export const userPrivilegesPrivilegeRelations = relations(userPrivilegesPrivilege, ({ one }) => ({
user: one(user, {
fields: [userPrivilegesPrivilege.userId],
references: [user.id]
}),
privilege: one(privilege, {
fields: [userPrivilegesPrivilege.privilegeId],
references: [privilege.id]
})
}));
export const privilegeRelations = relations(privilege, ({ many }) => ({
user_privileges_privileges: many(userPrivilegesPrivilege)
}));
export const userTokenRelations = relations(userToken, ({ one }) => ({
user: one(user, {
fields: [userToken.userId],
references: [user.id]
})
}));

View File

@ -0,0 +1,64 @@
import {
EMAIL_ENABLED,
EMAIL_FROM,
EMAIL_SMTP_HOST,
EMAIL_SMTP_PASS,
EMAIL_SMTP_PORT,
EMAIL_SMTP_SECURE,
EMAIL_SMTP_USER
} from '$env/static/private';
import nodemailer from 'nodemailer';
import type { EmailTemplate } from './template.interface';
export class Emails {
public transport?: nodemailer.Transporter;
public static from = EMAIL_FROM;
static sender: Emails;
static getSender() {
if (!Emails.sender) {
Emails.sender = new Emails();
}
return Emails.sender;
}
constructor() {
this.createTransport();
}
public async send(
to: string,
subject: string,
text: string,
html?: string,
from = Emails.from
): Promise<unknown> {
return this.transport?.sendMail({
to,
subject,
text,
html,
from
});
}
public async sendTemplate(to: string, subject: string, message: EmailTemplate): Promise<unknown> {
return this.send(to, subject, message.text, message.html);
}
protected createTransport() {
if (EMAIL_ENABLED !== 'true') return;
this.transport = nodemailer.createTransport({
host: EMAIL_SMTP_HOST,
port: Number(EMAIL_SMTP_PORT) || undefined,
secure: EMAIL_SMTP_SECURE === 'true',
auth: {
user: EMAIL_SMTP_USER,
pass: EMAIL_SMTP_PASS
}
});
}
}
export * from './templates';

View File

@ -0,0 +1,4 @@
export interface EmailTemplate {
text: string;
html: string;
}

View File

@ -0,0 +1,27 @@
import { PUBLIC_SITE_NAME } from '$env/static/public';
import type { EmailTemplate } from '../template.interface';
export const ForgotPasswordEmail = (username: string, url: string): EmailTemplate => ({
text: `
${PUBLIC_SITE_NAME}
Hello, ${username}! You have requested a password reset on ${PUBLIC_SITE_NAME}.
In order to change your password, please click on the following link.
Change your password: ${url}
If you did not request a password change on ${PUBLIC_SITE_NAME}, you can safely ignore this email.
`,
html: /* html */ `
<h1>${PUBLIC_SITE_NAME}</h1>
<p><strong>Hello, ${username}! You have requested a password reset on ${PUBLIC_SITE_NAME}.</strong></p>
<p>In order to change your password, please click on the following link.</p>
<p>Change your password: <a href="${url}" target="_blank">${url}</a></p>
<p>If you did not request a password change on ${PUBLIC_SITE_NAME}, you can safely ignore this email.</p>
`
});

View File

@ -0,0 +1,3 @@
export * from './forgot-password.email';
export * from './invitation.email';
export * from './registration.email';

View File

@ -0,0 +1,23 @@
import { PUBLIC_SITE_NAME } from '$env/static/public';
import type { EmailTemplate } from '../template.interface';
export const InvitationEmail = (url: string): EmailTemplate => ({
text: `
${PUBLIC_SITE_NAME}
Please click on the following link to create an account on ${PUBLIC_SITE_NAME}.
Create your account here: ${url}
This email was sent to you because you have requested an account on ${PUBLIC_SITE_NAME}. If you did not request this, you may safely ignore this email.
`,
html: /* html */ `
<h1>${PUBLIC_SITE_NAME}</h1>
<p><b>Please click on the following link to create an account on ${PUBLIC_SITE_NAME}.</b></p>
<p>Create your account here: <a href="${url}" target="_blank">${url}</a></p>
<p>This email was sent to you because you have requested an account on ${PUBLIC_SITE_NAME}. If you did not request this, you may safely ignore this email.</p>
`
});

View File

@ -0,0 +1,27 @@
import { PUBLIC_SITE_NAME } from '$env/static/public';
import type { EmailTemplate } from '../template.interface';
export const RegistrationEmail = (username: string, url: string): EmailTemplate => ({
text: `
${PUBLIC_SITE_NAME}
Welcome to ${PUBLIC_SITE_NAME}, ${username}!
In order to proceed with logging in, please click on the following link to activate your account.
Activate your account: ${url}
This email was sent to you because you have created an account on ${PUBLIC_SITE_NAME}. If you did not create an account, you may contact us or just let the account expire.
`,
html: /* html */ `
<h1>${PUBLIC_SITE_NAME}</h1>
<p><strong>Welcome to ${PUBLIC_SITE_NAME}, ${username}!</strong></p>
<p>In order to proceed with logging in, please click on the following link to activate your account.</p>
<p>Activate your account: <a href="${url}" target="_blank">${url}</a></p>
<p>This email was sent to you because you have created an account on ${PUBLIC_SITE_NAME}. If you did not create an account, you may contact us or just let the account expire.</p>
`
});

View File

@ -124,7 +124,7 @@ export class OAuth2Clients {
}); });
if (scope.includes('management')) { if (scope.includes('management')) {
allowedScopes.push('management', 'admin'); allowedScopes.push('management');
} }
// TODO: client picture // TODO: client picture

View File

@ -1,8 +1,13 @@
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { and, eq, or } from 'drizzle-orm'; import { and, eq, gt, or, sql } from 'drizzle-orm';
import { db, user, type User } from '../drizzle'; import { db, user, userToken, type User } from '../drizzle';
import type { UserSession } from './types'; import type { UserSession } from './types';
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { CryptoUtils } from '../crypto-utils';
import { EMAIL_ENABLED } from '$env/static/private';
import { Emails, ForgotPasswordEmail, InvitationEmail, RegistrationEmail } from '../email';
import { PUBLIC_SITE_NAME, PUBLIC_URL } from '$env/static/public';
import { UserTokens } from './tokens';
export class Users { export class Users {
static async getById(id: number): Promise<User | undefined> { static async getById(id: number): Promise<User | undefined> {
@ -71,6 +76,172 @@ export class Users {
} }
return currentUser; return currentUser;
} }
static async checkRegistration(username: string, email: string) {
return !(
await db
.select({ id: user.id })
.from(user)
.where(
or(
sql`lower(${user.username}) = ${username.toLowerCase()}`,
sql`lower(${user.email}) = ${email.toLowerCase()}`
)
)
)?.length;
} }
static async getActivationToken(token: string) {
const [returnedToken] = await db
.select({ id: userToken.id, token: userToken.token, userId: userToken.userId })
.from(userToken)
.where(
and(
eq(userToken.token, token),
eq(userToken.type, 'activation'),
gt(userToken.expires_at, new Date())
)
)
.limit(1);
if (!returnedToken?.userId) return undefined;
const [userInfo] = await db
.select()
.from(user)
.where(eq(user.id, returnedToken.userId as number));
return {
...returnedToken,
user: userInfo
};
}
static async activateUserBy(token: string, subject: User) {
await db
.update(user)
.set({ activated: 1, activity_at: new Date() })
.where(eq(user.id, subject.id));
await UserTokens.remove(token);
}
static async getPasswordToken(token: string) {
return db.query.userToken.findFirst({
columns: {
id: true,
token: true
},
where: and(
eq(userToken.token, token),
eq(userToken.type, 'password'),
gt(userToken.expires_at, new Date())
),
with: {
user: true
}
});
}
static async register({
username,
displayName,
email,
password,
activate = false
}: {
username: string;
displayName: string;
email: string;
password: string;
activate?: boolean;
}) {
const passwordHash = await Users.hashPassword(password);
const [retval] = await db.insert(user).values({
uuid: CryptoUtils.createUUID(),
email,
username,
password: passwordHash,
display_name: displayName,
activated: EMAIL_ENABLED === 'false' ? 1 : Number(activate),
activity_at: new Date()
});
const [newUser] = await db.select().from(user).where(eq(user.id, retval.insertId));
if (EMAIL_ENABLED !== 'false' && !activate) {
await Users.sendRegistrationEmail(newUser);
}
// TODO: audit log
return newUser;
}
static async sendRegistrationEmail(user: User) {
const token = await UserTokens.create(
'activation',
new Date(Date.now() + 3600 * 1000),
user.id
);
const params = new URLSearchParams({ activate: token.token });
const content = RegistrationEmail(user.username, `${PUBLIC_URL}/login?${params.toString()}`);
// TODO: logging
try {
await Emails.getSender().sendTemplate(
user.email,
`Activate your account on ${PUBLIC_SITE_NAME}`,
content
);
} catch (error) {
console.error(error);
await UserTokens.remove(token);
}
}
static async sendPasswordEmail(user: User) {
const token = await UserTokens.create('password', new Date(Date.now() + 3600 * 1000), user.id);
const params = new URLSearchParams({ token: token.token });
const content = ForgotPasswordEmail(
user.username,
`${PUBLIC_URL}/login/password?${params.toString()}`
);
// TODO: logging
try {
await Emails.getSender().sendTemplate(
user.email,
`Reset your password on ${PUBLIC_SITE_NAME}`,
content
);
} catch {
await UserTokens.remove(token);
}
}
static async sendInvitationEmail(email: string) {
const token = await UserTokens.create(
'login',
new Date(Date.now() + 3600 * 1000),
undefined,
email
);
const params = new URLSearchParams({ token: token.token });
const content = InvitationEmail(`${PUBLIC_URL}/register?${params.toString()}`);
// TODO: logging
try {
await Emails.getSender().sendTemplate(
email,
`You have been invited to create an account on ${PUBLIC_SITE_NAME}`,
content
);
} catch {
await UserTokens.remove(token);
}
}
}
export * from './totp';
export * from './types'; export * from './types';
export * from './tokens';

View File

@ -0,0 +1,28 @@
import { eq } from 'drizzle-orm';
import { CryptoUtils } from '../crypto-utils';
import { db, userToken, type UserToken } from '../drizzle';
export class UserTokens {
static async create(
type: (typeof userToken.$inferInsert)['type'],
expires: Date,
userId?: number,
nonce?: string
) {
const token = CryptoUtils.generateString(64);
const obj = <typeof userToken.$inferInsert>{
type,
token,
userId,
expires_at: expires,
nonce
};
const [retval] = await db.insert(userToken).values(obj);
return { id: retval.insertId, ...obj } as UserToken;
}
static async remove(token: string | { token: string }) {
const removeBy = typeof token === 'string' ? token : token.token;
await db.delete(userToken).where(eq(userToken.token, removeBy));
}
}

View File

@ -1,6 +1,7 @@
import { authenticator as totp } from 'otplib'; import { authenticator as totp } from 'otplib';
import { db, userToken, type User } from '../drizzle'; import { db, userToken, type User } from '../drizzle';
import { and, eq, gt, isNull, or } from 'drizzle-orm'; import { and, eq, gt, isNull, or } from 'drizzle-orm';
import { PUBLIC_SITE_NAME } from '$env/static/public';
totp.options = { totp.options = {
window: 2 window: 2
@ -12,7 +13,7 @@ export class TimeOTP {
} }
public static getUri(secret: string, username: string): string { public static getUri(secret: string, username: string): string {
return totp.keyuri(username, 'Icy Network', secret); return totp.keyuri(username, PUBLIC_SITE_NAME, secret);
} }
public static createSecret(): string { public static createSecret(): string {

View File

@ -15,6 +15,7 @@
import AvatarCard from '$lib/components/avatar/AvatarCard.svelte'; import AvatarCard from '$lib/components/avatar/AvatarCard.svelte';
import AvatarModal from '$lib/components/avatar/AvatarModal.svelte'; import AvatarModal from '$lib/components/avatar/AvatarModal.svelte';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import { PUBLIC_SITE_NAME } from '$env/static/public';
export let data: PageData; export let data: PageData;
export let form: ActionData; export let form: ActionData;
@ -51,8 +52,12 @@
}; };
</script> </script>
<svelte:head>
<title>{$t('account.title')} - {PUBLIC_SITE_NAME}</title>
</svelte:head>
<MainContainer> <MainContainer>
<h1>{$t('common.siteName')}</h1> <h1>{PUBLIC_SITE_NAME}</h1>
<SplitView> <SplitView>
<div> <div>
<h2>{$t('account.title')}</h2> <h2>{$t('account.title')}</h2>
@ -93,6 +98,7 @@
<label for="form-displayName">{$t('account.displayName')}</label> <label for="form-displayName">{$t('account.displayName')}</label>
<input <input
type="text" type="text"
autocomplete="nickname"
name="displayName" name="displayName"
value={form?.displayName || data.user.name} value={form?.displayName || data.user.name}
id="form-displayName" id="form-displayName"
@ -103,12 +109,17 @@
<FormSection title={$t('account.changeEmail')}> <FormSection title={$t('account.changeEmail')}>
<FormControl> <FormControl>
<label for="form-currentEmail">{$t('account.currentEmail')}</label> <label for="form-currentEmail">{$t('account.currentEmail')}</label>
<input type="email" name="currentEmail" id="form-currentEmail" /> <input
type="email"
name="currentEmail"
id="form-currentEmail"
autocomplete="email"
/>
</FormControl> </FormControl>
<FormControl> <FormControl>
<label for="form-newEmail">{$t('account.newEmail')}</label> <label for="form-newEmail">{$t('account.newEmail')}</label>
<input type="email" name="newEmail" id="form-newEmail" /> <input type="email" name="newEmail" id="form-newEmail" autocomplete="email" />
</FormControl> </FormControl>
</FormSection> </FormSection>
@ -136,7 +147,7 @@
</FormControl> </FormControl>
<FormControl> <FormControl>
<label for="form-repeatPassword">{$t('account.repeatPassword')}</label> <label for="form-repeatPassword">{$t('account.repeatNewPassword')}</label>
<input <input
type="password" type="password"
autocomplete="new-password" autocomplete="new-password"

View File

@ -13,7 +13,7 @@
</script> </script>
<MainContainer> <MainContainer>
<h1>{$t('common.siteName')}</h1> <h1>{PUBLIC_SITE_NAME}</h1>
<h2>{$t('account.otp.title')}</h2> <h2>{$t('account.otp.title')}</h2>
{#if form?.success} {#if form?.success}

View File

@ -93,3 +93,32 @@ export const actions = {
return redirect(303, redirectUrl); return redirect(303, redirectUrl);
} }
} as Actions; } as Actions;
export const load = async ({ locals, url }) => {
if (locals.session.data?.user) {
return redirect(301, url.searchParams.get('redirectTo') || '/');
}
// 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);
return {
activated: true
};
}
return {
activated: null
};
};

View File

@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import { PUBLIC_SITE_NAME } from '$env/static/public';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
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 SideContainer from '$lib/components/SideContainer.svelte'; import SideContainer from '$lib/components/SideContainer.svelte';
@ -7,19 +9,30 @@
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 type { ActionData } from './$types';
export let data: PageData;
export let form: ActionData; export let form: ActionData;
</script> </script>
<svelte:head>
<title>{$t('account.login.title')} - {PUBLIC_SITE_NAME}</title>
</svelte:head>
<SideContainer> <SideContainer>
<h1>{$t('common.siteName')}</h1> <h1>{PUBLIC_SITE_NAME}</h1>
<h2>{$t('account.login.title')}</h2> <h2>{$t('account.login.title')}</h2>
<form action="" method="POST" use:enhance> <form action="" method="POST" use:enhance>
<FormWrapper> <FormWrapper>
{#if data?.activated === false && !form}<Alert type="error"
>{$t('account.errors.activationFailed')}</Alert
>{/if}
{#if data?.activated === true && !form}<Alert type="success"
>{$t('account.activateSuccess')}</Alert
>{/if}
{#if form?.incorrect}<Alert type="error">{$t('account.errors.invalidLogin')}</Alert>{/if} {#if form?.incorrect}<Alert type="error">{$t('account.errors.invalidLogin')}</Alert>{/if}
{#if form?.otpRequired} {#if form?.otpRequired}
<!-- Two-factor code request --> <!-- Two-factor code request -->
<FormSection title={$t('account.login.otp')}> <FormSection title={$t('account.login.otp')}>
@ -49,11 +62,12 @@
</FormControl> </FormControl>
{/if} {/if}
<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>
</FormWrapper> </FormWrapper>
</form> </form>
<div class="welcome"> <div class="welcome">
<p class="text-bold">{$t('common.description')}</p> <p class="text-bold">{$t('common.description', { siteName: PUBLIC_SITE_NAME })}</p>
<p>{@html $t('common.cookieDisclaimer')}</p> <p>{@html $t('common.cookieDisclaimer')}</p>
</div> </div>
</SideContainer> </SideContainer>

View File

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { assets } from '$app/paths'; import { assets } from '$app/paths';
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 ColumnView from '$lib/components/ColumnView.svelte';
@ -11,9 +12,13 @@
export let data: PageData; export let data: PageData;
</script> </script>
<svelte:head>
<title>{$t('oauth2.authorize.title')} "{data.client?.title || ''}" - {PUBLIC_SITE_NAME}</title>
</svelte:head>
<MainContainer> <MainContainer>
{#if data.error} {#if data.error}
<h1>{$t('common.siteName')}</h1> <h1>{PUBLIC_SITE_NAME}</h1>
<ColumnView> <ColumnView>
<Alert type="error" <Alert type="error"
>{$t('oauth2.authorize.errorPage')}<br /><br /><code >{$t('oauth2.authorize.errorPage')}<br /><br /><code
@ -25,7 +30,7 @@
{/if} {/if}
{#if data.client} {#if data.client}
<h1 class="title">{$t('common.siteName')}</h1> <h1 class="title">{PUBLIC_SITE_NAME}</h1>
<h2 class="title">{$t('oauth2.authorize.title')}</h2> <h2 class="title">{$t('oauth2.authorize.title')}</h2>
<div class="user-client-wrapper"> <div class="user-client-wrapper">

View File

@ -0,0 +1,107 @@
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';
interface RegisterData {
username: string;
displayName: string;
email: string;
password: string;
}
const fields: (keyof RegisterData)[] = ['username', 'displayName', 'email', 'password'];
export const actions = {
default: async ({ request, locals }) => {
// Logged in users cannot make more accounts
if (locals.session.data?.user || REGISTRATIONS === 'false') {
return redirect(303, '/');
}
const body = await request.formData();
const changes = Changesets.take<RegisterData>(fields, body);
const { username, displayName, email, password } = changes;
// Each field must be present
if (!username || !displayName || !email || !password) {
return fail(400, {
username,
displayName,
email,
errors: ['required'],
fields: fields.reduce<string[]>(
(missing, field) => (!changes[field] ? [...missing, field] : missing),
[]
)
});
}
if (!usernameRegex.test(username)) {
return fail(400, {
username,
displayName,
email,
errors: ['invalidUsername'],
fields: ['username']
});
}
if (displayName.length < 3 || displayName.length > 32) {
return fail(400, {
username,
displayName,
email,
errors: ['invalidDisplayName'],
fields: ['displayName']
});
}
if (!emailRegex.test(email)) {
return fail(400, {
username,
displayName,
email,
errors: ['invalidEmail'],
fields: ['email']
});
}
if (!passwordRegex.test(password)) {
return fail(400, {
username,
displayName,
email,
errors: ['invalidPassword'],
fields: ['password']
});
}
if (!(await Users.checkRegistration(username, email))) {
return fail(400, {
username,
displayName,
email,
errors: ['existingRegistration'],
fields: ['username', 'email']
});
}
// TODO: check for registration token
const newUser = await Users.register({ username, displayName, password, email });
return {
success: newUser.activated ? 'userCreated' : 'emailSent'
};
}
};
export const load = ({ locals }) => {
if (locals.session.data?.user) {
return redirect(301, '/');
}
return {
enabled: REGISTRATIONS === 'true'
};
};

View File

@ -0,0 +1,134 @@
<script lang="ts">
import { PUBLIC_SITE_NAME } from '$env/static/public';
import type { SubmitFunction } from '@sveltejs/kit';
import type { PageData, ActionData } from './$types';
import { t } from '$lib/i18n';
import SideContainer from '$lib/components/SideContainer.svelte';
import Alert from '$lib/components/Alert.svelte';
import ColumnView from '$lib/components/ColumnView.svelte';
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
import FormControl from '$lib/components/form/FormControl.svelte';
import Button from '$lib/components/Button.svelte';
import FormSection from '$lib/components/form/FormSection.svelte';
import { enhance } from '$app/forms';
export let data: PageData;
export let form: ActionData;
let internalErrors: string[] = [];
let submitted = false;
$: errors = [...internalErrors, ...(form?.errors?.length ? form.errors : [])];
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.register.title')} - {PUBLIC_SITE_NAME}</title>
</svelte:head>
<SideContainer>
<h1>{PUBLIC_SITE_NAME}</h1>
<h2>{$t('account.register.title')}</h2>
<ColumnView>
{#if !data.enabled}
<Alert type="error">{$t('account.register.disabled')}</Alert>
{:else if form?.success}
<Alert type="success">{$t(`account.register.${form.success}`)}</Alert>
{:else}
<form action="" method="POST" use:enhance={enhanceFn}>
<FormWrapper>
{#if errors.length}
{#each errors as error}
<Alert type="error">{$t(`account.errors.${error}`)}</Alert>
{/each}
{/if}
<FormSection required>
<FormControl>
<label for="register-username">{$t('account.username')}</label>
<input
required
type="text"
name="username"
id="register-username"
autocomplete="username"
value={form?.username ?? ''}
/>
<span>{$t('account.usernameHint')}</span>
</FormControl>
<FormControl>
<label for="register-displayName">{$t('account.displayName')}</label>
<input
required
type="text"
name="displayName"
autocomplete="nickname"
id="register-displayName"
value={form?.displayName ?? ''}
/>
<span>{$t('account.displayNameHint')}</span>
</FormControl>
<FormControl>
<label for="register-email">{$t('account.email')}</label>
<input
required
type="email"
name="email"
id="register-email"
value={form?.email ?? ''}
autocomplete="email"
/>
</FormControl>
<FormControl>
<label for="register-password">{$t('account.password')}</label>
<input
required
type="password"
name="password"
id="register-password"
autocomplete="new-password"
/>
<span>{$t('account.passwordHint')}</span>
</FormControl>
<FormControl>
<label for="register-repeatPassword">{$t('account.repeatPassword')}</label>
<input
required
type="password"
name="repeatPassword"
id="register-repeatPassword"
autocomplete="new-password"
/>
</FormControl>
</FormSection>
<Button type="submit" variant="primary" disabled={submitted}
>{$t('account.register.submit')}</Button
>
</FormWrapper>
</form>
{/if}
<div><a href="/login">{$t('account.login.title')}</a></div>
</ColumnView>
</SideContainer>