Compare commits

..

40 Commits

Author SHA1 Message Date
aa059527ad test app: fix triplicate route name 2024-09-21 18:40:11 +02:00
6031d3a2e6 app: log all errors (debug) 2024-09-21 18:39:45 +02:00
b59e053cad config/mysql: create database by default, use swaf user with password swaf by default, don't create database in production 2024-09-21 18:39:02 +02:00
8ae520aa07 Upgrade dependencies
- Add a new migration to fix password hash length `IncreaseMagicLinkTokenLengthMigration`
- Fix types
2024-09-21 18:37:51 +02:00
bbd9480000 chore(back): clean leftover console.log 2022-03-07 19:25:25 +01:00
ecb2b13a83 Version 0.25.1 2022-03-07 18:58:49 +01:00
a823503cb4 Upgrade @tsconfig/svelte 2022-03-07 18:58:21 +01:00
f0304c0b9f Upgrade @types/config, @types/node, concurrently, lucide 2022-03-07 18:56:35 +01:00
ae31edb622 Upgrade redis package 2022-03-07 18:42:50 +01:00
d378771ffa Upgrade dependencies 2022-03-07 18:15:48 +01:00
869791b3ad Version 0.25.0 2022-03-07 17:47:28 +01:00
e19a627eb5 fix(front/auth/login form): don't show password login method if there is not UserPasswordComponent 2022-03-07 17:41:20 +01:00
3d960dccf3 fix(front/auth/register form): don't show password register method if there is no UserNameComponent
fixes #44
2022-03-07 17:41:20 +01:00
6714e413a2 feat(front/form): allow forms to be disabled, add disabled buttons style 2022-03-07 17:22:43 +01:00
27e9abc5f4 fix(back/auth): refactor auth success, fix message saying 'Welcome undefined' when there is no UserNameComponent 2022-03-07 17:02:03 +01:00
8c083d562d fix(front/auth/login): show password field if previousFormData has password 2022-03-07 16:31:40 +01:00
4cfcaac1cc fix(front/forms): simplify and fix usage of flashed previousFormData and validation errors 2022-03-06 18:55:06 +01:00
3eb33c64d7 fix(front/Field): prevent label from having a greater z-index than the mobile menu 2022-03-06 18:46:33 +01:00
fd2852c387 feat(front/NavMenu): put logout button on the Account link under an extendable dropdown menu 2022-03-06 18:37:46 +01:00
9f17c5b8cd feat(front/style): add hr default style 2022-03-06 18:36:02 +01:00
144a72895e fix(front/account): fix first panel tag name (div->section) 2022-03-06 18:35:41 +01:00
81c65344a9 chore(front): remove unnecessary containers and reformat 2022-03-06 16:57:31 +01:00
41a083ba52 feat(back/auth): add use() method to AuthProof and call it on successful login attempt 2022-03-06 16:34:57 +01:00
0c4349fac3 fix(common/Time): fix humanizeDuration for duration > 1 year 2022-03-06 13:22:12 +01:00
cb5001ce6e fix(front/CopyableText): don't display title when not provided 2022-03-06 13:21:04 +01:00
ee56113808 fix(front/FlashMessages): only display relevant flash bags 2022-03-06 13:20:20 +01:00
2c3286d313 feat(front/data-table): add actions cell, col-grow and col-grow-cell 2022-03-05 10:05:13 +01:00
2e3c5d16c4 fix(back/flash): retrieve all flashed fields 2022-03-05 10:03:49 +01:00
81a62be38d feat(front/CopyableText): add simple button mode 2022-03-02 11:36:23 +01:00
afef367e59 chore(front): cleanup outdated todo 2022-03-02 10:23:19 +01:00
1e72ec7172 fix(front/Icon): better match icon type against Lucide Icons full name instead of starting with fa to avoid collisions 2022-03-02 10:22:55 +01:00
0e0e633e08 feat(front/Form): allow overriding onSubmit function 2022-02-28 14:43:36 +01:00
7a49e47ae7 feat(front/Field): expose file list 2022-02-28 14:42:36 +01:00
67dc33adf4 fix(front/checkbox): prevent checkbox double click event 2022-02-19 11:58:36 +01:00
231aa8dcd7 fix(front/checkbox): change value to boolean and use handleInput 2022-02-19 11:30:06 +01:00
535c8afdb1 fix(websockets): send cookies manually for session authentication 2022-02-18 22:59:16 +01:00
dad4ff62f1 Version 0.24.10 2021-12-03 00:42:20 +01:00
8626d0b571 Upgrade dependencies 2021-12-03 00:42:12 +01:00
32a1721ef2 front/BaseTemplate: add facebook, twitter and generic url preview meta tags 2021-12-03 00:40:31 +01:00
689c860e2e front/ErrorTemplate: fix header logo being cropped 2021-11-28 21:58:06 +01:00
57 changed files with 4353 additions and 3589 deletions

View File

