Merge pull request 'Drop the calendar's legacy authentication path' (#15) from feature/calendar-drop-legacy-path into master
Jenkins Production Deployment

Reviewed-on: #15
This commit was merged in pull request #15.
This commit is contained in:
2026-09-06 21:41:01 +00:00
17 changed files with 253 additions and 1370 deletions
+23 -10
View File
@@ -33,15 +33,15 @@ Express.js REST API in TypeScript with a service-oriented layering. Domains: `Ca
| Layer | Location |
|---|---|
| Router | `src/models/calendar/Calendar.router.ts`, `…/events/events.router.ts`, `…/users/users.router.ts` |
| Services | `…/events/events.service.ts`, `…/users/users.service.ts`, `…/events/credentials.service.ts`, `…/events/icalgenerator.service.ts` |
| Router | `src/models/calendar/Calendar.router.ts`, `…/events/events.router.ts` |
| Services | `…/events/events.service.ts`, `…/events/credentials.service.ts`, `…/events/icalgenerator.service.ts` |
| DB pool | `src/models/calendar/Calendar.db.ts` (MariaDB, pool size 5) |
| Shared | `src/common/` (base route class, nodemailer wrapper), `src/middleware/logger.ts` (Winston) |
**Auth model:** Two of them, on purpose.
**Auth model:** One, since the calendar migration completed.
*Admin module (`src/models/admin/`)*the current one, used by feedback, tickets and the
admin app. better-auth 1.7 on its own `nachklang_admin` database (Kysely + mysql2; every
*Admin module (`src/models/admin/`)*used by every app: calendar, feedback, tickets and the
admin app itself. better-auth 1.7 on its own `nachklang_admin` database (Kysely + mysql2; every
other domain keeps the `mariadb` driver), mounted at `/admin/auth/*` for the auth handler
and `/admin` for the JSON routes. Sessions are httpOnly cookies scoped to
`.nachklang.art`, so one sign-in covers every app. Accounts are **invite-only** — public
@@ -59,11 +59,24 @@ two and the last-admin guard stops guarding; and both write endpoints accept
same at the `access` role. `ADMIN_BOOTSTRAP_EMAIL`
makes sure someone can always get in on a fresh database.
*Legacy calendar* — unchanged: users need a `@nachklang.art` email, and after activation
get a session token (30-day window, hash + IP stored in the DB), passed as query
parameters. Migration is planned but not started: `docs/calendar-auth-migration.md`.
Credentials for non-user calendar access (`MEMBER_CREDENTIAL`, `CHOIR_CREDENTIAL`,
`MANAGEMENT_CREDENTIAL`) come from `.env`.
*The calendar* used to be the exception - its own `users`/`sessions` tables, and a session
token passed in **query parameters**. That is gone: `docs/calendar-auth-migration.md` records
the migration, finished 2026-09-06. Writes sit behind `requireAppAccess('calendar')`; reads
resolve the same cookie optionally, because one URL serves an anonymous visitor, an iCal
subscription and a signed-in editor.
Two calendar-specific things survive that migration and are easy to break:
- **The `public` calendar answers with no credential of any kind.** nachklang.art reads it to
show the next upcoming event. Pinned by `test/calendar/credentials.service.test.ts` and
`test/calendar/events.router.test.ts`.
- **The shared passwords (`MEMBER_CREDENTIAL`, `CHOIR_CREDENTIAL`, `MANAGEMENT_CREDENTIAL`,
from `.env`) still open the restricted calendars for reading**, because an iCal client
cannot send a cookie. They can never write.
An event's creator is a display name and nothing else - nothing authorises on it. It resolves
from the admin module's `user.name` when the row carries an admin id, and otherwise from
`created_by_name`, a snapshot taken before the legacy `users` table was renamed aside.
**Admin database driver:** the admin pool is the **callback-style** `mysql2`, never
`mysql2/promise`. Kysely's `MysqlDialect` calls `pool.getConnection((err, conn) => ...)`;
+15 -29
View File
@@ -24,9 +24,9 @@ Two things this did *not* change, both deliberate:
this is the one caller that genuinely needs a credential in the URL. It grants read access
to one calendar and nothing else - `test/calendar/events.router.test.ts` pins that it can
never be used to write.
- The legacy `/calendar/users/*` routes still exist. Nothing calls them any more, and a
legacy session they mint no longer opens anything, but they are still live
password-accepting endpoints. Step 5 removes them.
- ~~The legacy `/calendar/users/*` routes still exist.~~ Removed by step 5 on 2026-09-06,
along with the `users` and `sessions` tables they used - renamed aside rather than dropped,
so nothing was destroyed.
---
@@ -43,37 +43,23 @@ Currently any account holding the `calendar` permission can edit, move, or delet
---
## 3. Activation token has no expiry
## 3. Activation token has no expiry — CLOSED 2026-09-06
> **Superseded for new accounts (2026-09-05).** The admin module
> (`src/models/admin/`) replaced account creation for the feedback, tickets and admin
> apps: accounts now come from `invitations`, whose tokens expire after 7 days and are
> stored only as a SHA-256 hash. The item below still stands for the legacy calendar
> `users` table, which the admin module deliberately left alone - see
> `docs/calendar-auth-migration.md`.
**File:** ~~`src/models/calendar/users/users.service.ts`~~ — deleted.
**File:** `src/models/calendar/users/users.service.ts``createUser` / `activateUser`
The e-mail activation link was valid indefinitely. Closed not by adding an expiry but by
removing the thing that issued it: step 5 of `docs/calendar-auth-migration.md` deleted the
calendar's own account system. Accounts now come only from the admin module's `invitations`,
whose tokens expire after 7 days and are stored as a SHA-256 hash.
The email activation link is valid indefinitely. Acceptable for a small, trusted userbase.
**Fix:**
1. Add an `activation_expires` column to the `users` table (e.g. `DATETIME`).
2. Set it to `NOW() + INTERVAL 24 HOUR` in `createUser`.
3. Check `activation_expires > NOW()` in `activateUser` before accepting the token.
Any activation link still sitting in an inbox now 404s. It only ever activated a legacy
account, which no longer opens anything.
---
## 4. Password reset token has no expiry
## 4. Password reset token has no expiry — CLOSED 2026-09-06
> **Superseded for new accounts (2026-09-05).** Password resets for admin-module accounts
> go through better-auth, whose reset tokens expire after one hour. As with item 3, the
> text below still applies to the legacy calendar `users` table.
**File:** ~~`src/models/calendar/users/users.service.ts`~~ — deleted.
**File:** `src/models/calendar/users/users.service.ts``initiatePasswordReset` / `finalizePasswordReset`
The reset token stored in `pw_reset_token_hash` never expires. Acceptable for a small, trusted userbase.
**Fix:**
1. Add a `pw_reset_expires` column to the `users` table (e.g. `DATETIME`).
2. Set it to `NOW() + INTERVAL 15 MINUTE` in `initiatePasswordReset`.
3. Check `pw_reset_expires > NOW()` in `finalizePasswordReset` before accepting the token.
Same as item 3: `pw_reset_token_hash` never expired, and the code that set it no longer
exists. Password resets go through better-auth, whose reset tokens expire after one hour.
+27 -55
View File
@@ -1,8 +1,13 @@
-- Local dev only. Real schema, provided directly by the repo owner
-- (calendars, events, event_versions, sessions, users) - not a guess.
-- Columns added by this repo's own migrations under sql/calendar/ are folded
-- in here rather than appended, so a fresh dev container matches production
-- after every migration has been applied. Keep the two in step.
-- Local dev only. Derived from the real schema, which was provided directly by
-- the repo owner (calendars, events, event_versions, and the sessions/users
-- tables that step 5 of docs/calendar-auth-migration.md renamed aside).
--
-- There is no users or sessions table here: a fresh dev database has no legacy
-- accounts to archive, so it starts where production ends up.
--
-- Changes made by this repo's own migrations under sql/calendar/ are folded in
-- here rather than appended, so a fresh dev container matches production once
-- every migration has been applied. Keep the two in step.
USE nachklang_calendar;
CREATE TABLE `calendars` (
@@ -12,47 +17,20 @@ CREATE TABLE `calendars` (
PRIMARY KEY (`calendar_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE `users` (
`user_id` int(11) NOT NULL AUTO_INCREMENT,
`full_name` text NOT NULL,
`password_hash` text DEFAULT NULL,
`email` text NOT NULL,
`is_active` tinyint(1) DEFAULT 0,
`pw_reset_token_hash` text DEFAULT NULL,
`activation_token` text DEFAULT NULL,
PRIMARY KEY (`user_id`),
UNIQUE KEY `email` (`email`) USING HASH
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE `sessions` (
`session_id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`session_key_hash` text DEFAULT NULL,
`created_date` datetime DEFAULT current_timestamp(),
`valid_until` datetime DEFAULT (current_timestamp() + interval 30 day),
`last_ip` text DEFAULT NULL,
PRIMARY KEY (`session_id`),
KEY `sessions_users_user_id_fk` (`user_id`),
CONSTRAINT `sessions_users_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE `events` (
`event_id` int(11) NOT NULL AUTO_INCREMENT,
`calendar_id` int(11) NOT NULL,
`uuid` text NOT NULL,
`created_date` datetime DEFAULT current_timestamp(),
-- Nullable since the cutover; see sql/calendar/003_allow_null_legacy_creator.sql.
`created_by_id` int(11) DEFAULT NULL,
-- Bridge to the admin module's user ids; see sql/calendar/001_add_admin_user_bridge.sql.
`created_by_user_id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
-- Archived creator name; see sql/calendar/002_snapshot_legacy_creator_names.sql.
-- Creator name, archived before the legacy users table went away;
-- see sql/calendar/002_snapshot_legacy_creator_names.sql.
`created_by_name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`event_id`),
KEY `events_calendars_calendar_id_fk` (`calendar_id`),
KEY `events_users_user_id_fk` (`created_by_id`),
KEY `events_created_by_user_idx` (`created_by_user_id`),
CONSTRAINT `events_calendars_calendar_id_fk` FOREIGN KEY (`calendar_id`) REFERENCES `calendars` (`calendar_id`),
CONSTRAINT `events_users_user_id_fk` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`user_id`)
CONSTRAINT `events_calendars_calendar_id_fk` FOREIGN KEY (`calendar_id`) REFERENCES `calendars` (`calendar_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE `event_versions` (
@@ -66,7 +44,6 @@ CREATE TABLE `event_versions` (
`repeat_frequency` text DEFAULT NULL,
`location` text DEFAULT NULL,
`url` text DEFAULT NULL,
`version_created_by_id` int(11) DEFAULT NULL,
-- Bridge to the admin module's user ids; see sql/calendar/001_add_admin_user_bridge.sql.
`version_created_by_user_id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
-- Archived editor name; see sql/calendar/002_snapshot_legacy_creator_names.sql.
@@ -75,10 +52,8 @@ CREATE TABLE `event_versions` (
`version_created_at` datetime DEFAULT current_timestamp(),
PRIMARY KEY (`event_version_id`),
KEY `event_versions_events_event_id_fk` (`event_id`),
KEY `event_versions_users_user_id_fk` (`version_created_by_id`),
KEY `event_versions_created_by_user_idx` (`version_created_by_user_id`),
CONSTRAINT `event_versions_events_event_id_fk` FOREIGN KEY (`event_id`) REFERENCES `events` (`event_id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `event_versions_users_user_id_fk` FOREIGN KEY (`version_created_by_id`) REFERENCES `users` (`user_id`)
CONSTRAINT `event_versions_events_event_id_fk` FOREIGN KEY (`event_id`) REFERENCES `events` (`event_id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
INSERT INTO calendars (calendar_id, name, includes_calendars) VALUES
@@ -88,20 +63,17 @@ INSERT INTO calendars (calendar_id, name, includes_calendars) VALUES
(4, 'choir', '[]'),
(5, 'birthdays', '[]');
-- Dev admin, password: devpassword
INSERT INTO users (email, password_hash, full_name, is_active) VALUES
('dev@nachklang.art', '$2b$10$vmj7POS/68SGE.eI7pGjMegrw0vNNZ2HVSUTra5NRsl8iOLwiMgZK', 'Dev Admin', 1);
-- Two events carry only an archived creator name, as every pre-cutover event
-- does, and one carries an admin user id whose name is resolved live. Dev
-- therefore exercises both name sources rather than only one. The one with an
-- id is deliberately a PUBLIC event, so the anonymous listing the website uses
-- covers both. The id is the dev admin from 04-admin-schema.sql.
INSERT INTO events (calendar_id, uuid, created_by_user_id, created_by_name) VALUES
(1, UUID(), NULL, 'Dev Admin'),
(1, UUID(), 'dev-user-0000-0000-0000-000000000001', NULL),
(1, UUID(), NULL, 'Dev Admin');
-- Two rows are left on the legacy path and one carries an admin user id, so
-- dev exercises both branches of the step 3 dual-read rather than only the
-- happy one. It is deliberately a PUBLIC event, so the anonymous listing the
-- website uses covers both. The id is the dev admin from 04-admin-schema.sql.
INSERT INTO events (calendar_id, uuid, created_by_id, created_by_user_id, created_by_name) VALUES
(1, UUID(), 1, NULL, 'Dev Admin'),
(1, UUID(), 1, 'dev-user-0000-0000-0000-000000000001', NULL),
(1, UUID(), 1, NULL, 'Dev Admin');
INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, location, url, status, version_created_by_id, version_created_by_user_id, version_created_by_name) VALUES
(1, 'Frühlingskonzert 2026', 'Erstes Konzert der Reihe', '2026-04-18 19:00:00', '2026-04-18 21:00:00', 0, 'Musikhochschule, Karlsruhe', 'https://www.nachklang.art/events/fruehlingskonzert-2026', 'PUBLIC', 1, NULL, 'Dev Admin'),
(2, 'Sommerkonzert 2026', 'Zweites Konzert der Reihe', '2026-07-11 19:00:00', '2026-07-11 21:00:00', 0, 'Christuskirche, Karlsruhe', 'https://www.nachklang.art/events/sommerkonzert-2026', 'PUBLIC', 1, 'dev-user-0000-0000-0000-000000000001', NULL),
(3, 'Adventskonzert 2026', 'Drittes Konzert der Reihe', '2026-12-05 19:00:00', '2026-12-05 21:00:00', 0, 'Stadtkirche, Karlsruhe', 'https://www.nachklang.art/events/adventskonzert-2026', 'DRAFT', 1, NULL, 'Dev Admin');
INSERT INTO event_versions (event_id, name, description, start_datetime, end_datetime, whole_day, location, url, status, version_created_by_user_id, version_created_by_name) VALUES
(1, 'Frühlingskonzert 2026', 'Erstes Konzert der Reihe', '2026-04-18 19:00:00', '2026-04-18 21:00:00', 0, 'Musikhochschule, Karlsruhe', 'https://www.nachklang.art/events/fruehlingskonzert-2026', 'PUBLIC', NULL, 'Dev Admin'),
(2, 'Sommerkonzert 2026', 'Zweites Konzert der Reihe', '2026-07-11 19:00:00', '2026-07-11 21:00:00', 0, 'Christuskirche, Karlsruhe', 'https://www.nachklang.art/events/sommerkonzert-2026', 'PUBLIC', 'dev-user-0000-0000-0000-000000000001', NULL),
(3, 'Adventskonzert 2026', 'Drittes Konzert der Reihe', '2026-12-05 19:00:00', '2026-12-05 21:00:00', 0, 'Stadtkirche, Karlsruhe', 'https://www.nachklang.art/events/adventskonzert-2026', 'DRAFT', NULL, 'Dev Admin');
+50 -55
View File
@@ -1,13 +1,13 @@
# Migrating the Calendar domain onto the admin identity module
Status: **steps 1-4 implemented 2026-09-06, not yet merged or deployed.** Step 2 dropped by
decision, part of step 5 brought forward. Only step 5, the removal of the legacy path, is
left to write.
Status: **complete.** Steps 1, 3 and 4 were deployed and verified in production on
2026-09-06; step 2 was dropped by decision and part of step 5 brought forward. Step 5 is
implemented and awaiting deploy - see its own checklist below, whose ordering is the
**opposite** of step 4's.
> Read the deploy checklist under step 4 before applying anything. "Done" below means the
> code exists on a branch, **not** that production has it - and in particular production has
> none of the three migrations. Step 5 is scoped but deliberately unstarted: it must not be
> built on top of a step 4 that has not been deployed and watched.
Verified live after step 4: the public calendar still answers anonymously, all 23 public
events kept a resolvable author, restricted calendars still refuse without a credential,
legacy query credentials answer 401, and `calendar.nachklang.art` is trusted for sign-out.
Written 2026-09-05 alongside the admin module (step 2 of `docs/plan-admin-auth.md` in the
nachklang-admin repo), which deliberately left the calendar alone. Steps 1-4 of that plan
@@ -157,62 +157,57 @@ Each step is meant to leave production working on its own.
**One-way door:** any iCal subscription whose URL carries `?sessionId=&sessionKey=` rather
than `?password=` stops working permanently. The shared-password URLs are unaffected.
5. **Drop the legacy path.** Not started - and deliberately not started until step 4 has been
deployed and watched, because it removes the fallback step 4 still leans on. Scoped and
decided 2026-09-06; what follows is the agreed shape, not a suggestion.
5. **Drop the legacy path.** **Implemented 2026-09-06** on `feature/calendar-drop-legacy-path`;
not yet deployed. Gated on step 4 being live, which it now is.
**Prerequisite: step 4 live in production and behaving.** Until then the legacy join is
what renders the author of every pre-cutover event, and the legacy routes are what an old
cached bundle talks to. Doing this first turns a recoverable deploy into an unrecoverable
one.
What went:
Code, in one branch:
- `src/models/calendar/users/` in its entirety - registration, login, activation, both
password-reset routes, and the session checking the feedback and tickets admin areas
used to authenticate against - plus its mount in `Calendar.router.ts`. That was the
API's last unauthenticated account-creation and mail-sending endpoint.
- The two `LEFT OUTER JOIN users` clauses in `events.service.ts` and the `legacy_*`
aliases they fed, along with `created_by_id` / `version_created_by_id` in the SELECT, the
row mapper and the `Event` interface. One name source remains besides the live admin
lookup: the snapshot, which is what made this safe.
- `X-Session-Id` / `X-Session-Key` from the CORS `allowedHeaders`. Nothing had read them
since the tickets and feedback cutover, or sent them since this one.
- The tripwire in `test/admin/auth-binding.ts` asserting neither module fell back to a
calendar header session. There is nothing left to fall back to.
- **Delete `src/models/calendar/users/` entirely** - `users.router.ts`, `users.service.ts`,
`session.interface.ts`, `user.interface.ts` - and the `calendarRouter.use('/users', ...)`
line in `Calendar.router.ts`. *(Decided: delete outright rather than unmount.)* This
removes the last unauthenticated account-creation and mail-sending endpoint in the API.
A survey on 2026-09-06 confirmed nothing outside that directory imports it, and nothing
outside it touches the `users`/`sessions` tables except the two joins below.
- **Drop the legacy half of the read** in `events.service.ts`: the two
`LEFT OUTER JOIN users` clauses, the `legacy_*` aliases, and `created_by_id` /
`version_created_by_id` from the SELECT and the row mapper. The snapshot fallback stays -
it is what makes this safe. Remove `createdById` / `lastModifiedById` from
`event.interface.ts` and their (already deprecated) swagger properties.
- **Remove `X-Session-Id` / `X-Session-Key`** from the CORS `allowedHeaders` in
`src/app.factory.ts`. Nothing has sent them since the tickets and feedback frontends were
redeployed.
- **Drop the obsolete test mocks**: `test/feedback/feedback.auth.test.ts`,
`test/tickets/tickets.auth.test.ts` and `test/admin/auth-binding.ts` each mock
`calendar/users/users.service.js` and assert `checkSession` is never called. That
tripwire is meaningless once the module does not exist; remove the mock and the
assertion, keep the rest.
`DEFERRED_SECURITY.md` items **3** and **4** (activation and reset tokens never expiring)
close with it - not by adding expiries but by deleting the code that issued them.
Database, as `sql/calendar/004_*.sql`:
### Deploy checklist — note the order is REVERSED from step 4
- Drop the foreign keys `events_users_user_id_fk` and `event_versions_users_user_id_fk`,
then the `created_by_id` and `version_created_by_id` columns.
- **`RENAME TABLE users TO users_legacy_archive`**, same for `sessions`. *(Decided: rename
rather than drop.)* The reasoning: the display names are already snapshotted so nothing
visible depends on these rows, but they still hold the old e-mail addresses and password
hashes, and a rename makes the tables unreachable without destroying anything. Dropping
them later is one statement, at a moment when nobody is mid-deploy.
- Mirror all of it in `docker/init/01-calendar-schema-dev.sql` (the archive tables need no
mirror - a fresh dev database has nothing to archive).
Step 4's migration only added columns, so it went first. `004` *removes* columns and a
table that the currently running build still selects and joins, so running it first fails
every calendar read including the public feed. The step 5 build references none of them and
runs happily against the old schema. Therefore:
Documentation: `DEFERRED_SECURITY.md` items **3** (activation token has no expiry) and
**4** (password reset token has no expiry) close outright - both describe code that ceases
to exist. Item 2 (no event ownership check) stays open.
1. **Confirm the snapshot is complete.** Both must return 0:
```sql
SELECT SUM(created_by_id IS NOT NULL AND created_by_name IS NULL) FROM events;
SELECT SUM(version_created_by_id IS NOT NULL AND version_created_by_name IS NULL) FROM event_versions;
```
A non-zero count is an event whose author `004` would erase. Re-run 002's backfill first.
2. **Deploy the API.** No frontend deploy is needed: the calendar frontend never read
`createdById` (its `Event` model has only the name), and nothing else is known to.
3. **Confirm the calendar still works** - the public feed, a signed-in read, and one save.
At this point the old columns and tables still exist, unused, so this step is fully
reversible by redeploying the previous build.
Two consequences to accept explicitly rather than discover:
Note that `tsc` does not remove output for deleted sources, so a build over an existing
`dist/` leaves `dist/src/models/calendar/users/*.js` behind. Nothing imports it and the
routes 404, but the deployed artifact still contains the code - clear `dist/` in the
pipeline if you want the artifact to match the source.
4. **Apply `sql/calendar/004_drop_legacy_auth.sql`.** This is the point of no return for
the columns; the accounts themselves are only renamed aside.
5. Optionally, later and at a quiet moment:
`DROP TABLE sessions_legacy_archive, users_legacy_archive;`
- Any activation or password-reset e-mail already sent points at
`api.nachklang.art/calendar/users/activate` and becomes a 404. Those links were only ever
valid for legacy accounts, which no longer open anything.
- `Event.createdById` disappears from the API response. The Angular frontend never read it
(its `Event` model has only `createdBy`, the name), so this is not a breaking change for
the only known consumer - but it is a wire-format removal, so check anything else that
reads `/calendar/events/*/json` first.
**One-way door:** `Event.createdById` and `lastModifiedById` leave the API response. Check
anything reading `/calendar/events/*/json` that is not the calendar frontend.
## What the code actually looks like (surveyed 2026-09-06)
+60
View File
@@ -0,0 +1,60 @@
-- Nachklang e.V. Calendar module — step 5 of docs/calendar-auth-migration.md.
-- Apply manually against the CALENDAR_DB database, after 003:
-- mysql -h <DB_HOST> -u <DB_USER> -p <CALENDAR_DB> < 004_drop_legacy_auth.sql
--
-- *** APPLY THIS AFTER DEPLOYING THE API, NOT BEFORE. ***
--
-- This is the opposite order from the step 4 cutover, and getting it wrong by
-- analogy is the obvious mistake. Step 4's migration only *added* things, so it
-- was safe ahead of the deploy. This one removes columns and a table that the
-- currently running build still selects and joins - applying it first fails
-- every calendar read, including the anonymous public feed the website uses.
-- The step 5 build touches none of them, so it runs happily against the old
-- schema; deploy it, confirm the calendar works, then run this.
--
-- Nothing here loses information that is still reachable: the creators' display
-- names were snapshotted into events.created_by_name and
-- event_versions.version_created_by_name by migration 002, and the step 4
-- runbook re-ran that backfill after the deploy. Verify before running:
--
-- SELECT SUM(created_by_id IS NOT NULL AND created_by_name IS NULL) FROM events;
-- SELECT SUM(version_created_by_id IS NOT NULL AND version_created_by_name IS NULL) FROM event_versions;
--
-- Both must be 0. A non-zero count is an event whose author this migration
-- would erase; re-run 002's backfill first.
-- The foreign keys have to go before the columns they are declared on.
-- IF EXISTS so that a re-run after a partial failure gets past them.
ALTER TABLE `events`
DROP FOREIGN KEY IF EXISTS `events_users_user_id_fk`;
ALTER TABLE `event_versions`
DROP FOREIGN KEY IF EXISTS `event_versions_users_user_id_fk`;
ALTER TABLE `events`
DROP INDEX IF EXISTS `events_users_user_id_fk`,
DROP COLUMN IF EXISTS `created_by_id`;
ALTER TABLE `event_versions`
DROP INDEX IF EXISTS `event_versions_users_user_id_fk`,
DROP COLUMN IF EXISTS `version_created_by_id`;
-- The accounts themselves are renamed aside rather than dropped.
--
-- Nothing visible depends on them any more - the names are snapshotted, and no
-- code has referenced these tables since the step 4 cutover. But they still
-- hold e-mail addresses and password hashes, and a rename makes them
-- unreachable without destroying anything.
--
-- `sessions` has a foreign key into `users`; InnoDB rewires it to the new name
-- on rename, so after this it reads REFERENCES `users_legacy_archive` and the
-- pair stays internally consistent whichever order they are renamed in.
-- Verified on MariaDB 11.
--
-- Unlike the statements above this is not re-runnable, and that is the safe
-- behaviour: a second run fails on a missing `sessions` rather than doing
-- anything. Drop them for real whenever you like, at a moment when nobody is
-- mid-deploy:
-- DROP TABLE `sessions_legacy_archive`, `users_legacy_archive`;
RENAME TABLE `sessions` TO `sessions_legacy_archive`;
RENAME TABLE `users` TO `users_legacy_archive`;
+6 -9
View File
@@ -63,15 +63,12 @@ export const createApp = (): express.Application => {
// the dev machine's LAN IP, never "localhost"). Dev-only, same as above.
const lanIpRegex = /^http:\/\/(192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}):\d+$/;
app.use(cors({
// X-Session-* are no longer read by anything on this side, and no longer
// sent by anything either: the tickets and feedback cutover took the last
// two readers off them, and the calendar cutover removed the last legacy
// credential path in the API (its session used to travel in query
// parameters - DEFERRED_SECURITY.md item 1, now closed). They stay allowed
// only so a browser still running a pre-cutover tickets or feedback bundle
// gets a clean 401 rather than a CORS preflight failure. Drop them once
// those have aged out - see docs/calendar-auth-migration.md step 5.
allowedHeaders: ['Content-Type', 'X-Session-Id', 'X-Session-Key'],
// Content-Type alone. X-Session-Id / X-Session-Key were allowed here
// through the two cutovers so that a browser still holding a pre-cutover
// bundle got a clean 401 rather than a confusing CORS preflight failure.
// Nothing has read them since the first cutover and nothing has sent them
// since the second, so they came out with the rest of the legacy path.
allowedHeaders: ['Content-Type'],
// The admin session lives in a cookie, so browsers must be allowed to send
// it cross-origin - this is what makes credentials: 'include' work.
credentials: true,
+7 -2
View File
@@ -5,7 +5,6 @@ import express, {Request, Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../middleware/logger.js';
import {eventsRouter} from './events/events.router.js';
import {usersRouter} from './users/users.router.js';
/**
* Router Definition
@@ -13,7 +12,13 @@ import {usersRouter} from './users/users.router.js';
export const calendarRouter = express.Router();
calendarRouter.use('/events', eventsRouter);
calendarRouter.use('/users', usersRouter);
/*
* There is no /calendar/users any more. It held this module's own accounts -
* registration, login, activation, password reset, and the session table the
* feedback and tickets admin areas used to authenticate against - and every one
* of those moved to the admin module. See docs/calendar-auth-migration.md.
*/
/**
+3 -23
View File
@@ -14,7 +14,6 @@
* - endDateTime
* - createdDate
* - location
* - createdById
* - url
* - wholeDay
* properties:
@@ -65,15 +64,6 @@
* type: string
* description: The name of the user who created the event
* example: "John Doe"
* createdById:
* type: integer
* deprecated: true
* description: >
* The legacy calendar user id of the creator. Being replaced by
* createdByUserId; see docs/calendar-auth-migration.md. Null on
* events created after the cutover.
* nullable: true
* example: 456
* createdByUserId:
* type: string
* nullable: true
@@ -83,14 +73,6 @@
* type: string
* description: The name of the user who last modified the event
* example: "John Doe"
* lastModifiedById:
* type: integer
* deprecated: true
* nullable: true
* description: >
* The legacy calendar user id of the last editor. Being replaced
* by lastModifiedByUserId.
* example: 456
* lastModifiedByUserId:
* type: string
* nullable: true
@@ -121,14 +103,12 @@ export interface Event {
createdDate: Date;
lastModifiedDate?: Date;
location: string;
/** Display name of the creator, from whichever id below resolved. */
/** Display name of the creator: the live admin name when the id below
* resolves, otherwise the name archived before the legacy users table was
* removed. See docs/calendar-auth-migration.md. */
createdBy?: string;
createdById?: number | null;
/** Set once the event's creator exists in the admin module. Preferred over
* createdById when both are present; see docs/calendar-auth-migration.md. */
createdByUserId?: string | null;
lastModifiedBy?: string;
lastModifiedById?: number | null;
lastModifiedByUserId?: string | null;
url: string;
wholeDay: boolean;
+25 -45
View File
@@ -9,49 +9,35 @@ import logger from '../../../middleware/logger.js';
dotenv.config();
/**
* Step 3 of docs/calendar-auth-migration.md: the dual read.
* How an event's creator is resolved, after step 5 of
* docs/calendar-auth-migration.md removed the legacy path.
*
* An event records its creator twice - `created_by_id`, the legacy INT into
* the calendar database's own `users` table, and `created_by_user_id`, the
* admin module's VARCHAR(36) id. Old rows have only the first, rows written
* after the step 4 cutover will have only the second, and the two live in
* different databases, so this file has to read both and prefer the new one.
* The creator is only ever rendered as a name - nothing authorises on it, there
* is no "only the creator may edit" rule anywhere - which is why an unresolvable
* one degrades to blank rather than to an error.
*
* The one thing the creator is used for is a display name. Nothing authorises
* on it - there is no "only the creator may edit" rule anywhere - which is why
* a name that cannot be resolved degrades to blank instead of to an error.
* Two sources remain, weaker first:
*
* That name has three possible sources, and they are tried weakest first:
* 1. `created_by_name`, a snapshot of the name as it stood when the calendar
* had its own `users` table. Migration 002 took it, migration 004 dropped
* the table it was taken from, and nothing has written it since. It exists
* so the authorship of pre-cutover events survived that removal.
* 2. The admin module's `user.name`, looked up live for rows carrying an admin
* id. It wins, because it is the only one that follows a rename.
*
* 1. LEGACY - joining the calendar's own `users` table on `created_by_id`.
* 2. `created_by_name`, the snapshot migration 002 took of exactly that join,
* so the authorship of pre-cutover events survives step 5 dropping the
* table. An archive: nothing writes it after the backfill.
* 3. The admin module's `user.name`, looked up live for rows that carry an
* admin id. It wins because it is the only one that follows a rename.
*
* Writes only ever set the admin id: since the step 4 cutover there is no
* calendar user id to write, which is why migration 003 made `created_by_id`
* nullable. The reads below still handle rows that predate that.
*
* Removal note: everything marked LEGACY below comes out in step 5, together
* with the `users`/`sessions` tables and the `created_by_id` columns. The
* snapshot stays - it is the reason step 5 can drop them.
* The third source - joining the calendar's own `users` table on
* `created_by_id` - is gone with the table and the column.
*/
/**
* The one SELECT the four read paths share. It was copied out four times
* before, which is precisely why the dual read had to be added in four
* places; callers append their own WHERE and ORDER BY.
* The one SELECT the four read paths share; callers append their own WHERE and
* ORDER BY. `v.*` carries the version row's own creator columns, so only the
* `events` columns need naming.
*
* `v.*` carries `version_created_by_user_id` and `version_created_by_name`
* along with the rest of the version row, so only the `events` columns need
* naming. The two joined names are aliased `legacy_*` because the unprefixed
* names are now real columns.
* There are no joins to a users table any more. There is no users table.
*/
const EVENT_SELECT = `
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_id, e.created_by_user_id, e.created_by_name,
u.full_name as legacy_created_by_name, u2.full_name as legacy_last_modified_by_name, v.* FROM events e
SELECT e.calendar_id, e.uuid, e.created_date, e.created_by_user_id, e.created_by_name, v.* FROM events e
INNER JOIN (
SELECT event_id, MAX(event_version_id) AS latest_version
FROM event_versions
@@ -59,9 +45,7 @@ const EVENT_SELECT = `
) latest_versions
ON e.event_id = latest_versions.event_id
INNER JOIN event_versions v
ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version
LEFT OUTER JOIN users u ON u.user_id = e.created_by_id
LEFT OUTER JOIN users u2 ON u2.user_id = v.version_created_by_id`;
ON v.event_id = latest_versions.event_id AND v.event_version_id = latest_versions.latest_version`;
/**
* Maps a result row to an Event. `status` is included only where it always
@@ -80,15 +64,11 @@ const toEvent = (row: any, includeStatus: boolean): Event => {
createdDate: row.created_date,
lastModifiedDate: row.version_created_at,
location: row.location,
// Name resolution, weakest first: the LEGACY join against the calendar
// users table, then the snapshot taken in migration 002, then - in
// resolveAdminNames below - the live admin name, which wins because it
// is the only one that follows an account being renamed.
createdBy: row.created_by_name ?? row.legacy_created_by_name,
createdById: row.created_by_id,
// The archived name. resolveAdminNames below overwrites it for rows that
// carry an admin id, which is the only source that follows a rename.
createdBy: row.created_by_name,
createdByUserId: row.created_by_user_id ?? null,
lastModifiedBy: row.version_created_by_name ?? row.legacy_last_modified_by_name,
lastModifiedById: row.version_created_by_id,
lastModifiedBy: row.version_created_by_name,
lastModifiedByUserId: row.version_created_by_user_id ?? null,
url: row.url,
wholeDay: row.whole_day,
@@ -112,7 +92,7 @@ const toEvent = (row: any, includeStatus: boolean): Event => {
* A failure here is swallowed on purpose. These endpoints include the public
* calendar the website reads anonymously, and a name is decoration: if the
* admin database is unreachable, an event should still render with whatever
* the legacy join produced rather than 500 the whole listing. The alternative
* the snapshot holds rather than 500 the whole listing. The alternative
* would widen the public calendar's blast radius to include the admin
* database, which it has never depended on before.
*/
@@ -1,53 +0,0 @@
/**
* @swagger
* components:
* schemas:
* Session:
* type: object
* required:
* - sessionId
* - userId
* - sessionKey
* - sessionKeyHash
* - lastIP
* properties:
* sessionId:
* type: integer
* description: The unique identifier for the session
* example: 789
* userId:
* type: integer
* description: The ID of the user this session belongs to
* example: 456
* sessionKey:
* type: string
* description: The session key used for authentication
* example: "abc123def456"
* sessionKeyHash:
* type: string
* description: The hashed session key (not returned in API responses)
* example: "$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG"
* createdDate:
* type: string
* format: date-time
* description: The date and time when the session was created
* example: "2023-05-01T10:00:00.000Z"
* validUntil:
* type: string
* format: date-time
* description: The date and time until when the session is valid
* example: "2023-05-08T10:00:00.000Z"
* lastIP:
* type: string
* description: The last IP address used with this session
* example: "192.168.1.1"
*/
export interface Session {
sessionId: number;
userId: number;
sessionKey: string;
sessionKeyHash: string;
createdDate?: Date;
validUntil?: Date;
lastIP: string;
}
@@ -1,42 +0,0 @@
/**
* @swagger
* components:
* schemas:
* User:
* type: object
* required:
* - userId
* - fullName
* - passwordHash
* - email
* - isActive
* properties:
* userId:
* type: integer
* description: The unique identifier for the user
* example: 456
* fullName:
* type: string
* description: The full name of the user
* example: "John Doe"
* passwordHash:
* type: string
* description: The hashed password of the user (not returned in API responses)
* example: "$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG"
* email:
* type: string
* format: email
* description: The email address of the user
* example: "john.doe@nachklang.art"
* isActive:
* type: boolean
* description: Whether the user account is active
* example: true
*/
export interface User {
userId: number;
fullName: string;
passwordHash: string;
email: string;
isActive: boolean;
}
-671
View File
@@ -1,671 +0,0 @@
/**
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import * as UserService from './users.service.js';
import {Session} from './session.interface.js';
import {User} from './user.interface.js';
import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger.js';
/**
* Router Definition
*/
export const usersRouter = express.Router();
/**
* Controller Definitions
*/
/**
* @swagger
* /calendar/users/register:
* post:
* summary: Register a new user
* description: Creates a new user account with the provided email, password, and full name. Only accepts official Nachklang email addresses.
* tags:
* - calendar
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - email
* - password
* - fullName
* properties:
* email:
* type: string
* format: email
* example: john.doe@nachklang.art
* description: Must be an official Nachklang email address
* password:
* type: string
* format: password
* example: securePassword123
* fullName:
* type: string
* example: John Doe
* responses:
* 201:
* description: User registered successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: integer
* example: 123
* sessionKey:
* type: string
* example: abc123def456
* 400:
* description: Bad request - missing or invalid parameters
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Missing parameters
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
// POST users/register
usersRouter.post('/register', async (req: Request, res: Response) => {
try {
const password: string = req.body.password;
const email: string = req.body.email;
const fullName: string = req.body.fullName;
const ip: string = req.socket.remoteAddress ?? '';
if (!password || !email || !fullName) {
// Missing
res.status(400).send(JSON.stringify({message: 'Missing parameters'}));
return;
}
const emailRegex = /^[a-zA-Z0-9\_\-\.]+@nachklang\.art$/;
if(!emailRegex.test(email)) {
res.status(400).send(JSON.stringify({message: 'Must use an official Nachklang email address'}));
return;
}
// Create the user and a session
const session: Session = await UserService.createUser(email, password, fullName, ip);
// Send the session details back to the user
res.status(201).send({
sessionId: session.sessionId,
sessionKey: session.sessionKey
});
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
}
});
/**
* @swagger
* /calendar/users/activate:
* get:
* summary: Activate a user account
* description: Activates a user account using the provided user ID and activation token.
* tags:
* - calendar
* parameters:
* - in: query
* name: id
* required: true
* schema:
* type: integer
* description: The ID of the user to activate
* - in: query
* name: token
* required: true
* schema:
* type: string
* description: The activation token sent to the user's email
* responses:
* 200:
* description: User activated successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: OK
* message:
* type: string
* example: User activated
* 400:
* description: Bad request - missing parameters or activation failed
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Error activating user. Please contact your administrator.
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
// GET /users/activate
usersRouter.get('/activate', async (req: Request, res: Response) => {
try {
const userId: number = parseInt(req.query.id as string ?? '-1', 10);
const token: string = req.query.token as string ?? '';
if (!userId || !token) {
// Missing
res.status(400).send(JSON.stringify({message: 'Missing parameters'}));
return;
}
// Create the user and a session
const success: boolean = await UserService.activateUser(userId, token);
// Send the session details back to the user
if(success) {
res.status(200).send({
'status': 'OK',
'message': 'User activated'
});
return;
}
res.status(400).send({'status': 'PROCESSING_ERROR','message': 'Error activating user. Please contact your administrator.'});
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
}
});
/**
* @swagger
* /calendar/users/login:
* post:
* summary: Login a user
* description: Authenticates a user with the provided email and password and returns a session.
* tags:
* - calendar
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - email
* - password
* properties:
* email:
* type: string
* format: email
* example: john.doe@nachklang.art
* password:
* type: string
* format: password
* example: securePassword123
* responses:
* 200:
* description: Login successful
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: integer
* example: 123
* sessionKey:
* type: string
* example: abc123def456
* 400:
* description: Bad request - missing parameters
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Missing parameters
* 401:
* description: Unauthorized - invalid credentials
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: Wrong username and / or password
* sessionId:
* type: integer
* example: -1
* sessionKey:
* type: string
* example: ""
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
// POST users/login
usersRouter.post('/login', async (req: Request, res: Response) => {
try {
const password: string = req.body.password;
const email: string = req.body.email;
const ip: string = req.socket.remoteAddress ?? '';
if (!password || !email) {
// Missing
res.status(400).send(JSON.stringify({message: 'Missing parameters'}));
return;
}
// Create a session
const session: Session | null = await UserService.login(email, password, ip);
if (!session || !session.sessionId) {
// Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({message: 'Wrong username and / or password', sessionId: -1, sessionKey: ''}));
return;
}
// Send the session details back to the user
res.status(200).send({
sessionId: session.sessionId,
sessionKey: session.sessionKey
});
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
}
});
/**
* @swagger
* /calendar/users/checkSessionValid:
* post:
* summary: Check if a session is valid
* description: Checks if the provided session is valid and returns the user information if it is.
* tags:
* - calendar
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - sessionId
* - sessionKey
* properties:
* sessionId:
* type: integer
* example: 123
* sessionKey:
* type: string
* example: abc123def456
* responses:
* 200:
* description: Session is valid
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/User'
* 401:
* description: Unauthorized - invalid session
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: ["Invalid session"]
* 500:
* description: Server error
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: PROCESSING_ERROR
* message:
* type: string
* example: Internal Server Error. Try again later.
* reference:
* type: string
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
*/
// POST users/checkSessionValid
usersRouter.post('/checkSessionValid', async (req: Request, res: Response) => {
try {
const ip: string = req.socket.remoteAddress ?? '';
const session_id = req.body.sessionId;
const session_key = req.body.sessionKey;
if (!session_id || !session_key) {
// Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({messages: ['No session detected']}));
return;
}
const user: User | null = await UserService.checkSession(session_id, session_key, ip);
if (!user || !user.userId) {
// Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({messages: ['Invalid session']}));
return;
}
res.status(200).send(user);
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
}
});
/**
* @swagger
* /calendar/users/initiatePasswordReset:
* post:
* summary: Initiates a password reset
* description: Checks if the user exists and if so, initiates a password reset by sending an email to the user.
* tags:
* - calendar
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Success
* description: A list of status messages
* 400:
* description: Problem with the request. Please consider the returned detailed error.
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Missing parameters
* description: A list of error messages
* 401:
* description: Problem with authorizing the user. Please check the provided credentials.
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Invalid session
* description: A list of error messages
* 500:
* description: A server error occurred. Please try again. If this issue persists, contact the admin.
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* description: The response status
* example: PROCESSING_ERROR
* message:
* type: string
* description: The detailed error message
* example: Internal Server Error. Try again later.
* reference:
* type: string
* description: An error reference for getting support concerning this error.
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* example: patrick@nachklang.art
*/
usersRouter.post('/initiatePasswordReset', async(req: Request, res: Response) => {
try {
const username = req.body.username;
if (!username) {
// Error logging in, probably wrong username / password
res.status(400).send(JSON.stringify({messages: ['No username given']}));
return;
}
const success: boolean = await UserService.initiatePasswordReset(username);
if (!success) {
// Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({messages: ['Error']}));
return;
}
res.status(200).send(JSON.stringify({messages: ['Success']}));
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
}
});
/**
* @swagger
* /calendar/users/finalizePasswordReset:
* post:
* summary: Finalizes the password reset
* description: Checks if the given token is valid and if so, finalizes the password reset by setting the new password.
* tags:
* - calendar
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Success
* description: A list of status messages
* 400:
* description: Problem with the request. Please consider the returned detailed error.
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Missing parameters
* description: A list of error messages
* 401:
* description: Problem with authorizing the user. Please check the provided credentials.
* content:
* application/json:
* schema:
* type: object
* properties:
* messages:
* type: array
* items:
* type: string
* example: Invalid session
* description: A list of error messages
* 500:
* description: A server error occurred. Please try again. If this issue persists, contact the admin.
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* description: The response status
* example: PROCESSING_ERROR
* message:
* type: string
* description: The detailed error message
* example: Internal Server Error. Try again later.
* reference:
* type: string
* description: An error reference for getting support concerning this error.
* example: 6ec1361c-4175-4e81-b2ef-a0792a9a1dc3
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* example: patrick@nachklang.art
* token:
* type: string
* example: 3ccd147f-720b-4e29-a8b7-46b63de31555
* password:
* type: string
* example: ExtremelyBadPassword
*/
usersRouter.post('/finalizePasswordReset', async(req: Request, res: Response) => {
try {
const username = req.body.username;
const token = req.body.token;
const newPassword = req.body.password;
if (!username) {
// Error logging in, probably wrong username / password
res.status(400).send(JSON.stringify({messages: ['No username, token or password given']}));
return;
}
const success: boolean = await UserService.finalizePasswordReset(username, token, newPassword);
if (!success) {
// Error logging in, probably wrong username / password
res.status(401).send(JSON.stringify({messages: ['Error']}));
return;
}
res.status(200).send(JSON.stringify({messages: ['Success']}));
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
}
});
-296
View File
@@ -1,296 +0,0 @@
import * as dotenv from 'dotenv';
import bcrypt from 'bcrypt';
import {Guid} from 'guid-typescript';
import {User} from './user.interface.js';
import {Session} from './session.interface.js';
import {NachklangCalendarDB} from '../Calendar.db.js';
import {MailService} from '../../../common/common.mail.js';
dotenv.config();
/**
* Data Model Interfaces
*/
/**
* Service Methods
*/
/**
* Creates a user record in the database, also creates a session. Returns the session if successful.
*/
export const createUser = async (email: string, password: string, fullName: string, ip: string): Promise<Session> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
// Hash password and generate + hash session key
const pwHash = bcrypt.hashSync(password, 10);
const sessionKey = Guid.create().toString();
const sessionKeyHash = bcrypt.hashSync(sessionKey, 10);
const activationToken = Guid.create().toString();
const activationTokenHash = bcrypt.hashSync(activationToken, 10);
// Create user entry in SQL
const userQuery = 'INSERT INTO users (email, password_hash, full_name, activation_token) VALUES (?, ?, ?, ?) RETURNING user_id';
const userIdRes = await conn.query(userQuery, [email, pwHash, fullName, activationTokenHash]);
// Get user id of the created user
let userId: number = -1;
for (const row of userIdRes) {
userId = row.user_id;
}
// Create session
const sessionQuery = 'INSERT INTO sessions (user_id, session_key_hash, created_date, valid_until, last_ip) VALUES (?,?,NOW(),DATE_ADD(NOW(), INTERVAL 30 DAY),?) RETURNING session_id';
const sessionIdRes = await conn.query(sessionQuery, [userId, sessionKeyHash, ip]);
await conn.commit();
// Get session id of the created session
let sessionId: number = -1;
for (const row of sessionIdRes) {
sessionId = row.session_id;
}
// Send email with activation link (after commit so we don't block on email
// delivery). sendMail never throws on a delivery failure - it logs and
// returns false - so a mail-server problem here can't roll back the
// already-committed user and leave registration reporting a false error.
await MailService.sendMail(email, 'Activate your Nachklang account', `Hi ${fullName},\n\nPlease click on the following link to activate your account:\n\nhttps://api.nachklang.art/calendar/users/activate?id=${userId}&token=${activationToken}`);
return {
sessionId: sessionId,
userId: userId,
sessionKey: sessionKey,
sessionKeyHash: 'HIDDEN',
lastIP: ip
};
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export const activateUser = async (userId: number, token: string): Promise<boolean> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
const checkTokenQuery = 'SELECT user_id, activation_token FROM users WHERE user_id = ? AND is_active = 0';
const userNameRes = await conn.query(checkTokenQuery, [userId]);
let storedTokenHash = '';
for (const row of userNameRes) {
storedTokenHash = row.activation_token;
}
if (!storedTokenHash || !bcrypt.compareSync(token, storedTokenHash)) {
return false;
}
const activateQuery = 'UPDATE users SET is_active = 1, activation_token = null WHERE user_id = ?';
const activateRes = await conn.execute(activateQuery, [userId]);
await conn.commit();
return activateRes.affectedRows !== 0;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
}
/**
* Checks if the given credentials are valid and creates a new session if they are.
* Returns the session information in case of a successful login
*/
export const login = async (email: string, password: string, ip: string): Promise<Session | null> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
// Get saved password hash
const query = 'SELECT user_id, password_hash FROM users WHERE email = ?';
const userRows = await conn.query(query, email);
let savedHash = '';
let userId = -1;
for (const row of userRows) {
savedHash = row.password_hash;
userId = row.user_id;
}
// Check for correct password
if (!bcrypt.compareSync(password, savedHash)) {
return null;
}
// Generate + hash session key
const sessionKey = Guid.create().toString();
const sessionKeyHash = bcrypt.hashSync(sessionKey, 10);
// Create session
const sessionQuery = 'INSERT INTO sessions (user_id, session_key_hash, created_date, valid_until, last_ip) VALUES (?,?,NOW(),DATE_ADD(NOW(), INTERVAL 30 DAY),?) RETURNING session_id';
const sessionIdRes = await conn.query(sessionQuery, [userId, sessionKeyHash, ip]);
await conn.commit();
// Get session id of the created session
let sessionId: number = -1;
for (const row of sessionIdRes) {
sessionId = row.session_id;
}
return {
sessionId: sessionId,
userId: userId,
sessionKey: sessionKey,
sessionKeyHash: 'HIDDEN',
lastIP: ip
};
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
/**
* Checks if the given session information are valid and returns the user information if they are
*/
export const checkSession = async (sessionId: string, sessionKey: string, ip: string): Promise<User | null> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
// Get saved session key hash
const query = 'SELECT user_id, session_key_hash, valid_until FROM sessions WHERE session_id = ?';
const sessionRows = await conn.query(query, sessionId);
let savedHash = '';
let userId = -1;
let validUntil = new Date();
for (const row of sessionRows) {
savedHash = row.session_key_hash;
userId = row.user_id;
validUntil = row.valid_until;
}
// Check for correct key
if (!bcrypt.compareSync(sessionKey, savedHash)) {
return null;
}
// Check if the session is still valid
if (validUntil <= new Date()) {
return null;
}
// Update session entry in SQL
const updateSessionsQuery = 'UPDATE sessions SET last_IP = ? WHERE session_id = ?';
await conn.query(updateSessionsQuery, [ip, sessionId]);
await conn.commit();
// Get the other required user information
const userQuery = 'SELECT user_id, email, full_name, is_active FROM users WHERE user_id = ?';
const userRows = await conn.query(userQuery, userId);
let email = '';
let fullName = '';
let is_active = false;
for (const row of userRows) {
email = row.email;
fullName = row.full_name;
is_active = row.is_active;
}
// Everything is fine, return user information
return {
userId: userId,
email: email,
passwordHash: 'HIDDEN',
fullName: fullName,
isActive: is_active
};
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
};
export const initiatePasswordReset = async (email: string): Promise<boolean> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
const checkUsernameQuery = 'SELECT user_id, full_Name FROM users WHERE email = ?';
const userNameRes = await conn.query(checkUsernameQuery, [email]);
if (userNameRes.length === 0) {
return false;
}
let userId: number = -1;
let fullName: string = '';
for(let row of userNameRes) {
userId = row.user_id;
fullName = row.full_Name;
}
let resetToken = Guid.create().toString();
let resetTokenHash = bcrypt.hashSync(resetToken, 10);
const updateQuery = 'UPDATE users SET pw_reset_token_hash = ? WHERE user_id = ?';
const updateRes = await conn.execute(updateQuery, [resetTokenHash, userId]);
if(updateRes.affectedRows === 0) {
return false;
}
await conn.commit();
await MailService.sendMail(email, 'Password Reset', `Hello ${fullName},\n\nYou requested a password reset for your BonkApp account. If you did not request this, please ignore this email.\n\nTo reset your password, please use the following reset token:\n\n${resetToken}`);
return true;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
}
export const finalizePasswordReset = async (email: string, token: string, newPassword: string): Promise<boolean> => {
let conn = await NachklangCalendarDB.getConnection();
try {
await conn.beginTransaction();
const checkTokenQuery = 'SELECT user_id, pw_reset_token_hash FROM users WHERE email = ?';
const userNameRes = await conn.query(checkTokenQuery, [email]);
if (userNameRes.length === 0) {
return false;
}
let userId: string = '';
let tokenHash: string = '';
for(let row of userNameRes) {
userId = row.user_id;
tokenHash = row.pw_reset_token_hash;
}
if(!bcrypt.compareSync(token, tokenHash)) {
return false;
}
const pwHash = bcrypt.hashSync(newPassword, 10);
const updatePasswordQuery = 'UPDATE users SET password_hash = ?, pw_reset_token_hash = NULL WHERE user_id = ?';
const updateRes = await conn.execute(updatePasswordQuery, [pwHash, userId]);
if(updateRes.affectedRows > 0) {
await conn.commit();
return true;
}
return false;
} catch (err) {
await conn.rollback();
throw err;
} finally {
await conn.end();
}
}
+8 -19
View File
@@ -7,9 +7,14 @@ import express, {Request, Response} from 'express';
* the calendar users table; both are now one binding to the shared admin gate.
*
* What is worth asserting is not how that gate works - admin.middleware.test.ts
* owns that - but that each module is bound to *its own* app, and that neither
* consults the calendar users service any more. The mocks live in the calling
* file because vi.mock is per-module-graph; only the assertions are shared.
* owns that - but that each module is bound to *its own* app. The mocks live in
* the calling file because vi.mock is per-module-graph; only the assertions are
* shared.
*
* There used to be a tripwire here asserting neither module fell back to the
* calendar's header sessions. It went with step 5: the calendar users service
* no longer exists, so there is nothing left to fall back to and nothing to
* assert against.
*/
export interface BindingMocks {
@@ -17,8 +22,6 @@ export interface BindingMocks {
getSession: Mock;
/** loadAccess from the mocked users.admin.service.js */
loadAccess: Mock;
/** checkSession from the mocked calendar users.service.js */
checkSession: Mock;
}
const makeReq = (): Request => ({headers: {cookie: 'nachklang.session_token=abc'}} as unknown as Request);
@@ -60,7 +63,6 @@ export const describeAdminBinding = (
m = mocks();
m.getSession.mockReset();
m.loadAccess.mockReset();
m.checkSession.mockReset();
});
it('responds 401 and does not call next() without a session', async () => {
@@ -101,18 +103,5 @@ export const describeAdminBinding = (
expect(next).toHaveBeenCalled();
expect(res.locals.admin).toMatchObject({id: 'u1', email: 'a@nachklang.art', displayName: 'Anna Admin'});
});
// Weaker than it looks and kept deliberately: neither module imports
// checkSession any more, so this cannot fail today. It is a tripwire for
// the change that would matter - someone reintroducing a header-session
// fallback "just for the calendar users who have not been invited yet",
// which is exactly the shortcut the cutover exists to close.
it('never falls back to a calendar header session', async () => {
m.getSession.mockResolvedValue(null);
await run();
expect(m.checkSession).not.toHaveBeenCalled();
});
});
};
+27 -49
View File
@@ -23,11 +23,11 @@ import * as EventService from '../../src/models/calendar/events/events.service.j
/**
* Step 3 of docs/calendar-auth-migration.md. The property under test is that
* an event's creator resolves from whichever of its three possible sources is
* strongest - the live admin name, then the snapshot from migration 002, then
* the legacy join - and that a failure to reach the admin database costs a name
* rather than the whole response: the public calendar is read anonymously by
* the website and has never depended on the admin database being up.
* an event's creator resolves from the stronger of its two remaining sources -
* the live admin name, else the name archived before the legacy users table was
* removed - and that a failure to reach the admin database costs a name rather
* than the whole response: the public calendar is read anonymously by the
* website and has never depended on the admin database being up.
*/
// One row of the shape the shared SELECT produces.
@@ -42,14 +42,10 @@ const row = (over: Record<string, unknown> = {}) => ({
created_date: new Date('2026-01-01T00:00:00Z'),
version_created_at: new Date('2026-01-02T00:00:00Z'),
location: '',
created_by_id: 7,
created_by_user_id: null,
created_by_name: null,
legacy_created_by_name: 'Legacy Person',
version_created_by_id: 7,
created_by_name: 'Archived Person',
version_created_by_user_id: null,
version_created_by_name: null,
legacy_last_modified_by_name: 'Legacy Person',
version_created_by_name: 'Archived Person',
url: '',
whole_day: 0,
repeat_frequency: '',
@@ -72,13 +68,12 @@ beforeEach(() => {
});
describe('creator names', () => {
it('uses the legacy join when the row has no admin id', async () => {
it('uses the archived name when the row has no admin id', async () => {
givenEvents(row());
const events = await EventService.getAllEvents(1);
expect(events[0].createdBy).toBe('Legacy Person');
expect(events[0].createdById).toBe(7);
expect(events[0].createdBy).toBe('Archived Person');
expect(events[0].createdByUserId).toBeNull();
// Nothing to resolve, so the admin database is not touched at all.
expect(AdminUsersService.findDisplayNames).not.toHaveBeenCalled();
@@ -97,23 +92,9 @@ describe('creator names', () => {
expect(events[0].createdBy).toBe('Neue Person');
expect(events[0].lastModifiedBy).toBe('Andere Person');
// The legacy id is still reported during the transition.
expect(events[0].createdById).toBe(7);
expect(events[0].createdByUserId).toBe('admin-1');
});
it('prefers the snapshot over the legacy join', async () => {
givenEvents(row({
created_by_name: 'Archived Person',
version_created_by_name: 'Archived Person'
}));
const events = await EventService.getAllEvents(1);
expect(events[0].createdBy).toBe('Archived Person');
expect(events[0].lastModifiedBy).toBe('Archived Person');
});
it('prefers the live admin name over the snapshot', async () => {
// A renamed account has to win over an archive that was correct when it
// was taken - otherwise renaming someone would leave stale names behind.
@@ -125,29 +106,26 @@ describe('creator names', () => {
expect(events[0].createdBy).toBe('Neue Person');
});
it('keeps the snapshot when step 5 has removed the legacy join', async () => {
// What a post-step-5 row looks like: no legacy id, no join, snapshot only.
givenEvents(row({
created_by_id: null,
legacy_created_by_name: undefined,
legacy_last_modified_by_name: undefined,
created_by_name: 'Archived Person',
version_created_by_name: 'Archived Person'
}));
const events = await EventService.getAllEvents(1);
expect(events[0].createdBy).toBe('Archived Person');
expect(events[0].lastModifiedBy).toBe('Archived Person');
});
it('falls back to the legacy name when the admin account is gone', async () => {
it('falls back to the archived name when the admin account is gone', async () => {
givenEvents(row({created_by_user_id: 'deleted'}));
(AdminUsersService.findDisplayNames as any).mockResolvedValue(new Map());
const events = await EventService.getAllEvents(1);
expect(events[0].createdBy).toBe('Legacy Person');
expect(events[0].createdBy).toBe('Archived Person');
});
it('leaves the name blank when a row has neither source', async () => {
// A post-cutover event whose author was later deleted from the admin
// module: no snapshot was ever taken for it, and the id resolves to
// nothing. Blank is the designed outcome - the creator is decoration.
givenEvents(row({created_by_user_id: 'deleted', created_by_name: null}));
(AdminUsersService.findDisplayNames as any).mockResolvedValue(new Map());
const events = await EventService.getAllEvents(1);
expect(events[0].createdBy).toBeNull();
expect(events).toHaveLength(1);
});
it('resolves a mixed result set in a single lookup', async () => {
@@ -160,7 +138,7 @@ describe('creator names', () => {
const events = await EventService.getAllEvents(1);
expect(events.map(e => e.createdBy)).toEqual(['Legacy Person', 'Neue Person', 'Neue Person']);
expect(events.map(e => e.createdBy)).toEqual(['Archived Person', 'Neue Person', 'Neue Person']);
expect(AdminUsersService.findDisplayNames).toHaveBeenCalledTimes(1);
});
@@ -172,8 +150,8 @@ describe('creator names', () => {
expect(events).toHaveLength(1);
expect(events[0].name).toBe('Konzert');
// Degrades to the legacy name rather than failing the request.
expect(events[0].createdBy).toBe('Legacy Person');
// Degrades to the archived name rather than failing the request.
expect(events[0].createdBy).toBe('Archived Person');
});
});
+1 -6
View File
@@ -1,8 +1,5 @@
import {vi, type Mock} from 'vitest';
vi.mock('../../src/models/calendar/users/users.service.js', () => ({
checkSession: vi.fn()
}));
vi.mock('../../src/models/admin/admin.auth.js', () => ({
auth: {api: {getSession: vi.fn()}}
}));
@@ -10,7 +7,6 @@ vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
loadAccess: vi.fn()
}));
import * as UserService from '../../src/models/calendar/users/users.service.js';
import {auth} from '../../src/models/admin/admin.auth.js';
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
import {requireAdminAuth} from '../../src/models/feedback/feedback.auth.js';
@@ -18,6 +14,5 @@ import {describeAdminBinding} from '../admin/auth-binding.js';
describeAdminBinding('feedback', 'tickets', requireAdminAuth, () => ({
getSession: auth.api.getSession as unknown as Mock,
loadAccess: UsersService.loadAccess as Mock,
checkSession: UserService.checkSession as Mock
loadAccess: UsersService.loadAccess as Mock
}));
+1 -6
View File
@@ -1,8 +1,5 @@
import {vi, type Mock} from 'vitest';
vi.mock('../../src/models/calendar/users/users.service.js', () => ({
checkSession: vi.fn()
}));
vi.mock('../../src/models/admin/admin.auth.js', () => ({
auth: {api: {getSession: vi.fn()}}
}));
@@ -10,7 +7,6 @@ vi.mock('../../src/models/admin/users/users.admin.service.js', () => ({
loadAccess: vi.fn()
}));
import * as UserService from '../../src/models/calendar/users/users.service.js';
import {auth} from '../../src/models/admin/admin.auth.js';
import * as UsersService from '../../src/models/admin/users/users.admin.service.js';
import {requireAdminAuth} from '../../src/models/tickets/tickets.auth.js';
@@ -18,6 +14,5 @@ import {describeAdminBinding} from '../admin/auth-binding.js';
describeAdminBinding('tickets', 'feedback', requireAdminAuth, () => ({
getSession: auth.api.getSession as unknown as Mock,
loadAccess: UsersService.loadAccess as Mock,
checkSession: UserService.checkSession as Mock
loadAccess: UsersService.loadAccess as Mock
}));