diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ed3a03f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.gitignore +.nuxt +.output +node_modules +.env +.env.* +!.env.example +README.md +Dockerfile* +docker-compose*.yml +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..58f6095 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +NUXT_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/dinner_ticket_system +NUXT_REDIS_URL=redis://127.0.0.1:6379 +NUXT_SESSION_COOKIE_NAME=dinner_ticket_session + +# Use your deployed HTTPS origin in production so WebAuthn/passkeys validate correctly. +NUXT_PUBLIC_APP_URL=http://localhost:20013 +NUXT_PUBLIC_RP_NAME=Dinner Ticket System +NITRO_HOST=0.0.0.0 +PORT=20013 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c48468f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM node:22-alpine AS base +WORKDIR /app +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable + +FROM base AS deps +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile + +FROM deps AS build +COPY . . +RUN pnpm build + +FROM base AS prod-deps +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile --prod + +FROM node:22-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NITRO_HOST=0.0.0.0 +ENV PORT=20013 +COPY --from=prod-deps /app/node_modules ./node_modules +COPY --from=build /app/.output ./.output +COPY --from=build /app/package.json ./package.json +EXPOSE 20013 +CMD ["node", ".output/server/index.mjs"] diff --git a/README.md b/README.md index 25b5821..98159c9 100644 --- a/README.md +++ b/README.md @@ -1,75 +1,121 @@ -# Nuxt Minimal Starter +# Dinner Ticket System -Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. +Nuxt 4 app with: + +- Public dinner ticket booking page +- Staff login with password and passkey support +- PostgreSQL-backed users and passkeys +- Redis-backed sessions and WebAuthn challenge storage +- Seeded `xiaomai` super-admin account +- Super-admin user creation and password reset flow +- First-login enforcement: temporary password change plus passkey enrollment + +## Environment + +Create `.env` from `.env.example` and set: + +```bash +NUXT_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/dinner_ticket_system +NUXT_REDIS_URL=redis://127.0.0.1:6379 +NUXT_PUBLIC_APP_URL=http://localhost:20013 +``` + +`NUXT_PUBLIC_APP_URL` should be your final HTTPS origin in production. Passkeys rely on the RP origin being stable and correct. ## Setup -Make sure to install dependencies: +Install dependencies: ```bash -# npm -npm install - -# pnpm pnpm install - -# yarn -yarn install - -# bun -bun install ``` -## Development Server +## Development -Start the development server on `http://localhost:3000`: +Start the app: ```bash -# npm -npm run dev - -# pnpm pnpm dev - -# yarn -yarn dev - -# bun -bun run dev ``` +The backend bootstraps its schema automatically on startup and seeds this initial super-admin account if it does not already exist: + +- Username: `xiaomai` +- Temporary password: `123456` + +On first login, the user is forced to change that temporary password and register a passkey before accessing the protected area. + ## Production -Build the application for production: +Build: ```bash -# npm -npm run build - -# pnpm pnpm build - -# yarn -yarn build - -# bun -bun run build ``` -Locally preview production build: +Preview the built server: ```bash -# npm -npm run preview - -# pnpm -pnpm preview - -# yarn -yarn preview - -# bun -bun run preview +node .output/server/index.mjs ``` -Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. +## Docker + +The repo now includes a production-ready container stack: + +- [Dockerfile](/mnt/d/SourceCode/tootaio/dinner-ticket-system/Dockerfile) +- [docker-compose.yml](/mnt/d/SourceCode/tootaio/dinner-ticket-system/docker-compose.yml) +- [.dockerignore](/mnt/d/SourceCode/tootaio/dinner-ticket-system/.dockerignore) + +Bring up the full environment: + +```bash +docker compose up --build +``` + +This starts: + +- Nuxt/Nitro app on `http://localhost:20013` +- PostgreSQL only on the internal Docker network +- Redis only on the internal Docker network + +The app container waits on PostgreSQL and Redis health checks, and exposes: + +- `GET /api/health` for container/runtime health + +Stop the stack: + +```bash +docker compose down +``` + +Stop and remove persisted database/cache volumes: + +```bash +docker compose down -v +``` + +For passkey testing in Docker, set `NUXT_PUBLIC_APP_URL` to the exact origin you open in the browser. In production, this should be your final HTTPS URL. + +## Protected Areas + +- `/login` +- `/security` +- `/management/users` + +## User Flows + +- Password login with Redis-backed session cookie +- Passkey login using WebAuthn discoverable credentials +- Super admin creates users with default password `123456` +- Users must change password and set a passkey after first login +- Users can change their own password from Security +- Super admin can reset a user's password back to `123456` + +## Verification + +The codebase currently verifies cleanly with: + +```bash +pnpm build +``` diff --git a/app/composables/useApiClient.ts b/app/composables/useApiClient.ts new file mode 100644 index 0000000..80c71b8 --- /dev/null +++ b/app/composables/useApiClient.ts @@ -0,0 +1,10 @@ +export function useApiClient() { + return async function apiClient(url: string, options?: Parameters>[1]) { + if (import.meta.server) { + const requestFetch = useRequestFetch() + return await requestFetch(url, options) + } + + return await $fetch(url, options) + } +} diff --git a/app/composables/useAuth.ts b/app/composables/useAuth.ts new file mode 100644 index 0000000..ef96f08 --- /dev/null +++ b/app/composables/useAuth.ts @@ -0,0 +1,54 @@ +import type { AuthUser } from '~~/shared/auth' + +export function useAuth() { + const user = useState('auth:user', () => null) + const loaded = useState('auth:loaded', () => false) + const loading = useState('auth:loading', () => false) + const apiClient = useApiClient() + + const isAuthenticated = computed(() => Boolean(user.value)) + const isSuperAdmin = computed(() => user.value?.role === 'super_admin') + const needsOnboarding = computed(() => { + return Boolean(user.value && (user.value.mustChangePassword || user.value.needsPasskeySetup)) + }) + + async function fetchSession(force = false) { + if (loaded.value && !force) { + return user.value + } + + loading.value = true + + try { + const response = await apiClient<{ user: AuthUser | null }>('/api/auth/me') + user.value = response.user + loaded.value = true + return user.value + } finally { + loading.value = false + } + } + + function setUser(nextUser: AuthUser | null) { + user.value = nextUser + loaded.value = true + } + + function clearUser() { + user.value = null + loaded.value = true + } + + return { + user, + loaded, + loading, + isAuthenticated, + isSuperAdmin, + needsOnboarding, + fetchSession, + refreshSession: () => fetchSession(true), + setUser, + clearUser + } +} diff --git a/app/layouts/default.vue b/app/layouts/default.vue index de025ab..533431f 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -1,37 +1,416 @@ diff --git a/app/middleware/auth.ts b/app/middleware/auth.ts new file mode 100644 index 0000000..7a4928c --- /dev/null +++ b/app/middleware/auth.ts @@ -0,0 +1,12 @@ +export default defineNuxtRouteMiddleware(async (to) => { + const auth = useAuth() + await auth.fetchSession() + + if (!auth.user.value) { + return navigateTo('/login') + } + + if (to.path !== '/security' && auth.needsOnboarding.value) { + return navigateTo('/security') + } +}) diff --git a/app/middleware/guest.ts b/app/middleware/guest.ts new file mode 100644 index 0000000..d12faab --- /dev/null +++ b/app/middleware/guest.ts @@ -0,0 +1,14 @@ +export default defineNuxtRouteMiddleware(async () => { + const auth = useAuth() + await auth.fetchSession() + + if (!auth.user.value) { + return + } + + if (auth.needsOnboarding.value) { + return navigateTo('/security') + } + + return navigateTo(auth.isSuperAdmin.value ? '/management/users' : '/security') +}) diff --git a/app/middleware/super-admin.ts b/app/middleware/super-admin.ts new file mode 100644 index 0000000..0971120 --- /dev/null +++ b/app/middleware/super-admin.ts @@ -0,0 +1,16 @@ +export default defineNuxtRouteMiddleware(async () => { + const auth = useAuth() + await auth.fetchSession() + + if (!auth.user.value) { + return navigateTo('/login') + } + + if (auth.needsOnboarding.value) { + return navigateTo('/security') + } + + if (!auth.isSuperAdmin.value) { + return navigateTo('/security') + } +}) diff --git a/app/pages/index.vue b/app/pages/index.vue index 192016c..01ba12d 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -1,10 +1,13 @@ diff --git a/app/pages/management/users/index.vue b/app/pages/management/users/index.vue new file mode 100644 index 0000000..2783480 --- /dev/null +++ b/app/pages/management/users/index.vue @@ -0,0 +1,469 @@ + + + diff --git a/app/pages/security/index.vue b/app/pages/security/index.vue new file mode 100644 index 0000000..c8cf9ba --- /dev/null +++ b/app/pages/security/index.vue @@ -0,0 +1,268 @@ + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a2d16dc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,64 @@ +services: + postgres: + image: postgres:17-alpine + container_name: dinner-ticket-postgres + restart: unless-stopped + environment: + POSTGRES_DB: dinner_ticket_system + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d dinner_ticket_system"] + interval: 5s + timeout: 5s + retries: 20 + start_period: 5s + + redis: + image: redis:7-alpine + container_name: dinner-ticket-redis + restart: unless-stopped + volumes: + - redis_data:/data + command: ["redis-server", "--appendonly", "yes"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 20 + start_period: 5s + + app: + build: + context: . + dockerfile: Dockerfile + container_name: dinner-ticket-app + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + environment: + NODE_ENV: production + NITRO_HOST: 0.0.0.0 + PORT: 20013 + NUXT_DATABASE_URL: postgresql://postgres:postgres@postgres:5432/dinner_ticket_system + NUXT_REDIS_URL: redis://redis:6379 + NUXT_SESSION_COOKIE_NAME: dinner_ticket_session + NUXT_PUBLIC_APP_URL: ${NUXT_PUBLIC_APP_URL:-http://localhost:20013} + NUXT_PUBLIC_RP_NAME: ${NUXT_PUBLIC_RP_NAME:-Dinner Ticket System} + ports: + - "20013:20013" + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:20013/api/health').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + +volumes: + postgres_data: + redis_data: diff --git a/nuxt.config.ts b/nuxt.config.ts index 91123a8..d34a834 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -6,6 +6,15 @@ export default defineNuxtConfig({ devtools: { enabled: true }, modules: ['@nuxt/ui'], css: ['~/assets/css/main.css'], + runtimeConfig: { + databaseUrl: '', + redisUrl: '', + sessionCookieName: 'dinner_ticket_session', + public: { + appUrl: '', + rpName: 'Dinner Ticket System' + } + }, vite: { plugins: [tailwindcss()] } diff --git a/package.json b/package.json index 041b65c..14fc08d 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,16 @@ "dev": "nuxt dev", "generate": "nuxt generate", "preview": "nuxt preview", + "start": "node .output/server/index.mjs", "postinstall": "nuxt prepare" }, "dependencies": { "@nuxt/ui": "4.6.1", + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^13.3.0", "nuxt": "^4.4.2", + "postgres": "^3.4.9", + "redis": "^5.11.0", "vue": "^3.5.32", "vue-router": "^5.0.4" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f00afe..dace5be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,21 @@ importers: '@nuxt/ui': specifier: 4.6.1 version: 4.6.1(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))(@tiptap/y-tiptap@3.0.3(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.30))(yjs@13.6.30))(db0@0.3.4)(embla-carousel@8.6.0)(ioredis@5.10.1)(magicast@0.5.2)(tailwindcss@4.2.2)(typescript@6.0.2)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(vue-router@5.0.4(@vue/compiler-sfc@3.5.32)(vue@3.5.32(typescript@6.0.2)))(vue@3.5.32(typescript@6.0.2))(yjs@13.6.30) + '@simplewebauthn/browser': + specifier: ^13.3.0 + version: 13.3.0 + '@simplewebauthn/server': + specifier: ^13.3.0 + version: 13.3.0 nuxt: specifier: ^4.4.2 version: 4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@parcel/watcher@2.5.6)(@vue/compiler-sfc@3.5.32)(cac@6.7.14)(db0@0.3.4)(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.2)(rollup-plugin-visualizer@7.0.1(rollup@4.60.1))(rollup@4.60.1)(srvx@0.11.15)(terser@5.46.1)(typescript@6.0.2)(vite@7.3.2(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(yaml@2.8.3) + postgres: + specifier: ^3.4.9 + version: 3.4.9 + redis: + specifier: ^5.11.0 + version: 5.11.0 vue: specifier: ^3.5.32 version: 3.5.32(typescript@6.0.2) @@ -371,6 +383,9 @@ packages: '@floating-ui/vue@1.1.11': resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==} + '@hexagon/base64@1.1.28': + resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} + '@iconify/collections@1.0.671': resolution: {integrity: sha512-+UO1BvRCf2zpuKg6REnJfgDjKvtMGjmTFKIZSRhOLK3A6sAFo1ZDB+AjDE1g0lzhKZ4KZsYM7b5tCHFFvalQQA==} @@ -427,6 +442,9 @@ packages: '@kwsites/promise-deferred@1.1.1': resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} + '@levischuck/tiny-cbor@0.2.11': + resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} + '@mapbox/node-pre-gyp@2.0.3': resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==} engines: {node: '>=18'} @@ -1024,6 +1042,43 @@ packages: resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} engines: {node: '>= 10.0.0'} + '@peculiar/asn1-android@2.6.0': + resolution: {integrity: sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==} + + '@peculiar/asn1-cms@2.6.1': + resolution: {integrity: sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==} + + '@peculiar/asn1-csr@2.6.1': + resolution: {integrity: sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==} + + '@peculiar/asn1-ecc@2.6.1': + resolution: {integrity: sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==} + + '@peculiar/asn1-pfx@2.6.1': + resolution: {integrity: sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==} + + '@peculiar/asn1-pkcs8@2.6.1': + resolution: {integrity: sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==} + + '@peculiar/asn1-pkcs9@2.6.1': + resolution: {integrity: sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==} + + '@peculiar/asn1-rsa@2.6.1': + resolution: {integrity: sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==} + + '@peculiar/asn1-schema@2.6.0': + resolution: {integrity: sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==} + + '@peculiar/asn1-x509-attr@2.6.1': + resolution: {integrity: sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==} + + '@peculiar/asn1-x509@2.6.1': + resolution: {integrity: sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==} + + '@peculiar/x509@1.14.3': + resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} + engines: {node: '>=20.0.0'} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1040,6 +1095,39 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@redis/bloom@5.11.0': + resolution: {integrity: sha512-KYiVilAhAFN3057afUb/tfYJpsEyTkQB+tQcn5gVVA7DgcNOAj8lLxe4j8ov8BF6I9C1Fe/kwlbuAICcTMX8Lw==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.11.0 + + '@redis/client@5.11.0': + resolution: {integrity: sha512-GHoprlNQD51Xq2Ztd94HHV94MdFZQ3CVrpA04Fz8MVoHM0B7SlbmPEVIjwTbcv58z8QyjnrOuikS0rWF03k5dQ==} + engines: {node: '>= 18'} + peerDependencies: + '@node-rs/xxhash': ^1.1.0 + peerDependenciesMeta: + '@node-rs/xxhash': + optional: true + + '@redis/json@5.11.0': + resolution: {integrity: sha512-1iAy9kAtcD0quB21RbPTbUqqy+T2Uu2JxucwE+B4A+VaDbIRvpZR6DMqV8Iqaws2YxJYB3GC5JVNzPYio2ErUg==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.11.0 + + '@redis/search@5.11.0': + resolution: {integrity: sha512-g1l7f3Rnyk/xI99oGHIgWHSKFl45Re5YTIcO8j/JE8olz389yUFyz2+A6nqVy/Zi031VgPDWscbbgOk8hlhZ3g==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.11.0 + + '@redis/time-series@5.11.0': + resolution: {integrity: sha512-TWFeOcU4xkj0DkndnOyhtxvX1KWD+78UHT3XX3x3XRBUGWeQrKo3jqzDsZwxbggUgf9yLJr/akFHXru66X5UQA==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.11.0 + '@remirror/core-constants@3.0.0': resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} @@ -1252,6 +1340,13 @@ packages: '@simple-git/argv-parser@1.1.0': resolution: {integrity: sha512-sUKOu2lb5vGIWADNNLpscyj07DAeQZU3KLbnE2Tj53tW6BbDQKMly2CCfnR4oYzqtRELCPWfwaPg+Q0T8qfKBg==} + '@simplewebauthn/browser@13.3.0': + resolution: {integrity: sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==} + + '@simplewebauthn/server@13.3.0': + resolution: {integrity: sha512-MLHYFrYG8/wK2i+86XMhiecK72nMaHKKt4bo+7Q1TbuG9iGjlSdfkPWKO5ZFE/BX+ygCJ7pr8H/AJeyAj1EaTQ==} + engines: {node: '>=20.0.0'} + '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} @@ -1838,6 +1933,10 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + asn1js@3.0.7: + resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==} + engines: {node: '>=12.0.0'} + ast-kit@2.2.0: resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==} engines: {node: '>=20.19.0'} @@ -3292,6 +3391,10 @@ packages: resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} engines: {node: ^10 || ^12 || >=14} + postgres@3.4.9: + resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==} + engines: {node: '>=12'} + powershell-utils@0.1.0: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} @@ -3369,6 +3472,13 @@ packages: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.1.5: + resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} + engines: {node: '>=16.0.0'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -3411,6 +3521,13 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} + redis@5.11.0: + resolution: {integrity: sha512-YwXjATVDT+AuxcyfOwZn046aml9jMlQPvU1VXIlLDVAExe0u93aTfPYSeRgG4p9Q/Jlkj+LXJ1XEoFV+j2JKcQ==} + engines: {node: '>= 18'} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + regexp-tree@0.1.27: resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} hasBin: true @@ -3708,9 +3825,16 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsyringe@4.10.0: + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} + engines: {node: '>= 6.0.0'} + type-fest@5.5.0: resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==} engines: {node: '>=20'} @@ -4475,6 +4599,8 @@ snapshots: - '@vue/composition-api' - vue + '@hexagon/base64@1.1.28': {} + '@iconify/collections@1.0.671': dependencies: '@iconify/types': 2.0.0 @@ -4547,6 +4673,8 @@ snapshots: '@kwsites/promise-deferred@1.1.1': {} + '@levischuck/tiny-cbor@0.2.11': {} + '@mapbox/node-pre-gyp@2.0.3': dependencies: consola: 3.4.2 @@ -5321,6 +5449,102 @@ snapshots: '@parcel/watcher-win32-ia32': 2.5.6 '@parcel/watcher-win32-x64': 2.5.6 + '@peculiar/asn1-android@2.6.0': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-cms@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + '@peculiar/asn1-x509-attr': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-csr@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pfx@2.6.1': + dependencies: + '@peculiar/asn1-cms': 2.6.1 + '@peculiar/asn1-pkcs8': 2.6.1 + '@peculiar/asn1-rsa': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs8@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs9@2.6.1': + dependencies: + '@peculiar/asn1-cms': 2.6.1 + '@peculiar/asn1-pfx': 2.6.1 + '@peculiar/asn1-pkcs8': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + '@peculiar/asn1-x509-attr': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.6.0': + dependencies: + asn1js: 3.0.7 + pvtsutils: 1.3.6 + tslib: 2.8.1 + + '@peculiar/asn1-x509-attr@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + asn1js: 3.0.7 + pvtsutils: 1.3.6 + tslib: 2.8.1 + + '@peculiar/x509@1.14.3': + dependencies: + '@peculiar/asn1-cms': 2.6.1 + '@peculiar/asn1-csr': 2.6.1 + '@peculiar/asn1-ecc': 2.6.1 + '@peculiar/asn1-pkcs9': 2.6.1 + '@peculiar/asn1-rsa': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + pvtsutils: 1.3.6 + reflect-metadata: 0.2.2 + tslib: 2.8.1 + tsyringe: 4.10.0 + '@pkgjs/parseargs@0.11.0': optional: true @@ -5338,6 +5562,26 @@ snapshots: '@poppinss/exception@1.2.3': {} + '@redis/bloom@5.11.0(@redis/client@5.11.0)': + dependencies: + '@redis/client': 5.11.0 + + '@redis/client@5.11.0': + dependencies: + cluster-key-slot: 1.1.2 + + '@redis/json@5.11.0(@redis/client@5.11.0)': + dependencies: + '@redis/client': 5.11.0 + + '@redis/search@5.11.0(@redis/client@5.11.0)': + dependencies: + '@redis/client': 5.11.0 + + '@redis/time-series@5.11.0(@redis/client@5.11.0)': + dependencies: + '@redis/client': 5.11.0 + '@remirror/core-constants@3.0.0': {} '@rolldown/pluginutils@1.0.0-rc.15': {} @@ -5488,6 +5732,19 @@ snapshots: dependencies: '@simple-git/args-pathspec': 1.0.3 + '@simplewebauthn/browser@13.3.0': {} + + '@simplewebauthn/server@13.3.0': + dependencies: + '@hexagon/base64': 1.1.28 + '@levischuck/tiny-cbor': 0.2.11 + '@peculiar/asn1-android': 2.6.0 + '@peculiar/asn1-ecc': 2.6.1 + '@peculiar/asn1-rsa': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + '@peculiar/x509': 1.14.3 + '@sindresorhus/is@7.2.0': {} '@sindresorhus/merge-streams@4.0.0': {} @@ -6098,6 +6355,12 @@ snapshots: dependencies: tslib: 2.8.1 + asn1js@3.0.7: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 + ast-kit@2.2.0: dependencies: '@babel/parser': 7.29.2 @@ -7748,6 +8011,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres@3.4.9: {} + powershell-utils@0.1.0: {} pretty-bytes@7.1.0: {} @@ -7861,6 +8126,12 @@ snapshots: punycode.js@2.3.1: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.1.5: {} + quansync@0.2.11: {} queue-microtask@1.2.3: {} @@ -7906,6 +8177,18 @@ snapshots: dependencies: redis-errors: 1.2.0 + redis@5.11.0: + dependencies: + '@redis/bloom': 5.11.0(@redis/client@5.11.0) + '@redis/client': 5.11.0 + '@redis/json': 5.11.0(@redis/client@5.11.0) + '@redis/search': 5.11.0(@redis/client@5.11.0) + '@redis/time-series': 5.11.0(@redis/client@5.11.0) + transitivePeerDependencies: + - '@node-rs/xxhash' + + reflect-metadata@0.2.2: {} + regexp-tree@0.1.27: {} reka-ui@2.9.3(vue@3.5.32(typescript@6.0.2)): @@ -8232,8 +8515,14 @@ snapshots: tr46@0.0.3: {} + tslib@1.14.1: {} + tslib@2.8.1: {} + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 + type-fest@5.5.0: dependencies: tagged-tag: 1.0.0 diff --git a/server/api/admin/users.get.ts b/server/api/admin/users.get.ts new file mode 100644 index 0000000..4910fa3 --- /dev/null +++ b/server/api/admin/users.get.ts @@ -0,0 +1,10 @@ +import { requireRole } from '../../utils/auth' +import { listUsers } from '../../utils/user-repository' + +export default defineEventHandler(async (event) => { + await requireRole(event, 'super_admin') + + return { + users: await listUsers() + } +}) diff --git a/server/api/admin/users.post.ts b/server/api/admin/users.post.ts new file mode 100644 index 0000000..c36cee6 --- /dev/null +++ b/server/api/admin/users.post.ts @@ -0,0 +1,74 @@ +import { + DEFAULT_USER_PASSWORD, + USERNAME_PATTERN, + isValidPhoneNumber, + normalizePhoneNumber, + type UserRole +} from '~~/shared/auth' + +import { normalizeUsername, requireRole } from '../../utils/auth' +import { hashPassword } from '../../utils/password' +import { createUser } from '../../utils/user-repository' + +export default defineEventHandler(async (event) => { + const auth = await requireRole(event, 'super_admin') + const body = await readBody<{ + username?: string + fullName?: string + phoneNumber?: string + role?: UserRole + }>(event) + + const username = normalizeUsername(body.username || '') + const fullName = body.fullName?.trim() || '' + const phoneNumber = normalizePhoneNumber(body.phoneNumber || '') + const role = body.role === 'super_admin' ? 'super_admin' : 'staff' + + if (!USERNAME_PATTERN.test(username)) { + throw createError({ + statusCode: 400, + statusMessage: 'Username must be 3 to 32 characters using lowercase letters, numbers, dot, dash, or underscore' + }) + } + + if (fullName.length < 2) { + throw createError({ + statusCode: 400, + statusMessage: 'Full name must be at least 2 characters' + }) + } + + if (!isValidPhoneNumber(phoneNumber)) { + throw createError({ + statusCode: 400, + statusMessage: 'Phone number must contain 8 to 15 digits' + }) + } + + const passwordHash = await hashPassword(DEFAULT_USER_PASSWORD) + + try { + const user = await createUser({ + username, + fullName, + phoneNumber, + role, + passwordHash, + createdBy: auth.user.id + }) + + return { + user, + defaultPassword: DEFAULT_USER_PASSWORD + } + } catch (error: any) { + if (error?.code === '23505') { + throw createError({ + statusCode: 409, + statusMessage: 'Username already exists' + }) + } + + throw error + } +}) diff --git a/server/api/admin/users/[id].patch.ts b/server/api/admin/users/[id].patch.ts new file mode 100644 index 0000000..976fa8d --- /dev/null +++ b/server/api/admin/users/[id].patch.ts @@ -0,0 +1,78 @@ +import { + isValidPhoneNumber, + normalizePhoneNumber, + type UserRole +} from '~~/shared/auth' + +import { requireRole } from '../../../utils/auth' +import { getUserById, updateUserProfile } from '../../../utils/user-repository' + +export default defineEventHandler(async (event) => { + const auth = await requireRole(event, 'super_admin') + const userId = getRouterParam(event, 'id') + + if (!userId) { + throw createError({ + statusCode: 400, + statusMessage: 'User id is required' + }) + } + + const body = await readBody<{ + fullName?: string + phoneNumber?: string + role?: UserRole + }>(event) + + const fullName = body.fullName?.trim() || '' + const phoneNumber = normalizePhoneNumber(body.phoneNumber || '') + const role = body.role + + if (fullName.length < 2) { + throw createError({ + statusCode: 400, + statusMessage: 'Display name must be at least 2 characters' + }) + } + + if (!isValidPhoneNumber(phoneNumber)) { + throw createError({ + statusCode: 400, + statusMessage: 'Phone number must contain 8 to 15 digits' + }) + } + + if (role !== 'super_admin' && role !== 'staff') { + throw createError({ + statusCode: 400, + statusMessage: 'Role is invalid' + }) + } + + const user = await getUserById(userId) + + if (!user) { + throw createError({ + statusCode: 404, + statusMessage: 'User not found' + }) + } + + if (auth.user.id === userId && role !== 'super_admin') { + throw createError({ + statusCode: 400, + statusMessage: 'You cannot remove your own super admin access' + }) + } + + const updatedUser = await updateUserProfile({ + userId, + fullName, + phoneNumber, + role + }) + + return { + user: updatedUser + } +}) diff --git a/server/api/admin/users/[id]/reset-password.post.ts b/server/api/admin/users/[id]/reset-password.post.ts new file mode 100644 index 0000000..b17c847 --- /dev/null +++ b/server/api/admin/users/[id]/reset-password.post.ts @@ -0,0 +1,42 @@ +import { DEFAULT_USER_PASSWORD } from '~~/shared/auth' + +import { requireRole } from '../../../../utils/auth' +import { hashPassword } from '../../../../utils/password' +import { getUserById, updateUserPassword } from '../../../../utils/user-repository' + +export default defineEventHandler(async (event) => { + await requireRole(event, 'super_admin') + + const userId = getRouterParam(event, 'id') + + if (!userId) { + throw createError({ + statusCode: 400, + statusMessage: 'User id is required' + }) + } + + const user = await getUserById(userId) + + if (!user) { + throw createError({ + statusCode: 404, + statusMessage: 'User not found' + }) + } + + const passwordHash = await hashPassword(DEFAULT_USER_PASSWORD) + + await updateUserPassword({ + userId, + passwordHash, + mustChangePassword: true + }) + + const updatedUser = await getUserById(userId) + + return { + user: updatedUser, + defaultPassword: DEFAULT_USER_PASSWORD + } +}) diff --git a/server/api/auth/change-password.post.ts b/server/api/auth/change-password.post.ts new file mode 100644 index 0000000..988127b --- /dev/null +++ b/server/api/auth/change-password.post.ts @@ -0,0 +1,60 @@ +import { MIN_PASSWORD_LENGTH } from '~~/shared/auth' + +import { requireAuth } from '../../utils/auth' +import { hashPassword, verifyPassword } from '../../utils/password' +import { getUserById, updateUserPassword } from '../../utils/user-repository' + +export default defineEventHandler(async (event) => { + const auth = await requireAuth(event) + const body = await readBody<{ + currentPassword?: string + newPassword?: string + }>(event) + + const currentPassword = body.currentPassword?.trim() || '' + const newPassword = body.newPassword?.trim() || '' + + if (!currentPassword || !newPassword) { + throw createError({ + statusCode: 400, + statusMessage: 'Current password and new password are required' + }) + } + + if (newPassword.length < MIN_PASSWORD_LENGTH) { + throw createError({ + statusCode: 400, + statusMessage: `New password must be at least ${MIN_PASSWORD_LENGTH} characters` + }) + } + + if (currentPassword === newPassword) { + throw createError({ + statusCode: 400, + statusMessage: 'New password must be different from the current password' + }) + } + + const currentPasswordMatches = await verifyPassword(currentPassword, auth.user.passwordHash) + + if (!currentPasswordMatches) { + throw createError({ + statusCode: 400, + statusMessage: 'Current password is incorrect' + }) + } + + const passwordHash = await hashPassword(newPassword) + + await updateUserPassword({ + userId: auth.user.id, + passwordHash, + mustChangePassword: false + }) + + const updatedUser = await getUserById(auth.user.id) + + return { + user: updatedUser + } +}) diff --git a/server/api/auth/login.post.ts b/server/api/auth/login.post.ts new file mode 100644 index 0000000..06dc653 --- /dev/null +++ b/server/api/auth/login.post.ts @@ -0,0 +1,46 @@ +import { verifyPassword } from '../../utils/password' +import { normalizeUsername, signInUser } from '../../utils/auth' +import { getUserByUsername } from '../../utils/user-repository' + +export default defineEventHandler(async (event) => { + const body = await readBody<{ + username?: string + password?: string + remember?: boolean + }>(event) + + const username = normalizeUsername(body.username || '') + const password = body.password?.trim() || '' + const remember = body.remember !== false + + if (!username || !password) { + throw createError({ + statusCode: 400, + statusMessage: 'Username and password are required' + }) + } + + const user = await getUserByUsername(username) + + if (!user || !user.isActive) { + throw createError({ + statusCode: 401, + statusMessage: 'Invalid username or password' + }) + } + + const passwordMatches = await verifyPassword(password, user.passwordHash) + + if (!passwordMatches) { + throw createError({ + statusCode: 401, + statusMessage: 'Invalid username or password' + }) + } + + const authenticatedUser = await signInUser(event, user, remember) + + return { + user: authenticatedUser + } +}) diff --git a/server/api/auth/logout.post.ts b/server/api/auth/logout.post.ts new file mode 100644 index 0000000..d54e8b4 --- /dev/null +++ b/server/api/auth/logout.post.ts @@ -0,0 +1,9 @@ +import { destroyUserSession } from '../../utils/session' + +export default defineEventHandler(async (event) => { + await destroyUserSession(event) + + return { + ok: true + } +}) diff --git a/server/api/auth/me.get.ts b/server/api/auth/me.get.ts new file mode 100644 index 0000000..73967da --- /dev/null +++ b/server/api/auth/me.get.ts @@ -0,0 +1,9 @@ +import { getAuthContext } from '../../utils/auth' + +export default defineEventHandler(async (event) => { + const auth = await getAuthContext(event) + + return { + user: auth?.user ?? null + } +}) diff --git a/server/api/auth/passkey/login/options.post.ts b/server/api/auth/passkey/login/options.post.ts new file mode 100644 index 0000000..b960ff1 --- /dev/null +++ b/server/api/auth/passkey/login/options.post.ts @@ -0,0 +1,17 @@ +import { generateAuthenticationOptions } from '@simplewebauthn/server' + +import { createLoginChallenge, getWebAuthnConfig } from '../../../../utils/webauthn' + +export default defineEventHandler(async (event) => { + const config = getWebAuthnConfig(event) + const options = await generateAuthenticationOptions({ + rpID: config.rpID, + userVerification: 'preferred' + }) + const challengeToken = await createLoginChallenge(options.challenge) + + return { + options, + challengeToken + } +}) diff --git a/server/api/auth/passkey/login/verify.post.ts b/server/api/auth/passkey/login/verify.post.ts new file mode 100644 index 0000000..5242832 --- /dev/null +++ b/server/api/auth/passkey/login/verify.post.ts @@ -0,0 +1,80 @@ +import { verifyAuthenticationResponse, type AuthenticationResponseJSON } from '@simplewebauthn/server' + +import { getUserById, getCredentialForVerification, updateCredentialCounter } from '../../../../utils/user-repository' +import { consumeLoginChallenge, getWebAuthnConfig, toWebAuthnCredential } from '../../../../utils/webauthn' +import { signInUser } from '../../../../utils/auth' + +export default defineEventHandler(async (event) => { + const body = await readBody<{ + response?: AuthenticationResponseJSON + challengeToken?: string + remember?: boolean + }>(event) + + const response = body.response + const challengeToken = body.challengeToken?.trim() || '' + const remember = body.remember !== false + + if (!response || !challengeToken) { + throw createError({ + statusCode: 400, + statusMessage: 'Passkey login payload is incomplete' + }) + } + + const expectedChallenge = await consumeLoginChallenge(challengeToken) + + if (!expectedChallenge) { + throw createError({ + statusCode: 400, + statusMessage: 'Passkey login challenge expired. Try again.' + }) + } + + const storedCredential = await getCredentialForVerification(response.id) + + if (!storedCredential) { + throw createError({ + statusCode: 401, + statusMessage: 'Passkey is not recognized' + }) + } + + const config = getWebAuthnConfig(event) + const verification = await verifyAuthenticationResponse({ + response, + expectedChallenge, + expectedOrigin: config.origin, + expectedRPID: config.rpID, + credential: toWebAuthnCredential(storedCredential) + }) + + if (!verification.verified) { + throw createError({ + statusCode: 401, + statusMessage: 'Passkey authentication failed' + }) + } + + const user = await getUserById(storedCredential.userId) + + if (!user || !user.isActive) { + throw createError({ + statusCode: 401, + statusMessage: 'User account is not available' + }) + } + + await updateCredentialCounter({ + credentialId: storedCredential.credentialId, + counter: verification.authenticationInfo.newCounter, + deviceType: verification.authenticationInfo.credentialDeviceType, + backedUp: verification.authenticationInfo.credentialBackedUp + }) + + const authenticatedUser = await signInUser(event, user, remember) + + return { + user: authenticatedUser + } +}) diff --git a/server/api/auth/passkey/register/options.post.ts b/server/api/auth/passkey/register/options.post.ts new file mode 100644 index 0000000..7a707a8 --- /dev/null +++ b/server/api/auth/passkey/register/options.post.ts @@ -0,0 +1,30 @@ +import { generateRegistrationOptions } from '@simplewebauthn/server' + +import { requireAuth } from '../../../../utils/auth' +import { listCredentialDescriptors } from '../../../../utils/user-repository' +import { getWebAuthnConfig, storeRegistrationChallenge } from '../../../../utils/webauthn' + +export default defineEventHandler(async (event) => { + const auth = await requireAuth(event) + const config = getWebAuthnConfig(event) + const excludeCredentials = await listCredentialDescriptors(auth.user.id) + const options = await generateRegistrationOptions({ + rpName: config.rpName, + rpID: config.rpID, + userName: auth.user.username, + userDisplayName: auth.user.fullName, + userID: Buffer.from(auth.user.id), + excludeCredentials, + authenticatorSelection: { + residentKey: 'required', + userVerification: 'preferred' + }, + preferredAuthenticatorType: 'localDevice' + }) + + await storeRegistrationChallenge(auth.user.id, options.challenge) + + return { + options + } +}) diff --git a/server/api/auth/passkey/register/verify.post.ts b/server/api/auth/passkey/register/verify.post.ts new file mode 100644 index 0000000..f352ba3 --- /dev/null +++ b/server/api/auth/passkey/register/verify.post.ts @@ -0,0 +1,74 @@ +import { verifyRegistrationResponse, type RegistrationResponseJSON } from '@simplewebauthn/server' + +import { requireAuth } from '../../../../utils/auth' +import { createUserPasskey, getUserById, listUserPasskeys } from '../../../../utils/user-repository' +import { buildPasskeyLabel, consumeRegistrationChallenge, getWebAuthnConfig } from '../../../../utils/webauthn' + +export default defineEventHandler(async (event) => { + const auth = await requireAuth(event) + const body = await readBody<{ + response?: RegistrationResponseJSON + }>(event) + + if (!body.response) { + throw createError({ + statusCode: 400, + statusMessage: 'Passkey registration response is required' + }) + } + + const expectedChallenge = await consumeRegistrationChallenge(auth.user.id) + + if (!expectedChallenge) { + throw createError({ + statusCode: 400, + statusMessage: 'Passkey registration challenge expired. Try again.' + }) + } + + const config = getWebAuthnConfig(event) + const verification = await verifyRegistrationResponse({ + response: body.response, + expectedChallenge, + expectedOrigin: config.origin, + expectedRPID: config.rpID + }) + + if (!verification.verified || !verification.registrationInfo) { + throw createError({ + statusCode: 400, + statusMessage: 'Passkey registration could not be verified' + }) + } + + try { + await createUserPasskey({ + userId: auth.user.id, + credentialId: verification.registrationInfo.credential.id, + publicKey: verification.registrationInfo.credential.publicKey, + counter: verification.registrationInfo.credential.counter, + deviceType: verification.registrationInfo.credentialDeviceType, + backedUp: verification.registrationInfo.credentialBackedUp, + transports: body.response.response.transports || [], + label: buildPasskeyLabel() + }) + } catch (error: any) { + if (error?.code === '23505') { + throw createError({ + statusCode: 409, + statusMessage: 'This passkey is already registered' + }) + } + + throw error + } + + const updatedUser = await getUserById(auth.user.id) + const passkeys = await listUserPasskeys(auth.user.id) + + return { + ok: true, + user: updatedUser, + passkeys + } +}) diff --git a/server/api/auth/passkeys.get.ts b/server/api/auth/passkeys.get.ts new file mode 100644 index 0000000..a26b999 --- /dev/null +++ b/server/api/auth/passkeys.get.ts @@ -0,0 +1,11 @@ +import { requireAuth } from '../../utils/auth' +import { listUserPasskeys } from '../../utils/user-repository' + +export default defineEventHandler(async (event) => { + const auth = await requireAuth(event) + const passkeys = await listUserPasskeys(auth.user.id) + + return { + passkeys + } +}) diff --git a/server/api/health.get.ts b/server/api/health.get.ts new file mode 100644 index 0000000..2eff8f6 --- /dev/null +++ b/server/api/health.get.ts @@ -0,0 +1,23 @@ +import { ensureDatabaseReady } from '../utils/db-init' +import { getRedisClient } from '../utils/redis' +import { getSqlClient } from '../utils/postgres' + +export default defineEventHandler(async () => { + await ensureDatabaseReady() + + const sql = getSqlClient() + await sql`select 1` + + const redis = await getRedisClient() + await redis.ping() + + return { + ok: true, + services: { + app: 'up', + postgres: 'up', + redis: 'up' + }, + timestamp: new Date().toISOString() + } +}) diff --git a/server/api/public/contacts.get.ts b/server/api/public/contacts.get.ts new file mode 100644 index 0000000..19a9b1a --- /dev/null +++ b/server/api/public/contacts.get.ts @@ -0,0 +1,7 @@ +import { listPublicContacts } from '../../utils/user-repository' + +export default defineEventHandler(async () => { + return { + contacts: await listPublicContacts() + } +}) diff --git a/server/plugins/bootstrap.ts b/server/plugins/bootstrap.ts new file mode 100644 index 0000000..c9574b8 --- /dev/null +++ b/server/plugins/bootstrap.ts @@ -0,0 +1,14 @@ +import { ensureDatabaseReady } from '../utils/db-init' +import { withRetry } from '../utils/retry' + +export default defineNitroPlugin(async () => { + await withRetry( + () => ensureDatabaseReady(), + { + label: 'database bootstrap', + retries: 15, + delayMs: 1_000, + factor: 1.4 + } + ) +}) diff --git a/server/utils/auth.ts b/server/utils/auth.ts new file mode 100644 index 0000000..ac0964b --- /dev/null +++ b/server/utils/auth.ts @@ -0,0 +1,78 @@ +import type { H3Event } from 'h3' + +import type { UserRole } from '~~/shared/auth' + +import { createUserSession, destroyUserSession, getUserSession } from './session' +import { getUserById, updateLastLogin, type UserAuthRecord } from './user-repository' + +export function normalizeUsername(value: string) { + return value.trim().toLowerCase() +} + +export async function getAuthContext(event: H3Event): Promise<{ + session: Awaited> + user: UserAuthRecord +} | null> { + const session = await getUserSession(event) + + if (!session) { + return null + } + + const user = await getUserById(session.userId) + + if (!user || !user.isActive) { + await destroyUserSession(event) + return null + } + + return { + session, + user + } +} + +export async function requireAuth(event: H3Event) { + const auth = await getAuthContext(event) + + if (!auth) { + throw createError({ + statusCode: 401, + statusMessage: 'Authentication required' + }) + } + + return auth +} + +export async function requireRole(event: H3Event, role: UserRole) { + const auth = await requireAuth(event) + + if (auth.user.role !== role) { + throw createError({ + statusCode: 403, + statusMessage: 'You are not allowed to perform this action' + }) + } + + return auth +} + +export async function signInUser(event: H3Event, user: UserAuthRecord, remember: boolean) { + await createUserSession(event, { + userId: user.id, + remember + }) + await updateLastLogin(user.id) + + const refreshedUser = await getUserById(user.id) + + if (!refreshedUser) { + throw createError({ + statusCode: 500, + statusMessage: 'Unable to load authenticated user' + }) + } + + return refreshedUser +} diff --git a/server/utils/base64url.ts b/server/utils/base64url.ts new file mode 100644 index 0000000..05c394c --- /dev/null +++ b/server/utils/base64url.ts @@ -0,0 +1,41 @@ +import { randomBytes } from 'node:crypto' + +export function encodeBase64Url(value: Buffer | Uint8Array | string): string { + const buffer = typeof value === 'string' + ? Buffer.from(value, 'utf8') + : Buffer.from(value) + + return buffer + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, '') +} + +export function decodeBase64Url(value: string): Buffer { + const normalized = value + .replace(/-/g, '+') + .replace(/_/g, '/') + + const padding = normalized.length % 4 === 0 + ? '' + : '='.repeat(4 - (normalized.length % 4)) + + return Buffer.from(`${normalized}${padding}`, 'base64') +} + +export function randomToken(length = 32): string { + return encodeBase64Url(randomBytes(length)) +} + +export function toIsoString(value: Date | string | null): string | null { + if (!value) { + return null + } + + if (value instanceof Date) { + return value.toISOString() + } + + return new Date(value).toISOString() +} diff --git a/server/utils/db-init.ts b/server/utils/db-init.ts new file mode 100644 index 0000000..9e49b6a --- /dev/null +++ b/server/utils/db-init.ts @@ -0,0 +1,106 @@ +import { randomUUID } from 'node:crypto' + +import { DEFAULT_USER_PASSWORD } from '~~/shared/auth' + +import { hashPassword } from './password' +import { getSqlClient } from './postgres' + +let databaseReadyPromise: Promise | null = null + +export async function ensureDatabaseReady() { + if (!databaseReadyPromise) { + databaseReadyPromise = initializeDatabase() + } + + return databaseReadyPromise +} + +async function initializeDatabase() { + const sql = getSqlClient() + + await sql` + create table if not exists users ( + id text primary key, + username text not null unique, + full_name text not null, + phone_number text, + role text not null check (role in ('super_admin', 'staff')), + password_hash text not null, + must_change_password boolean not null default true, + is_active boolean not null default true, + created_by text references users(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + last_login_at timestamptz + ) + ` + + await sql` + alter table users + add column if not exists phone_number text + ` + + await sql` + create table if not exists user_passkeys ( + id text primary key, + user_id text not null references users(id) on delete cascade, + credential_id text not null unique, + public_key text not null, + counter bigint not null default 0, + device_type text not null check (device_type in ('singleDevice', 'multiDevice')), + backed_up boolean not null default false, + transports jsonb not null default '[]'::jsonb, + label text not null, + created_at timestamptz not null default now(), + last_used_at timestamptz + ) + ` + + await sql` + create index if not exists user_passkeys_user_id_idx + on user_passkeys (user_id) + ` + + const [existingSuperAdmin] = await sql<{ id: string }[]>` + select id + from users + where username = 'xiaomai' + limit 1 + ` + + if (!existingSuperAdmin) { + const passwordHash = await hashPassword(DEFAULT_USER_PASSWORD) + + await sql` + insert into users ( + id, + username, + full_name, + role, + password_hash, + must_change_password, + is_active, + created_by + ) + values ( + ${randomUUID()}, + 'xiaomai', + 'Xiaomai', + 'super_admin', + ${passwordHash}, + true, + true, + null + ) + ` + } + + await sql` + update users + set + phone_number = '601157753558', + updated_at = now() + where username = 'xiaomai' + and (phone_number is null or phone_number = '') + ` +} diff --git a/server/utils/password.ts b/server/utils/password.ts new file mode 100644 index 0000000..9c098f3 --- /dev/null +++ b/server/utils/password.ts @@ -0,0 +1,52 @@ +import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto' +import { promisify } from 'node:util' + +import { decodeBase64Url, encodeBase64Url } from './base64url' + +const scrypt = promisify(scryptCallback) +const SCRYPT_COST = 16_384 +const SCRYPT_BLOCK_SIZE = 8 +const SCRYPT_PARALLELIZATION = 1 +const KEY_LENGTH = 64 + +export async function hashPassword(password: string): Promise { + const salt = encodeBase64Url(randomBytes(16)) + const derivedKey = await scrypt(password, salt, KEY_LENGTH, { + N: SCRYPT_COST, + r: SCRYPT_BLOCK_SIZE, + p: SCRYPT_PARALLELIZATION + }) as Buffer + + return [ + 'scrypt', + SCRYPT_COST, + SCRYPT_BLOCK_SIZE, + SCRYPT_PARALLELIZATION, + salt, + encodeBase64Url(derivedKey) + ].join('$') +} + +export async function verifyPassword(password: string, storedHash: string): Promise { + const [algorithm, cost, blockSize, parallelization, salt, key] = storedHash.split('$') + + if ( + algorithm !== 'scrypt' + || !cost + || !blockSize + || !parallelization + || !salt + || !key + ) { + return false + } + + const expectedKey = decodeBase64Url(key) + const derivedKey = await scrypt(password, salt, expectedKey.length, { + N: Number(cost), + r: Number(blockSize), + p: Number(parallelization) + }) as Buffer + + return timingSafeEqual(expectedKey, derivedKey) +} diff --git a/server/utils/postgres.ts b/server/utils/postgres.ts new file mode 100644 index 0000000..67c4265 --- /dev/null +++ b/server/utils/postgres.ts @@ -0,0 +1,18 @@ +import postgres from 'postgres' + +let sqlClient: postgres.Sql | null = null + +export function getSqlClient() { + if (!sqlClient) { + const config = useRuntimeConfig() + + sqlClient = postgres( + config.databaseUrl || 'postgresql://postgres:postgres@127.0.0.1:5432/dinner_ticket_system', + { + max: 10 + } + ) + } + + return sqlClient +} diff --git a/server/utils/redis.ts b/server/utils/redis.ts new file mode 100644 index 0000000..41ccf47 --- /dev/null +++ b/server/utils/redis.ts @@ -0,0 +1,20 @@ +import { createClient, type RedisClientType } from 'redis' + +let redisClientPromise: Promise | null = null + +export async function getRedisClient() { + if (!redisClientPromise) { + const config = useRuntimeConfig() + const client = createClient({ + url: config.redisUrl || 'redis://127.0.0.1:6379' + }) + + client.on('error', (error) => { + console.error('Redis connection error', error) + }) + + redisClientPromise = client.connect().then(() => client) + } + + return redisClientPromise +} diff --git a/server/utils/retry.ts b/server/utils/retry.ts new file mode 100644 index 0000000..acad7b3 --- /dev/null +++ b/server/utils/retry.ts @@ -0,0 +1,39 @@ +export interface RetryOptions { + retries?: number + delayMs?: number + factor?: number + label?: string +} + +function sleep(delayMs: number) { + return new Promise((resolve) => setTimeout(resolve, delayMs)) +} + +export async function withRetry( + operation: () => Promise, + options: RetryOptions = {} +) { + const retries = options.retries ?? 10 + const delayMs = options.delayMs ?? 1_000 + const factor = options.factor ?? 1.5 + const label = options.label ?? 'operation' + + let attempt = 0 + let currentDelay = delayMs + + while (true) { + try { + return await operation() + } catch (error) { + attempt += 1 + + if (attempt > retries) { + throw error + } + + console.warn(`${label} failed on attempt ${attempt}. Retrying in ${currentDelay}ms.`) + await sleep(currentDelay) + currentDelay = Math.round(currentDelay * factor) + } + } +} diff --git a/server/utils/session.ts b/server/utils/session.ts new file mode 100644 index 0000000..d6648b2 --- /dev/null +++ b/server/utils/session.ts @@ -0,0 +1,103 @@ +import { randomUUID } from 'node:crypto' + +import type { H3Event } from 'h3' + +import { deleteCookie, getCookie, getRequestURL, setCookie } from 'h3' + +import { randomToken } from './base64url' +import { getRedisClient } from './redis' + +const DEFAULT_SESSION_TTL_SECONDS = 60 * 60 * 24 * 7 +const SHORT_SESSION_TTL_SECONDS = 60 * 60 * 24 + +interface StoredSession { + id: string + userId: string + remember: boolean + createdAt: string +} + +function getSessionCookieName() { + const config = useRuntimeConfig() + return config.sessionCookieName || 'dinner_ticket_session' +} + +function getSessionStorageKey(token: string) { + return `auth:session:${token}` +} + +function shouldUseSecureCookies(event: H3Event) { + const url = getRequestURL(event) + return url.protocol === 'https:' +} + +function getSessionTtl(remember: boolean) { + return remember ? DEFAULT_SESSION_TTL_SECONDS : SHORT_SESSION_TTL_SECONDS +} + +export async function createUserSession(event: H3Event, input: { + userId: string + remember: boolean +}) { + const token = randomToken(32) + const session: StoredSession = { + id: randomUUID(), + userId: input.userId, + remember: input.remember, + createdAt: new Date().toISOString() + } + const redis = await getRedisClient() + const ttl = getSessionTtl(input.remember) + + await redis.set(getSessionStorageKey(token), JSON.stringify(session), { + expiration: { + type: 'EX', + value: ttl + } + }) + + setCookie(event, getSessionCookieName(), token, { + httpOnly: true, + sameSite: 'lax', + secure: shouldUseSecureCookies(event), + path: '/', + maxAge: ttl + }) + + return session +} + +export async function getUserSession(event: H3Event): Promise { + const token = getCookie(event, getSessionCookieName()) + + if (!token) { + return null + } + + const redis = await getRedisClient() + const raw = await redis.get(getSessionStorageKey(token)) + + if (!raw) { + deleteCookie(event, getSessionCookieName(), { path: '/' }) + return null + } + + try { + return JSON.parse(raw) as StoredSession + } catch { + await redis.del(getSessionStorageKey(token)) + deleteCookie(event, getSessionCookieName(), { path: '/' }) + return null + } +} + +export async function destroyUserSession(event: H3Event) { + const token = getCookie(event, getSessionCookieName()) + + if (token) { + const redis = await getRedisClient() + await redis.del(getSessionStorageKey(token)) + } + + deleteCookie(event, getSessionCookieName(), { path: '/' }) +} diff --git a/server/utils/user-repository.ts b/server/utils/user-repository.ts new file mode 100644 index 0000000..e8aaec3 --- /dev/null +++ b/server/utils/user-repository.ts @@ -0,0 +1,492 @@ +import { randomUUID } from 'node:crypto' + +import type { AuthenticatorTransportFuture, CredentialDeviceType } from '@simplewebauthn/server' + +import type { AuthUser, ManagedUser, PasskeySummary, PublicContact, UserRole } from '~~/shared/auth' + +import { encodeBase64Url, toIsoString } from './base64url' +import { ensureDatabaseReady } from './db-init' +import { getSqlClient } from './postgres' + +type DbUserRow = { + id: string + username: string + full_name: string + phone_number: string | null + role: UserRole + password_hash: string + must_change_password: boolean + is_active: boolean + created_by: string | null + created_at: Date | string + last_login_at: Date | string | null + passkey_count: number | string +} + +type DbPasskeyRow = { + id: string + user_id: string + credential_id: string + public_key: string + counter: number | string + device_type: CredentialDeviceType + backed_up: boolean + transports: AuthenticatorTransportFuture[] | string + label: string + created_at: Date | string + last_used_at: Date | string | null +} + +export interface UserAuthRecord extends AuthUser { + passwordHash: string + createdBy: string | null +} + +export interface PasskeyRecord { + id: string + userId: string + credentialId: string + publicKey: string + counter: number + deviceType: CredentialDeviceType + backedUp: boolean + transports: AuthenticatorTransportFuture[] + label: string + createdAt: string + lastUsedAt: string | null +} + +function parseCount(value: number | string): number { + return typeof value === 'number' ? value : Number.parseInt(value, 10) +} + +function parseCounter(value: number | string): number { + return typeof value === 'number' ? value : Number.parseInt(value, 10) +} + +function parseTransports(value: AuthenticatorTransportFuture[] | string): AuthenticatorTransportFuture[] { + if (Array.isArray(value)) { + return value + } + + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +function mapAuthUser(row: DbUserRow): AuthUser { + const passkeyCount = parseCount(row.passkey_count) + + return { + id: row.id, + username: row.username, + fullName: row.full_name, + phoneNumber: row.phone_number, + role: row.role, + isActive: row.is_active, + mustChangePassword: row.must_change_password, + needsPasskeySetup: passkeyCount === 0, + passkeyCount, + createdAt: toIsoString(row.created_at) ?? new Date().toISOString(), + lastLoginAt: toIsoString(row.last_login_at) + } +} + +function mapManagedUser(row: DbUserRow): ManagedUser { + return { + ...mapAuthUser(row), + createdBy: row.created_by + } +} + +function mapUserAuthRecord(row: DbUserRow): UserAuthRecord { + return { + ...mapAuthUser(row), + passwordHash: row.password_hash, + createdBy: row.created_by + } +} + +function mapPasskeyRecord(row: DbPasskeyRow): PasskeyRecord { + return { + id: row.id, + userId: row.user_id, + credentialId: row.credential_id, + publicKey: row.public_key, + counter: parseCounter(row.counter), + deviceType: row.device_type, + backedUp: row.backed_up, + transports: parseTransports(row.transports), + label: row.label, + createdAt: toIsoString(row.created_at) ?? new Date().toISOString(), + lastUsedAt: toIsoString(row.last_used_at) + } +} + +function mapPasskeySummary(row: DbPasskeyRow): PasskeySummary { + const record = mapPasskeyRecord(row) + + return { + id: record.id, + label: record.label, + createdAt: record.createdAt, + lastUsedAt: record.lastUsedAt, + deviceType: record.deviceType, + backedUp: record.backedUp + } +} + +export async function getUserById(userId: string): Promise { + await ensureDatabaseReady() + const sql = getSqlClient() + + const [row] = await sql` + select + users.id, + users.username, + users.full_name, + users.phone_number, + users.role, + users.password_hash, + users.must_change_password, + users.is_active, + users.created_by, + users.created_at, + users.last_login_at, + coalesce(passkey_totals.passkey_count, 0) as passkey_count + from users + left join ( + select user_id, count(*)::int as passkey_count + from user_passkeys + group by user_id + ) as passkey_totals on passkey_totals.user_id = users.id + where users.id = ${userId} + limit 1 + ` + + return row ? mapUserAuthRecord(row) : null +} + +export async function getUserByUsername(username: string): Promise { + await ensureDatabaseReady() + const sql = getSqlClient() + + const [row] = await sql` + select + users.id, + users.username, + users.full_name, + users.phone_number, + users.role, + users.password_hash, + users.must_change_password, + users.is_active, + users.created_by, + users.created_at, + users.last_login_at, + coalesce(passkey_totals.passkey_count, 0) as passkey_count + from users + left join ( + select user_id, count(*)::int as passkey_count + from user_passkeys + group by user_id + ) as passkey_totals on passkey_totals.user_id = users.id + where users.username = ${username} + limit 1 + ` + + return row ? mapUserAuthRecord(row) : null +} + +export async function listUsers(): Promise { + await ensureDatabaseReady() + const sql = getSqlClient() + + const rows = await sql` + select + users.id, + users.username, + users.full_name, + users.phone_number, + users.role, + users.password_hash, + users.must_change_password, + users.is_active, + users.created_by, + users.created_at, + users.last_login_at, + coalesce(passkey_totals.passkey_count, 0) as passkey_count + from users + left join ( + select user_id, count(*)::int as passkey_count + from user_passkeys + group by user_id + ) as passkey_totals on passkey_totals.user_id = users.id + order by + case when users.role = 'super_admin' then 0 else 1 end, + users.created_at asc + ` + + return rows.map(mapManagedUser) +} + +export async function listPublicContacts(): Promise { + await ensureDatabaseReady() + const sql = getSqlClient() + + const rows = await sql[]>` + select + users.id, + users.full_name, + users.phone_number, + users.role + from users + where users.is_active = true + and users.phone_number is not null + and users.phone_number <> '' + order by + case when users.role = 'super_admin' then 0 else 1 end, + users.full_name asc + ` + + return rows.map((row) => ({ + id: row.id, + fullName: row.full_name, + phoneNumber: row.phone_number || '', + role: row.role + })) +} + +export async function createUser(input: { + username: string + fullName: string + phoneNumber: string + role: UserRole + passwordHash: string + createdBy: string +}): Promise { + await ensureDatabaseReady() + const sql = getSqlClient() + + const [row] = await sql` + insert into users ( + id, + username, + full_name, + phone_number, + role, + password_hash, + must_change_password, + is_active, + created_by + ) + values ( + ${randomUUID()}, + ${input.username}, + ${input.fullName}, + ${input.phoneNumber}, + ${input.role}, + ${input.passwordHash}, + true, + true, + ${input.createdBy} + ) + returning + id, + username, + full_name, + phone_number, + role, + password_hash, + must_change_password, + is_active, + created_by, + created_at, + last_login_at, + 0::int as passkey_count + ` + + return mapUserAuthRecord(row) +} + +export async function updateUserProfile(input: { + userId: string + fullName: string + phoneNumber: string + role: UserRole +}) { + await ensureDatabaseReady() + const sql = getSqlClient() + + await sql` + update users + set + full_name = ${input.fullName}, + phone_number = ${input.phoneNumber}, + role = ${input.role}, + updated_at = now() + where id = ${input.userId} + ` + + return getUserById(input.userId) +} + +export async function updateUserPassword(input: { + userId: string + passwordHash: string + mustChangePassword: boolean +}) { + await ensureDatabaseReady() + const sql = getSqlClient() + + await sql` + update users + set + password_hash = ${input.passwordHash}, + must_change_password = ${input.mustChangePassword}, + updated_at = now() + where id = ${input.userId} + ` +} + +export async function updateLastLogin(userId: string) { + await ensureDatabaseReady() + const sql = getSqlClient() + + await sql` + update users + set + last_login_at = now(), + updated_at = now() + where id = ${userId} + ` +} + +export async function listUserPasskeys(userId: string): Promise { + await ensureDatabaseReady() + const sql = getSqlClient() + + const rows = await sql` + select + id, + user_id, + credential_id, + public_key, + counter, + device_type, + backed_up, + transports, + label, + created_at, + last_used_at + from user_passkeys + where user_id = ${userId} + order by created_at asc + ` + + return rows.map(mapPasskeySummary) +} + +export async function getCredentialForVerification(credentialId: string): Promise { + await ensureDatabaseReady() + const sql = getSqlClient() + + const [row] = await sql` + select + id, + user_id, + credential_id, + public_key, + counter, + device_type, + backed_up, + transports, + label, + created_at, + last_used_at + from user_passkeys + where credential_id = ${credentialId} + limit 1 + ` + + return row ? mapPasskeyRecord(row) : null +} + +export async function listCredentialDescriptors(userId: string) { + await ensureDatabaseReady() + const sql = getSqlClient() + + const rows = await sql[]>` + select credential_id, transports + from user_passkeys + where user_id = ${userId} + order by created_at asc + ` + + return rows.map((row) => ({ + id: row.credential_id, + transports: parseTransports(row.transports) + })) +} + +export async function createUserPasskey(input: { + userId: string + credentialId: string + publicKey: Uint8Array + counter: number + deviceType: CredentialDeviceType + backedUp: boolean + transports: AuthenticatorTransportFuture[] + label: string +}) { + await ensureDatabaseReady() + const sql = getSqlClient() + + await sql` + insert into user_passkeys ( + id, + user_id, + credential_id, + public_key, + counter, + device_type, + backed_up, + transports, + label + ) + values ( + ${randomUUID()}, + ${input.userId}, + ${input.credentialId}, + ${encodeBase64Url(input.publicKey)}, + ${input.counter}, + ${input.deviceType}, + ${input.backedUp}, + ${sql.json(input.transports)}, + ${input.label} + ) + ` +} + +export async function updateCredentialCounter(input: { + credentialId: string + counter: number + deviceType: CredentialDeviceType + backedUp: boolean +}) { + await ensureDatabaseReady() + const sql = getSqlClient() + + await sql` + update user_passkeys + set + counter = ${input.counter}, + device_type = ${input.deviceType}, + backed_up = ${input.backedUp}, + last_used_at = now() + where credential_id = ${input.credentialId} + ` +} diff --git a/server/utils/webauthn.ts b/server/utils/webauthn.ts new file mode 100644 index 0000000..7ee4a2d --- /dev/null +++ b/server/utils/webauthn.ts @@ -0,0 +1,104 @@ +import type { H3Event } from 'h3' + +import type { AuthenticatorTransportFuture, WebAuthnCredential } from '@simplewebauthn/server' + +import { getRequestURL } from 'h3' + +import { decodeBase64Url, randomToken } from './base64url' +import { getRedisClient } from './redis' +import type { PasskeyRecord } from './user-repository' + +const CHALLENGE_TTL_SECONDS = 60 * 5 + +function getAppOrigin(event: H3Event) { + const config = useRuntimeConfig() + + if (config.public.appUrl) { + return new URL(config.public.appUrl).origin + } + + const url = getRequestURL(event) + return `${url.protocol}//${url.host}` +} + +export function getWebAuthnConfig(event: H3Event) { + const config = useRuntimeConfig() + const origin = getAppOrigin(event) + const originUrl = new URL(origin) + + return { + origin, + rpID: originUrl.hostname, + rpName: config.public.rpName || 'Dinner Ticket System' + } +} + +function getRegistrationChallengeKey(userId: string) { + return `webauthn:register:${userId}` +} + +function getLoginChallengeKey(token: string) { + return `webauthn:login:${token}` +} + +export async function storeRegistrationChallenge(userId: string, challenge: string) { + const redis = await getRedisClient() + + await redis.set(getRegistrationChallengeKey(userId), challenge, { + expiration: { + type: 'EX', + value: CHALLENGE_TTL_SECONDS + } + }) +} + +export async function consumeRegistrationChallenge(userId: string) { + const redis = await getRedisClient() + const key = getRegistrationChallengeKey(userId) + const challenge = await redis.get(key) + + if (challenge) { + await redis.del(key) + } + + return challenge +} + +export async function createLoginChallenge(challenge: string) { + const token = randomToken(24) + const redis = await getRedisClient() + + await redis.set(getLoginChallengeKey(token), challenge, { + expiration: { + type: 'EX', + value: CHALLENGE_TTL_SECONDS + } + }) + + return token +} + +export async function consumeLoginChallenge(token: string) { + const redis = await getRedisClient() + const key = getLoginChallengeKey(token) + const challenge = await redis.get(key) + + if (challenge) { + await redis.del(key) + } + + return challenge +} + +export function toWebAuthnCredential(passkey: PasskeyRecord): WebAuthnCredential { + return { + id: passkey.credentialId, + publicKey: decodeBase64Url(passkey.publicKey), + counter: passkey.counter, + transports: passkey.transports as AuthenticatorTransportFuture[] + } +} + +export function buildPasskeyLabel() { + return `Passkey ${new Date().toISOString().slice(0, 16).replace('T', ' ')}` +} diff --git a/shared/auth.ts b/shared/auth.ts new file mode 100644 index 0000000..cf02cf0 --- /dev/null +++ b/shared/auth.ts @@ -0,0 +1,57 @@ +export const DEFAULT_USER_PASSWORD = '123456' +export const MIN_PASSWORD_LENGTH = 8 +export const USERNAME_PATTERN = /^[a-z0-9._-]{3,32}$/ +export const PHONE_NUMBER_PATTERN = /^\+?\d{8,15}$/ + +export type UserRole = 'super_admin' | 'staff' + +export function normalizePhoneNumber(value: string) { + const trimmed = value.trim() + + if (!trimmed) { + return '' + } + + const hasPlusPrefix = trimmed.startsWith('+') + const digitsOnly = trimmed.replace(/\D/g, '') + + return hasPlusPrefix ? `+${digitsOnly}` : digitsOnly +} + +export function isValidPhoneNumber(value: string) { + return PHONE_NUMBER_PATTERN.test(normalizePhoneNumber(value)) +} + +export interface AuthUser { + id: string + username: string + fullName: string + phoneNumber: string | null + role: UserRole + isActive: boolean + mustChangePassword: boolean + needsPasskeySetup: boolean + passkeyCount: number + createdAt: string + lastLoginAt: string | null +} + +export interface ManagedUser extends AuthUser { + createdBy: string | null +} + +export interface PublicContact { + id: string + fullName: string + phoneNumber: string + role: UserRole +} + +export interface PasskeySummary { + id: string + label: string + createdAt: string + lastUsedAt: string | null + deviceType: 'singleDevice' | 'multiDevice' + backedUp: boolean +}