From ee9807e8e0ca8cc5ed08193eec8de877f835f718 Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Mon, 19 Jan 2026 15:53:05 -0700 Subject: [PATCH] Passkeys! --- package.json | 2 + pnpm-lock.yaml | 211 +++++++++++- src/components/MemberChart.vue | 72 ++-- src/components/settings/ApiTokenManager.vue | 222 ++++++++++++ src/components/settings/PasskeyManager.vue | 157 +++++++++ src/components/settings/PasswordForm.vue | 132 ++++++++ src/components/settings/ProfileForm.vue | 103 ++++++ src/db/schema.ts | 33 ++ src/pages/api/auth/passkey/delete/index.ts | 35 ++ src/pages/api/auth/passkey/login/finish.ts | 102 ++++++ src/pages/api/auth/passkey/login/start.ts | 18 + src/pages/api/auth/passkey/register/finish.ts | 81 +++++ src/pages/api/auth/passkey/register/start.ts | 45 +++ src/pages/api/user/change-password.ts | 85 +++-- src/pages/api/user/tokens/index.ts | 12 +- src/pages/api/user/update-profile.ts | 52 ++- src/pages/dashboard/settings.astro | 317 ++---------------- src/pages/login.astro | 39 +++ 18 files changed, 1358 insertions(+), 360 deletions(-) create mode 100644 src/components/settings/ApiTokenManager.vue create mode 100644 src/components/settings/PasskeyManager.vue create mode 100644 src/components/settings/PasswordForm.vue create mode 100644 src/components/settings/ProfileForm.vue create mode 100644 src/pages/api/auth/passkey/delete/index.ts create mode 100644 src/pages/api/auth/passkey/login/finish.ts create mode 100644 src/pages/api/auth/passkey/login/start.ts create mode 100644 src/pages/api/auth/passkey/register/finish.ts create mode 100644 src/pages/api/auth/passkey/register/start.ts diff --git a/package.json b/package.json index dcbac49..7981a25 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "@ceereals/vue-pdf": "^0.2.1", "@iconify/vue": "^5.0.0", "@libsql/client": "^0.17.0", + "@simplewebauthn/browser": "^13.2.2", + "@simplewebauthn/server": "^13.2.2", "@tailwindcss/vite": "^4.1.18", "astro": "^5.16.11", "astro-icon": "^1.1.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 445a344..9b9664a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,12 @@ importers: '@libsql/client': specifier: ^0.17.0 version: 0.17.0 + '@simplewebauthn/browser': + specifier: ^13.2.2 + version: 13.2.2 + '@simplewebauthn/server': + specifier: ^13.2.2 + version: 13.2.2 '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@6.4.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2)) @@ -624,6 +630,9 @@ packages: cpu: [x64] os: [win32] + '@hexagon/base64@1.1.28': + resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} + '@iconify-json/heroicons@1.2.3': resolution: {integrity: sha512-n+vmCEgTesRsOpp5AB5ILB6srsgsYK+bieoQBNlafvoEhjVXLq8nIGN4B0v/s4DUfa0dOrjwE/cKJgIKdJXOEg==} @@ -801,6 +810,9 @@ packages: '@kurkle/color@0.3.4': resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@levischuck/tiny-cbor@0.2.11': + resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} + '@libsql/client@0.17.0': resolution: {integrity: sha512-TLjSU9Otdpq0SpKHl1tD1Nc9MKhrsZbCFGot3EbCxRa8m1E5R1mMwoOjKMMM31IyF7fr+hPNHLpYfwbMKNusmg==} @@ -864,6 +876,43 @@ packages: '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + '@peculiar/asn1-android@2.6.0': + resolution: {integrity: sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==} + + '@peculiar/asn1-cms@2.6.0': + resolution: {integrity: sha512-2uZqP+ggSncESeUF/9Su8rWqGclEfEiz1SyU02WX5fUONFfkjzS2Z/F1Li0ofSmf4JqYXIOdCAZqIXAIBAT1OA==} + + '@peculiar/asn1-csr@2.6.0': + resolution: {integrity: sha512-BeWIu5VpTIhfRysfEp73SGbwjjoLL/JWXhJ/9mo4vXnz3tRGm+NGm3KNcRzQ9VMVqwYS2RHlolz21svzRXIHPQ==} + + '@peculiar/asn1-ecc@2.6.0': + resolution: {integrity: sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw==} + + '@peculiar/asn1-pfx@2.6.0': + resolution: {integrity: sha512-rtUvtf+tyKGgokHHmZzeUojRZJYPxoD/jaN1+VAB4kKR7tXrnDCA/RAWXAIhMJJC+7W27IIRGe9djvxKgsldCQ==} + + '@peculiar/asn1-pkcs8@2.6.0': + resolution: {integrity: sha512-KyQ4D8G/NrS7Fw3XCJrngxmjwO/3htnA0lL9gDICvEQ+GJ+EPFqldcJQTwPIdvx98Tua+WjkdKHSC0/Km7T+lA==} + + '@peculiar/asn1-pkcs9@2.6.0': + resolution: {integrity: sha512-b78OQ6OciW0aqZxdzliXGYHASeCvvw5caqidbpQRYW2mBtXIX2WhofNXTEe7NyxTb0P6J62kAAWLwn0HuMF1Fw==} + + '@peculiar/asn1-rsa@2.6.0': + resolution: {integrity: sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w==} + + '@peculiar/asn1-schema@2.6.0': + resolution: {integrity: sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==} + + '@peculiar/asn1-x509-attr@2.6.0': + resolution: {integrity: sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA==} + + '@peculiar/asn1-x509@2.6.0': + resolution: {integrity: sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==} + + '@peculiar/x509@1.14.3': + resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} + engines: {node: '>=20.0.0'} + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -1061,6 +1110,13 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@simplewebauthn/browser@13.2.2': + resolution: {integrity: sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==} + + '@simplewebauthn/server@13.2.2': + resolution: {integrity: sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA==} + engines: {node: '>=20.0.0'} + '@sindresorhus/merge-streams@4.0.0': resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} @@ -1374,6 +1430,10 @@ packages: array-iterate@2.0.1: resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} + asn1js@3.0.7: + resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==} + engines: {node: '>=12.0.0'} + astro-icon@1.1.5: resolution: {integrity: sha512-CJYS5nWOw9jz4RpGWmzNQY7D0y2ZZacH7atL2K9DeJXJVaz7/5WrxeyIxO8KASk1jCM96Q4LjRx/F3R+InjJrw==} @@ -2306,7 +2366,6 @@ packages: libsql@0.5.22: resolution: {integrity: sha512-NscWthMQt7fpU8lqd7LXMvT9pi+KhhmTHAJWUB/Lj6MWa0MKFv0F2V4C6WKKpjCVZl0VwcDz4nOI3CyaT1DDiA==} - cpu: [x64, arm64, wasm32, arm] os: [darwin, linux, win32] lightningcss-android-arm64@1.30.2: @@ -2815,6 +2874,13 @@ packages: pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + 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==} @@ -2844,6 +2910,9 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -3138,9 +3207,16 @@ packages: typescript: optional: true + 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'} + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -4154,6 +4230,8 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true + '@hexagon/base64@1.1.28': {} + '@iconify-json/heroicons@1.2.3': dependencies: '@iconify/types': 2.0.0 @@ -4314,6 +4392,8 @@ snapshots: '@kurkle/color@0.3.4': {} + '@levischuck/tiny-cbor@0.2.11': {} + '@libsql/client@0.17.0': dependencies: '@libsql/core': 0.17.0 @@ -4380,6 +4460,102 @@ snapshots: '@oslojs/encoding@1.1.0': {} + '@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.0': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.0 + '@peculiar/asn1-x509-attr': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-csr@2.6.0': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.6.0': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pfx@2.6.0': + dependencies: + '@peculiar/asn1-cms': 2.6.0 + '@peculiar/asn1-pkcs8': 2.6.0 + '@peculiar/asn1-rsa': 2.6.0 + '@peculiar/asn1-schema': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs8@2.6.0': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs9@2.6.0': + dependencies: + '@peculiar/asn1-cms': 2.6.0 + '@peculiar/asn1-pfx': 2.6.0 + '@peculiar/asn1-pkcs8': 2.6.0 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.0 + '@peculiar/asn1-x509-attr': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.6.0': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.0 + 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.0': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.6.0': + 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.0 + '@peculiar/asn1-csr': 2.6.0 + '@peculiar/asn1-ecc': 2.6.0 + '@peculiar/asn1-pkcs9': 2.6.0 + '@peculiar/asn1-rsa': 2.6.0 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.0 + pvtsutils: 1.3.6 + reflect-metadata: 0.2.2 + tslib: 2.8.1 + tsyringe: 4.10.0 + '@polka/url@1.0.0-next.29': {} '@react-pdf/fns@3.1.2': {} @@ -4580,6 +4756,19 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@simplewebauthn/browser@13.2.2': {} + + '@simplewebauthn/server@13.2.2': + dependencies: + '@hexagon/base64': 1.1.28 + '@levischuck/tiny-cbor': 0.2.11 + '@peculiar/asn1-android': 2.6.0 + '@peculiar/asn1-ecc': 2.6.0 + '@peculiar/asn1-rsa': 2.6.0 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.0 + '@peculiar/x509': 1.14.3 + '@sindresorhus/merge-streams@4.0.0': {} '@swc/helpers@0.5.18': @@ -4948,6 +5137,12 @@ snapshots: array-iterate@2.0.1: {} + asn1js@3.0.7: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 + astro-icon@1.1.5: dependencies: '@iconify/tools': 4.2.0 @@ -6644,6 +6839,12 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.1.5: {} + quansync@0.2.11: {} queue@6.0.2: @@ -6673,6 +6874,8 @@ snapshots: readdirp@5.0.0: {} + reflect-metadata@0.2.2: {} + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -7074,8 +7277,14 @@ snapshots: optionalDependencies: typescript: 5.9.3 + tslib@1.14.1: {} + tslib@2.8.1: {} + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 diff --git a/src/components/MemberChart.vue b/src/components/MemberChart.vue index 05c89b6..84e56ce 100644 --- a/src/components/MemberChart.vue +++ b/src/components/MemberChart.vue @@ -1,12 +1,12 @@ diff --git a/src/components/settings/ApiTokenManager.vue b/src/components/settings/ApiTokenManager.vue new file mode 100644 index 0000000..5e5939b --- /dev/null +++ b/src/components/settings/ApiTokenManager.vue @@ -0,0 +1,222 @@ + + + diff --git a/src/components/settings/PasskeyManager.vue b/src/components/settings/PasskeyManager.vue new file mode 100644 index 0000000..f6ca65b --- /dev/null +++ b/src/components/settings/PasskeyManager.vue @@ -0,0 +1,157 @@ + + + diff --git a/src/components/settings/PasswordForm.vue b/src/components/settings/PasswordForm.vue new file mode 100644 index 0000000..56d3eb8 --- /dev/null +++ b/src/components/settings/PasswordForm.vue @@ -0,0 +1,132 @@ + + + diff --git a/src/components/settings/ProfileForm.vue b/src/components/settings/ProfileForm.vue new file mode 100644 index 0000000..dbb60a4 --- /dev/null +++ b/src/components/settings/ProfileForm.vue @@ -0,0 +1,103 @@ + + + diff --git a/src/db/schema.ts b/src/db/schema.ts index 5bb2ea2..7022f1f 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -323,3 +323,36 @@ export const invoiceItems = sqliteTable( invoiceIdIdx: index("invoice_items_invoice_id_idx").on(table.invoiceId), }), ); + +export const passkeys = sqliteTable( + "passkeys", + { + id: text("id").primaryKey(), // The Credential ID + userId: text("user_id").notNull(), + publicKey: text("public_key").notNull(), // Base64 encoded public key + counter: integer("counter").notNull(), + deviceType: text("device_type").notNull(), // 'singleDevice' or 'multiDevice' + backedUp: integer("backed_up", { mode: "boolean" }).notNull(), + transports: text("transports"), // JSON stringified array + lastUsedAt: integer("last_used_at", { mode: "timestamp" }), + createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn( + () => new Date(), + ), + }, + (table: any) => ({ + userFk: foreignKey({ + columns: [table.userId], + foreignColumns: [users.id], + }), + userIdIdx: index("passkeys_user_id_idx").on(table.userId), + }), +); + +export const passkeyChallenges = sqliteTable("passkey_challenges", { + id: text("id") + .primaryKey() + .$defaultFn(() => nanoid()), + challenge: text("challenge").notNull().unique(), + userId: text("user_id"), + expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), +}); diff --git a/src/pages/api/auth/passkey/delete/index.ts b/src/pages/api/auth/passkey/delete/index.ts new file mode 100644 index 0000000..0c689f8 --- /dev/null +++ b/src/pages/api/auth/passkey/delete/index.ts @@ -0,0 +1,35 @@ +import type { APIRoute } from "astro"; +import { db } from "../../../../../db"; +import { passkeys } from "../../../../../db/schema"; +import { eq, and } from "drizzle-orm"; + +export const DELETE: APIRoute = async ({ request, locals }) => { + const user = locals.user; + + if (!user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + }); + } + + const url = new URL(request.url); + const id = url.searchParams.get("id"); + + if (!id) { + return new Response(JSON.stringify({ error: "Passkey ID is required" }), { + status: 400, + }); + } + + try { + await db + .delete(passkeys) + .where(and(eq(passkeys.id, id), eq(passkeys.userId, user.id))); + + return new Response(JSON.stringify({ success: true })); + } catch (error) { + return new Response(JSON.stringify({ error: "Failed to delete passkey" }), { + status: 500, + }); + } +}; diff --git a/src/pages/api/auth/passkey/login/finish.ts b/src/pages/api/auth/passkey/login/finish.ts new file mode 100644 index 0000000..dda86ae --- /dev/null +++ b/src/pages/api/auth/passkey/login/finish.ts @@ -0,0 +1,102 @@ +import type { APIRoute } from "astro"; +import { verifyAuthenticationResponse } from "@simplewebauthn/server"; +import { db } from "../../../../../db"; +import { users, passkeys, passkeyChallenges } from "../../../../../db/schema"; +import { eq, and, gt } from "drizzle-orm"; +import { createSession } from "../../../../../lib/auth"; + +export const POST: APIRoute = async ({ request, cookies }) => { + const body = await request.json(); + const { id } = body; + + const passkey = await db.query.passkeys.findFirst({ + where: eq(passkeys.id, id), + }); + + if (!passkey) { + return new Response(JSON.stringify({ error: "Passkey not found" }), { + status: 400, + }); + } + + const user = await db.query.users.findFirst({ + where: eq(users.id, passkey.userId), + }); + + if (!user) return new Response(null, { status: 400 }); + + const clientDataJSON = Buffer.from( + body.response.clientDataJSON, + "base64url", + ).toString("utf-8"); + const clientData = JSON.parse(clientDataJSON); + const challenge = clientData.challenge; + + const dbChallenge = await db.query.passkeyChallenges.findFirst({ + where: and( + eq(passkeyChallenges.challenge, challenge), + gt(passkeyChallenges.expiresAt, new Date()), + ), + }); + + if (!dbChallenge) { + return new Response( + JSON.stringify({ error: "Invalid or expired challenge" }), + { + status: 400, + }, + ); + } + + let verification; + try { + verification = await verifyAuthenticationResponse({ + response: body, + expectedChallenge: challenge as string, + expectedOrigin: new URL(request.url).origin, + expectedRPID: new URL(request.url).hostname, + credential: { + id: passkey.id, + publicKey: new Uint8Array(Buffer.from(passkey.publicKey, "base64")), + counter: passkey.counter, + transports: passkey.transports + ? JSON.parse(passkey.transports) + : undefined, + }, + }); + } catch (error) { + return new Response(JSON.stringify({ error: (error as Error).message }), { + status: 400, + }); + } + + if (verification.verified) { + const { authenticationInfo } = verification; + + await db + .update(passkeys) + .set({ + counter: authenticationInfo.newCounter, + lastUsedAt: new Date(), + }) + .where(eq(passkeys.id, passkey.id)); + + const { sessionId, expiresAt } = await createSession(user.id); + + cookies.set("session_id", sessionId, { + path: "/", + httpOnly: true, + secure: import.meta.env.PROD, + sameSite: "lax", + expires: expiresAt, + }); + + await db + .delete(passkeyChallenges) + .where(eq(passkeyChallenges.challenge, challenge)); + + return new Response(JSON.stringify({ verified: true })); + } + + return new Response(JSON.stringify({ verified: false }), { status: 400 }); +}; diff --git a/src/pages/api/auth/passkey/login/start.ts b/src/pages/api/auth/passkey/login/start.ts new file mode 100644 index 0000000..81697cc --- /dev/null +++ b/src/pages/api/auth/passkey/login/start.ts @@ -0,0 +1,18 @@ +import type { APIRoute } from "astro"; +import { generateAuthenticationOptions } from "@simplewebauthn/server"; +import { db } from "../../../../../db"; +import { passkeyChallenges } from "../../../../../db/schema"; + +export const GET: APIRoute = async ({ request }) => { + const options = await generateAuthenticationOptions({ + rpID: new URL(request.url).hostname, + userVerification: "preferred", + }); + + await db.insert(passkeyChallenges).values({ + challenge: options.challenge, + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + }); + + return new Response(JSON.stringify(options)); +}; diff --git a/src/pages/api/auth/passkey/register/finish.ts b/src/pages/api/auth/passkey/register/finish.ts new file mode 100644 index 0000000..c933342 --- /dev/null +++ b/src/pages/api/auth/passkey/register/finish.ts @@ -0,0 +1,81 @@ +import type { APIRoute } from "astro"; +import { verifyRegistrationResponse } from "@simplewebauthn/server"; +import { db } from "../../../../../db"; +import { passkeys, passkeyChallenges } from "../../../../../db/schema"; +import { eq, and, gt } from "drizzle-orm"; + +export const POST: APIRoute = async ({ request, locals }) => { + const user = locals.user; + + if (!user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + }); + } + + const body = await request.json(); + + const clientDataJSON = Buffer.from( + body.response.clientDataJSON, + "base64url", + ).toString("utf-8"); + const clientData = JSON.parse(clientDataJSON); + const challenge = clientData.challenge; + + const dbChallenge = await db.query.passkeyChallenges.findFirst({ + where: and( + eq(passkeyChallenges.challenge, challenge), + eq(passkeyChallenges.userId, user.id), + gt(passkeyChallenges.expiresAt, new Date()), + ), + }); + + if (!dbChallenge) { + return new Response( + JSON.stringify({ error: "Invalid or expired challenge" }), + { + status: 400, + }, + ); + } + + let verification; + try { + verification = await verifyRegistrationResponse({ + response: body, + expectedChallenge: challenge, + expectedOrigin: new URL(request.url).origin, + expectedRPID: new URL(request.url).hostname, + }); + } catch (error) { + return new Response(JSON.stringify({ error: (error as Error).message }), { + status: 400, + }); + } + + if (verification.verified && verification.registrationInfo) { + const { registrationInfo } = verification; + const { credential, credentialDeviceType, credentialBackedUp } = + registrationInfo; + + await db.insert(passkeys).values({ + id: credential.id, + userId: user.id, + publicKey: Buffer.from(credential.publicKey).toString("base64"), + counter: credential.counter, + deviceType: credentialDeviceType, + backedUp: credentialBackedUp, + transports: body.response.transports + ? JSON.stringify(body.response.transports) + : undefined, + }); + + await db + .delete(passkeyChallenges) + .where(eq(passkeyChallenges.challenge, challenge)); + + return new Response(JSON.stringify({ verified: true })); + } + + return new Response(JSON.stringify({ verified: false }), { status: 400 }); +}; diff --git a/src/pages/api/auth/passkey/register/start.ts b/src/pages/api/auth/passkey/register/start.ts new file mode 100644 index 0000000..d36eb81 --- /dev/null +++ b/src/pages/api/auth/passkey/register/start.ts @@ -0,0 +1,45 @@ +import type { APIRoute } from "astro"; +import { generateRegistrationOptions } from "@simplewebauthn/server"; +import { db } from "../../../../../db"; +import { passkeys, passkeyChallenges } from "../../../../../db/schema"; +import { eq } from "drizzle-orm"; + +export const GET: APIRoute = async ({ request, locals }) => { + const user = locals.user; + + if (!user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + }); + } + + // Get user's existing passkeys to prevent registering the same authenticator twice + const userPasskeys = await db.query.passkeys.findMany({ + where: eq(passkeys.userId, user.id), + }); + + const options = await generateRegistrationOptions({ + rpName: "Chronus", + rpID: new URL(request.url).hostname, + userName: user.email, + attestationType: "none", + excludeCredentials: userPasskeys.map((passkey) => ({ + id: passkey.id, + transports: passkey.transports + ? JSON.parse(passkey.transports) + : undefined, + })), + authenticatorSelection: { + residentKey: "preferred", + userVerification: "preferred", + }, + }); + + await db.insert(passkeyChallenges).values({ + challenge: options.challenge, + userId: user.id, + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + }); + + return new Response(JSON.stringify(options)); +}; diff --git a/src/pages/api/user/change-password.ts b/src/pages/api/user/change-password.ts index 824435f..a142b37 100644 --- a/src/pages/api/user/change-password.ts +++ b/src/pages/api/user/change-password.ts @@ -1,61 +1,104 @@ -import type { APIRoute } from 'astro'; -import { db } from '../../../db'; -import { users } from '../../../db/schema'; -import { eq } from 'drizzle-orm'; -import bcrypt from 'bcryptjs'; +import type { APIRoute } from "astro"; +import { db } from "../../../db"; +import { users } from "../../../db/schema"; +import { eq } from "drizzle-orm"; +import bcrypt from "bcryptjs"; export const POST: APIRoute = async ({ request, locals, redirect }) => { const user = locals.user; + const contentType = request.headers.get("content-type"); + const isJson = contentType?.includes("application/json"); + if (!user) { - return redirect('/login'); + if (isJson) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + }); + } + return redirect("/login"); } - const formData = await request.formData(); - const currentPassword = formData.get('currentPassword') as string; - const newPassword = formData.get('newPassword') as string; - const confirmPassword = formData.get('confirmPassword') as string; + let currentPassword, newPassword, confirmPassword; + + if (isJson) { + const body = await request.json(); + currentPassword = body.currentPassword; + newPassword = body.newPassword; + confirmPassword = body.confirmPassword; + } else { + const formData = await request.formData(); + currentPassword = formData.get("currentPassword") as string; + newPassword = formData.get("newPassword") as string; + confirmPassword = formData.get("confirmPassword") as string; + } if (!currentPassword || !newPassword || !confirmPassword) { - return new Response('All fields are required', { status: 400 }); + const msg = "All fields are required"; + if (isJson) + return new Response(JSON.stringify({ error: msg }), { status: 400 }); + return new Response(msg, { status: 400 }); } if (newPassword !== confirmPassword) { - return new Response('New passwords do not match', { status: 400 }); + const msg = "New passwords do not match"; + if (isJson) + return new Response(JSON.stringify({ error: msg }), { status: 400 }); + return new Response(msg, { status: 400 }); } if (newPassword.length < 8) { - return new Response('Password must be at least 8 characters', { status: 400 }); + const msg = "Password must be at least 8 characters"; + if (isJson) + return new Response(JSON.stringify({ error: msg }), { status: 400 }); + return new Response(msg, { status: 400 }); } try { // Get current user from database - const dbUser = await db.select() + const dbUser = await db + .select() .from(users) .where(eq(users.id, user.id)) .get(); if (!dbUser) { - return new Response('User not found', { status: 404 }); + const msg = "User not found"; + if (isJson) + return new Response(JSON.stringify({ error: msg }), { status: 404 }); + return new Response(msg, { status: 404 }); } // Verify current password - const passwordMatch = await bcrypt.compare(currentPassword, dbUser.passwordHash); + const passwordMatch = await bcrypt.compare( + currentPassword, + dbUser.passwordHash, + ); if (!passwordMatch) { - return new Response('Current password is incorrect', { status: 400 }); + const msg = "Current password is incorrect"; + if (isJson) + return new Response(JSON.stringify({ error: msg }), { status: 400 }); + return new Response(msg, { status: 400 }); } // Hash new password const hashedPassword = await bcrypt.hash(newPassword, 10); // Update password - await db.update(users) + await db + .update(users) .set({ passwordHash: hashedPassword }) .where(eq(users.id, user.id)) .run(); - return redirect('/dashboard/settings?success=password'); + if (isJson) { + return new Response(JSON.stringify({ success: true }), { status: 200 }); + } + return redirect("/dashboard/settings?success=password"); } catch (error) { - console.error('Error changing password:', error); - return new Response('Failed to change password', { status: 500 }); + console.error("Error changing password:", error); + const msg = "Failed to change password"; + if (isJson) + return new Response(JSON.stringify({ error: msg }), { status: 500 }); + return new Response(msg, { status: 500 }); } }; diff --git a/src/pages/api/user/tokens/index.ts b/src/pages/api/user/tokens/index.ts index b8c12b0..9fa1a7b 100644 --- a/src/pages/api/user/tokens/index.ts +++ b/src/pages/api/user/tokens/index.ts @@ -12,8 +12,16 @@ export const POST: APIRoute = async ({ request, locals }) => { }); } - const formData = await request.formData(); - const name = formData.get("name")?.toString(); + let name: string | undefined; + + const contentType = request.headers.get("content-type"); + if (contentType?.includes("application/json")) { + const body = await request.json(); + name = body.name; + } else { + const formData = await request.formData(); + name = formData.get("name")?.toString(); + } if (!name) { return new Response(JSON.stringify({ error: "Name is required" }), { diff --git a/src/pages/api/user/update-profile.ts b/src/pages/api/user/update-profile.ts index 78c969d..2dd068d 100644 --- a/src/pages/api/user/update-profile.ts +++ b/src/pages/api/user/update-profile.ts @@ -1,30 +1,58 @@ -import type { APIRoute } from 'astro'; -import { db } from '../../../db'; -import { users } from '../../../db/schema'; -import { eq } from 'drizzle-orm'; +import type { APIRoute } from "astro"; +import { db } from "../../../db"; +import { users } from "../../../db/schema"; +import { eq } from "drizzle-orm"; export const POST: APIRoute = async ({ request, locals, redirect }) => { const user = locals.user; + const contentType = request.headers.get("content-type"); + const isJson = contentType?.includes("application/json"); + if (!user) { - return redirect('/login'); + if (isJson) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + }); + } + return redirect("/login"); } - const formData = await request.formData(); - const name = formData.get('name') as string; + let name: string | undefined; + + if (isJson) { + const body = await request.json(); + name = body.name; + } else { + const formData = await request.formData(); + name = formData.get("name") as string; + } if (!name || name.trim().length === 0) { - return new Response('Name is required', { status: 400 }); + const msg = "Name is required"; + if (isJson) { + return new Response(JSON.stringify({ error: msg }), { status: 400 }); + } + return new Response(msg, { status: 400 }); } try { - await db.update(users) + await db + .update(users) .set({ name: name.trim() }) .where(eq(users.id, user.id)) .run(); - return redirect('/dashboard/settings?success=profile'); + if (isJson) { + return new Response(JSON.stringify({ success: true }), { status: 200 }); + } + + return redirect("/dashboard/settings?success=profile"); } catch (error) { - console.error('Error updating profile:', error); - return new Response('Failed to update profile', { status: 500 }); + console.error("Error updating profile:", error); + const msg = "Failed to update profile"; + if (isJson) { + return new Response(JSON.stringify({ error: msg }), { status: 500 }); + } + return new Response(msg, { status: 500 }); } }; diff --git a/src/pages/dashboard/settings.astro b/src/pages/dashboard/settings.astro index dbc5df7..9c2fe27 100644 --- a/src/pages/dashboard/settings.astro +++ b/src/pages/dashboard/settings.astro @@ -2,8 +2,12 @@ import DashboardLayout from '../../layouts/DashboardLayout.astro'; import { Icon } from 'astro-icon/components'; import { db } from '../../db'; -import { apiTokens } from '../../db/schema'; +import { apiTokens, passkeys } from '../../db/schema'; import { eq, desc } from 'drizzle-orm'; +import ProfileForm from '../../components/settings/ProfileForm.vue'; +import PasswordForm from '../../components/settings/PasswordForm.vue'; +import ApiTokenManager from '../../components/settings/ApiTokenManager.vue'; +import PasskeyManager from '../../components/settings/PasskeyManager.vue'; const user = Astro.locals.user; if (!user) return Astro.redirect('/login'); @@ -16,6 +20,12 @@ const userTokens = await db.select() .where(eq(apiTokens.userId, user.id)) .orderBy(desc(apiTokens.createdAt)) .all(); + +const userPasskeys = await db.select() + .from(passkeys) + .where(eq(passkeys.userId, user.id)) + .orderBy(desc(passkeys.createdAt)) + .all(); --- @@ -40,177 +50,25 @@ const userTokens = await db.select() )} -
-
-

