diff --git a/package-lock.json b/package-lock.json index ef62603..f3f18db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "jose": "^5.3.0", "mime-types": "^2.1.35", "mysql2": "^3.9.7", + "nodemailer": "^6.9.13", "otplib": "^12.0.1", "qrcode": "^1.5.3", "svelte-kit-cookie-session": "^4.0.0", @@ -29,6 +30,7 @@ "@types/eslint": "^8.56.0", "@types/mime-types": "^2.1.4", "@types/node": "^20.12.12", + "@types/nodemailer": "^6.4.15", "@types/qrcode": "^1.5.5", "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^7.0.0", @@ -1559,6 +1561,15 @@ "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": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz", @@ -4108,6 +4119,14 @@ "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", "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": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/package.json b/package.json index 5587956..8cfd4bc 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@types/eslint": "^8.56.0", "@types/mime-types": "^2.1.4", "@types/node": "^20.12.12", + "@types/nodemailer": "^6.4.15", "@types/qrcode": "^1.5.5", "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^7.0.0", @@ -44,6 +45,7 @@ "jose": "^5.3.0", "mime-types": "^2.1.35", "mysql2": "^3.9.7", + "nodemailer": "^6.9.13", "otplib": "^12.0.1", "qrcode": "^1.5.3", "svelte-kit-cookie-session": "^4.0.0", diff --git a/src/app.css b/src/app.css index 7398b29..738a483 100644 --- a/src/app.css +++ b/src/app.css @@ -11,10 +11,12 @@ --in-normalized-background: #000; --in-input-background: #fff; --in-input-background-disabled: #c2c2c2; + --in-input-required-color: #ff0000; --in-input-color: #000; --in-input-color-disabled: #414141; --in-input-border-color: #ddd; --in-input-border-color-disabled: #a0a0a0; + --in-input-border-color-invalid: #ff0000; --in-alert-color: #006597; --in-error-color: #b52e2e; diff --git a/src/lib/components/Button.svelte b/src/lib/components/Button.svelte index a2a04bc..fc5fe58 100644 --- a/src/lib/components/Button.svelte +++ b/src/lib/components/Button.svelte @@ -3,10 +3,13 @@ export let type: 'button' | 'submit' = 'button'; export let variant: 'default' | 'primary' | 'link' = 'default'; + export let disabled = false; const dispath = createEventDispatcher(); - + diff --git a/src/lib/components/form/FormSection.svelte b/src/lib/components/form/FormSection.svelte index 3b82774..6bcf124 100644 --- a/src/lib/components/form/FormSection.svelte +++ b/src/lib/components/form/FormSection.svelte @@ -1,9 +1,13 @@
{#if title}
{title}
{/if} + {#if required}{$t('common.required')}{/if}
@@ -20,4 +24,15 @@ font-size: 1.2rem; font-weight: 700; } + + .form-required { + font-size: 0.9rem; + + &::before { + content: '*'; + color: var(--in-input-required-color); + margin-right: 6px; + font-weight: 700; + } + } diff --git a/src/lib/i18n/en/account.json b/src/lib/i18n/en/account.json index 0caace5..ed8c56e 100644 --- a/src/lib/i18n/en/account.json +++ b/src/lib/i18n/en/account.json @@ -1,17 +1,23 @@ { "title": "Manage your account", "username": "Username", + "usernameHint": "Only the English alphabet, numbers and _-. are allowed.", "displayName": "Display Name", + "displayNameHint": "Must be between 3 and 32 characters.", "changeEmail": "Change email address", "currentEmail": "Current email address", + "email": "Email address", "newEmail": "New email address", + "password": "Password", + "repeatPassword": "Repeat password", "changePassword": "Change password", "currentPassword": "Current password", "newPassword": "New password", - "repeatPassword": "Repeat new password", + "repeatNewPassword": "Repeat 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.", "avatar": { "title": "Profile avatar", "change": "Change avatar", @@ -30,16 +36,27 @@ "otp": "Two-factor authentication is enabled", "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": { "invalidLogin": "Invalid email or password!", "invalidRequest": "Invalid request! Please try again.", "emailRequired": "Email address is required.", + "invalidUsername": "The username entered is invalid or not allowed", "invalidEmail": "The email address is invalid.", "passwordRequired": "The password is required.", "passwordMismatch": "The passwords do not match!", "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." + "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": { "title": "Two-factor authentication", diff --git a/src/lib/i18n/en/common.json b/src/lib/i18n/en/common.json index a4c56c5..12b84c6 100644 --- a/src/lib/i18n/en/common.json +++ b/src/lib/i18n/en/common.json @@ -1,10 +1,10 @@ { - "siteName": "Icy Network", - "description": "Icy Network is a Single-Sign-On service used by other applications.", + "description": "{{siteName}} 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 completely open source and can be audited by anyone.", "submit": "Submit", "cancel": "Cancel", "manage": "Manage", "back": "Go back", - "home": "Home page" + "home": "Home page", + "required": "Required fields" } diff --git a/src/lib/i18n/en/oauth2.json b/src/lib/i18n/en/oauth2.json index fec6a4f..a0035bb 100644 --- a/src/lib/i18n/en/oauth2.json +++ b/src/lib/i18n/en/oauth2.json @@ -11,8 +11,7 @@ "email": "Your email address", "picture": "Your profile picture", "account": "Changing your password or other account settings", - "management": "Manage Icy Network on your behalf", - "admin": "Commit administrative actions to the extent of your user privileges" + "management": "Commit administrative actions to the extent of your user privileges" }, "link": { "website": "Website", diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts index f654f3e..55dd7af 100644 --- a/src/lib/i18n/index.ts +++ b/src/lib/i18n/index.ts @@ -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 = { loaders: [ { locale: 'en', diff --git a/src/lib/server/drizzle/migrations/relations.ts b/src/lib/server/drizzle/migrations/relations.ts deleted file mode 100644 index b0ceb00..0000000 --- a/src/lib/server/drizzle/migrations/relations.ts +++ /dev/null @@ -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] - }) -})); diff --git a/src/lib/server/drizzle/schema.ts b/src/lib/server/drizzle/schema.ts index 31ef498..4a54ae0 100644 --- a/src/lib/server/drizzle/schema.ts +++ b/src/lib/server/drizzle/schema.ts @@ -1,4 +1,4 @@ -import { sql } from 'drizzle-orm'; +import { relations, sql } from 'drizzle-orm'; import { mysqlTable, int, @@ -225,3 +225,117 @@ export const userToken = mysqlTable('user_token', { .default(sql`current_timestamp(6)`) .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] + }) +})); diff --git a/src/lib/server/email/index.ts b/src/lib/server/email/index.ts new file mode 100644 index 0000000..1a6361e --- /dev/null +++ b/src/lib/server/email/index.ts @@ -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 { + return this.transport?.sendMail({ + to, + subject, + text, + html, + from + }); + } + + public async sendTemplate(to: string, subject: string, message: EmailTemplate): Promise { + 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'; diff --git a/src/lib/server/email/template.interface.ts b/src/lib/server/email/template.interface.ts new file mode 100644 index 0000000..4baea66 --- /dev/null +++ b/src/lib/server/email/template.interface.ts @@ -0,0 +1,4 @@ +export interface EmailTemplate { + text: string; + html: string; +} diff --git a/src/lib/server/email/templates/forgot-password.email.ts b/src/lib/server/email/templates/forgot-password.email.ts new file mode 100644 index 0000000..2d30ebc --- /dev/null +++ b/src/lib/server/email/templates/forgot-password.email.ts @@ -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 */ ` +

${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.

+ ` +}); diff --git a/src/lib/server/email/templates/index.ts b/src/lib/server/email/templates/index.ts new file mode 100644 index 0000000..33f22bd --- /dev/null +++ b/src/lib/server/email/templates/index.ts @@ -0,0 +1,3 @@ +export * from './forgot-password.email'; +export * from './invitation.email'; +export * from './registration.email'; diff --git a/src/lib/server/email/templates/invitation.email.ts b/src/lib/server/email/templates/invitation.email.ts new file mode 100644 index 0000000..a654e3c --- /dev/null +++ b/src/lib/server/email/templates/invitation.email.ts @@ -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 */ ` +

${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.

+ ` +}); diff --git a/src/lib/server/email/templates/registration.email.ts b/src/lib/server/email/templates/registration.email.ts new file mode 100644 index 0000000..82f6f97 --- /dev/null +++ b/src/lib/server/email/templates/registration.email.ts @@ -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 */ ` +

${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.

+ ` +}); diff --git a/src/lib/server/oauth2/model/client.ts b/src/lib/server/oauth2/model/client.ts index c885e47..4d91523 100644 --- a/src/lib/server/oauth2/model/client.ts +++ b/src/lib/server/oauth2/model/client.ts @@ -124,7 +124,7 @@ export class OAuth2Clients { }); if (scope.includes('management')) { - allowedScopes.push('management', 'admin'); + allowedScopes.push('management'); } // TODO: client picture diff --git a/src/lib/server/users/index.ts b/src/lib/server/users/index.ts index c13c885..fd4c330 100644 --- a/src/lib/server/users/index.ts +++ b/src/lib/server/users/index.ts @@ -1,8 +1,13 @@ import bcrypt from 'bcryptjs'; -import { and, eq, or } from 'drizzle-orm'; -import { db, user, type User } from '../drizzle'; +import { and, eq, gt, or, sql } from 'drizzle-orm'; +import { db, user, userToken, type User } from '../drizzle'; import type { UserSession } from './types'; 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 { static async getById(id: number): Promise { @@ -71,6 +76,172 @@ export class Users { } 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 './tokens'; diff --git a/src/lib/server/users/tokens.ts b/src/lib/server/users/tokens.ts new file mode 100644 index 0000000..1abbfd2 --- /dev/null +++ b/src/lib/server/users/tokens.ts @@ -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 = { + 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)); + } +} diff --git a/src/lib/server/users/totp.ts b/src/lib/server/users/totp.ts index f210ad2..1cd5bda 100644 --- a/src/lib/server/users/totp.ts +++ b/src/lib/server/users/totp.ts @@ -1,6 +1,7 @@ import { authenticator as totp } from 'otplib'; import { db, userToken, type User } from '../drizzle'; import { and, eq, gt, isNull, or } from 'drizzle-orm'; +import { PUBLIC_SITE_NAME } from '$env/static/public'; totp.options = { window: 2 @@ -12,7 +13,7 @@ export class TimeOTP { } 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 { diff --git a/src/routes/account/+page.svelte b/src/routes/account/+page.svelte index c85de51..3efa4b0 100644 --- a/src/routes/account/+page.svelte +++ b/src/routes/account/+page.svelte @@ -15,6 +15,7 @@ import AvatarCard from '$lib/components/avatar/AvatarCard.svelte'; import AvatarModal from '$lib/components/avatar/AvatarModal.svelte'; import { writable } from 'svelte/store'; + import { PUBLIC_SITE_NAME } from '$env/static/public'; export let data: PageData; export let form: ActionData; @@ -51,8 +52,12 @@ }; + + {$t('account.title')} - {PUBLIC_SITE_NAME} + + -

{$t('common.siteName')}

+

{PUBLIC_SITE_NAME}

{$t('account.title')}

@@ -93,6 +98,7 @@ - + - + @@ -136,7 +147,7 @@ - + -

{$t('common.siteName')}

+

{PUBLIC_SITE_NAME}

{$t('account.otp.title')}

{#if form?.success} diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index ff8c544..2fb348b 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -93,3 +93,32 @@ export const actions = { return redirect(303, redirectUrl); } } 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 + }; +}; diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index 0dcc1fa..8000bc0 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -1,5 +1,7 @@ + + {$t('account.login.title')} - {PUBLIC_SITE_NAME} + + -

{$t('common.siteName')}

+

{PUBLIC_SITE_NAME}

{$t('account.login.title')}

+ {#if data?.activated === false && !form}{$t('account.errors.activationFailed')}{/if} + {#if data?.activated === true && !form}{$t('account.activateSuccess')}{/if} {#if form?.incorrect}{$t('account.errors.invalidLogin')}{/if} + {#if form?.otpRequired} @@ -49,11 +62,12 @@ {/if} +
-

{$t('common.description')}

+

{$t('common.description', { siteName: PUBLIC_SITE_NAME })}

{@html $t('common.cookieDisclaimer')}

diff --git a/src/routes/oauth2/authorize/+page.svelte b/src/routes/oauth2/authorize/+page.svelte index b69dce5..f7daf23 100644 --- a/src/routes/oauth2/authorize/+page.svelte +++ b/src/routes/oauth2/authorize/+page.svelte @@ -1,5 +1,6 @@ + + {$t('oauth2.authorize.title')} "{data.client?.title || ''}" - {PUBLIC_SITE_NAME} + + {#if data.error} -

{$t('common.siteName')}

+

{PUBLIC_SITE_NAME}

{$t('oauth2.authorize.errorPage')}

{$t('common.siteName')} +

{PUBLIC_SITE_NAME}

{$t('oauth2.authorize.title')}

diff --git a/src/routes/register/+page.server.ts b/src/routes/register/+page.server.ts new file mode 100644 index 0000000..54b7bc2 --- /dev/null +++ b/src/routes/register/+page.server.ts @@ -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(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( + (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' + }; +}; diff --git a/src/routes/register/+page.svelte b/src/routes/register/+page.svelte new file mode 100644 index 0000000..4230a9b --- /dev/null +++ b/src/routes/register/+page.svelte @@ -0,0 +1,134 @@ + + + + {$t('account.register.title')} - {PUBLIC_SITE_NAME} + + + +

{PUBLIC_SITE_NAME}

+

{$t('account.register.title')}

+ + + {#if !data.enabled} + {$t('account.register.disabled')} + {:else if form?.success} + {$t(`account.register.${form.success}`)} + {:else} +
+ + {#if errors.length} + {#each errors as error} + {$t(`account.errors.${error}`)} + {/each} + {/if} + + + + + + {$t('account.usernameHint')} + + + + + + {$t('account.displayNameHint')} + + + + + + + + + + + {$t('account.passwordHint')} + + + + + + + + + + +
+ {/if} + +
+