@ -37,10 +37,10 @@
mysql: {
connectionLimit: 10,
host: "127.0.0.1",
user: "root",
password: "",
user: "swaf",
password: "swaf",
database: "swaf",
create_database_automatically: false,
create_database_automatically: true,
},
redis: {
host: "127.0.0.1",

View File

@ -16,6 +16,9 @@
secure: true,
allow_invalid_tls: false,
},
mysql: {
create_database_automatically: false,
},
session: {
cookie: {
secure: true,

View File

@ -3,9 +3,6 @@
approval_mode: true,
},
mysql: {
host: "127.0.0.1",
user: "root",
password: "",
database: "swaf_test",
create_database_automatically: true,
},

View File

@ -1,6 +1,6 @@
{
"name": "swaf",
"version": "0.24.9",
"version": "0.25.1",
"description": "Structure Web Application Framework.",
"repository": "https://eternae.ink/ashpie/swaf",
"author": "Alice Gaudon <alice@gaudon.pro>",
@ -25,9 +25,9 @@
},
"devDependencies": {
"@sveltejs/eslint-config": "sveltejs/eslint-config",
"@tsconfig/svelte": "^2.0.1",
"@tsconfig/svelte": "^3.0.0",
"@types/compression": "^1.7.0",
"@types/config": "^0.0.40",
"@types/config": "^0.0.41",
"@types/connect-flash": "^0.0.37",
"@types/cookie": "^0.4.0",
"@types/cookie-parser": "^1.4.2",
@ -38,11 +38,10 @@
"@types/jest": "^27.0.2",
"@types/mjml": "^4.0.4",
"@types/mysql": "^2.15.10",
"@types/node": "^16.11.10",
"@types/node": "^20.15.0",
"@types/nodemailer": "^6.4.0",
"@types/nunjucks": "^3.1.3",
"@types/on-finished": "^2.3.1",
"@types/redis": "^2.8.18",
"@types/require-from-string": "^1.2.0",
"@types/supertest": "^2.0.10",
"@types/uuid": "^8.0.0",
@ -50,7 +49,7 @@
"@typescript-eslint/eslint-plugin": "^5.3.0",
"@typescript-eslint/parser": "^5.3.0",
"chokidar": "^3.5.1",
"concurrently": "^6.0.0",
"concurrently": "^7.0.0",
"eslint": "^8.2.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-node": "^11.1.0",
@ -59,7 +58,7 @@
"jest": "^27.3.1",
"jest-resolve": "^27.3.1",
"jest-ts-webcompat-resolver": "^1.0.0",
"maildev": "^1.1.0",
"maildev": "^2.1.0",
"node-fetch": "^3.0.0",
"nodemon": "^2.0.6",
"sass": "^1.32.12",
@ -83,7 +82,7 @@
"express-session": "^1.17.1",
"formidable": "^2.0.1",
"geoip-lite": "^1.4.2",
"lucide": "^0.16.17",
"lucide": "^0.17.7",
"mjml": "^4.6.2",
"mysql": "^2.18.1",
"nanoid": "^3.1.20",
@ -91,7 +90,7 @@
"normalize.css": "^8.0.1",
"nunjucks": "^3.2.1",
"on-finished": "^2.3.0",
"redis": "^3.0.2",
"redis": "^4.0.4",
"require-from-string": "^2.0.2",
"rollup": "^2.42.3",
"rollup-plugin-css-only": "^3.1.0",

View File

@ -18,7 +18,7 @@ import Extendable, {MissingComponentError} from "./Extendable.js";
import {BadRequestError, HttpError, NotFoundHttpError, ServerError, ServiceUnavailableHttpError} from "./HttpError.js";
import {logger, loggingContextMiddleware} from "./Logger.js";
import SecurityError from "./SecurityError.js";
import {doesFileExist, Type} from "./Utils.js";
import {doesFileExist, Type, WrappingError} from "./Utils.js";
import WebSocketListener from "./WebSocketListener.js";
import TemplateError = nunjucks.lib.TemplateError;
import AppLocalsCoreComponents from "./components/core/AppLocalsCoreComponents.js";
@ -145,6 +145,11 @@ export default abstract class Application implements Extendable<ApplicationCompo
app.use((err: unknown, req: Request, res: Response, next: NextFunction) => {
if (res.headersSent) return next(err);
logger.debug(err);
if (err instanceof WrappingError) {
logger.debug("-> Caused by", err.cause);
}
// Transform single validation errors into a validation bag for convenience
if (err instanceof ValidationError) {
const bag = new ValidationBag();
@ -326,8 +331,8 @@ export default abstract class Application implements Extendable<ApplicationCompo
for (const file of fs.readdirSync(configDir)) {
const fullPath = path.resolve(configDir, file);
const stats = fs.lstatSync(fullPath);
if (stats.uid !== process.getuid())
throw new SecurityError(`${fullPath} is not owned by this process (${process.getuid()}).`);
if (stats.uid !== process.getuid?.())
throw new SecurityError(`${fullPath} is not owned by this process (${process.getuid?.()}).`);
const mode = (stats.mode & parseInt('777', 8)).toString(8);
if (mode !== '400')

View File

@ -0,0 +1,51 @@
import config from "config";
import cookie from "cookie";
import cookieParser from "cookie-parser";
import {Request} from "express";
import {Session} from "express-session";
import {IncomingMessage} from "http";
import {WebSocket} from "ws";
import Application from "./Application.js";
import RedisComponent from "./components/RedisComponent.js";
import {logger} from "./Logger.js";
import WebSocketListener from "./WebSocketListener.js";
export default abstract class SessionWebSocketListener<A extends Application> extends WebSocketListener<A> {
public async handle(socket: WebSocket, request: IncomingMessage): Promise<void> {
socket.once('message', (data, isBinary) => {
if (isBinary) return socket.close(1003);
const cookies = cookie.parse(data.toString());
const sid = cookieParser.signedCookie(cookies['connect.sid'], config.get('session.secret'));
if (!sid) {
socket.close(1002, 'Could not decrypt provided session cookie.');
return;
}
const store = this.getApp().as(RedisComponent).getStore();
store.get(sid, (err, session) => {
if (err || !session) {
logger.error(err, 'Error while initializing session in websocket for sid ' + sid);
socket.close(1011);
return;
}
session.id = sid;
store.createSession(<Request>request, session);
this.handleSessionSocket(socket, request, session as Session).catch(err => {
logger.error(err, 'Error in websocket listener.');
});
});
});
}
protected abstract handleSessionSocket(
socket: WebSocket,
request: IncomingMessage,
session: Session,
): Promise<void>;
}

View File

@ -4,12 +4,12 @@ import Application from "./Application.js";
import AccountController from "./auth/AccountController.js";
import AuthComponent from "./auth/AuthComponent.js";
import AuthController from "./auth/AuthController.js";
import AddUsedToMagicLinksMigration from "./auth/magic_link/AddUsedToMagicLinksMigration.js";
import CreateMagicLinksTableMigration from "./auth/magic_link/CreateMagicLinksTableMigration.js";
import AddUsedToMagicLinksMigration from "./auth/magic_link/migrations/AddUsedToMagicLinksMigration.js";
import CreateMagicLinksTableMigration from "./auth/magic_link/migrations/CreateMagicLinksTableMigration.js";
import MagicLinkAuthMethod from "./auth/magic_link/MagicLinkAuthMethod.js";
import MagicLinkController from "./auth/magic_link/MagicLinkController.js";
import MagicLinkWebSocketListener from "./auth/magic_link/MagicLinkWebSocketListener.js";
import MakeMagicLinksSessionNotUniqueMigration from "./auth/magic_link/MakeMagicLinksSessionNotUniqueMigration.js";
import MakeMagicLinksSessionNotUniqueMigration from "./auth/magic_link/migrations/MakeMagicLinksSessionNotUniqueMigration.js";
import AddApprovedFieldToUsersTableMigration from "./auth/migrations/AddApprovedFieldToUsersTableMigration.js";
import AddNameChangedAtToUsersMigration from "./auth/migrations/AddNameChangedAtToUsersMigration.js";
import AddNameToUsersMigration from "./auth/migrations/AddNameToUsersMigration.js";
@ -42,6 +42,7 @@ import BackendController from "./helpers/BackendController.js";
import MailController from "./mail/MailController.js";
import {MAGIC_LINK_MAIL} from "./Mails.js";
import CreateMigrationsTable from "./migrations/CreateMigrationsTable.js";
import IncreaseMagicLinkTokenLengthMigration from "./auth/magic_link/migrations/IncreaseMagicLinkTokenLengthMigration.js";
export const MIGRATIONS = [
CreateMigrationsTable,
@ -52,6 +53,7 @@ export const MIGRATIONS = [
MakeMagicLinksSessionNotUniqueMigration,
AddUsedToMagicLinksMigration,
AddNameChangedAtToUsersMigration,
IncreaseMagicLinkTokenLengthMigration,
];
export default class TestApp extends Application {

View File

@ -1,4 +1,3 @@
import {Session} from "express-session";
import {IncomingMessage} from "http";
import WebSocket from "ws";
@ -20,6 +19,5 @@ export default abstract class WebSocketListener<T extends Application> {
public abstract handle(
socket: WebSocket,
request: IncomingMessage,
session: Session | null,
): Promise<void>;
}

View File

@ -155,7 +155,7 @@ h1 {
a {
text-decoration: none;
.icon.lucide-external-link { //todo add js
.icon.lucide-external-link {
--icon-size: 16px;
margin-left: 4px;
margin-top: -3px;
@ -166,6 +166,13 @@ ul {
list-style-type: '- ';
}
hr {
margin: 0;
border: 0;
border-top: 1px solid var(--on-background);
opacity: 0.2;
}
.primary, .bold {
--color: var(--primary-on-background);
--background-color: var(--background);
@ -249,7 +256,7 @@ button, .button {
margin-left: 8px;
}
&:hover::after {
&:hover::after:not([disabled]) {
content: "";
position: absolute;
@ -260,4 +267,10 @@ button, .button {
background-color: var(--on-background);
opacity: 0.2;
}
&[disabled] {
position: relative;
cursor: not-allowed;
opacity: 0.1;
}
}

View File

@ -23,6 +23,46 @@
thead tr:hover {
background-color: transparent;
}
tbody td.actions {
display: flex;
flex-direction: row;
button {
padding: 8px;
.icon {
margin: 0 !important;
}
.tip {
display: none;
}
}
}
thead th.col-grow {
width: 100%;
}
tbody td.col-grow-cell {
> * {
display: flex;
flex-direction: row;
> * {
width: 0;
flex-grow: 1;
white-space: nowrap;
text-overflow: ellipsis;
}
* {
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
.data-table-container {

View File

@ -17,6 +17,7 @@ export default class WebsocketClient {
const websocket = new WebSocket(this.websocketUrl);
websocket.onopen = () => {
console.debug('Websocket connected');
websocket.send(document.cookie);
};
websocket.onmessage = (e) => {
this.listener(websocket, e);

View File

@ -1,5 +1,7 @@
import {createIcons, icons} from "lucide";
import {toLucideIconsPascalCase} from "../../common/StringUtils.js";
let hasAlreadyReplacedIcons = false;
export function replaceIcons(once: boolean): void {
@ -9,3 +11,7 @@ export function replaceIcons(once: boolean): void {
hasAlreadyReplacedIcons = true;
}
}
export function isLucideIcon(iconName: string): boolean {
return Object.keys(icons).indexOf(toLucideIconsPascalCase(iconName)) >= 0;
}

View File

@ -15,9 +15,11 @@
</script>
<BaseTemplate title="Account" description="Manage your account settings and data.">
<div class="container">
<div class="panel">
<h2><Icon name="user"/> Personal information</h2>
<section class="panel">
<h2>
<Icon name="user"/>
Personal information
</h2>
{#if $locals.display_email_warning && $locals.emails.length <= 0}
<Message type="warning" content="To avoid losing access to your account, please add an email address."/>
@ -30,7 +32,7 @@
{#if mainEmail}
<p>Contact email: {mainEmail} <a href="#emails">More...</a></p>
{/if}
</div>
</section>
{#if $locals.has_name_component}
<NamePanel/>
@ -41,7 +43,10 @@
{/if}
<section class="panel">
<h2 id="emails"><Icon name="shield"/> Email addresses</h2>
<h2 id="emails">
<Icon name="shield"/>
Email addresses
</h2>
<div class="data-table-container">
<table class="data-table">
@ -71,7 +76,8 @@
<td>{email.email}</td>
<td class="actions">
<Form action={route('set-main-email')} button
submitIcon="refresh-ccw" submitText="Set as main address" submitClass="warning"
submitIcon="refresh-ccw" submitText="Set as main address"
submitClass="warning"
confirm="Are you sure you want to set {email.email} as your main address?">
<Field type="hidden" name="id" value={email.id}/>
</Form>
@ -100,5 +106,4 @@
</Form>
{/if}
</section>
</div>
</BaseTemplate>

View File

@ -6,8 +6,8 @@
import Icon from "../utils/Icon.svelte";
import {hasRoute, route} from "../../../common/Routing";
let registerUsingMagicLink = $locals.previousFormData?.['auth_method'] !== 'password';
let loginUsingMagicLink = true;
let registerUsingMagicLink = $locals.flash.previousFormData?.[0]?.['auth_method'] !== 'password';
let loginUsingMagicLink = !$locals.flash.previousFormData?.[0]?.['password'];
let queryStr = '';
let previousUrl: string = $locals.previousUrl as string | undefined;
@ -22,49 +22,72 @@
description="Join {$locals.app.name} and share your files!"
h1="Authentication and registration">
<div class="container">
{#if hasRoute('login')}
<section class="panel">
<h2><Icon name="log-in"/> Log in</h2>
<h2>
<Icon name="log-in"/>
Log in
</h2>
<Form action={route('login') + queryStr} submitText="Authenticate" submitIcon="log-in">
<Field type="text" name="identifier" value={$locals.query?.identifier} icon="at-sign"
hint={loginUsingMagicLink ? 'You will receive a magic link in your mailbox. Click on the link from any device to authenticate here.' : ''}
placeholder="Your email address or username" required/>
{#if !loginUsingMagicLink}
<Field type="password" name="password" placeholder="Your password" icon="key" required/>
<button on:click={() => loginUsingMagicLink=true} type="button"><Icon name="mail"/> Use magic link</button>
{#if $locals.hasPassword}
{#if loginUsingMagicLink}
<button on:click={() => loginUsingMagicLink=false} type="button">
<Icon name="key"/>
Use password
</button>
{:else}
<button on:click={() => loginUsingMagicLink=false} type="button"><Icon name="key"/> Use password</button>
<Field type="password" name="password" placeholder="Your password" icon="key" required/>
<button on:click={() => loginUsingMagicLink=true} type="button">
<Icon name="mail"/>
Use magic link
</button>
{/if}
<Field type="checkbox" name="persist_session" icon="clock" placeholder="Stay logged in on this computer."/>
{/if}
<Field type="checkbox" name="persist_session" icon="clock"
placeholder="Stay logged in on this computer."/>
</Form>
</section>
{/if}
{#if hasRoute('register')}
<section class="panel">
<h2><Icon name="user-plus"/> Register</h2>
<h2>
<Icon name="user-plus"/>
Register
</h2>
<Form action={route('register') + queryStr} submitText="Register" submitIcon="check">
<Field type="hidden" name="auth_method" value={registerUsingMagicLink ? 'magic_link': 'password'}/>
{#if $locals.has_username}
{#if $locals.hasUsername}
<Field type="text" name={registerUsingMagicLink ? 'name' : 'identifier'} icon="user"
placeholder="Choose your username"
pattern="[0-9a-z_-]+" required/>
{/if}
{#if registerUsingMagicLink}
{#if registerUsingMagicLink || !$locals.canRegisterWithPassword}
<Field type="email" name="identifier" icon="at-sign" placeholder="Your email address"
hint="You will receive a magic link in your mailbox. Click on the link from any device to register here."
required/>
<button on:click={() => registerUsingMagicLink=false} type="button"><Icon name="key"/>Use password</button>
{#if $locals.canRegisterWithPassword}
<button on:click={() => registerUsingMagicLink=false} type="button">
<Icon name="key"/>
Use password
</button>
{/if}
{:else}
<Field type="password" name="password" icon="key" placeholder="Choose a password" required/>
<Field type="password" name="password_confirmation" icon="key" placeholder="Confirm your password" required/>
<button on:click={() => registerUsingMagicLink=true} type="button"><Icon name="at-sign"/>Use email address instead</button>
<Field type="password" name="password_confirmation" icon="key" placeholder="Confirm your password"
required/>
<button on:click={() => registerUsingMagicLink=true} type="button">
<Icon name="at-sign"/>
Use email address instead
</button>
{/if}
<Field type="checkbox" name="terms" icon="file-text" required>
@ -73,5 +96,4 @@
</Form>
</section>
{/if}
</div>
</BaseTemplate>

View File

@ -8,7 +8,6 @@
</script>
<BaseTemplate title="{$locals.app.name} backend" h1={false}>
<div class="container">
<Breadcrumb currentPageTitle="Backend"/>
<h1>App administration</h1>
@ -29,5 +28,4 @@
</ul>
</nav>
</div>
</div>
</BaseTemplate>

View File

@ -2,8 +2,9 @@
import Icon from "../utils/Icon.svelte";
import { fade } from "svelte/transition";
export let title: string;
export let title: string | undefined = undefined;
export let content: string;
export let buttonMode: boolean = false;
let contentNode: HTMLElement;
let copiedOverlay: HTMLElement;
@ -68,6 +69,11 @@
margin: 8px;
}
}
}
.button-mode-button {
position: relative;
}
.copied-overlay {
position: absolute;
@ -80,16 +86,38 @@
text-align: center;
background-color: var(--success);
}
.content.hidden {
pointer-events: none;
overflow: hidden;
width: 0;
height: 0;
margin: 0;
padding: 0;
position: absolute;
}
</style>
<div class="copyable-text panel">
{#if buttonMode}
<div class="content hidden" bind:this={contentNode} on:click={selectAll}>{content}</div>
<button class="bold button-mode-button" on:click={copy} title="{content}">
<Icon name="copy"/>
{#if showCopiedOverlay}
<div class="copied-overlay" bind:this={copiedOverlay} out:fade on:mouseleave={releaseOverlay}><Icon name="check"/></div>
{/if}
</button>
{:else}
<div class="copyable-text panel">
{#if title}
<div class="title">{title}</div>
{/if}
<div class="content" bind:this={contentNode} on:click={selectAll}>{content}</div>
<button class="bold copy-button" on:click={copy}><Icon name="copy"/></button>
{#if showCopiedOverlay}
<div class="copied-overlay" bind:this={copiedOverlay} out:fade on:mouseleave={releaseOverlay}>Copied!</div>
{/if}
</div>
</div>
{/if}

View File

@ -3,6 +3,13 @@
import Message from "./Message.svelte";
export let flashed = $locals.flash;
const displayedCategories = [
'success',
'info',
'warning',
'error',
'error-alert',
];
</script>
<style lang="scss">
@ -13,7 +20,7 @@
<div class="messages">
{#if flashed}
{#each Object.entries(flashed) as [key, bag], i}
{#each Object.entries(flashed).filter(entry => displayedCategories.indexOf(entry[0]) >= 0) as [key, bag], i}
{#each bag as content}
<Message type={key} content={content}/>
{/each}

View File

@ -67,16 +67,19 @@
}
ul {
display: flex;
flex-direction: column;
gap: 8px;
align-items: stretch;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
list-style: none;
align-items: center;
@include large-ge {
display: flex;
flex-direction: row;
align-items: center;
}
}
}

View File

@ -0,0 +1,72 @@
<script lang="ts">
import Icon from "../utils/Icon.svelte";
export let open: boolean = false;
let hovered = false;
function onMouseEnter() {
hovered = true;
}
function onMouseLeave() {
hovered = false;
}
</script>
<style lang="scss">
@import "../../scss/helpers";
ul {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
@include large-ge() {
ul:not(.open) {
display: none;
}
ul {
position: absolute;
top: calc(100% - 3px);
right: 0;
z-index: 1;
@include surface(3);
padding: 16px;
border-top: 3px solid #ffffff1c;
border-radius: 0 0 3px 3px;
}
}
.icon-container {
position: absolute;
z-index: 2;
left: 50%;
top: calc(100% - 8px);
transition: transform 50ms linear;
transform: translateX(-50%);
&.open {
transform: translateX(-50%) translateY(8px) rotateX(180deg);
}
}
@include medium-le {
.icon-container {
display: none;
}
}
</style>
<div class="icon-container" class:open={open || hovered}>
<Icon name="chevron-down"/>
</div>
<ul class:open={open || hovered} on:mouseenter={onMouseEnter} on:mouseleave={onMouseLeave}>
<slot/>
</ul>

View File

@ -6,27 +6,34 @@
export let icon;
export let text;
export let action = false;
export let hovered = false;
function onMouseEnter() {
hovered = true;
}
function onMouseLeave() {
hovered = false;
}
</script>
<style lang="scss">
@import "../../scss/helpers";
li {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
flex-direction: column;
align-items: stretch;
gap: 8px;
line-height: 1;
@include medium-le {
&:not(:first-child) {
margin-top: 8px;
}
}
@mixin aHover {
background-color: rgba(0, 0, 0, 0.07);
@include large-ge {
&:not(:first-child) {
margin-left: 8px;
@include darkMode {
background-color: rgba(255, 255, 255, 0.07);
}
}
@ -38,17 +45,23 @@
height: auto;
padding: 8px;
white-space: nowrap;
border-radius: 3px;
&:hover {
background-color: rgba(0, 0, 0, 0.07);
@include darkMode {
background-color: rgba(255, 255, 255, 0.07);
}
}
text-transform: uppercase;
@include medium-le {
&:hover {
@include aHover;
}
}
}
@include large-ge {
&:hover > a {
@include aHover;
}
}
:global(form) {
@ -69,7 +82,7 @@
}
</style>
<li>
<li on:mouseenter={onMouseEnter} on:mouseleave={onMouseLeave}>
{#if action}
<Form action={href} submitIcon={icon} submitText={text}/>
{:else}
@ -77,4 +90,5 @@
<Icon name={icon}/>
<span class="tip">{text}</span></a>
{/if}
<slot/>
</li>

View File

@ -8,7 +8,6 @@
</script>
<BaseTemplate title="{$locals.app.name} {h1}" {h1}>
<div class="container">
<div class="panel">
{#if $locals.err}
<Message type="error" content={$locals.err}/>
@ -17,5 +16,4 @@
<p>You can now close this page.</p>
{/if}
</div>
</div>
</BaseTemplate>

View File

@ -38,7 +38,6 @@
</script>
<BaseTemplate h1="Authentication lobby" title="{$locals.app.name} authentication lobby">
<div class="container">
<div class="panel">
<Message type="success" sticky
content={`We sent a link to ${$locals.email}. To authenticate, open it from any device.`}/>
@ -47,5 +46,4 @@
<p class="center">Waiting for you to open the link...</p>
</div>
</div>
</BaseTemplate>

View File

@ -3,10 +3,12 @@
import BaseFooter from "./base/BaseFooter.svelte";
import BaseHeader from "./base/BaseHeader.svelte";
import CommonScripts from "./CommonScripts.svelte";
import {locals} from '../../ts/stores.js';
export let title: string;
export let h1: string = title;
export let description: string;
export let previewImageUrl: string | undefined = undefined;
export let refresh_after: number | undefined = undefined;
export let noHeader: boolean = false;
export let noH1: boolean = false;
@ -37,8 +39,22 @@
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>{title || 'Undefined title'}</title>
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:title" content={title}>
<meta property="og:url" content="{$locals.app.public_url + $locals.url}">
<meta property="twitter:title" content={title}>
<meta property="twitter:url" content={$locals.app.public_url + $locals.url}>
{#if description}
<meta name="description" content={description}>
<meta property="og:description" content={description}>
<meta property="twitter:description" content={description}>
{/if}
{#if previewImageUrl}
<meta property="og:image" content={previewImageUrl}>
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:image" content={previewImageUrl}>
{/if}
<link rel="shortcut icon" type="image/png" href="/img/logox1024.png">

View File

@ -135,7 +135,7 @@
background-image: url(../../img/logo.svg);
background-repeat: no-repeat;
background-position: center;
background-size: 64px;
background-size: contain;
opacity: 0.075;
filter: contrast(0);

View File

@ -2,8 +2,11 @@
import {locals} from "../../../ts/stores.js";
import NavMenuItem from "../../components/NavMenuItem.svelte";
import {hasRoute, route} from "../../../../common/Routing";
import NavMenuDropdown from "../../components/NavMenuDropdown.svelte";
import BaseNavMenuAuthAccountDropdownAdditionalLinks from "./BaseNavMenuAuthAccountDropdownAdditionalLinks.svelte";
export let noLoginLink = false;
let accountItemHovered;
</script>
{#if hasRoute('auth')}
@ -12,8 +15,12 @@
<NavMenuItem href={route('backend')} icon="settings" text="Backend"/>
{/if}
<NavMenuItem href={route('account')} icon="user" text={$locals.user.name || 'Account'}/>
<NavMenuItem href={route('account')} icon="user" text={$locals.user.name || 'Account'} bind:hovered={accountItemHovered}>
<NavMenuDropdown bind:open={accountItemHovered}>
<BaseNavMenuAuthAccountDropdownAdditionalLinks/>
<NavMenuItem href={route('logout')} icon="log-out" text="Logout" action/>
</NavMenuDropdown>
</NavMenuItem>
{:else if !noLoginLink}
<NavMenuItem href={route('auth')} icon="log-in" text="Log in / Register"/>
{/if}

View File

@ -8,19 +8,19 @@
export let type: string;
export let name: string;
type FieldValue = string | number | Record<string, FieldValue>;
type FieldValue = string | number | boolean | Record<string, FieldValue>;
export let value: FieldValue | undefined = undefined;
export let initialValue: FieldValue | undefined = undefined;
export let placeholder: string | undefined = undefined;
export let hint: string | undefined = undefined;
export let extraData: string[] | undefined = undefined;
export let icon: string | undefined = undefined;
export let validation = $locals.validation?.[name] as { message: string, value?: string } | undefined;
export let validation = $locals.flash.validation?.[0]?.[name] as { message: string, value?: string } | undefined;
const formId = getContext('formId');
const fieldId = `${formId}-${name}-field`;
const previousFormData = $locals.previousFormData as FieldValue | undefined || [];
const previousFormData = $locals.flash.previousFormData?.[0] as Record<string, FieldValue> | undefined || {};
let previousFieldData = previousFormData[name];
if (typeof value === 'number' && previousFieldData) previousFieldData = Number(previousFieldData);
@ -45,9 +45,13 @@
}
}
function focusInput() {
function focusInput(e) {
if (input) {
if (['file', 'checkbox', 'color'].indexOf(input.type) >= 0) {
if (input.type === 'checkbox') {
if (e.target !== label && e.target !== input) {
input.click();
}
} else if (['file', 'color'].indexOf(input.type) >= 0) {
input.click();
} else {
input.focus();
@ -58,31 +62,37 @@
}
function handleInput() {
// in here, you can switch on type and implement
// whatever behaviour you need
value = type.match(/^(number|range)$/)
? +this.value
: this.value;
if (this.type === 'file') {
switch (this.type) {
case 'number':
case 'range':
value = +this.value;
break;
case 'file':
handleFileInput();
}
if (this.type === 'datetime-local') {
break;
case 'datetime-local':
value = dateToIsoString(new Date(this.value));
break;
case 'checkbox':
value = !!this.checked;
break;
default:
value = this.value;
break;
}
}
let input: HTMLInputElement;
let label: HTMLLabelElement;
function chooseFile() {
input.click();
}
let files: FileList | undefined;
export let fileList: FileList | undefined = undefined;
function handleFileInput() {
files = input.files;
fileList = input.files;
}
let focused = false;
@ -98,6 +108,7 @@
.control {
position: relative;
z-index: 0;
display: flex;
align-items: start;
flex-direction: row;
@ -423,7 +434,8 @@
on:focusout={() => focused = false}></textarea>
</div>
{:else if type === 'checkbox'}
<input {type} {name} id={fieldId} checked={value === 'on'} {...$$restProps} bind:this={input}>
<input {type} {name} id={fieldId} checked={!!value} {...$$restProps} bind:this={input}
on:change={handleInput}>
{:else if type === 'datetime-local'}
<input {type} bind:this={input} on:input={handleInput} value={initialDatetimeLocalValue}>
<input type="hidden" {name} {value}>
@ -434,15 +446,15 @@
<div class="sections">
{#if type !== 'duration'}
<label for={fieldId}>{@html placeholder || ''}
<label for={fieldId} bind:this={label}>{@html placeholder || ''}
<slot/>
</label>
{/if}
{#if type === 'file'}
{#if files}
{#if fileList}
<div class="files">
{#each files as file}
{#each fileList as file}
<div class="file">
<div class="name" title="Type: {file.type}">
<Icon name="file"/> {file.name}

View File

@ -11,6 +11,7 @@
export let submitText: string;
export let submitIcon: string;
export let submitClass: string = undefined;
export let submitDisabled: boolean = false;
export let isBoldSubmit: boolean = true;
export let resetButton: boolean = false;
export let confirm: string = undefined;
@ -19,11 +20,11 @@
const formId = nextAvailableFormId++;
setContext('formId', formId);
function handleSubmit(e) {
if (confirm && !window.confirm(confirm)) {
export let onSubmit = function(e) {
if (submitDisabled || confirm && !window.confirm(confirm)) {
e.preventDefault();
}
}
};
</script>
<style lang="scss">
@ -38,7 +39,7 @@
}
</style>
<form {action} method="POST" id="{formId}-form" on:submit={handleSubmit} enctype={withFiles ? 'multipart/form-data' : undefined}>
<form {action} method="POST" id="{formId}-form" on:submit={onSubmit} enctype={withFiles ? 'multipart/form-data' : undefined}>
<CsrfTokenField/>
<slot/>
<div class="form-controls">
@ -46,7 +47,7 @@
<button type="reset"><Icon name="trash"/>Reset</button>
{/if}
<button type="submit" class={submitClass} class:bold={isBoldSubmit}>
<button type="submit" class={submitClass} class:bold={isBoldSubmit} disabled={submitDisabled}>
{#if submitIcon}
<Icon name={submitIcon}/>
{/if}

View File

@ -1,5 +1,5 @@
<script lang="ts">
import {replaceIcons} from "../../ts/icons.js";
import {replaceIcons, isLucideIcon} from "../../ts/icons.js";
import {afterUpdate, onMount} from "svelte";
export let name: string;
@ -39,9 +39,9 @@
</style>
{#if name}
{#if name.startsWith('fa') }
<i class="{name} icon" aria-hidden="true" {...$$restProps}></i>
{:else}
{#if isLucideIcon(name) >= 0 }
<i icon-name="{name}" class="icon" aria-hidden="true" {...$$restProps}></i>
{:else}
<i class="{name} icon" aria-hidden="true" {...$$restProps}></i>
{/if}
{/if}

View File

@ -12,6 +12,15 @@ import UserNameComponent from "./models/UserNameComponent.js";
import UserPasswordComponent from "./password/UserPasswordComponent.js";
export default class AuthController extends Controller {
public static flashSuccessfulAuthenticationWelcomeMessage(
user: User,
req: Request,
messagePrefix: string,
): void {
const name = user.asOptional(UserNameComponent)?.getName();
req.flash('success', `${messagePrefix} Welcome${name ? `, ${name}` : ''}.`);
}
public getRoutesPrefix(): string {
return '/auth';
}
@ -35,10 +44,12 @@ export default class AuthController extends Controller {
const userModelFactory = ModelFactory.get(User);
const hasUsername = userModelFactory.hasComponent(UserNameComponent);
const hasPassword = userModelFactory.hasComponent(UserPasswordComponent);
res.formatViewData('auth/auth', {
auth_methods: authGuard.getAuthMethodNames(),
has_username: hasUsername,
register_with_password: hasUsername && userModelFactory.hasComponent(UserPasswordComponent),
hasUsername: hasUsername,
hasPassword: hasPassword,
canRegisterWithPassword: hasUsername && hasPassword,
});
}

View File

@ -160,6 +160,9 @@ export default class AuthGuard {
throw new PendingApprovalAuthError();
}
// Mark auth proof as used
await proof.use?.();
// Login
session.isAuthenticated = true;
session.persistent = persistSession;

View File

@ -38,4 +38,10 @@ export default interface AuthProof<R> {
* instance.
*/
revoke(): Promise<void>;
/**
* This method is called when the AuthProof was used in a successful login attempt.
* If you modify the AuthProof, you should make sure changes are persistent.
*/
use?(): Promise<void>;
}

View File

@ -83,7 +83,7 @@ export default class MagicLinkAuthMethod implements AuthMethod<MagicLink> {
}
private async auth(req: Request, res: Response, isRegistration: boolean, email: string): Promise<void> {
const geo = geoip.lookup(req.ip);
const geo = req.ip ? geoip.lookup(req.ip) : null;
const actionType = isRegistration ? AuthMagicLinkActionType.REGISTER : AuthMagicLinkActionType.LOGIN;
if (isRegistration) {

View File

@ -12,6 +12,7 @@ import Mail from "../../mail/Mail.js";
import MailTemplate from "../../mail/MailTemplate.js";
import Throttler from "../../Throttler.js";
import AuthComponent, {AuthMiddleware} from "../AuthComponent.js";
import AuthController from "../AuthController.js";
import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuard.js";
import MagicLink from "../models/MagicLink.js";
import MagicLinkUserNameComponent from "../models/MagicLinkUserNameComponent.js";
@ -141,7 +142,7 @@ export default class MagicLinkController<A extends Application> extends Controll
}
if (await validLink.isAuthorized()) {
validLink.use();
validLink.useLink();
await validLink.save();
await this.performAction(validLink, req, res);
return;
@ -198,8 +199,7 @@ export default class MagicLinkController<A extends Application> extends Controll
if (!res.headersSent && user) {
// Auth success
const name = user.asOptional(UserNameComponent)?.getName();
req.flash('success', `Authentication success. Welcome${name ? `, ${name}` : ''}`);
AuthController.flashSuccessfulAuthenticationWelcomeMessage(user, req, 'Authentication success.');
res.redirect(req.getIntendedUrl() || route('home'));
}
break;

View File

@ -3,10 +3,10 @@ import {IncomingMessage} from "http";
import WebSocket from "ws";
import Application from "../../Application.js";
import WebSocketListener from "../../WebSocketListener.js";
import SessionWebSocketListener from "../../SessionWebSocketListener.js";
import MagicLink from "../models/MagicLink.js";
export default class MagicLinkWebSocketListener<A extends Application> extends WebSocketListener<A> {
export default class MagicLinkWebSocketListener<A extends Application> extends SessionWebSocketListener<A> {
private readonly connections: { [p: string]: (() => void)[] | undefined } = {};
public refreshMagicLink(sessionId: string): void {
@ -16,13 +16,7 @@ export default class MagicLinkWebSocketListener<A extends Application> extends W
}
}
public async handle(socket: WebSocket, request: IncomingMessage, session: Session | null): Promise<void> {
// Drop if requested without session
if (!session) {
socket.close(1002, 'Session is required for this request.');
return;
}
public async handleSessionSocket(socket: WebSocket, request: IncomingMessage, session: Session): Promise<void> {
// Refuse any incoming data
socket.on('message', () => {
socket.close(1003);
@ -37,19 +31,22 @@ export default class MagicLinkWebSocketListener<A extends Application> extends W
// Refresh if immediately applicable
if (!magicLink || !await magicLink.isValid() || await magicLink.isAuthorized()) {
socket.send('refresh');
socket.close(1000);
const reason = magicLink ?
'Magic link state changed.' :
'Magic link not found for session ' + session.id;
socket.close(1000, reason);
return;
}
const validityTimeout = setTimeout(() => {
socket.send('refresh');
socket.close(1000);
socket.close(1000, 'Timed out');
}, magicLink.getExpirationDate().getTime() - new Date().getTime());
const f = () => {
clearTimeout(validityTimeout);
socket.send('refresh');
socket.close(1000);
socket.close(1000, 'Closed by server');
};
socket.on('close', () => {

View File

@ -1,4 +1,4 @@
import Migration from "../../db/Migration.js";
import Migration from "../../../db/Migration.js";
export default class AddUsedToMagicLinksMigration extends Migration {
public async install(): Promise<void> {

View File

@ -1,6 +1,6 @@
import Migration from "../../db/Migration.js";
import ModelFactory from "../../db/ModelFactory.js";
import MagicLink from "../models/MagicLink.js";
import Migration from "../../../db/Migration.js";
import ModelFactory from "../../../db/ModelFactory.js";
import MagicLink from "../../models/MagicLink.js";
export default class CreateMagicLinksTableMigration extends Migration {
public async install(): Promise<void> {

View File

@ -0,0 +1,13 @@
import Migration from "../../../db/Migration.js";
export default class IncreaseMagicLinkTokenLengthMigration extends Migration {
public async install(): Promise<void> {
await this.query(`ALTER TABLE magic_links
MODIFY COLUMN token VARCHAR(128)`);
}
public async rollback(): Promise<void> {
await this.query(`ALTER TABLE magic_links
MODIFY COLUMN token CHAR(96)`);
}
}

View File

@ -1,4 +1,4 @@
import Migration from "../../db/Migration.js";
import Migration from "../../../db/Migration.js";
export default class MakeMagicLinksSessionNotUniqueMigration extends Migration {
public async install(): Promise<void> {

View File

@ -26,7 +26,7 @@ export default class MagicLink extends Model implements AuthProof<User> {
protected init(): void {
this.setValidation('session_id').defined().length(32);
this.setValidation('email').defined().regexp(EMAIL_REGEX);
this.setValidation('token').defined().length(96);
this.setValidation('token').defined().maxLength(128);
this.setValidation('action_type').defined().maxLength(64);
this.setValidation('original_url').defined().maxLength(1745);
this.setValidation('authorized').defined();
@ -62,7 +62,7 @@ export default class MagicLink extends Model implements AuthProof<User> {
return this.used;
}
public use(): void {
public useLink(): void {
this.used = true;
}

View File

@ -8,6 +8,7 @@ import Validator, {InvalidFormatValidationError} from "../../db/Validator.js";
import {ServerError} from "../../HttpError.js";
import Throttler from "../../Throttler.js";
import AuthComponent from "../AuthComponent.js";
import AuthController from "../AuthController.js";
import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuard.js";
import AuthMethod from "../AuthMethod.js";
import User from "../models/User.js";
@ -73,7 +74,7 @@ export default class PasswordAuthMethod implements AuthMethod<PasswordAuthProof>
Throttler.throttle('login_failed_attempts_user', 3, 3 * 60 * 1000, // 3min
user.getOrFail('id').toString(), 1000, 60 * 1000); // 1min
Throttler.throttle('login_failed_attempts_ip', 50, 60 * 1000, // 1min
req.ip, 1000, 3600 * 1000); // 1h
req.ip || "", 1000, 3600 * 1000); // 1h
if (e instanceof PendingApprovalAuthError) {
req.flash('error', 'Your account is still being reviewed.');
@ -89,7 +90,7 @@ export default class PasswordAuthMethod implements AuthMethod<PasswordAuthProof>
}
}
req.flash('success', `Welcome, ${user.name}.`);
AuthController.flashSuccessfulAuthenticationWelcomeMessage(user, req, 'Authentication success.');
res.redirect(req.getIntendedUrl() || route('home'));
}
@ -97,7 +98,7 @@ export default class PasswordAuthMethod implements AuthMethod<PasswordAuthProof>
if (!ModelFactory.get(User).hasComponent(UserNameComponent))
throw new ServerError('Cannot register with password without UserNameComponent.');
Throttler.throttle('register_password', 10, 30000, req.ip);
Throttler.throttle('register_password', 10, 30000, req.ip || "");
req.body.identifier = identifier;
@ -136,8 +137,9 @@ export default class PasswordAuthMethod implements AuthMethod<PasswordAuthProof>
}
const user = await passwordAuthProof.getResource();
if (!user) throw new Error('Password auth proof has no user.');
req.flash('success', `Your account was successfully created! Welcome, ${user?.as(UserNameComponent).getName()}.`);
AuthController.flashSuccessfulAuthenticationWelcomeMessage(user, req, 'Your account was successfully created!');
res.redirect(req.getIntendedUrl() || route('home'));
}

View File

@ -0,0 +1,2 @@
export const toLucideIconsPascalCase = (string: string): string =>
string.replace(/(\w)(\w*)(_|-|\s*)/g, (g0, g1, g2) => g1.toUpperCase() + g2.toLowerCase());

View File

@ -67,7 +67,7 @@ export class Time {
public static humanizeDuration(
duration: number,
short: boolean = false,
skipOneUnitNumber: boolean = false,
skipNumberTextPartWhenSingular: boolean = false,
units: TimeUnit[] = [
this.UNITS.SECOND,
this.UNITS.MINUTE,
@ -77,13 +77,13 @@ export class Time {
this.UNITS.YEAR,
],
): string {
for (let i = units.length - 1; i > 0; i--) {
if (duration >= units[i - 1].milliseconds && duration < units[i].milliseconds) {
const amount = Math.floor(duration / units[i - 1].milliseconds);
const unit = short ?
units[i - 1].shortName :
' ' + units[i - 1].longName + (amount > 1 ? 's' : '');
return (amount > 1 || !skipOneUnitNumber ? amount : '') + unit;
for (const unit of units.reverse()) {
if (duration >= unit.milliseconds) {
const amount = Math.floor(duration / unit.milliseconds);
const unitTextPart = short ?
unit.shortName :
' ' + unit.longName + (amount > 1 ? 's' : '');
return (amount > 1 || !skipNumberTextPartWhenSingular ? amount : '') + unitTextPart;
}
}

View File

@ -5,19 +5,6 @@ import ApplicationComponent from "../ApplicationComponent.js";
export default class FormHelperComponent extends ApplicationComponent {
public async initRoutes(router: Router): Promise<void> {
router.use((req, res, next) => {
res.setLazyLocal('validation', () => {
const validation = req.flash('validation');
return validation.length > 0 ? validation[0] : null;
});
res.setLazyLocal('previousFormData', () => {
const previousFormData = req.flash('previousFormData');
return previousFormData.length > 0 ? previousFormData[0] : null;
});
next();
});
router.use((req, res, next) => {
if (['GET', 'POST'].find(m => m === req.method)) {
if (typeof req.body === 'object' && Object.keys(req.body).length > 0) {

View File

@ -1,7 +1,7 @@
import config from "config";
import {Express} from "express";
import session, {Store} from "express-session";
import redis, {RedisClient} from "redis";
import {createClient, RedisClientType} from "redis";
import ApplicationComponent from "../ApplicationComponent.js";
import CacheProvider from "../CacheProvider.js";
@ -9,22 +9,30 @@ import {logger} from "../Logger.js";
export default class RedisComponent extends ApplicationComponent implements CacheProvider {
private readonly prefix: string = config.get('redis.prefix');
private redisClient?: RedisClient;
private redisClient?: RedisClientType;
private store: Store = new RedisStore(this);
public async start(_app: Express): Promise<void> {
this.redisClient = redis.createClient(config.get('redis.port'), config.get('redis.host'), {
const redisUrl = `redis://${config.get('redis.host')}:${config.get('redis.port')}`;
this.redisClient = createClient({
url: redisUrl,
password: config.has('redis.password') ? config.get<string>('redis.password') : undefined,
});
this.redisClient.on('error', (err: Error) => {
logger.error(err, 'An error occurred with redis.');
});
await this.redisClient.connect();
}
public async stop(): Promise<void> {
const redisClient = this.redisClient;
if (redisClient) {
await this.close('Redis connection', callback => redisClient.quit(callback));
await this.close('Redis connection', callback => {
redisClient.quit()
.then(() => callback())
.catch(callback);
});
}
}
@ -33,24 +41,14 @@ export default class RedisComponent extends ApplicationComponent implements Cach
}
public isReady(): boolean {
return this.redisClient !== undefined && this.redisClient.connected;
return this.redisClient !== undefined && this.redisClient.isOpen;
}
public async get<T extends string | undefined>(key: string, defaultValue?: T): Promise<T> {
return await new Promise<T>((resolve, reject) => {
if (!this.redisClient) {
reject(`Redis client was not initialized.`);
return;
throw new Error(`Redis client was not initialized.`);
}
this.redisClient.get(this.prefix + key, (err, val) => {
if (err) {
reject(err);
return;
}
resolve((val || defaultValue || undefined) as T);
});
});
return (await this.redisClient.get(this.prefix + key)|| defaultValue || undefined) as T;
}
public async has(key: string): Promise<boolean> {
@ -58,49 +56,27 @@ export default class RedisComponent extends ApplicationComponent implements Cach
}
public async forget(key: string): Promise<void> {
return await new Promise<void>((resolve, reject) => {
if (!this.redisClient) {
reject(`Redis client was not initialized.`);
return;
throw new Error(`Redis client was not initialized.`);
}
this.redisClient.del(this.prefix + key, (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
await this.redisClient.del(this.prefix + key);
}
public async remember(key: string, value: string, ttl: number): Promise<void> {
return await new Promise<void>((resolve, reject) => {
if (!this.redisClient) {
reject(`Redis client was not initialized.`);
return;
throw new Error(`Redis client was not initialized.`);
}
this.redisClient.psetex(this.prefix + key, ttl, value, (err) => {
if (err) return reject(err);
resolve();
});
});
await this.redisClient.pSetEx(this.prefix + key, ttl, value);
}
public async persist(key: string, ttl: number): Promise<void> {
return await new Promise<void>((resolve, reject) => {
if (!this.redisClient) {
reject(`Redis client was not initialized.`);
return;
throw new Error(`Redis client was not initialized.`);
}
this.redisClient.pexpire(this.prefix + key, ttl, (err) => {
if (err) return reject(err);
resolve();
});
});
await this.redisClient.pExpire(this.prefix + key, ttl);
}
}

View File

@ -29,8 +29,9 @@ export default class SessionComponent extends ApplicationComponent {
store: this.storeComponent.getStore(),
resave: false,
cookie: {
httpOnly: true,
httpOnly: false,
secure: config.get('session.cookie.secure'),
sameSite: 'strict',
},
rolling: true,
}));
@ -61,13 +62,7 @@ export default class SessionComponent extends ApplicationComponent {
// Views flash function
res.setLazyLocal('flash', () => {
return {
info: req.flash('info'),
success: req.flash('success'),
warning: req.flash('warning'),
error: req.flash('error'),
'error-alert': req.flash('error-alert'),
};
return req.flash();
});
next();
});

View File

@ -1,8 +1,5 @@
import config from "config";
import cookie from "cookie";
import cookieParser from "cookie-parser";
import {Express, Request, Router} from "express";
import {Session} from "express-session";
import {Express, Router} from "express";
import {WebSocketServer} from "ws";
import Application from "../Application.js";
@ -45,39 +42,13 @@ export default class WebSocketServerComponent extends ApplicationComponent {
if (!listener) {
socket.close(1002, `Path not found ${request.url}`);
return;
} else if (!request.headers.cookie) {
listener.handle(socket, request, null).catch(err => {
logger.error(err, 'Error in websocket listener.');
});
return;
}
logger.debug(`Websocket on ${request.url}`);
const cookies = cookie.parse(request.headers.cookie);
const sid = cookieParser.signedCookie(cookies['connect.sid'], config.get('session.secret'));
if (!sid) {
socket.close(1002);
return;
}
const store = app.as(RedisComponent).getStore();
store.get(sid, (err, session) => {
if (err || !session) {
logger.error(err, 'Error while initializing session in websocket.');
socket.close(1011);
return;
}
session.id = sid;
store.createSession(<Request>request, session);
listener.handle(socket, request, session as Session).catch(err => {
listener.handle(socket, request).catch(err => {
logger.error(err, 'Error in websocket listener.');
});
});
});
}
public async stop(): Promise<void> {

View File

@ -59,8 +59,8 @@ export default abstract class Model implements Extendable<ModelComponent<Model>>
[key: string]: ModelFieldData;
public constructor(factory: ModelFactory<never>, isNew: boolean) {
if (!(factory instanceof ModelFactory)) throw new Error('Cannot instantiate model directly.');
public constructor(isNew: boolean) {
const factory = ModelFactory.get(this.constructor as ModelType<Model>);
this._factory = factory;
this.init?.();
this._exists = !isNew;
@ -245,10 +245,10 @@ export default abstract class Model implements Extendable<ModelComponent<Model>>
}
}
export interface ModelType<M extends Model> extends Type<M> {
export interface ModelType<M extends Model> {
table: string;
new(factory: ModelFactory<never>, isNew: boolean): M;
new(isNew: boolean): M;
getPrimaryKeyFields(): (keyof M & string)[];

View File

@ -40,7 +40,7 @@ export default class ModelFactory<M extends Model> {
}
public create(data: Pick<M, keyof M>, isNewModel: boolean): M {
const model = new this.modelType(this as unknown as ModelFactory<never>, isNewModel);
const model = new this.modelType(isNewModel);
for (const component of this.components) {
model.addComponent(new component(model));
}

View File

@ -2,14 +2,14 @@ import {Connection} from "mysql";
import {ServerError} from "../HttpError.js";
import Model, {ModelType} from "./Model.js";
import ModelQuery, {WhereTest} from "./ModelQuery.js";
import ModelQuery, {ModelFieldData, WhereTest} from "./ModelQuery.js";
export const EMAIL_REGEX = /^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/;
export default class Validator<V> {
export default class Validator<V extends ModelFieldData> {
public static async validate(
validationMap: { [p: string]: Validator<unknown> },
body: { [p: string]: unknown },
validationMap: { [p: string]: Validator<ModelFieldData> },
body: { [p: string]: ModelFieldData },
): Promise<void> {
const bag = new ValidationBag();

View File

@ -27,7 +27,7 @@ describe('Register with username and password (password)', () => {
test('General case', async () => {
const res = await agent.get('/csrf').expect(200);
cookies = res.get('Set-Cookie');
cookies = res.get('Set-Cookie') ?? [];
csrf = res.text;
// Register user
@ -90,7 +90,7 @@ describe('Register with username and password (password)', () => {
.count()).toStrictEqual(0);
const res1 = await agent.get('/csrf').expect(200);
const cookies = res1.get('Set-Cookie');
const cookies = res1.get('Set-Cookie') || [];
const csrf = res1.text;
// Register user
@ -139,7 +139,7 @@ describe('Register with username and password (password)', () => {
describe('Register with email (magic_link)', () => {
test('General case', async () => {
const res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie');
const cookies = res.get('Set-Cookie') || [];
const csrf = res.text;
await agent.post('/auth/register?' + querystring.stringify({redirect_uri: '/redirect-uri'}))
@ -180,7 +180,7 @@ describe('Register with email (magic_link)', () => {
test('Cannot register without specifying username', async () => {
let res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie');
const cookies = res.get('Set-Cookie') || [];
const csrf = res.text;
res = await agent.post('/auth/register')
@ -198,7 +198,7 @@ describe('Register with email (magic_link)', () => {
test('Cannot register taken username', async () => {
const res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie');
const cookies = res.get('Set-Cookie') || [];
const csrf = res.text;
await agent.post('/auth/register')
@ -240,7 +240,7 @@ describe('Register with email (magic_link)', () => {
test('Cannot register taken email', async () => {
const res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie');
const cookies = res.get('Set-Cookie') || [];
const csrf = res.text;
await agent.post('/auth/register')
@ -284,7 +284,7 @@ describe('Register with email (magic_link)', () => {
describe('Authenticate with username and password (password)', () => {
test('Force auth_method', async () => {
let res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie');
const cookies = res.get('Set-Cookie') || [];
const csrf = res.text;
// Not authenticated
@ -319,7 +319,7 @@ describe('Authenticate with username and password (password)', () => {
test('Automatic auth_method', async () => {
let res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie');
const cookies = res.get('Set-Cookie') || [];
const csrf = res.text;
// Not authenticated
@ -352,7 +352,7 @@ describe('Authenticate with username and password (password)', () => {
test('Non-existing username', async () => {
let res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie');
const cookies = res.get('Set-Cookie') || [];
const csrf = res.text;
// Not authenticated
@ -385,7 +385,7 @@ describe('Authenticate with username and password (password)', () => {
test('No password user', async () => {
let res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie');
const cookies = res.get('Set-Cookie') || [];
const csrf = res.text;
// Not authenticated
@ -440,13 +440,14 @@ describe('Authenticate with username and password (password)', () => {
describe('Authenticate with email (magic_link)', () => {
test('Force auth_method', async () => {
const res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie');
const cookies = res.get('Set-Cookie') || [];
const csrf = res.text;
// Not authenticated
await agent.get('/is-auth').set('Cookie', cookies).expect(401);
// Authenticate
// TODO deprecated querystring => URLSearchParams
await agent.post('/auth/login?' + querystring.stringify({redirect_uri: '/redirect-uri'}))
.set('Cookie', cookies)
.send({
@ -464,7 +465,7 @@ describe('Authenticate with email (magic_link)', () => {
test('Automatic auth_method', async () => {
const res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie');
const cookies = res.get('Set-Cookie') || [];
const csrf = res.text;
// Not authenticated
@ -487,7 +488,7 @@ describe('Authenticate with email (magic_link)', () => {
test('Non-existing email (forced auth_method)', async () => {
let res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie');
const cookies = res.get('Set-Cookie') || [];
const csrf = res.text;
// Not authenticated
@ -508,7 +509,7 @@ describe('Authenticate with email (magic_link)', () => {
test('Non-existing email (automatic auth_method)', async () => {
let res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie');
const cookies = res.get('Set-Cookie') || [];
const csrf = res.text;
// Not authenticated
@ -530,7 +531,7 @@ describe('Authenticate with email (magic_link)', () => {
describe('Authenticate with email and password (password)', () => {
test('Prepare user', async () => {
const res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie');
const cookies = res.get('Set-Cookie') || [];
const csrf = res.text;
await agent.post('/auth/register')
@ -569,7 +570,7 @@ describe('Authenticate with email and password (password)', () => {
test('Force auth_method', async () => {
let res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie');
const cookies = res.get('Set-Cookie') || [];
const csrf = res.text;
// Not authenticated
@ -604,7 +605,7 @@ describe('Authenticate with email and password (password)', () => {
test('Automatic auth_method', async () => {
let res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie');
const cookies = res.get('Set-Cookie') || [];
const csrf = res.text;
// Not authenticated
@ -641,7 +642,7 @@ describe('Change password', () => {
let cookies: string[], csrf: string;
test('Prepare user', async () => {
const res = await agent.get('/csrf').expect(200);
cookies = res.get('Set-Cookie');
cookies = res.get('Set-Cookie') || [];
csrf = res.text;
await agent.post('/auth/register')
@ -821,7 +822,7 @@ describe('Change password', () => {
test('Can\'t remove password without contact email', async () => {
const res = await agent.get('/csrf').expect(200);
cookies = res.get('Set-Cookie');
cookies = res.get('Set-Cookie') || [];
csrf = res.text;
await agent.post('/auth/register')
@ -863,7 +864,7 @@ describe('Change username', () => {
let cookies: string[], csrf: string;
test('Prepare user', async () => {
const res = await agent.get('/csrf').expect(200);
cookies = res.get('Set-Cookie');
cookies = res.get('Set-Cookie') || [];
csrf = res.text;
await agent.post('/auth/register')
@ -962,7 +963,7 @@ describe('Manage email addresses', () => {
let cookies: string[], csrf: string;
test('Prepare user', async () => {
const res = await agent.get('/csrf').expect(200);
cookies = res.get('Set-Cookie');
cookies = res.get('Set-Cookie') || [];
csrf = res.text;
await agent.post('/auth/register')
@ -1169,7 +1170,7 @@ describe('Session persistence', () => {
test('Not persistent at registration', async () => {
let res = await agent.get('/csrf').expect(200);
cookies = res.get('Set-Cookie');
cookies = res.get('Set-Cookie') || [];
csrf = res.text;
await agent.post('/auth/register')
@ -1185,12 +1186,12 @@ describe('Session persistence', () => {
await followMagicLinkFromMail(agent, cookies);
expect(cookies[0]).toMatch(/^connect\.sid=.+; Path=\/; HttpOnly$/);
expect(cookies[0]).toMatch(/^connect\.sid=.+; Path=\/; SameSite=Strict$/);
res = await agent.get('/csrf')
.set('Cookie', cookies)
.expect(200);
expect(res.get('Set-Cookie')[0]).toMatch(/^connect\.sid=.+; Path=\/; Expires=.+; HttpOnly$/);
expect(res.get('Set-Cookie')?.[0]).toMatch(/^connect\.sid=.+; Path=\/; Expires=.+; SameSite=Strict$/);
// Logout
await agent.post('/auth/logout')
@ -1217,7 +1218,7 @@ describe('Session persistence', () => {
const res = await agent.get('/csrf')
.set('Cookie', cookies)
.expect(200);
expect(res.get('Set-Cookie')[0]).toMatch(/^connect\.sid=.+; Path=\/; Expires=.+; HttpOnly$/);
expect(res.get('Set-Cookie')?.[0]).toMatch(/^connect\.sid=.+; Path=\/; Expires=.+; SameSite=Strict$/);
// Logout
await agent.post('/auth/logout')
@ -1244,7 +1245,7 @@ describe('Session persistence', () => {
const res = await agent.get('/csrf')
.set('Cookie', cookies)
.expect(200);
expect(res.get('Set-Cookie')[0]).toMatch(/^connect\.sid=.+; Path=\/; HttpOnly$/);
expect(res.get('Set-Cookie')?.[0]).toMatch(/^connect\.sid=.+; Path=\/; SameSite=Strict$/);
// Logout
await agent.post('/auth/logout')

View File

@ -25,7 +25,7 @@ describe('Register with username and password (password)', () => {
test('General case', async () => {
const res = await agent.get('/csrf').expect(200);
cookies = res.get('Set-Cookie');
cookies = res.get('Set-Cookie') || [];
csrf = res.text;
// Register user
@ -63,7 +63,7 @@ describe('Register with username and password (password)', () => {
describe('Register with email (magic_link)', () => {
test('General case', async () => {
const res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie');
const cookies = res.get('Set-Cookie') || [];
const csrf = res.text;
await agent.post('/auth/register?' + querystring.stringify({redirect_uri: '/redirect-uri'}))
@ -106,7 +106,7 @@ describe('Register with email (magic_link)', () => {
describe('Authenticate with username and password (password)', () => {
test('Force auth_method', async () => {
const res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie');
const cookies = res.get('Set-Cookie') || [];
const csrf = res.text;
// Not authenticated
@ -134,7 +134,7 @@ describe('Authenticate with username and password (password)', () => {
describe('Authenticate with email (magic_link)', () => {
test('Force auth_method', async () => {
const res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie');
const cookies = res.get('Set-Cookie') || [];
const csrf = res.text;
// Not authenticated

View File

@ -22,7 +22,7 @@ test('Approval Mode', () => {
describe('Register with username and password (password)', () => {
test('Must be disabled', async () => {
const res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie');
const cookies = res.get('Set-Cookie') || [];
const csrf = res.text;
// Register user
@ -43,7 +43,7 @@ describe('Register with username and password (password)', () => {
describe('Register with email (magic_link)', () => {
test('General case', async () => {
const res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie');
const cookies = res.get('Set-Cookie') || [];
const csrf = res.text;
await agent.post('/auth/register')
@ -77,7 +77,7 @@ describe('Register with email (magic_link)', () => {
test('Cannot register taken email', async () => {
const res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie');
const cookies = res.get('Set-Cookie') || [];
const csrf = res.text;
await agent.post('/auth/register')
@ -106,7 +106,7 @@ describe('Register with email (magic_link)', () => {
const res2 = await agent.get('/csrf').expect(200);
await agent.post('/auth/register')
.set('Cookie', res2.get('Set-Cookie'))
.set('Cookie', res2.get('Set-Cookie') || [])
.send({
csrf: res2.text,
auth_method: 'magic_link',

View File

@ -39,7 +39,7 @@ describe('Test CSRF protection', () => {
.expect(401)
.then(res => {
expect(res.text).toContain(`You weren't assigned any CSRF token.`);
cookies = res.get('Set-Cookie');
cookies = res.get('Set-Cookie') || [];
agent.get('/')
.set('Cookie', cookies)

View File

@ -71,14 +71,14 @@ export function authAppProvider(withUsername: boolean = true, approvalMode: bool
const proof = await PasswordAuthProof.getProofForSession(req.getSession());
if (proof) res.sendStatus(200);
else res.sendStatus(404);
}, 'is-auth');
}, 'has-any-password-auth-proof');
this.get('/has-any-magic-link', async (req, res) => {
const proofs = await MagicLink.select()
.where('session_id', req.getSession().id)
.get();
if (proofs.length > 0) res.sendStatus(200);
else res.sendStatus(404);
}, 'is-auth');
}, 'has-any-magic-link');
}
}());

6822
yarn.lock

File diff suppressed because it is too large Load Diff