From 739d311e9e6c504821aa6d26f8ef7de52e2207d7 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:35:27 -0600 Subject: [PATCH] chore: sync dependency branch with main --- .github/workflows/ci.yml | 6 +- .github/workflows/container-image.yml | 8 +- .github/workflows/e2e-runner.yml | 2 +- .github/workflows/e2e.yml | 2 +- client/package.json | 2 +- client/src/components/AppHeader.vue | 5 +- .../src/components/sidebar/SidebarNavItem.vue | 2 +- client/src/features/admin/UserFormDrawer.vue | 50 +- client/src/features/admin/UsersPage.vue | 431 ++++++++++------- .../src/features/auth/MagicLinkLoginView.vue | 66 +++ .../src/features/auth/composables/useAuth.ts | 22 +- .../src/features/settings/AccountSettings.vue | 24 +- .../features/settings/MagicLinksSettings.vue | 432 ++++++++++++++++++ .../settings/composables/useMagicLinks.ts | 62 +++ client/src/router/guards/auth.guard.ts | 2 +- client/src/router/index.ts | 13 +- packages/types/src/audit.ts | 7 + packages/types/src/auth.ts | 23 + pnpm-lock.yaml | 18 +- server/src/db/migrations/0000_baseline.sql | 22 +- .../src/db/migrations/meta/0000_snapshot.json | 138 +++++- server/src/db/migrations/meta/_journal.json | 2 +- server/src/db/schema/auth.ts | 25 +- .../src/modules/auth/auth.controller.test.ts | 54 ++- server/src/modules/auth/auth.controller.ts | 56 ++- server/src/modules/auth/auth.module.ts | 4 + server/src/modules/auth/auth.service.test.ts | 83 +++- server/src/modules/auth/auth.service.ts | 46 +- .../modules/auth/dto/create-magic-link.dto.ts | 15 + .../modules/auth/dto/magic-link-login.dto.ts | 8 + .../modules/auth/dto/update-magic-link.dto.ts | 6 + .../auth/magic-link.repository.test.ts | 126 +++++ .../src/modules/auth/magic-link.repository.ts | 128 ++++++ .../modules/auth/magic-link.service.test.ts | 238 ++++++++++ server/src/modules/auth/magic-link.service.ts | 210 +++++++++ server/src/modules/auth/oidc/oidc.service.ts | 5 + .../user/dto/create-shared-user.dto.ts | 28 ++ .../src/modules/user/user.controller.test.ts | 4 +- server/src/modules/user/user.controller.ts | 19 +- .../src/modules/user/user.repository.test.ts | 12 +- server/src/modules/user/user.repository.ts | 8 +- server/src/modules/user/user.service.test.ts | 2 +- server/src/modules/user/user.service.ts | 43 +- 43 files changed, 2234 insertions(+), 225 deletions(-) create mode 100644 client/src/features/auth/MagicLinkLoginView.vue create mode 100644 client/src/features/settings/MagicLinksSettings.vue create mode 100644 client/src/features/settings/composables/useMagicLinks.ts create mode 100644 server/src/modules/auth/dto/create-magic-link.dto.ts create mode 100644 server/src/modules/auth/dto/magic-link-login.dto.ts create mode 100644 server/src/modules/auth/dto/update-magic-link.dto.ts create mode 100644 server/src/modules/auth/magic-link.repository.test.ts create mode 100644 server/src/modules/auth/magic-link.repository.ts create mode 100644 server/src/modules/auth/magic-link.service.test.ts create mode 100644 server/src/modules/auth/magic-link.service.ts create mode 100644 server/src/modules/user/dto/create-shared-user.dto.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bee30c1fa..204d300be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Dependency review - uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2 + uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 detect-changes: name: Detect Changed Paths @@ -310,7 +310,7 @@ jobs: steps.has-changes.outputs.run == 'true' && hashFiles(format('test-results/{0}/junit.xml', matrix.package)) != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: test-report-${{ matrix.package }} path: test-results/${{ matrix.package }} @@ -352,7 +352,7 @@ jobs: fetch-depth: 0 - name: Set up Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: ${{ env.NODE_VERSION }} diff --git a/.github/workflows/container-image.yml b/.github/workflows/container-image.yml index f9c7ba489..dbba8ae87 100644 --- a/.github/workflows/container-image.yml +++ b/.github/workflows/container-image.yml @@ -99,10 +99,10 @@ jobs: echo "CI verification passed: ${success_count} successful run(s) found." - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Build image - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . file: ./Dockerfile @@ -114,7 +114,7 @@ jobs: cache-to: type=gha,mode=max - name: Scan image with Trivy - uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: ${{ steps.vars.outputs.scan_tag }} format: table @@ -124,7 +124,7 @@ jobs: exit-code: "1" - name: Log in to GHCR - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} diff --git a/.github/workflows/e2e-runner.yml b/.github/workflows/e2e-runner.yml index 543201aa6..a540b34e1 100644 --- a/.github/workflows/e2e-runner.yml +++ b/.github/workflows/e2e-runner.yml @@ -89,7 +89,7 @@ jobs: - name: Upload e2e artifacts if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ inputs.artifact-name }} path: test-results/server diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 72f4a596f..4ec3e008a 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -33,7 +33,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: ${{ env.NODE_VERSION }} diff --git a/client/package.json b/client/package.json index 5c4223063..f2811014c 100644 --- a/client/package.json +++ b/client/package.json @@ -47,7 +47,7 @@ "vue-echarts": "8.1.0-beta.2", "vue-router": "^5.0.6", "vue-sonner": "^2.0.9", - "vue-virtual-scroller": "^2.0.1" + "vue-virtual-scroller": "^3.0.0" }, "devDependencies": { "@tsconfig/node24": "^24.0.4", diff --git a/client/src/components/AppHeader.vue b/client/src/components/AppHeader.vue index 8c98f2453..5534dfa8c 100644 --- a/client/src/components/AppHeader.vue +++ b/client/src/components/AppHeader.vue @@ -51,6 +51,7 @@ const isBookDockActive = computed(() => route.name === 'book-dock') const isStatisticsActive = computed(() => route.name === 'statistics') const iconRadiusClass = computed(() => (themeStore.radius === 'sharp' ? 'rounded-none' : 'rounded-full')) +const canChangePassword = computed(() => user.value?.provisioningMethod !== 'oidc' && user.value?.provisioningMethod !== 'shared') function navigateToBookDock() { router.push({ name: 'book-dock' }) @@ -675,8 +676,8 @@ function formatBadgeClass(fmt: string): string { Account - - + + Change Password diff --git a/client/src/components/sidebar/SidebarNavItem.vue b/client/src/components/sidebar/SidebarNavItem.vue index 9095e4a26..d24de1ad1 100644 --- a/client/src/components/sidebar/SidebarNavItem.vue +++ b/client/src/components/sidebar/SidebarNavItem.vue @@ -45,7 +45,7 @@ const buttonClass = [ /> {{ label }} diff --git a/client/src/features/admin/UserFormDrawer.vue b/client/src/features/admin/UserFormDrawer.vue index 296e3cbe9..dc14a65f2 100644 --- a/client/src/features/admin/UserFormDrawer.vue +++ b/client/src/features/admin/UserFormDrawer.vue @@ -44,6 +44,7 @@ const name = ref('') const username = ref('') const email = ref('') const active = ref(true) +const isSharedAccount = ref(false) const selectedPermissionNames = ref>(new Set()) const selectedLibraryIds = ref>(new Set()) const error = ref(null) @@ -67,6 +68,7 @@ watch( username.value = u?.username ?? '' email.value = u?.email ?? '' active.value = u?.active ?? true + isSharedAccount.value = u?.provisioningMethod === 'shared' selectedPermissionNames.value = new Set(u?.permissions?.filter((p) => p !== '*') ?? []) selectedLibraryIds.value = new Set() error.value = null @@ -150,11 +152,32 @@ async function handleSubmit() { return } } else { - if (!trimmedEmail) { + if (!isSharedAccount.value && !trimmedEmail) { error.value = 'Email is required' return } + if (isSharedAccount.value) { + const res = await api('/api/v1/users/shared', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: name.value, + username: username.value, + email: trimmedEmail || undefined, + permissionNames: [...selectedPermissionNames.value], + libraryIds: [...selectedLibraryIds.value], + }), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + error.value = err.message ?? 'Failed to create shared account' + return + } + emit('saved') + return + } + const res = await api('/api/v1/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -188,13 +211,29 @@ async function handleSubmit() {
-

{{ isEdit ? 'Edit User' : 'Create User' }}

+

+ {{ isEdit ? 'Edit User' : isSharedAccount ? 'Create Shared Account' : 'Create User' }} +

+ +
+ +
+
+

Shared account

+

Manage login links from Settings - Magic Links.

+
@@ -217,11 +256,14 @@ async function handleSubmit() {
- +
diff --git a/client/src/features/admin/UsersPage.vue b/client/src/features/admin/UsersPage.vue index 66ad9f107..fc94a8ea3 100644 --- a/client/src/features/admin/UsersPage.vue +++ b/client/src/features/admin/UsersPage.vue @@ -1,6 +1,7 @@ diff --git a/client/src/features/auth/MagicLinkLoginView.vue b/client/src/features/auth/MagicLinkLoginView.vue new file mode 100644 index 000000000..c75f33c5e --- /dev/null +++ b/client/src/features/auth/MagicLinkLoginView.vue @@ -0,0 +1,66 @@ + + + diff --git a/client/src/features/auth/composables/useAuth.ts b/client/src/features/auth/composables/useAuth.ts index 341ed0032..d39e6744f 100644 --- a/client/src/features/auth/composables/useAuth.ts +++ b/client/src/features/auth/composables/useAuth.ts @@ -154,5 +154,25 @@ export function useAuth() { router.push('/login') } - return { user, isLoading, init, login, logout, me, setup } + async function loginWithMagicLink(token: string): Promise { + const res = await fetch('/api/v1/auth/magic-links/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ token }), + }) + + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.message ?? 'Invalid or expired magic link') + } + + const data: AuthResponse = await res.json() + setAccessToken(data.accessToken) + user.value = data.user + startSessionRefresh() + router.push('/') + } + + return { user, isLoading, init, login, loginWithMagicLink, logout, me, setup } } diff --git a/client/src/features/settings/AccountSettings.vue b/client/src/features/settings/AccountSettings.vue index 10f2076b0..3c9f97b3b 100644 --- a/client/src/features/settings/AccountSettings.vue +++ b/client/src/features/settings/AccountSettings.vue @@ -300,19 +300,35 @@ function closeUnlinkDialog() { {{ savingProfile ? 'Saving...' : 'Save profile' }} - - Account type: {{ user?.provisioningMethod === 'oidc' ? 'OIDC / SSO' : 'Local' }} + + Account type: + {{ user?.provisioningMethod === 'oidc' ? 'OIDC / SSO' : user?.provisioningMethod === 'shared' ? 'Shared' : 'Local' }} +
- - Account type: {{ user?.provisioningMethod === 'oidc' ? 'OIDC / SSO' : 'Local' }} + + Account type: + {{ user?.provisioningMethod === 'oidc' ? 'OIDC / SSO' : user?.provisioningMethod === 'shared' ? 'Shared' : 'Local' }} +

{{ profileError }}

diff --git a/client/src/features/settings/MagicLinksSettings.vue b/client/src/features/settings/MagicLinksSettings.vue new file mode 100644 index 000000000..c683e4ee1 --- /dev/null +++ b/client/src/features/settings/MagicLinksSettings.vue @@ -0,0 +1,432 @@ + + + diff --git a/client/src/features/settings/composables/useMagicLinks.ts b/client/src/features/settings/composables/useMagicLinks.ts new file mode 100644 index 000000000..d663a651c --- /dev/null +++ b/client/src/features/settings/composables/useMagicLinks.ts @@ -0,0 +1,62 @@ +import { ref } from 'vue' +import { api } from '@/lib/api' +import type { MagicLinkToken, MagicLinkTokenCreateResponse } from '@bookorbit/types' + +const tokens = ref([]) +const loading = ref(false) +const error = ref(null) + +export function useMagicLinks() { + async function loadTokens() { + loading.value = true + error.value = null + try { + const res = await api('/api/v1/auth/magic-links') + if (!res.ok) throw new Error('Failed to load magic links') + tokens.value = await res.json() + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to load' + } finally { + loading.value = false + } + } + + async function createToken(data: { userId: number; label: string; expiresAt?: string }): Promise { + const res = await api('/api/v1/auth/magic-links', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.message ?? 'Failed to create magic link') + } + const result: MagicLinkTokenCreateResponse = await res.json() + await loadTokens() + return result + } + + async function revokeToken(id: number) { + const res = await api(`/api/v1/auth/magic-links/${id}`, { method: 'DELETE' }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.message ?? 'Failed to revoke magic link') + } + await loadTokens() + } + + async function setActive(id: number, isActive: boolean) { + const res = await api(`/api/v1/auth/magic-links/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ isActive }), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.message ?? 'Failed to update magic link') + } + await loadTokens() + } + + return { tokens, loading, error, loadTokens, createToken, revokeToken, setActive } +} diff --git a/client/src/router/guards/auth.guard.ts b/client/src/router/guards/auth.guard.ts index 3f2216549..ad6a7f934 100644 --- a/client/src/router/guards/auth.guard.ts +++ b/client/src/router/guards/auth.guard.ts @@ -29,7 +29,7 @@ export function registerAuthGuard(router: Router): void { return { path: '/login', query: { redirect: to.fullPath } } } - if (user.value.isDefaultPassword) { + if (user.value.isDefaultPassword && user.value.provisioningMethod !== 'shared') { useChangePasswordDialog().open(true) // Allow navigation to '/' but block everything else if (to.path !== '/') return { path: '/' } diff --git a/client/src/router/index.ts b/client/src/router/index.ts index e812bb13a..cb7ce086f 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -121,7 +121,7 @@ export const routes: RouteRecordRaw[] = [ path: 'admin/users', name: 'settings-admin-users', component: () => import('@/features/admin/UsersPage.vue'), - meta: { maxWidth: 'max-w-4xl', title: 'Users' }, + meta: { maxWidth: 'max-w-6xl', title: 'Users' }, }, { path: 'admin/metadata', @@ -164,6 +164,11 @@ export const routes: RouteRecordRaw[] = [ component: () => import('@/features/audit/AuditLogPage.vue'), meta: { maxWidth: 'max-w-7xl', title: 'Audit Log' }, }, + { + path: 'admin/magic-links', + name: 'settings-admin-magic-links', + redirect: { name: 'settings-admin-users', query: { tab: 'magic-links' } }, + }, { path: ':pathMatch(.*)*', redirect: { name: 'settings-libraries' } }, ], }, @@ -293,6 +298,12 @@ export const routes: RouteRecordRaw[] = [ component: () => import('@/features/auth/OidcCallbackPage.vue'), meta: { public: true, title: 'Completing Sign In' }, }, + { + path: '/magic', + name: 'magic-link-login', + component: () => import('@/features/auth/MagicLinkLoginView.vue'), + meta: { public: true, title: 'Magic Link Login' }, + }, { path: '/:pathMatch(.*)*', redirect: '/' }, ] diff --git a/packages/types/src/audit.ts b/packages/types/src/audit.ts index 50f75df97..2ee710d11 100644 --- a/packages/types/src/audit.ts +++ b/packages/types/src/audit.ts @@ -9,6 +9,12 @@ export enum AuditAction { AuthPasswordAdminReset = "auth.password.admin_reset", AuthSessionRevoke = "auth.session.revoke", + MagicLinkCreate = "magic_link.create", + MagicLinkLogin = "magic_link.login", + MagicLinkRevoke = "magic_link.revoke", + MagicLinkActivate = "magic_link.activate", + MagicLinkDeactivate = "magic_link.deactivate", + OidcLogin = "auth.oidc.login", OidcUserProvisioned = "auth.oidc.user_provisioned", OidcIdentityLinked = "auth.oidc.identity_linked", @@ -121,6 +127,7 @@ export enum AuditResource { Language = "language", Series = "series", OidcIdentity = "oidc_identity", + MagicLinkToken = "magic_link_token", } export interface AuditLogEntry { diff --git a/packages/types/src/auth.ts b/packages/types/src/auth.ts index 5c9be486a..8919e9d4e 100644 --- a/packages/types/src/auth.ts +++ b/packages/types/src/auth.ts @@ -10,6 +10,7 @@ export const ProvisioningMethod = { Local: "local", Manual: "manual", Oidc: "oidc", + Shared: "shared", } as const; export type ProvisioningMethod = (typeof ProvisioningMethod)[keyof typeof ProvisioningMethod]; @@ -142,3 +143,25 @@ export interface Session { createdAt: string; expiresAt: string; } + +export interface MagicLinkToken { + id: number; + userId: number; + username: string; + createdByUsername: string | null; + label: string; + rawToken: string; + isActive: boolean; + expiresAt: string | null; + lastUsedAt: string | null; + useCount: number; + createdAt: string; + revokedAt: string | null; +} + +export interface MagicLinkTokenCreateResponse { + id: number; + token: string; + label: string; + expiresAt: string | null; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5670ab48d..cfb5fad7d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,8 +100,8 @@ importers: specifier: ^2.0.9 version: 2.0.9 vue-virtual-scroller: - specifier: ^2.0.1 - version: 2.0.1(vue@3.5.33(typescript@5.9.3)) + specifier: ^3.0.2 + version: 3.0.2(vue@3.5.32(typescript@5.9.3)) devDependencies: '@tsconfig/node24': specifier: ^24.0.4 @@ -4913,9 +4913,6 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} - mitt@2.1.0: - resolution: {integrity: sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==} - mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} @@ -6231,8 +6228,8 @@ packages: peerDependencies: typescript: '>=5.0.0' - vue-virtual-scroller@2.0.1: - resolution: {integrity: sha512-3Drq8C61C4B3reSaZJr5nXBf/B7Beq1+h5/kYZB25MLYljTy97ISeUufRX9z6ZSZlFDXyafAOLK9XwajOWJY1A==} + vue-virtual-scroller@3.0.2: + resolution: {integrity: sha512-zneKxsBecfFBpH2NAk8zF0IWBRSYRaXnXK/dPQ/AnXE9xlbRyW6sSO9EUb5L5cSFz2Dpfb2giN30/y+/Xvu5PQ==} peerDependencies: vue: ^3.3.0 @@ -10698,8 +10695,6 @@ snapshots: minipass@7.1.3: {} - mitt@2.1.0: {} - mitt@3.0.1: {} mlly@1.8.2: @@ -12082,10 +12077,9 @@ snapshots: '@vue/language-core': 3.2.7 typescript: 5.9.3 - vue-virtual-scroller@2.0.1(vue@3.5.33(typescript@5.9.3)): + vue-virtual-scroller@3.0.2(vue@3.5.32(typescript@5.9.3)): dependencies: - mitt: 2.1.0 - vue: 3.5.33(typescript@5.9.3) + vue: 3.5.32(typescript@5.9.3) vue@3.5.33(typescript@5.9.3): dependencies: diff --git a/server/src/db/migrations/0000_baseline.sql b/server/src/db/migrations/0000_baseline.sql index 87739a84b..a6b3b3dc0 100644 --- a/server/src/db/migrations/0000_baseline.sql +++ b/server/src/db/migrations/0000_baseline.sql @@ -22,6 +22,23 @@ CREATE TABLE "app_settings" ( CONSTRAINT "app_settings_key_unique" UNIQUE("key") ); --> statement-breakpoint +CREATE TABLE "magic_access_tokens" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "created_by" integer, + "label" varchar(100) NOT NULL, + "token_hash" varchar(255) NOT NULL, + "raw_token" varchar(255) NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "expires_at" timestamp with time zone, + "last_used_at" timestamp with time zone, + "use_count" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "revoked_at" timestamp with time zone, + CONSTRAINT "magic_access_tokens_token_hash_unique" UNIQUE("token_hash"), + CONSTRAINT "magic_access_tokens_use_count_nonneg_chk" CHECK ("magic_access_tokens"."use_count" >= 0) +); +--> statement-breakpoint CREATE TABLE "password_reset_tokens" ( "id" serial PRIMARY KEY NOT NULL, "user_id" integer NOT NULL, @@ -80,7 +97,7 @@ CREATE TABLE "users" ( "updated_at" timestamp with time zone DEFAULT now() NOT NULL, CONSTRAINT "users_username_unique" UNIQUE("username"), CONSTRAINT "users_email_unique" UNIQUE("email"), - CONSTRAINT "users_provisioning_method_chk" CHECK ("users"."provisioning_method" in ('local', 'manual', 'oidc')), + CONSTRAINT "users_provisioning_method_chk" CHECK ("users"."provisioning_method" in ('local', 'manual', 'oidc', 'shared')), CONSTRAINT "users_token_version_nonnegative_chk" CHECK ("users"."token_version" >= 0), CONSTRAINT "users_failed_login_attempts_nonnegative_chk" CHECK ("users"."failed_login_attempts" >= 0), CONSTRAINT "users_avatar_version_nonnegative_chk" CHECK ("users"."avatar_version" >= 0) @@ -890,6 +907,8 @@ CREATE TABLE "dismissed_inline_duplicate_pairs" ( ); --> statement-breakpoint ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "magic_access_tokens" ADD CONSTRAINT "magic_access_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "magic_access_tokens" ADD CONSTRAINT "magic_access_tokens_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint ALTER TABLE "password_reset_tokens" ADD CONSTRAINT "password_reset_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "user_library_access" ADD CONSTRAINT "user_library_access_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint @@ -1004,6 +1023,7 @@ CREATE INDEX "idx_audit_resource" ON "audit_log" USING btree ("resource","resour CREATE INDEX "idx_audit_action" ON "audit_log" USING btree ("action");--> statement-breakpoint CREATE INDEX "idx_audit_ip" ON "audit_log" USING btree ("ip");--> statement-breakpoint CREATE INDEX "idx_audit_created_at" ON "audit_log" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "magic_access_tokens_user_id_idx" ON "magic_access_tokens" USING btree ("user_id");--> statement-breakpoint CREATE INDEX "password_reset_tokens_user_id_idx" ON "password_reset_tokens" USING btree ("user_id");--> statement-breakpoint CREATE INDEX "password_reset_tokens_expires_at_idx" ON "password_reset_tokens" USING btree ("expires_at");--> statement-breakpoint CREATE INDEX "refresh_tokens_user_id_idx" ON "refresh_tokens" USING btree ("user_id");--> statement-breakpoint diff --git a/server/src/db/migrations/meta/0000_snapshot.json b/server/src/db/migrations/meta/0000_snapshot.json index 3c9d33b04..c2f9b4ba1 100644 --- a/server/src/db/migrations/meta/0000_snapshot.json +++ b/server/src/db/migrations/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "c229dc64-65a0-42a3-907d-e5a2bd75e111", + "id": "2de4dd88-2590-4da4-b868-f9341ffe3e9f", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -214,6 +214,140 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.magic_access_tokens": { + "name": "magic_access_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "raw_token": { + "name": "raw_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "magic_access_tokens_user_id_idx": { + "name": "magic_access_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "magic_access_tokens_user_id_users_id_fk": { + "name": "magic_access_tokens_user_id_users_id_fk", + "tableFrom": "magic_access_tokens", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "magic_access_tokens_created_by_users_id_fk": { + "name": "magic_access_tokens_created_by_users_id_fk", + "tableFrom": "magic_access_tokens", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "magic_access_tokens_token_hash_unique": { + "name": "magic_access_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": ["token_hash"] + } + }, + "policies": {}, + "checkConstraints": { + "magic_access_tokens_use_count_nonneg_chk": { + "name": "magic_access_tokens_use_count_nonneg_chk", + "value": "\"magic_access_tokens\".\"use_count\" >= 0" + } + }, + "isRLSEnabled": false + }, "public.password_reset_tokens": { "name": "password_reset_tokens", "schema": "", @@ -747,7 +881,7 @@ "checkConstraints": { "users_provisioning_method_chk": { "name": "users_provisioning_method_chk", - "value": "\"users\".\"provisioning_method\" in ('local', 'manual', 'oidc')" + "value": "\"users\".\"provisioning_method\" in ('local', 'manual', 'oidc', 'shared')" }, "users_token_version_nonnegative_chk": { "name": "users_token_version_nonnegative_chk", diff --git a/server/src/db/migrations/meta/_journal.json b/server/src/db/migrations/meta/_journal.json index 12130c2f1..ca59a7d0e 100644 --- a/server/src/db/migrations/meta/_journal.json +++ b/server/src/db/migrations/meta/_journal.json @@ -5,7 +5,7 @@ { "idx": 0, "version": "7", - "when": 1776791082894, + "when": 1777215796833, "tag": "0000_baseline", "breakpoints": true } diff --git a/server/src/db/schema/auth.ts b/server/src/db/schema/auth.ts index ceac5ae9f..69a32f128 100644 --- a/server/src/db/schema/auth.ts +++ b/server/src/db/schema/auth.ts @@ -54,7 +54,7 @@ export const users = pgTable( uniqueIndex('users_oidc_subject_issuer_uidx') .on(t.oidcSubject, t.oidcIssuer) .where(sql`${t.oidcSubject} is not null and ${t.oidcIssuer} is not null`), - check('users_provisioning_method_chk', sql`${t.provisioningMethod} in ('local', 'manual', 'oidc')`), + check('users_provisioning_method_chk', sql`${t.provisioningMethod} in ('local', 'manual', 'oidc', 'shared')`), check('users_token_version_nonnegative_chk', sql`${t.tokenVersion} >= 0`), check('users_failed_login_attempts_nonnegative_chk', sql`${t.failedLoginAttempts} >= 0`), check('users_avatar_version_nonnegative_chk', sql`${t.avatarVersion} >= 0`), @@ -141,3 +141,26 @@ export type NewUser = typeof users.$inferInsert; export type RefreshToken = typeof refreshTokens.$inferSelect; export type AppSetting = typeof appSettings.$inferSelect; + +export const magicAccessTokens = pgTable( + 'magic_access_tokens', + { + id: serial('id').primaryKey(), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + createdBy: integer('created_by').references(() => users.id, { onDelete: 'set null' }), + label: varchar('label', { length: 100 }).notNull(), + tokenHash: varchar('token_hash', { length: 255 }).notNull().unique(), + rawToken: varchar('raw_token', { length: 255 }).notNull(), + isActive: boolean('is_active').notNull().default(true), + expiresAt: timestamp('expires_at', { withTimezone: true }), + lastUsedAt: timestamp('last_used_at', { withTimezone: true }), + useCount: integer('use_count').notNull().default(0), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + revokedAt: timestamp('revoked_at', { withTimezone: true }), + }, + (t) => [index('magic_access_tokens_user_id_idx').on(t.userId), check('magic_access_tokens_use_count_nonneg_chk', sql`${t.useCount} >= 0`)], +); + +export type MagicAccessToken = typeof magicAccessTokens.$inferSelect; diff --git a/server/src/modules/auth/auth.controller.test.ts b/server/src/modules/auth/auth.controller.test.ts index 16f8ed01f..ee19765e9 100644 --- a/server/src/modules/auth/auth.controller.test.ts +++ b/server/src/modules/auth/auth.controller.test.ts @@ -27,8 +27,16 @@ function makeController() { unlinkIdentity: vi.fn(), }; - const controller = new AuthController(authService as never, oidcService as never); - return { controller, authService, oidcService }; + const magicLinkService = { + createToken: vi.fn(), + loginWithToken: vi.fn(), + listTokens: vi.fn(), + revokeToken: vi.fn(), + setActive: vi.fn(), + }; + + const controller = new AuthController(authService as never, oidcService as never, magicLinkService as never); + return { controller, authService, oidcService, magicLinkService }; } describe('AuthController', () => { @@ -92,6 +100,38 @@ describe('AuthController', () => { expect(oidcService.unlinkIdentity).toHaveBeenCalledWith(7, 3, 'Secret1!'); }); + it('delegates magic link endpoints to MagicLinkService', async () => { + const { controller, magicLinkService } = makeController(); + const user = { id: 1, isSuperuser: true, username: 'admin' } as never; + const reply = { setCookie: vi.fn() } as never; + const req = { ip: '10.0.0.1' } as never; + + magicLinkService.createToken.mockResolvedValue({ id: 1, token: 'raw', label: 'Demo', expiresAt: null }); + magicLinkService.listTokens.mockResolvedValue([]); + magicLinkService.setActive.mockResolvedValue({ id: 1, isActive: false }); + magicLinkService.revokeToken.mockResolvedValue(undefined); + magicLinkService.loginWithToken.mockResolvedValue({ accessToken: 'jwt' }); + + await controller.createMagicLink(user, { userId: 2, label: 'Demo' } as never); + await controller.listMagicLinks(user); + await controller.updateMagicLink(user, 1, { isActive: false } as never); + await controller.revokeMagicLink(user, 1); + await controller.loginWithMagicLink({ token: 'raw-token' } as never, reply, req); + + expect(magicLinkService.createToken).toHaveBeenCalledWith(user, { userId: 2, label: 'Demo' }); + expect(magicLinkService.listTokens).toHaveBeenCalled(); + expect(magicLinkService.setActive).toHaveBeenCalledWith(user, 1, false); + expect(magicLinkService.revokeToken).toHaveBeenCalledWith(user, 1); + expect(magicLinkService.loginWithToken).toHaveBeenCalledWith('raw-token', reply, '10.0.0.1'); + }); + + it('throws ForbiddenException from listMagicLinks for non-superuser', () => { + const { controller } = makeController(); + const user = { id: 2, isSuperuser: false, username: 'viewer' } as never; + + expect(() => controller.listMagicLinks(user)).toThrow(); + }); + it('defines throttling metadata for sensitive public endpoints', () => { const limitKey = 'THROTTLER:LIMITdefault'; const ttlKey = 'THROTTLER:TTLdefault'; @@ -127,5 +167,15 @@ describe('AuthController', () => { expect(oidcStateTtl).toBe(60_000); expect(oidcCallbackLimit).toBe(5); expect(oidcCallbackTtl).toBe(60_000); + + const createMlLimit = Reflect.getMetadata(limitKey, AuthController.prototype.createMagicLink) as number; + const createMlTtl = Reflect.getMetadata(ttlKey, AuthController.prototype.createMagicLink) as number; + const loginMlLimit = Reflect.getMetadata(limitKey, AuthController.prototype.loginWithMagicLink) as number; + const loginMlTtl = Reflect.getMetadata(ttlKey, AuthController.prototype.loginWithMagicLink) as number; + + expect(createMlLimit).toBe(5); + expect(createMlTtl).toBe(60_000); + expect(loginMlLimit).toBe(10); + expect(loginMlTtl).toBe(60_000); }); }); diff --git a/server/src/modules/auth/auth.controller.ts b/server/src/modules/auth/auth.controller.ts index 9fca0f670..1e2d7401e 100644 --- a/server/src/modules/auth/auth.controller.ts +++ b/server/src/modules/auth/auth.controller.ts @@ -1,4 +1,19 @@ -import { Body, Controller, Delete, Get, Headers, HttpCode, HttpStatus, Param, ParseIntPipe, Post, Req, Res } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + ForbiddenException, + Get, + Headers, + HttpCode, + HttpStatus, + Param, + ParseIntPipe, + Patch, + Post, + Req, + Res, +} from '@nestjs/common'; import { Throttle } from '@nestjs/throttler'; import type { FastifyReply, FastifyRequest } from 'fastify'; @@ -9,6 +24,7 @@ import { Auditable } from '../../common/decorators/auditable.decorator'; import { Public } from '../../common/decorators/public.decorator'; import type { RequestUser } from '../../common/types/request-user'; import { AuthService } from './auth.service'; +import { MagicLinkService } from './magic-link.service'; import { ChangePasswordDto } from './dto/change-password.dto'; import { ForgotPasswordDto } from './dto/forgot-password.dto'; import { LoginDto } from './dto/login.dto'; @@ -18,6 +34,9 @@ import { RegisterDto } from './dto/register.dto'; import { ResetPasswordDto } from './dto/reset-password.dto'; import { SetupDto } from './dto/setup.dto'; import { OidcService } from './oidc/oidc.service'; +import { CreateMagicLinkDto } from './dto/create-magic-link.dto'; +import { MagicLinkLoginDto } from './dto/magic-link-login.dto'; +import { UpdateMagicLinkDto } from './dto/update-magic-link.dto'; const ONE_MINUTE_MS = 60_000; const ONE_HOUR_MS = 3_600_000; @@ -27,6 +46,7 @@ export class AuthController { constructor( private readonly authService: AuthService, private readonly oidcService: OidcService, + private readonly magicLinkService: MagicLinkService, ) {} @Public() @@ -172,4 +192,38 @@ export class AuthController { oidcUnlinkIdentity(@CurrentUser() user: RequestUser, @Param('providerId', ParseIntPipe) providerId: number, @Body() dto: OidcUnlinkDto) { return this.oidcService.unlinkIdentity(user.id, providerId, dto.password); } + + @Post('magic-links') + @Throttle({ default: { limit: 5, ttl: ONE_MINUTE_MS } }) + createMagicLink(@CurrentUser() user: RequestUser, @Body() dto: CreateMagicLinkDto) { + return this.magicLinkService.createToken(user, dto); + } + + @Get('magic-links') + listMagicLinks(@CurrentUser() user: RequestUser) { + if (!user.isSuperuser) { + throw new ForbiddenException('Only superusers can view magic links'); + } + return this.magicLinkService.listTokens(); + } + + @Patch('magic-links/:id') + @HttpCode(HttpStatus.OK) + updateMagicLink(@CurrentUser() user: RequestUser, @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateMagicLinkDto) { + return this.magicLinkService.setActive(user, id, dto.isActive); + } + + @Delete('magic-links/:id') + @HttpCode(HttpStatus.NO_CONTENT) + revokeMagicLink(@CurrentUser() user: RequestUser, @Param('id', ParseIntPipe) id: number) { + return this.magicLinkService.revokeToken(user, id); + } + + @Post('magic-links/login') + @Public() + @Throttle({ default: { limit: 10, ttl: ONE_MINUTE_MS } }) + @HttpCode(HttpStatus.OK) + loginWithMagicLink(@Body() dto: MagicLinkLoginDto, @Res({ passthrough: true }) reply: FastifyReply, @Req() req: FastifyRequest) { + return this.magicLinkService.loginWithToken(dto.token, reply, req.ip); + } } diff --git a/server/src/modules/auth/auth.module.ts b/server/src/modules/auth/auth.module.ts index e624ddd0a..60d5668d9 100644 --- a/server/src/modules/auth/auth.module.ts +++ b/server/src/modules/auth/auth.module.ts @@ -12,6 +12,8 @@ import { UserModule } from '../user/user.module'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { JwtStrategy } from './jwt.strategy'; +import { MagicLinkRepository } from './magic-link.repository'; +import { MagicLinkService } from './magic-link.service'; import { BackchannelLogoutService } from './oidc/backchannel-logout.service'; import { OidcClaimExtractorService } from './oidc/oidc-claim-extractor.service'; import { OidcCleanupService } from './oidc/oidc-cleanup.service'; @@ -43,6 +45,8 @@ import { OidcTokenValidatorService } from './oidc/oidc-token-validator.service'; providers: [ AuthService, JwtStrategy, + MagicLinkRepository, + MagicLinkService, OidcService, OidcStateService, OidcDiscoveryService, diff --git a/server/src/modules/auth/auth.service.test.ts b/server/src/modules/auth/auth.service.test.ts index 781974aef..0942f7c68 100644 --- a/server/src/modules/auth/auth.service.test.ts +++ b/server/src/modules/auth/auth.service.test.ts @@ -100,6 +100,11 @@ function makeService(dbOverrides?: Record) { getDiscoveryDoc: vi.fn(), }; + const magicLinkRepo = { + hasActiveByUserId: vi.fn().mockResolvedValue(true), + countActiveByUserId: vi.fn().mockResolvedValue(0), + }; + const service = new AuthService( userService as never, jwtService as never, @@ -108,10 +113,11 @@ function makeService(dbOverrides?: Record) { oidcSessionRepo as never, oidcDiscovery as never, { emit: vi.fn() } as never, + magicLinkRepo as never, db, ); - return { service, db, userService, jwtService, config, systemMailService, appSettings, oidcSessionRepo, oidcDiscovery }; + return { service, db, userService, jwtService, config, systemMailService, appSettings, oidcSessionRepo, oidcDiscovery, magicLinkRepo }; } describe('AuthService', () => { @@ -535,6 +541,24 @@ describe('AuthService', () => { const result = await service.validateUser(1, 2); expect(result).toEqual(user); }); + + it('throws UnauthorizedException for shared user with no active magic links', async () => { + const { service, userService, magicLinkRepo } = makeService(); + userService.findByIdWithPermissions.mockResolvedValue(makeFullUser({ tokenVersion: 2, provisioningMethod: 'shared' })); + magicLinkRepo.hasActiveByUserId.mockResolvedValue(false); + + await expect(service.validateUser(1, 2)).rejects.toThrow(UnauthorizedException); + }); + + it('returns shared user when active magic links exist', async () => { + const { service, userService, magicLinkRepo } = makeService(); + const user = makeFullUser({ tokenVersion: 2, provisioningMethod: 'shared' }); + userService.findByIdWithPermissions.mockResolvedValue(user); + magicLinkRepo.hasActiveByUserId.mockResolvedValue(true); + + const result = await service.validateUser(1, 2); + expect(result).toEqual(user); + }); }); describe('forgotPassword', () => { @@ -601,6 +625,22 @@ describe('AuthService', () => { expect(systemMailService.sendPasswordReset).not.toHaveBeenCalled(); }); + it('silently returns without sending email for shared user', async () => { + const { service, userService, systemMailService } = makeService(); + userService.findByEmail.mockResolvedValue({ + id: 1, + email: 'demo@example.com', + name: 'Demo', + active: true, + provisioningMethod: 'shared', + username: 'demo', + }); + + await service.forgotPassword({ email: 'demo@example.com' }); + await new Promise((r) => setImmediate(r)); + expect(systemMailService.sendPasswordReset).not.toHaveBeenCalled(); + }); + it('SEC-028: masks email address in logs for unknown email (no user enumeration via logs)', async () => { const { service, userService } = makeService(); userService.findByEmail.mockResolvedValue(null); @@ -676,6 +716,17 @@ describe('AuthService', () => { await expect(service.changePassword(1, { currentPassword: 'old', newPassword: 'New@1234' }, makeReply())).rejects.toThrow(BadRequestException); }); + it('throws BadRequestException for shared-provisioned users', async () => { + const { service, db } = makeService(); + (db.query as never as Record>).users.findFirst.mockResolvedValue({ + id: 1, + provisioningMethod: 'shared', + passwordHash: 'hash', + }); + + await expect(service.changePassword(1, { currentPassword: 'old', newPassword: 'New@1234' }, makeReply())).rejects.toThrow(BadRequestException); + }); + it('throws UnauthorizedException when current password is wrong', async () => { const { service, db } = makeService(); (db.query as never as Record>).users.findFirst.mockResolvedValue({ @@ -777,6 +828,24 @@ describe('AuthService', () => { await expect(service.resetPassword({ token: 'valid', newPassword: 'NewPassword1' })).rejects.toThrow(BadRequestException); }); + it('throws BadRequestException for shared user', async () => { + const { service, db } = makeService(); + (db.query as never as Record>).passwordResetTokens.findFirst.mockResolvedValue({ + id: 1, + userId: 1, + expiresAt: new Date(Date.now() + 10_000), + usedAt: null, + }); + (db.query as never as Record>).users.findFirst.mockResolvedValue({ + id: 1, + username: 'demo', + active: true, + provisioningMethod: 'shared', + }); + + await expect(service.resetPassword({ token: 'valid', newPassword: 'NewPassword1' })).rejects.toThrow(BadRequestException); + }); + it('resets password, marks token used, and revokes sessions on success', async () => { const { service, db } = makeService(); (db.query as never as Record>).passwordResetTokens.findFirst.mockResolvedValue({ @@ -889,4 +958,16 @@ describe('AuthService', () => { expect(oidcSessionRepo.revokeByUserId).toHaveBeenCalledWith(8); }); }); + + describe('revokeAllUserSessions', () => { + it('bumps tokenVersion and revokes all refresh tokens in a transaction', async () => { + const { service, db } = makeService(); + + await service.revokeAllUserSessions(5); + + expect(db.transaction).toHaveBeenCalled(); + expect(db.update).toHaveBeenCalled(); + expect(db.set).toHaveBeenCalled(); + }); + }); }); diff --git a/server/src/modules/auth/auth.service.ts b/server/src/modules/auth/auth.service.ts index 4fac64073..a2281a0be 100644 --- a/server/src/modules/auth/auth.service.ts +++ b/server/src/modules/auth/auth.service.ts @@ -37,6 +37,7 @@ import { ResetPasswordDto } from './dto/reset-password.dto'; import { SetupDto } from './dto/setup.dto'; import { OidcDiscoveryService } from './oidc/oidc-discovery.service'; import { OidcSessionRepository } from './oidc/oidc-session.repository'; +import { MagicLinkRepository } from './magic-link.repository'; function parseDurationMs(duration: string): number { const match = duration.match(/^(\d+)([smhd])$/); @@ -74,6 +75,7 @@ export class AuthService { private readonly oidcSessionRepo: OidcSessionRepository, private readonly oidcDiscovery: OidcDiscoveryService, private readonly auditEvents: AuditEventsService, + private readonly magicLinkRepo: MagicLinkRepository, @Inject(DB) private readonly db: NodePgDatabase, ) {} @@ -318,6 +320,20 @@ export class AuthService { throw new UnauthorizedException('Account disabled'); } + if (userForToken.provisioningMethod === 'shared') { + const hasActive = await this.magicLinkRepo.hasActiveByUserId(row.userId); + + if (!hasActive) { + this.logger.warn( + `[auth.refresh] [fail] userId=${row.userId} errorClass=UnauthorizedException error="all magic links revoked" - refresh failed`, + ); + await this.revokeAllUserSessions(row.userId); + this.clearRefreshCookie(reply); + this.clearAccessCookie(reply); + throw new UnauthorizedException(); + } + } + // Rotate: revoke old, issue new await this.db.update(schema.refreshTokens).set({ revokedAt: new Date() }).where(eq(schema.refreshTokens.id, row.id)); @@ -401,9 +417,28 @@ export class AuthService { const user = await this.userService.findByIdWithPermissions(userId); if (!user || !user.active) throw new UnauthorizedException(); if (user.tokenVersion !== tokenVersion) throw new UnauthorizedException(); + + if (user.provisioningMethod === 'shared') { + const hasActive = await this.magicLinkRepo.hasActiveByUserId(userId); + if (!hasActive) throw new UnauthorizedException(); + } + return user; } + async revokeAllUserSessions(userId: number) { + await this.db.transaction(async (tx) => { + await tx + .update(schema.users) + .set({ tokenVersion: sql`${schema.users.tokenVersion} + 1` }) + .where(eq(schema.users.id, userId)); + await tx + .update(schema.refreshTokens) + .set({ revokedAt: new Date() }) + .where(and(eq(schema.refreshTokens.userId, userId), isNull(schema.refreshTokens.revokedAt))); + }); + } + async getSessions(userId: number) { const rows = await this.db.query.refreshTokens.findMany({ where: and(eq(schema.refreshTokens.userId, userId), isNull(schema.refreshTokens.revokedAt), gt(schema.refreshTokens.expiresAt, new Date())), @@ -444,6 +479,11 @@ export class AuthService { return; } + if (user.provisioningMethod === 'shared') { + this.logger.log(`Password reset requested for shared account: ${maskEmail(email)}`); + return; + } + const rawToken = await this.userService.generatePasswordResetToken(user.id); await this.systemMailService.sendPasswordReset(user.email, user.name, rawToken); @@ -468,7 +508,7 @@ export class AuthService { } const user = await this.db.query.users.findFirst({ where: eq(schema.users.id, row.userId) }); - if (!user || !user.active || user.provisioningMethod === 'oidc') { + if (!user || !user.active || user.provisioningMethod === 'oidc' || user.provisioningMethod === 'shared') { throw new BadRequestException('Invalid or expired reset token'); } @@ -507,6 +547,10 @@ export class AuthService { throw new BadRequestException('OIDC accounts cannot change their password here'); } + if (user.provisioningMethod === 'shared') { + throw new BadRequestException('Shared accounts cannot change their password'); + } + const valid = await compare(dto.currentPassword, user.passwordHash); if (!valid) throw new UnauthorizedException('Current password is incorrect'); diff --git a/server/src/modules/auth/dto/create-magic-link.dto.ts b/server/src/modules/auth/dto/create-magic-link.dto.ts new file mode 100644 index 000000000..36d9ef00d --- /dev/null +++ b/server/src/modules/auth/dto/create-magic-link.dto.ts @@ -0,0 +1,15 @@ +import { IsDateString, IsInt, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class CreateMagicLinkDto { + @IsInt() + userId: number; + + @IsString() + @MinLength(1) + @MaxLength(100) + label: string; + + @IsDateString() + @IsOptional() + expiresAt?: string; +} diff --git a/server/src/modules/auth/dto/magic-link-login.dto.ts b/server/src/modules/auth/dto/magic-link-login.dto.ts new file mode 100644 index 000000000..b33a9860e --- /dev/null +++ b/server/src/modules/auth/dto/magic-link-login.dto.ts @@ -0,0 +1,8 @@ +import { IsString, MaxLength, MinLength } from 'class-validator'; + +export class MagicLinkLoginDto { + @IsString() + @MinLength(1) + @MaxLength(512) + token: string; +} diff --git a/server/src/modules/auth/dto/update-magic-link.dto.ts b/server/src/modules/auth/dto/update-magic-link.dto.ts new file mode 100644 index 000000000..58cddcb6f --- /dev/null +++ b/server/src/modules/auth/dto/update-magic-link.dto.ts @@ -0,0 +1,6 @@ +import { IsBoolean } from 'class-validator'; + +export class UpdateMagicLinkDto { + @IsBoolean() + isActive: boolean; +} diff --git a/server/src/modules/auth/magic-link.repository.test.ts b/server/src/modules/auth/magic-link.repository.test.ts new file mode 100644 index 000000000..20610ca90 --- /dev/null +++ b/server/src/modules/auth/magic-link.repository.test.ts @@ -0,0 +1,126 @@ +import { MagicLinkRepository } from './magic-link.repository'; + +describe('MagicLinkRepository', () => { + function makeDb() { + const db: Record = { + query: { + magicAccessTokens: { findFirst: vi.fn() }, + }, + insert: vi.fn().mockReturnThis(), + values: vi.fn().mockReturnThis(), + returning: vi.fn().mockResolvedValue([{ id: 1, label: 'Test', expiresAt: null, rawToken: 'raw' }]), + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue([{ total: 3 }]), + innerJoin: vi.fn().mockReturnThis(), + leftJoin: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockResolvedValue([]), + update: vi.fn().mockReturnThis(), + set: vi.fn().mockReturnThis(), + }; + return db as never; + } + + it('create inserts and returns the new row', async () => { + const db = makeDb(); + const repo = new MagicLinkRepository(db); + + const result = await repo.create({ userId: 1, createdBy: 2, label: 'Test' }); + + expect(result).toEqual({ id: 1, label: 'Test', expiresAt: null, rawToken: 'raw' }); + expect((db as Record).insert).toHaveBeenCalled(); + }); + + it('findByTokenHash delegates to query', async () => { + const db = makeDb(); + const repo = new MagicLinkRepository(db); + (db.query as Record>).magicAccessTokens.findFirst.mockResolvedValue({ id: 1 }); + + const result = await repo.findByTokenHash('hash'); + expect(result).toEqual({ id: 1 }); + }); + + it('findById delegates to query', async () => { + const db = makeDb(); + const repo = new MagicLinkRepository(db); + (db.query as Record>).magicAccessTokens.findFirst.mockResolvedValue({ id: 5 }); + + const result = await repo.findById(5); + expect(result).toEqual({ id: 5 }); + }); + + it('findAll joins users and returns rows', async () => { + const db = makeDb(); + const repo = new MagicLinkRepository(db); + + const result = await repo.findAll(); + expect(result).toEqual([]); + expect((db as Record).innerJoin).toHaveBeenCalled(); + expect((db as Record).leftJoin).toHaveBeenCalled(); + }); + + it('countActiveByUserId returns integer count', async () => { + const db = makeDb(); + const repo = new MagicLinkRepository(db); + + const count = await repo.countActiveByUserId(1); + expect(count).toBe(3); + }); + + it('hasActiveByUserId returns true when count > 0', async () => { + const db = makeDb(); + const repo = new MagicLinkRepository(db); + + const result = await repo.hasActiveByUserId(1); + expect(result).toBe(true); + }); + + it('hasActiveByUserId returns false when count is 0', async () => { + const db = makeDb(); + (db as Record).where.mockResolvedValue([{ total: 0 }]); + const repo = new MagicLinkRepository(db); + + const result = await repo.hasActiveByUserId(1); + expect(result).toBe(false); + }); + + it('setActive updates and returns the row', async () => { + const db = makeDb(); + (db as Record).returning = vi.fn().mockResolvedValue([{ id: 1, userId: 2, isActive: false }]); + (db as Record).where = vi.fn().mockReturnThis(); + const repo = new MagicLinkRepository(db); + + const result = await repo.setActive(1, false); + expect(result).toEqual({ id: 1, userId: 2, isActive: false }); + }); + + it('setActive returns null when no row matched', async () => { + const db = makeDb(); + (db as Record).returning = vi.fn().mockResolvedValue([]); + (db as Record).where = vi.fn().mockReturnThis(); + const repo = new MagicLinkRepository(db); + + const result = await repo.setActive(999, false); + expect(result).toBeNull(); + }); + + it('revoke sets revokedAt and returns the row', async () => { + const db = makeDb(); + (db as Record).returning = vi.fn().mockResolvedValue([{ id: 1, userId: 2 }]); + (db as Record).where = vi.fn().mockReturnThis(); + const repo = new MagicLinkRepository(db); + + const result = await repo.revoke(1); + expect(result).toEqual({ id: 1, userId: 2 }); + }); + + it('updateUsage increments use_count', async () => { + const db = makeDb(); + (db as Record).where = vi.fn().mockResolvedValue(undefined); + const repo = new MagicLinkRepository(db); + + await repo.updateUsage(1); + expect((db as Record).update).toHaveBeenCalled(); + expect((db as Record).set).toHaveBeenCalled(); + }); +}); diff --git a/server/src/modules/auth/magic-link.repository.ts b/server/src/modules/auth/magic-link.repository.ts new file mode 100644 index 000000000..31689d9f4 --- /dev/null +++ b/server/src/modules/auth/magic-link.repository.ts @@ -0,0 +1,128 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { createHash, randomBytes } from 'crypto'; +import { and, eq, gt, isNull, or, sql } from 'drizzle-orm'; +import { alias } from 'drizzle-orm/pg-core'; +import { NodePgDatabase } from 'drizzle-orm/node-postgres'; + +import { DB } from '../../db'; +import * as schema from '../../db/schema'; + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +@Injectable() +export class MagicLinkRepository { + constructor(@Inject(DB) private readonly db: NodePgDatabase) {} + + async create(data: { userId: number; createdBy: number; label: string; expiresAt?: Date }) { + const rawToken = randomBytes(32).toString('hex'); + const tokenHash = sha256(rawToken); + + const [row] = await this.db + .insert(schema.magicAccessTokens) + .values({ + userId: data.userId, + createdBy: data.createdBy, + label: data.label, + tokenHash, + rawToken, + expiresAt: data.expiresAt ?? null, + }) + .returning({ + id: schema.magicAccessTokens.id, + label: schema.magicAccessTokens.label, + expiresAt: schema.magicAccessTokens.expiresAt, + rawToken: schema.magicAccessTokens.rawToken, + }); + + return row; + } + + async findByTokenHash(tokenHash: string) { + return this.db.query.magicAccessTokens.findFirst({ + where: eq(schema.magicAccessTokens.tokenHash, tokenHash), + }); + } + + async findAll() { + const creator = alias(schema.users, 'creator'); + + const rows = await this.db + .select({ + id: schema.magicAccessTokens.id, + userId: schema.magicAccessTokens.userId, + username: schema.users.username, + createdByUsername: creator.username, + label: schema.magicAccessTokens.label, + rawToken: schema.magicAccessTokens.rawToken, + isActive: schema.magicAccessTokens.isActive, + expiresAt: schema.magicAccessTokens.expiresAt, + lastUsedAt: schema.magicAccessTokens.lastUsedAt, + useCount: schema.magicAccessTokens.useCount, + createdAt: schema.magicAccessTokens.createdAt, + revokedAt: schema.magicAccessTokens.revokedAt, + }) + .from(schema.magicAccessTokens) + .innerJoin(schema.users, eq(schema.users.id, schema.magicAccessTokens.userId)) + .leftJoin(creator, eq(creator.id, schema.magicAccessTokens.createdBy)) + .orderBy(schema.magicAccessTokens.createdAt); + + return rows; + } + + private activeCondition(userId: number) { + return and( + eq(schema.magicAccessTokens.userId, userId), + isNull(schema.magicAccessTokens.revokedAt), + eq(schema.magicAccessTokens.isActive, true), + or(isNull(schema.magicAccessTokens.expiresAt), gt(schema.magicAccessTokens.expiresAt, sql`now()`)), + ); + } + + async countActiveByUserId(userId: number): Promise { + const [{ total }] = await this.db + .select({ total: sql`count(*)::int` }) + .from(schema.magicAccessTokens) + .where(this.activeCondition(userId)); + return total; + } + + async hasActiveByUserId(userId: number): Promise { + const count = await this.countActiveByUserId(userId); + return count > 0; + } + + async setActive(id: number, isActive: boolean) { + const [row] = await this.db.update(schema.magicAccessTokens).set({ isActive }).where(eq(schema.magicAccessTokens.id, id)).returning({ + id: schema.magicAccessTokens.id, + userId: schema.magicAccessTokens.userId, + isActive: schema.magicAccessTokens.isActive, + }); + return row ?? null; + } + + async revoke(id: number) { + const [row] = await this.db.update(schema.magicAccessTokens).set({ revokedAt: new Date() }).where(eq(schema.magicAccessTokens.id, id)).returning({ + id: schema.magicAccessTokens.id, + userId: schema.magicAccessTokens.userId, + }); + return row ?? null; + } + + async updateUsage(id: number) { + await this.db + .update(schema.magicAccessTokens) + .set({ + lastUsedAt: new Date(), + useCount: sql`${schema.magicAccessTokens.useCount} + 1`, + }) + .where(eq(schema.magicAccessTokens.id, id)); + } + + async findById(id: number) { + return this.db.query.magicAccessTokens.findFirst({ + where: eq(schema.magicAccessTokens.id, id), + }); + } +} diff --git a/server/src/modules/auth/magic-link.service.test.ts b/server/src/modules/auth/magic-link.service.test.ts new file mode 100644 index 000000000..5a2d8e654 --- /dev/null +++ b/server/src/modules/auth/magic-link.service.test.ts @@ -0,0 +1,238 @@ +import { BadRequestException, ForbiddenException, NotFoundException, UnauthorizedException } from '@nestjs/common'; + +import { MagicLinkService } from './magic-link.service'; + +function makeActor(overrides: Partial<{ id: number; isSuperuser: boolean; username: string }> = {}) { + return { id: 1, isSuperuser: true, username: 'admin', ...overrides } as never; +} + +function makeService() { + const magicLinkRepo = { + create: vi.fn().mockResolvedValue({ id: 1, rawToken: 'raw-token-hex', label: 'Demo', expiresAt: null }), + findByTokenHash: vi.fn(), + findAll: vi.fn().mockResolvedValue([]), + countActiveByUserId: vi.fn().mockResolvedValue(0), + hasActiveByUserId: vi.fn().mockResolvedValue(true), + setActive: vi.fn(), + revoke: vi.fn(), + updateUsage: vi.fn(), + findById: vi.fn(), + }; + + const userService = { + findById: vi.fn().mockResolvedValue({ id: 2, provisioningMethod: 'shared', active: true, username: 'demo' }), + }; + + const authService = { + issueTokensForUser: vi.fn().mockResolvedValue({ accessToken: 'jwt', user: {} }), + revokeAllUserSessions: vi.fn().mockResolvedValue(undefined), + }; + + const auditEvents = { emit: vi.fn() }; + + const service = new MagicLinkService(magicLinkRepo as never, userService as never, authService as never, auditEvents as never); + + return { service, magicLinkRepo, userService, authService, auditEvents }; +} + +describe('MagicLinkService', () => { + describe('createToken', () => { + it('throws ForbiddenException for non-superuser', async () => { + const { service } = makeService(); + await expect(service.createToken(makeActor({ isSuperuser: false }), { userId: 2, label: 'Test' } as never)).rejects.toThrow(ForbiddenException); + }); + + it('throws BadRequestException for non-shared user', async () => { + const { service, userService } = makeService(); + userService.findById.mockResolvedValue({ id: 2, provisioningMethod: 'local' }); + + await expect(service.createToken(makeActor(), { userId: 2, label: 'Test' } as never)).rejects.toThrow(BadRequestException); + }); + + it('throws BadRequestException when max tokens reached', async () => { + const { service, magicLinkRepo } = makeService(); + magicLinkRepo.countActiveByUserId.mockResolvedValue(5); + + await expect(service.createToken(makeActor(), { userId: 2, label: 'Test' } as never)).rejects.toThrow(BadRequestException); + }); + + it('throws BadRequestException when expiry is in the past', async () => { + const { service } = makeService(); + const pastDate = new Date(Date.now() - 60_000).toISOString(); + + await expect(service.createToken(makeActor(), { userId: 2, label: 'Test', expiresAt: pastDate } as never)).rejects.toThrow(BadRequestException); + }); + + it('creates token and emits audit event on success', async () => { + const { service, magicLinkRepo, auditEvents } = makeService(); + + const result = await service.createToken(makeActor(), { userId: 2, label: 'Demo' } as never); + + expect(magicLinkRepo.create).toHaveBeenCalledWith({ userId: 2, createdBy: 1, label: 'Demo', expiresAt: undefined }); + expect(result).toEqual({ id: 1, token: 'raw-token-hex', label: 'Demo', expiresAt: null }); + expect(auditEvents.emit).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ action: 'magic_link.create' })); + }); + }); + + describe('loginWithToken', () => { + const reply = {} as never; + + it('throws UnauthorizedException when token not found', async () => { + const { service, magicLinkRepo } = makeService(); + magicLinkRepo.findByTokenHash.mockResolvedValue(null); + + await expect(service.loginWithToken('bad-token', reply)).rejects.toThrow(UnauthorizedException); + }); + + it('throws UnauthorizedException when token is revoked', async () => { + const { service, magicLinkRepo } = makeService(); + magicLinkRepo.findByTokenHash.mockResolvedValue({ id: 1, userId: 2, revokedAt: new Date(), isActive: true, expiresAt: null }); + + await expect(service.loginWithToken('token', reply)).rejects.toThrow(UnauthorizedException); + }); + + it('throws UnauthorizedException when token is deactivated', async () => { + const { service, magicLinkRepo } = makeService(); + magicLinkRepo.findByTokenHash.mockResolvedValue({ id: 1, userId: 2, revokedAt: null, isActive: false, expiresAt: null }); + + await expect(service.loginWithToken('token', reply)).rejects.toThrow(UnauthorizedException); + }); + + it('throws UnauthorizedException when token is expired', async () => { + const { service, magicLinkRepo } = makeService(); + magicLinkRepo.findByTokenHash.mockResolvedValue({ + id: 1, + userId: 2, + revokedAt: null, + isActive: true, + expiresAt: new Date(Date.now() - 1000), + }); + + await expect(service.loginWithToken('token', reply)).rejects.toThrow(UnauthorizedException); + }); + + it('throws UnauthorizedException when user is inactive', async () => { + const { service, magicLinkRepo, userService } = makeService(); + magicLinkRepo.findByTokenHash.mockResolvedValue({ + id: 1, + userId: 2, + revokedAt: null, + isActive: true, + expiresAt: null, + label: 'Demo', + }); + userService.findById.mockResolvedValue({ id: 2, active: false, username: 'demo' }); + + await expect(service.loginWithToken('token', reply)).rejects.toThrow(UnauthorizedException); + }); + + it('updates usage, issues tokens, and emits audit on success', async () => { + const { service, magicLinkRepo, authService, auditEvents, userService } = makeService(); + magicLinkRepo.findByTokenHash.mockResolvedValue({ + id: 5, + userId: 2, + revokedAt: null, + isActive: true, + expiresAt: null, + label: 'Demo', + }); + userService.findById.mockResolvedValue({ id: 2, active: true, username: 'demo' }); + + const result = await service.loginWithToken('valid-token', reply, '10.0.0.1'); + + expect(magicLinkRepo.updateUsage).toHaveBeenCalledWith(5); + expect(authService.issueTokensForUser).toHaveBeenCalledWith(2, reply); + expect(result).toEqual({ accessToken: 'jwt', user: {} }); + expect(auditEvents.emit).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ action: 'magic_link.login' })); + }); + }); + + describe('listTokens', () => { + it('delegates to repository', async () => { + const { service, magicLinkRepo } = makeService(); + magicLinkRepo.findAll.mockResolvedValue([{ id: 1 }]); + + const result = await service.listTokens(); + expect(result).toEqual([{ id: 1 }]); + }); + }); + + describe('setActive', () => { + it('throws ForbiddenException for non-superuser', async () => { + const { service } = makeService(); + await expect(service.setActive(makeActor({ isSuperuser: false }), 1, false)).rejects.toThrow(ForbiddenException); + }); + + it('throws NotFoundException when token not found', async () => { + const { service, magicLinkRepo } = makeService(); + magicLinkRepo.findById.mockResolvedValue(null); + + await expect(service.setActive(makeActor(), 1, false)).rejects.toThrow(NotFoundException); + }); + + it('throws BadRequestException when token is already revoked', async () => { + const { service, magicLinkRepo } = makeService(); + magicLinkRepo.findById.mockResolvedValue({ id: 1, revokedAt: new Date() }); + + await expect(service.setActive(makeActor(), 1, false)).rejects.toThrow(BadRequestException); + }); + + it('deactivates token, revokes sessions, and emits audit', async () => { + const { service, magicLinkRepo, authService, auditEvents } = makeService(); + magicLinkRepo.findById.mockResolvedValue({ id: 1, revokedAt: null }); + magicLinkRepo.setActive.mockResolvedValue({ id: 1, userId: 2, isActive: false }); + + const result = await service.setActive(makeActor(), 1, false); + + expect(magicLinkRepo.setActive).toHaveBeenCalledWith(1, false); + expect(authService.revokeAllUserSessions).toHaveBeenCalledWith(2); + expect(result).toEqual({ id: 1, isActive: false }); + expect(auditEvents.emit).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ action: 'magic_link.deactivate' })); + }); + + it('activates token without revoking sessions and emits audit', async () => { + const { service, magicLinkRepo, authService, auditEvents } = makeService(); + magicLinkRepo.findById.mockResolvedValue({ id: 1, revokedAt: null }); + magicLinkRepo.setActive.mockResolvedValue({ id: 1, userId: 2, isActive: true }); + + const result = await service.setActive(makeActor(), 1, true); + + expect(authService.revokeAllUserSessions).not.toHaveBeenCalled(); + expect(result).toEqual({ id: 1, isActive: true }); + expect(auditEvents.emit).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ action: 'magic_link.activate' })); + }); + }); + + describe('revokeToken', () => { + it('throws ForbiddenException for non-superuser', async () => { + const { service } = makeService(); + await expect(service.revokeToken(makeActor({ isSuperuser: false }), 1)).rejects.toThrow(ForbiddenException); + }); + + it('throws NotFoundException when token not found', async () => { + const { service, magicLinkRepo } = makeService(); + magicLinkRepo.findById.mockResolvedValue(null); + + await expect(service.revokeToken(makeActor(), 1)).rejects.toThrow(NotFoundException); + }); + + it('throws BadRequestException when token is already revoked', async () => { + const { service, magicLinkRepo } = makeService(); + magicLinkRepo.findById.mockResolvedValue({ id: 1, revokedAt: new Date() }); + + await expect(service.revokeToken(makeActor(), 1)).rejects.toThrow(BadRequestException); + }); + + it('revokes token, revokes sessions, and emits audit', async () => { + const { service, magicLinkRepo, authService, auditEvents } = makeService(); + magicLinkRepo.findById.mockResolvedValue({ id: 1, revokedAt: null }); + magicLinkRepo.revoke.mockResolvedValue({ id: 1, userId: 2 }); + + await service.revokeToken(makeActor(), 1); + + expect(magicLinkRepo.revoke).toHaveBeenCalledWith(1); + expect(authService.revokeAllUserSessions).toHaveBeenCalledWith(2); + expect(auditEvents.emit).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ action: 'magic_link.revoke' })); + }); + }); +}); diff --git a/server/src/modules/auth/magic-link.service.ts b/server/src/modules/auth/magic-link.service.ts new file mode 100644 index 000000000..eedfb013c --- /dev/null +++ b/server/src/modules/auth/magic-link.service.ts @@ -0,0 +1,210 @@ +import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { createHash } from 'crypto'; +import type { FastifyReply } from 'fastify'; + +import { AuditAction, AuditResource } from '@bookorbit/types'; + +import type { RequestUser } from '../../common/types/request-user'; +import { AUDIT_EVENT, AuditEventsService } from '../audit/audit-events.service'; +import { UserService } from '../user/user.service'; +import { AuthService } from './auth.service'; +import { CreateMagicLinkDto } from './dto/create-magic-link.dto'; +import { MagicLinkRepository } from './magic-link.repository'; + +const MAX_TOKENS_PER_USER = 5; + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +@Injectable() +export class MagicLinkService { + private readonly logger = new Logger(MagicLinkService.name); + + constructor( + private readonly magicLinkRepo: MagicLinkRepository, + private readonly userService: UserService, + private readonly authService: AuthService, + private readonly auditEvents: AuditEventsService, + ) {} + + async createToken(actor: RequestUser, dto: CreateMagicLinkDto) { + if (!actor.isSuperuser) { + throw new ForbiddenException('Only superusers can create magic links'); + } + + const targetUser = await this.userService.findById(dto.userId); + if (targetUser.provisioningMethod !== 'shared') { + throw new BadRequestException('Magic links can only be created for shared accounts'); + } + + const activeCount = await this.magicLinkRepo.countActiveByUserId(dto.userId); + if (activeCount >= MAX_TOKENS_PER_USER) { + throw new BadRequestException(`Maximum of ${MAX_TOKENS_PER_USER} active magic links per user`); + } + + const expiresAt = dto.expiresAt ? new Date(dto.expiresAt) : undefined; + if (expiresAt && expiresAt <= new Date()) { + throw new BadRequestException('Expiry date must be in the future'); + } + + const result = await this.magicLinkRepo.create({ + userId: dto.userId, + createdBy: actor.id, + label: dto.label, + expiresAt, + }); + + this.logger.log( + `[magic_link.create] [end] tokenId=${result.id} userId=${dto.userId} createdBy=${actor.id} label="${dto.label}" - magic link created`, + ); + + this.auditEvents.emit(AUDIT_EVENT, { + userId: actor.id, + actorUsername: actor.username, + action: AuditAction.MagicLinkCreate, + resource: AuditResource.MagicLinkToken, + resourceId: result.id, + description: `Created magic link '${dto.label}' for user #${dto.userId}`, + }); + + return { + id: result.id, + token: result.rawToken, + label: result.label, + expiresAt: result.expiresAt?.toISOString() ?? null, + }; + } + + async loginWithToken(rawToken: string, reply: FastifyReply, ip?: string) { + const tokenHash = sha256(rawToken); + const row = await this.magicLinkRepo.findByTokenHash(tokenHash); + + if (!row) { + this.logger.warn('[magic_link.login] [fail] errorClass=UnauthorizedException error="token not found" - magic link login failed'); + throw new UnauthorizedException('Invalid or expired magic link'); + } + + if (row.revokedAt) { + this.logger.warn( + `[magic_link.login] [fail] tokenId=${row.id} userId=${row.userId} errorClass=UnauthorizedException error="token revoked" - magic link login failed`, + ); + throw new UnauthorizedException('Invalid or expired magic link'); + } + + if (!row.isActive) { + this.logger.warn( + `[magic_link.login] [fail] tokenId=${row.id} userId=${row.userId} errorClass=UnauthorizedException error="token deactivated" - magic link login failed`, + ); + throw new UnauthorizedException('Invalid or expired magic link'); + } + + if (row.expiresAt && row.expiresAt < new Date()) { + this.logger.warn( + `[magic_link.login] [fail] tokenId=${row.id} userId=${row.userId} errorClass=UnauthorizedException error="token expired" - magic link login failed`, + ); + throw new UnauthorizedException('Invalid or expired magic link'); + } + + const user = await this.userService.findById(row.userId).catch(() => null); + if (!user || !user.active) { + this.logger.warn( + `[magic_link.login] [fail] tokenId=${row.id} userId=${row.userId} errorClass=UnauthorizedException error="user inactive or not found" - magic link login failed`, + ); + throw new UnauthorizedException('Invalid or expired magic link'); + } + + await this.magicLinkRepo.updateUsage(row.id); + const result = await this.authService.issueTokensForUser(row.userId, reply); + + this.logger.log(`[magic_link.login] [end] tokenId=${row.id} userId=${row.userId} ip=${ip ?? 'unknown'} - magic link login completed`); + + this.auditEvents.emit(AUDIT_EVENT, { + userId: row.userId, + actorUsername: user.username, + action: AuditAction.MagicLinkLogin, + resource: AuditResource.MagicLinkToken, + resourceId: row.id, + description: `Magic link login for user '${user.username}' (link: '${row.label}')`, + ip, + }); + + return result; + } + + async listTokens() { + return this.magicLinkRepo.findAll(); + } + + async setActive(actor: RequestUser, tokenId: number, isActive: boolean) { + if (!actor.isSuperuser) { + throw new ForbiddenException('Only superusers can manage magic links'); + } + + const existing = await this.magicLinkRepo.findById(tokenId); + if (!existing) throw new NotFoundException('Magic link not found'); + if (existing.revokedAt) throw new BadRequestException('Cannot change a revoked magic link'); + + const updated = await this.magicLinkRepo.setActive(tokenId, isActive); + if (!updated) throw new NotFoundException('Magic link not found'); + + if (!isActive) { + await this.authService.revokeAllUserSessions(updated.userId); + this.logger.log(`[magic_link.deactivate] [end] tokenId=${tokenId} userId=${updated.userId} deactivatedBy=${actor.id} - magic link deactivated`); + + this.auditEvents.emit(AUDIT_EVENT, { + userId: actor.id, + actorUsername: actor.username, + action: AuditAction.MagicLinkDeactivate, + resource: AuditResource.MagicLinkToken, + resourceId: tokenId, + description: `Deactivated magic link #${tokenId} for user #${updated.userId}`, + }); + } else { + this.logger.log(`[magic_link.activate] [end] tokenId=${tokenId} userId=${updated.userId} activatedBy=${actor.id} - magic link activated`); + + this.auditEvents.emit(AUDIT_EVENT, { + userId: actor.id, + actorUsername: actor.username, + action: AuditAction.MagicLinkActivate, + resource: AuditResource.MagicLinkToken, + resourceId: tokenId, + description: `Activated magic link #${tokenId} for user #${updated.userId}`, + }); + } + + return { id: updated.id, isActive: updated.isActive }; + } + + async revokeToken(actor: RequestUser, tokenId: number) { + if (!actor.isSuperuser) { + throw new ForbiddenException('Only superusers can revoke magic links'); + } + + const existing = await this.magicLinkRepo.findById(tokenId); + if (!existing) { + throw new NotFoundException('Magic link not found'); + } + if (existing.revokedAt) { + throw new BadRequestException('Magic link is already revoked'); + } + + const revoked = await this.magicLinkRepo.revoke(tokenId); + if (!revoked) { + throw new NotFoundException('Magic link not found'); + } + + await this.authService.revokeAllUserSessions(revoked.userId); + + this.logger.log(`[magic_link.revoke] [end] tokenId=${tokenId} userId=${revoked.userId} revokedBy=${actor.id} - magic link revoked`); + + this.auditEvents.emit(AUDIT_EVENT, { + userId: actor.id, + actorUsername: actor.username, + action: AuditAction.MagicLinkRevoke, + resource: AuditResource.MagicLinkToken, + resourceId: tokenId, + description: `Revoked magic link #${tokenId} for user #${revoked.userId}`, + }); + } +} diff --git a/server/src/modules/auth/oidc/oidc.service.ts b/server/src/modules/auth/oidc/oidc.service.ts index a25d045d7..ee7ab104a 100644 --- a/server/src/modules/auth/oidc/oidc.service.ts +++ b/server/src/modules/auth/oidc/oidc.service.ts @@ -79,6 +79,11 @@ export class OidcService { const provider = await this.providerService.findBySlugOrFail(providerSlug); if (!provider.enabled) throw new UnauthorizedException('OIDC provider is not enabled'); + const user = await this.userService.findById(userId); + if (user?.provisioningMethod === 'shared') { + throw new BadRequestException('Shared accounts cannot link OIDC identities'); + } + const existing = await this.identityRepo.findByUserAndProvider(userId, provider.id); if (existing) throw new BadRequestException('You already have an identity linked to this provider'); diff --git a/server/src/modules/user/dto/create-shared-user.dto.ts b/server/src/modules/user/dto/create-shared-user.dto.ts new file mode 100644 index 000000000..bc623fb32 --- /dev/null +++ b/server/src/modules/user/dto/create-shared-user.dto.ts @@ -0,0 +1,28 @@ +import { Permission } from '@bookorbit/types'; +import { IsArray, IsEmail, IsEnum, IsInt, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class CreateSharedUserDto { + @IsString() + @MinLength(3) + @MaxLength(100) + username: string; + + @IsString() + @MinLength(1) + @MaxLength(255) + name: string; + + @IsEmail() + @IsOptional() + email?: string; + + @IsArray() + @IsEnum(Permission, { each: true }) + @IsOptional() + permissionNames?: Permission[]; + + @IsArray() + @IsInt({ each: true }) + @IsOptional() + libraryIds?: number[]; +} diff --git a/server/src/modules/user/user.controller.test.ts b/server/src/modules/user/user.controller.test.ts index 90dec9356..94fd444a8 100644 --- a/server/src/modules/user/user.controller.test.ts +++ b/server/src/modules/user/user.controller.test.ts @@ -46,11 +46,11 @@ describe('UserController', () => { }); it('delegates list and profile update operations', async () => { - await controller.findAll(undefined, 25); + await controller.findAll(undefined, 25, undefined); await controller.findAssignable(); await controller.updateMe({ id: 7 } as any, { name: 'Updated' } as any); - expect(userService.findAll).toHaveBeenCalledWith(undefined, 25); + expect(userService.findAll).toHaveBeenCalledWith(undefined, 25, undefined); expect(userService.findAssignable).toHaveBeenCalled(); expect(userService.updateMe).toHaveBeenCalledWith(7, { name: 'Updated' }); }); diff --git a/server/src/modules/user/user.controller.ts b/server/src/modules/user/user.controller.ts index 6f253f67d..90f0a8e29 100644 --- a/server/src/modules/user/user.controller.ts +++ b/server/src/modules/user/user.controller.ts @@ -29,6 +29,7 @@ import { Auditable } from '../../common/decorators/auditable.decorator'; import type { MultipartRequest } from '../../common/types/multipart-request'; import type { RequestUser } from '../../common/types/request-user'; import { CreateUserDto } from './dto/create-user.dto'; +import { CreateSharedUserDto } from './dto/create-shared-user.dto'; import { SetLibrariesDto } from './dto/set-libraries.dto'; import { SetPermissionsDto } from './dto/set-permissions.dto'; import { UpdateMeDto } from './dto/update-me.dto'; @@ -50,8 +51,9 @@ export class UserController { findAll( @Query('page', new ParseIntPipe({ optional: true })) page?: number, @Query('pageSize', new ParseIntPipe({ optional: true })) pageSize?: number, + @Query('provisioningMethod') provisioningMethod?: string, ) { - return this.userService.findAll(page, pageSize); + return this.userService.findAll(page, pageSize, provisioningMethod); } // Must be before :id routes to avoid named segments being parsed as ints @@ -163,6 +165,21 @@ export class UserController { return this.userService.createUser(dto); } + @Post('shared') + @RequirePermission(Permission.ManageUsers) + @Auditable({ + action: AuditAction.UserCreate, + resource: AuditResource.User, + getResourceId: (_, res: unknown) => (res as { id?: number })?.id, + description: (_, res: unknown) => `Created shared user '${(res as { username?: string })?.username ?? 'unknown'}'`, + }) + createSharedUser(@Body() dto: CreateSharedUserDto, @CurrentUser() requestingUser: RequestUser) { + if (!requestingUser.isSuperuser) { + throw new BadRequestException('Only superusers can create shared accounts'); + } + return this.userService.createSharedUser(dto); + } + @Patch(':id') @RequirePermission(Permission.ManageUsers) @Auditable({ diff --git a/server/src/modules/user/user.repository.test.ts b/server/src/modules/user/user.repository.test.ts index df2be7f89..d08e671ec 100644 --- a/server/src/modules/user/user.repository.test.ts +++ b/server/src/modules/user/user.repository.test.ts @@ -98,9 +98,11 @@ describe('UserRepository', () => { const idOffset = vi.fn().mockResolvedValue([]); const idLimit = vi.fn().mockReturnValue({ offset: idOffset }); const idOrderBy = vi.fn().mockReturnValue({ limit: idLimit }); - const idFrom = vi.fn().mockReturnValue({ orderBy: idOrderBy }); + const idWhere = vi.fn().mockReturnValue({ orderBy: idOrderBy }); + const idFrom = vi.fn().mockReturnValue({ where: idWhere }); - const countFrom = vi.fn().mockResolvedValue([{ total: '7' }]); + const countWhere = vi.fn().mockResolvedValue([{ total: '7' }]); + const countFrom = vi.fn().mockReturnValue({ where: countWhere }); select.mockReturnValueOnce({ from: idFrom }).mockReturnValueOnce({ from: countFrom }); @@ -114,9 +116,11 @@ describe('UserRepository', () => { const idOffset = vi.fn().mockResolvedValue([{ id: 20 }, { id: 10 }]); const idLimit = vi.fn().mockReturnValue({ offset: idOffset }); const idOrderBy = vi.fn().mockReturnValue({ limit: idLimit }); - const idFrom = vi.fn().mockReturnValue({ orderBy: idOrderBy }); + const idWhere = vi.fn().mockReturnValue({ orderBy: idOrderBy }); + const idFrom = vi.fn().mockReturnValue({ where: idWhere }); - const countFrom = vi.fn().mockResolvedValue([{ total: 2 }]); + const countWhere = vi.fn().mockResolvedValue([{ total: 2 }]); + const countFrom = vi.fn().mockReturnValue({ where: countWhere }); const rowsOrderBy = vi.fn().mockResolvedValue([ { diff --git a/server/src/modules/user/user.repository.ts b/server/src/modules/user/user.repository.ts index 9606a158b..56781d4ac 100644 --- a/server/src/modules/user/user.repository.ts +++ b/server/src/modules/user/user.repository.ts @@ -15,12 +15,14 @@ type Db = NodePgDatabase; export class UserRepository { constructor(@Inject(DB) private readonly db: Db) {} - async findAll(page: number, pageSize: number) { + async findAll(page: number, pageSize: number, provisioningMethod?: string) { const offset = page * pageSize; + const conditions = provisioningMethod ? eq(schema.users.provisioningMethod, provisioningMethod) : undefined; + const [userPage, [{ total }]] = await Promise.all([ - this.db.select({ id: schema.users.id }).from(schema.users).orderBy(schema.users.username).limit(pageSize).offset(offset), - this.db.select({ total: count() }).from(schema.users), + this.db.select({ id: schema.users.id }).from(schema.users).where(conditions).orderBy(schema.users.username).limit(pageSize).offset(offset), + this.db.select({ total: count() }).from(schema.users).where(conditions), ]); const normalizedTotal = Number(total); diff --git a/server/src/modules/user/user.service.test.ts b/server/src/modules/user/user.service.test.ts index 23986d714..16164b09a 100644 --- a/server/src/modules/user/user.service.test.ts +++ b/server/src/modules/user/user.service.test.ts @@ -93,7 +93,7 @@ describe('UserService', () => { expect(userRepo.incrementTokenVersion).toHaveBeenCalledWith(7); expect(userRepo.findByIdWithPermissions).toHaveBeenCalledWith(7); expect(userRepo.create).toHaveBeenCalledWith({ username: 'new' }); - expect(userRepo.findAll).toHaveBeenCalledWith(1, 20); + expect(userRepo.findAll).toHaveBeenCalledWith(1, 20, undefined); expect(userRepo.findAssignable).toHaveBeenCalled(); expect(userRepo.setPermissions).toHaveBeenCalledWith(7, [Permission.LibraryDownload]); }); diff --git a/server/src/modules/user/user.service.ts b/server/src/modules/user/user.service.ts index d0ec890e1..532be42c7 100644 --- a/server/src/modules/user/user.service.ts +++ b/server/src/modules/user/user.service.ts @@ -7,6 +7,7 @@ import type { UserSettings } from '@bookorbit/types'; import type { RequestUser } from '../../common/types/request-user'; import { CreateUserDto } from './dto/create-user.dto'; +import { CreateSharedUserDto } from './dto/create-shared-user.dto'; import { SetPermissionsDto } from './dto/set-permissions.dto'; import { UpdateMeDto } from './dto/update-me.dto'; import { UpdateUserDto } from './dto/update-user.dto'; @@ -68,8 +69,8 @@ export class UserService { return this.userRepo.create(data); } - findAll(page = 0, pageSize = 50) { - return this.userRepo.findAll(page, pageSize); + findAll(page = 0, pageSize = 50, provisioningMethod?: string) { + return this.userRepo.findAll(page, pageSize, provisioningMethod); } findAssignable() { @@ -115,6 +116,38 @@ export class UserService { return { id: user.id, username: user.username, name: user.name, resetUrl }; } + async createSharedUser(dto: CreateSharedUserDto) { + const existing = await this.userRepo.findByUsername(dto.username); + if (existing) throw new ConflictException('Username already taken'); + if (dto.email) { + const existingEmail = await this.userRepo.findByEmail(dto.email); + if (existingEmail) throw new ConflictException('Email already in use'); + } + + const passwordHash = await hash(randomBytes(32).toString('hex'), 12); + const user = await this.userRepo.create({ + username: dto.username, + name: dto.name, + email: dto.email ?? null, + passwordHash, + isDefaultPassword: false, + provisioningMethod: 'shared', + }); + + const permissionNames = this.uniquePermissions(dto.permissionNames ?? []); + if (permissionNames.length > 0) { + await this.userRepo.setPermissions(user.id, permissionNames); + } + + const libraryIds = this.uniqueIds(dto.libraryIds ?? []); + if (libraryIds.length > 0) { + await this.assertKnownLibraryIds(libraryIds); + await this.userRepo.assignViewerLibraries(user.id, libraryIds); + } + + return { id: user.id, username: user.username, name: user.name }; + } + async updateUser(id: number, dto: UpdateUserDto, requestingUser: RequestUser) { if (id === requestingUser.id && dto.active === false) { throw new ConflictException('You cannot deactivate your own account'); @@ -190,6 +223,9 @@ export class UserService { } const target = await this.userRepo.findByIdWithPermissions(targetUserId); if (!target) throw new NotFoundException('User not found'); + if (target.provisioningMethod === 'shared') { + throw new BadRequestException('Shared accounts cannot be made superuser'); + } if (!isSuperuser && target.isSuperuser) { const otherSuperusers = await this.userRepo.countOtherSuperusers(targetUserId); if (otherSuperusers === 0) { @@ -226,6 +262,9 @@ export class UserService { if (target.provisioningMethod === 'oidc') { throw new BadRequestException('OIDC accounts cannot reset their password here'); } + if (target.provisioningMethod === 'shared') { + throw new BadRequestException('Shared accounts do not have passwords'); + } const appUrl = this.config.get('app.appUrl') ?? 'http://localhost:5173'; const rawToken = await this.userRepo.generateResetToken(targetUserId); return { resetUrl: `${appUrl}/reset-password?token=${rawToken}` };