- - Profile Information -

- -
-
- - -
- -
- - -
- Email cannot be changed -
-
- -
- -
-
-
-
+ -
-
-

- - Change Password -

+ -
-
- - -
- -
- - -
- Minimum 8 characters -
-
- -
- - -
- -
- -
-
-
-
+ + ({ + ...pk, + lastUsedAt: pk.lastUsedAt ? pk.lastUsedAt.toISOString() : null, + createdAt: pk.createdAt ? pk.createdAt.toISOString() : null + }))} /> -
-
-
-

- - API Tokens -

- -
+ ({ + ...t, + lastUsedAt: t.lastUsedAt ? t.lastUsedAt.toISOString() : null, + createdAt: t.createdAt ? t.createdAt.toISOString() : '' + }))} /> -
- - - - - - - - - - - {userTokens.length === 0 ? ( - - - - ) : ( - userTokens.map(token => ( - - - - - - - )) - )} - -
NameLast UsedCreatedActions
- No API tokens found. Create one to access the API. -
{token.name} - {token.lastUsedAt ? token.lastUsedAt.toLocaleDateString() : 'Never'} - - {token.createdAt ? token.createdAt.toLocaleDateString() : 'N/A'} - - -
-
-
-
- -

@@ -238,132 +96,5 @@ const userTokens = await db.select()

- - - - - - - - - - - -
diff --git a/src/pages/login.astro b/src/pages/login.astro index 9d628e2..2e9e20a 100644 --- a/src/pages/login.astro +++ b/src/pages/login.astro @@ -16,6 +16,40 @@ const errorMessage = --- +
@@ -60,6 +94,11 @@ const errorMessage = + +
OR