diff --git a/.node-version b/.node-version index 119f15a..7d1aef0 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -20.15.1 \ No newline at end of file +22.1.0 \ No newline at end of file diff --git a/package.json b/package.json index 325c70e..3b14949 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,10 @@ "eslint-plugin-svelte": "^2.43.0", "just-clone": "^6.2.0", "just-debounce-it": "^3.2.0", + "lucia": "3.2.0", + "lucide-svelte": "^0.408.0", + "nodemailer": "^6.9.14", + "oslo": "^1.2.1", "postcss": "^8.4.40", "postcss-import": "^16.1.0", "postcss-load-config": "^5.1.0", @@ -71,10 +75,6 @@ "zod": "^3.23.8" }, "type": "module", - "engines": { - "node": ">=18.0.0 <19.0.0 || >=20.0.0 <21.0.0", - "pnpm": ">=8" - }, "dependencies": { "@fontsource/fira-mono": "^5.0.13", "@hono/zod-validator": "^0.2.2", @@ -91,6 +91,7 @@ "arctic": "^1.9.2", "bits-ui": "^0.21.12", "boardgamegeekclient": "^1.9.1", + "bullmq": "^5.11.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cookie": "^0.6.0", @@ -99,6 +100,7 @@ "drizzle-orm": "^0.32.1", "feather-icons": "^4.29.2", "formsnap": "^1.0.1", + "handlebars": "^4.7.8", "hono": "^4.5.2", "hono-rate-limiter": "^0.4.0", "html-entities": "^2.5.2", @@ -107,10 +109,7 @@ "just-capitalize": "^3.2.0", "just-kebab-case": "^4.2.0", "loader": "^2.1.1", - "lucia": "3.2.0", - "lucide-svelte": "^0.408.0", "open-props": "^1.7.5", - "oslo": "^1.2.1", "pg": "^8.12.0", "postgres": "^3.4.4", "qrcode": "^1.5.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f36d828..632134c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: boardgamegeekclient: specifier: ^1.9.1 version: 1.9.1 + bullmq: + specifier: ^5.11.0 + version: 5.11.0 class-variance-authority: specifier: ^0.7.0 version: 0.7.0 @@ -77,6 +80,9 @@ importers: formsnap: specifier: ^1.0.1 version: 1.0.1(svelte@5.0.0-next.175)(sveltekit-superforms@2.16.1(@sveltejs/kit@2.5.18(@sveltejs/vite-plugin-svelte@3.1.1(svelte@5.0.0-next.175)(vite@5.3.5(@types/node@20.14.13)(sass@1.77.8)))(svelte@5.0.0-next.175)(vite@5.3.5(@types/node@20.14.13)(sass@1.77.8)))(svelte@5.0.0-next.175)) + handlebars: + specifier: ^4.7.8 + version: 4.7.8 hono: specifier: ^4.5.2 version: 4.5.2 @@ -101,18 +107,9 @@ importers: loader: specifier: ^2.1.1 version: 2.1.1 - lucia: - specifier: 3.2.0 - version: 3.2.0 - lucide-svelte: - specifier: ^0.408.0 - version: 0.408.0(svelte@5.0.0-next.175) open-props: specifier: ^1.7.5 version: 1.7.5 - oslo: - specifier: ^1.2.1 - version: 1.2.1 pg: specifier: ^8.12.0 version: 8.12.0 @@ -210,6 +207,18 @@ importers: just-debounce-it: specifier: ^3.2.0 version: 3.2.0 + lucia: + specifier: 3.2.0 + version: 3.2.0 + lucide-svelte: + specifier: ^0.408.0 + version: 0.408.0(svelte@5.0.0-next.175) + nodemailer: + specifier: ^6.9.14 + version: 6.9.14 + oslo: + specifier: ^1.2.1 + version: 1.2.1 postcss: specifier: ^8.4.40 version: 8.4.40 @@ -1386,6 +1395,36 @@ packages: peerDependencies: svelte: ^3.0.0 || ^4.0.0 || ^5.0.0-next.118 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + cpu: [x64] + os: [win32] + '@neondatabase/serverless@0.9.4': resolution: {integrity: sha512-D0AXgJh6xkf+XTlsO7iwE2Q1w8981E1cLCPAALMU2YKtkF/1SF6BiAzYARZFYo175ON+b1RNIy9TdSFHm5nteg==} @@ -2225,6 +2264,9 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + bullmq@5.11.0: + resolution: {integrity: sha512-qVzyWGZqie3VHaYEgRXhId/j8ebfmj6MExEJyUByMsUJA5pVciVle3hKLer5fyMwtQ8lTMP7GwhXV/NZ+HzlRA==} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -2357,6 +2399,10 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + cross-spawn@7.0.3: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} @@ -2943,6 +2989,11 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + handlebars@4.7.8: + resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + engines: {node: '>=0.4.7'} + hasBin: true + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -3205,6 +3256,10 @@ packages: peerDependencies: svelte: ^3 || ^4 || ^5.0.0-next.42 + luxon@3.4.4: + resolution: {integrity: sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==} + engines: {node: '>=12'} + magic-string@0.30.10: resolution: {integrity: sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==} @@ -3338,6 +3393,13 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.3: + resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + hasBin: true + + msgpackr@1.11.0: + resolution: {integrity: sha512-I8qXuuALqJe5laEBYoFykChhSXLikZmUhccjGsPuSJ/7uPip2TJ7lwdIQwWSAi0jGZDXv4WOP8Qg65QZRuXxXw==} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -3358,6 +3420,12 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -3367,6 +3435,10 @@ packages: encoding: optional: true + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-gyp-build@4.8.1: resolution: {integrity: sha512-OSs33Z9yWr148JZcbZd5WiAXhh/n9z8TxQcdMhIOlpN9AhWpLfvVFO73+m77bBABQMaY9XSvIa+qk0jlI7Gcaw==} hasBin: true @@ -3374,6 +3446,10 @@ packages: node-releases@2.0.14: resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} + nodemailer@6.9.14: + resolution: {integrity: sha512-Dobp/ebDKBvz91sbtRKhcznLThrKxKt97GI2FAlAyy+fk19j73Uz3sBXolVtmcXjaorivqsbbbjDY+Jkt4/bQA==} + engines: {node: '>=6.0.0'} + nopt@5.0.0: resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} engines: {node: '>=6'} @@ -4519,6 +4595,11 @@ packages: ufo@1.5.3: resolution: {integrity: sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==} + uglify-js@3.19.1: + resolution: {integrity: sha512-y/2wiW+ceTYR2TSSptAhfnEtpLaQ4Ups5zrjB2d3kuVxHj16j/QJwPl5PvuGy9uARb39J0+iKxcRPvtpsx4A4A==} + engines: {node: '>=0.8.0'} + hasBin: true + ultrahtml@1.5.3: resolution: {integrity: sha512-GykOvZwgDWZlTQMtp5jrD4BVL+gNn2NVlVafjcFUJ7taY20tqYdwdoWBFy6GBJsNTZe1GkGPkSl5knQAjtgceg==} @@ -4557,6 +4638,10 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -4670,6 +4755,9 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -5579,6 +5667,24 @@ snapshots: nanoid: 5.0.7 svelte: 5.0.0-next.175 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + optional: true + '@neondatabase/serverless@0.9.4': dependencies: '@types/pg': 8.11.6 @@ -6367,6 +6473,18 @@ snapshots: buffer-from@1.1.2: {} + bullmq@5.11.0: + dependencies: + cron-parser: 4.9.0 + ioredis: 5.4.1 + msgpackr: 1.11.0 + node-abort-controller: 3.1.1 + semver: 7.6.3 + tslib: 2.6.3 + uuid: 9.0.1 + transitivePeerDependencies: + - supports-color + bytes@3.1.2: {} cac@6.7.14: {} @@ -6493,6 +6611,10 @@ snapshots: create-require@1.1.1: {} + cron-parser@4.9.0: + dependencies: + luxon: 3.4.4 + cross-spawn@7.0.3: dependencies: path-key: 3.1.1 @@ -7127,6 +7249,15 @@ snapshots: graphemer@1.4.0: {} + handlebars@4.7.8: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.1 + has-flag@4.0.0: {} has-property-descriptors@1.0.2: @@ -7373,6 +7504,8 @@ snapshots: dependencies: svelte: 5.0.0-next.175 + luxon@3.4.4: {} + magic-string@0.30.10: dependencies: '@jridgewell/sourcemap-codec': 1.4.15 @@ -7481,6 +7614,22 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.3: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + optional: true + + msgpackr@1.11.0: + optionalDependencies: + msgpackr-extract: 3.0.3 + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -7495,14 +7644,25 @@ snapshots: negotiator@0.6.3: {} + neo-async@2.6.2: {} + + node-abort-controller@3.1.1: {} + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.0.3 + optional: true + node-gyp-build@4.8.1: {} node-releases@2.0.14: {} + nodemailer@6.9.14: {} + nopt@5.0.0: dependencies: abbrev: 1.1.1 @@ -8737,6 +8897,9 @@ snapshots: ufo@1.5.3: {} + uglify-js@3.19.1: + optional: true + ultrahtml@1.5.3: {} undici-types@5.26.5: {} @@ -8770,6 +8933,8 @@ snapshots: utils-merge@1.0.1: {} + uuid@9.0.1: {} + v8-compile-cache-lib@3.0.1: {} valibot@0.31.1: @@ -8878,6 +9043,8 @@ snapshots: word-wrap@1.2.5: {} + wordwrap@1.0.0: {} + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 diff --git a/src/lib/server/api/controllers/iam.controller.ts b/src/lib/server/api/controllers/iam.controller.ts index 372e240..35e6fc6 100644 --- a/src/lib/server/api/controllers/iam.controller.ts +++ b/src/lib/server/api/controllers/iam.controller.ts @@ -1,22 +1,21 @@ import { Hono } from 'hono'; -import { zValidator } from '@hono/zod-validator'; +import { injectable } from 'tsyringe'; +import type { HonoTypes } from '../types'; import { requireAuth } from "../middleware/auth.middleware"; -import { registerEmailPasswordDto } from '$lib/dtos/register-emailpassword.dto'; -import { limiter } from '../middleware/rate-limiter.middleware'; +import type { Controller } from '../interfaces/controller.interface'; -const app = new Hono() - .get('/me', requireAuth, async (c) => { - const user = c.var.user; - return c.json({ user }); - }) - .get('/user', requireAuth, async (c) => { - const user = c.var.user; - return c.json({ user }); - }) - .post('/login/request', zValidator('json', registerEmailPasswordDto), limiter({ limit: 10, minutes: 60 }), async (c) => { - const { email } = c.req.valid('json'); - await this.loginRequestsService.create({ email }); - return c.json({ message: 'Verification email sent' }); - }); +@injectable() +export class IamController implements Controller { + controller = new Hono(); -export default app; + constructor( + ) { } + + routes() { + return this.controller + .get('/me', requireAuth, async (c) => { + const user = c.var.user; + return c.json({ user }); + }); + } +} diff --git a/src/lib/server/api/controllers/login.controller.ts b/src/lib/server/api/controllers/login.controller.ts index aeecf6e..fab874c 100644 --- a/src/lib/server/api/controllers/login.controller.ts +++ b/src/lib/server/api/controllers/login.controller.ts @@ -1,13 +1,26 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; -import { registerEmailPasswordDto } from '$lib/dtos/register-emailpassword.dto'; +import { inject, injectable } from 'tsyringe'; +import type { HonoTypes } from '../types'; import { limiter } from '../middleware/rate-limiter.middleware'; +import type { Controller } from '../interfaces/controller.interface'; +import { signInEmailDto } from '$lib/dtos/signin-email.dto'; +import type { LoginRequestsService } from '../services/loginrequest.service'; -const app = new Hono() - .post('/', zValidator('json', registerEmailPasswordDto), limiter({ limit: 10, minutes: 60 }), async (c) => { - const { email } = c.req.valid('json'); - await loginRequestsService.create({ email }); - return c.json({ message: 'Verification email sent' }); - }); +@injectable() +export class LoginController implements Controller { + controller = new Hono(); -export default app; + constructor( + @inject('LoginRequestsService') private readonly loginRequestsService: LoginRequestsService + ) { } + + routes() { + return this.controller + .post('/', zValidator('json', signInEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => { + const { username, password } = c.req.valid('json'); + await this.loginRequestsService.verify({ username, password }); + return c.json({ message: 'Verification email sent' }); + }) + } +} diff --git a/src/lib/server/api/infrastructure/database/tables/credentials.table.ts b/src/lib/server/api/infrastructure/database/tables/credentials.table.ts new file mode 100644 index 0000000..1683b6a --- /dev/null +++ b/src/lib/server/api/infrastructure/database/tables/credentials.table.ts @@ -0,0 +1,20 @@ +import { pgTable, text, uuid } from "drizzle-orm/pg-core"; +import { timestamps } from '../utils'; +import { usersTable } from "./users.table"; + +enum CredentialsType { + SECRET = 'secret', + PASSWORD = 'password', + TOTP = 'totp', + HOTP = 'hotp' +} + +export const credentialsTable = pgTable('credentials', { + id: uuid('id').primaryKey().defaultRandom(), + user_id: uuid('user_id') + .notNull() + .references(() => usersTable.id, { onDelete: 'cascade' }), + type: text('type').notNull().default(CredentialsType.PASSWORD), + secret_data: text('secret_data').notNull(), + ...timestamps +}); \ No newline at end of file diff --git a/src/lib/server/api/infrastructure/database/tables/expansions.ts b/src/lib/server/api/infrastructure/database/tables/expansions.ts index bdbf2e3..ff56e4d 100644 --- a/src/lib/server/api/infrastructure/database/tables/expansions.ts +++ b/src/lib/server/api/infrastructure/database/tables/expansions.ts @@ -1,4 +1,4 @@ -import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; +import { pgTable, text, uuid } from 'drizzle-orm/pg-core'; import { createId as cuid2 } from '@paralleldrive/cuid2'; import { type InferSelectModel, relations } from 'drizzle-orm'; import games from './games'; diff --git a/src/lib/server/api/infrastructure/database/tables/federatedIdentity.table.ts b/src/lib/server/api/infrastructure/database/tables/federatedIdentity.table.ts new file mode 100644 index 0000000..c47f817 --- /dev/null +++ b/src/lib/server/api/infrastructure/database/tables/federatedIdentity.table.ts @@ -0,0 +1,14 @@ +import { pgTable, text, uuid } from "drizzle-orm/pg-core"; +import { usersTable } from "./users.table"; +import { timestamps } from '../utils'; + +export const federatedIdentityTable = pgTable('federated_identity', { + id: uuid('id').primaryKey().defaultRandom(), + user_id: uuid('user_id') + .notNull() + .references(() => usersTable.id, { onDelete: 'cascade' }), + idenitity_provider: text('idenitity_provider').notNull(), + federated_user_id: text('federated_user_id').notNull(), + federated_username: text('federated_username').notNull(), + ...timestamps +}); \ No newline at end of file diff --git a/src/lib/server/api/interfaces/controller.interface.ts b/src/lib/server/api/interfaces/controller.interface.ts new file mode 100644 index 0000000..852e695 --- /dev/null +++ b/src/lib/server/api/interfaces/controller.interface.ts @@ -0,0 +1,8 @@ +import { Hono } from 'hono'; +import type { HonoTypes } from '../types'; +import type { BlankSchema } from 'hono/types'; + +export interface Controller { + controller: Hono; + routes(): any; +} diff --git a/src/lib/server/api/mockTest.ts b/src/lib/server/api/mockTest.ts new file mode 100644 index 0000000..a475662 --- /dev/null +++ b/src/lib/server/api/mockTest.ts @@ -0,0 +1,10 @@ +import { Argon2id } from "oslo/password"; + +export async function hash(value: string) { + const argon2 = new Argon2id() + return argon2.hash(value); +} + +export function verify(hashedValue: string, value: string) { + return new Argon2id().verify(hashedValue, value); +} diff --git a/src/lib/server/api/providers/database.provider.ts b/src/lib/server/api/providers/database.provider.ts index f4221d5..c7da118 100644 --- a/src/lib/server/api/providers/database.provider.ts +++ b/src/lib/server/api/providers/database.provider.ts @@ -1,3 +1,4 @@ +import { container } from 'tsyringe'; import { db } from '../infrastructure/database'; // Symbol diff --git a/src/lib/server/api/providers/lucia.provider.ts b/src/lib/server/api/providers/lucia.provider.ts index e5af95a..6546d43 100644 --- a/src/lib/server/api/providers/lucia.provider.ts +++ b/src/lib/server/api/providers/lucia.provider.ts @@ -1,10 +1,11 @@ -// import { lucia } from '../infrastructure/auth/lucia'; +import { container } from 'tsyringe'; +import { lucia } from '../infrastructure/auth/lucia'; -// // Symbol -// export const LuciaProvider = Symbol('LUCIA_PROVIDER'); +// Symbol +export const LuciaProvider = Symbol('LUCIA_PROVIDER'); -// // Type -// export type LuciaProvider = typeof lucia; +// Type +export type LuciaProvider = typeof lucia; -// // Register -// container.register(LuciaProvider, { useValue: lucia }); +// Register +container.register(LuciaProvider, { useValue: lucia }); diff --git a/src/lib/server/api/repositories/credentials.repository.ts b/src/lib/server/api/repositories/credentials.repository.ts new file mode 100644 index 0000000..7bc32d3 --- /dev/null +++ b/src/lib/server/api/repositories/credentials.repository.ts @@ -0,0 +1,35 @@ +import { eq, type InferInsertModel } from "drizzle-orm"; +import { credentialsTable } from "../infrastructure/database/tables/credentials.table"; +import { db } from "../infrastructure/database"; +import { takeFirstOrThrow } from "../infrastructure/database/utils"; + +export type CreateCredentials = InferInsertModel; +export type UpdateCredentials = Partial; + +export class CredentialsRepository { + + async findOneById(id: string) { + return db.query.credentialsTable.findFirst({ + where: eq(credentialsTable.id, id) + }); + } + + async findOneByIdOrThrow(id: string) { + const credentials = await this.findOneById(id); + if (!credentials) throw Error('Credentials not found'); + return credentials; + } + + async create(data: CreateCredentials) { + return db.insert(credentialsTable).values(data).returning().then(takeFirstOrThrow); + } + + async update(id: string, data: UpdateCredentials) { + return db + .update(credentialsTable) + .set(data) + .where(eq(credentialsTable.id, id)) + .returning() + .then(takeFirstOrThrow); + } +} \ No newline at end of file diff --git a/src/lib/server/api/repositories/users.repository.ts b/src/lib/server/api/repositories/users.repository.ts index 2621363..addeaf8 100644 --- a/src/lib/server/api/repositories/users.repository.ts +++ b/src/lib/server/api/repositories/users.repository.ts @@ -35,6 +35,12 @@ export class UsersRepository { return user; } + async findOneByUsername(username: string) { + return db.query.usersTable.findFirst({ + where: eq(usersTable.username, username) + }); + } + async findOneByEmail(email: string) { return db.query.usersTable.findFirst({ where: eq(usersTable.email, email) diff --git a/src/lib/server/api/services/hashing.service.ts b/src/lib/server/api/services/hashing.service.ts index e1b7cd6..5a03f4e 100644 --- a/src/lib/server/api/services/hashing.service.ts +++ b/src/lib/server/api/services/hashing.service.ts @@ -1,3 +1,4 @@ +import { injectable } from "tsyringe"; import { Argon2id } from "oslo/password"; /* ---------------------------------- Note ---------------------------------- */ @@ -19,6 +20,7 @@ node_modules/.pnpm/@node-rs+argon2@1.7.0/node_modules/@node-rs/argon2/index.js:1 /* -------------------------------------------------------------------------- */ // If you don't use a hasher from oslo, which are preconfigured with recommended parameters from OWASP, // ensure that you configure them properly. +@injectable() export class HashingService { private readonly hasher = new Argon2id(); diff --git a/src/lib/server/api/services/iam.service.ts b/src/lib/server/api/services/iam.service.ts index 395c748..84b44da 100644 --- a/src/lib/server/api/services/iam.service.ts +++ b/src/lib/server/api/services/iam.service.ts @@ -1,4 +1,5 @@ -import { lucia } from '../infrastructure/auth/lucia'; +import { inject, injectable } from 'tsyringe'; +import { LuciaProvider } from '../providers/lucia.provider'; /* -------------------------------------------------------------------------- */ /* Service */ @@ -17,8 +18,13 @@ Create private functions to handle complex logic and keep the public methods as simple as possible. This makes the service easier to read, test and understand. */ /* -------------------------------------------------------------------------- */ +@injectable() export class IamService { + constructor( + @inject(LuciaProvider) private readonly lucia: LuciaProvider, + ) { } + async logout(sessionId: string) { - return lucia.invalidateSession(sessionId); + return this.lucia.invalidateSession(sessionId); } } diff --git a/src/lib/server/api/services/loginrequest.service.ts b/src/lib/server/api/services/loginrequest.service.ts new file mode 100644 index 0000000..5b9c1eb --- /dev/null +++ b/src/lib/server/api/services/loginrequest.service.ts @@ -0,0 +1,75 @@ +import { inject, injectable } from 'tsyringe'; +import { BadRequest } from '../common/errors'; +import { DatabaseProvider } from '../providers'; +import { MailerService } from './mailer.service'; +import { TokensService } from './tokens.service'; +import { LuciaProvider } from '../providers/lucia.provider'; +import { UsersRepository } from '../repositories/users.repository'; +import type { SignInEmailDto } from '../../../dtos/signin-email.dto'; +import type { RegisterEmailDto } from '../../../dtos/register-email.dto'; +import { LoginRequestsRepository } from '../repositories/login-requests.repository'; + +@injectable() +export class LoginRequestsService { + constructor( + @inject(LuciaProvider) private readonly lucia: LuciaProvider, + @inject(DatabaseProvider) private readonly db: DatabaseProvider, + @inject(TokensService) private readonly tokensService: TokensService, + @inject(MailerService) private readonly mailerService: MailerService, + @inject(UsersRepository) private readonly usersRepository: UsersRepository, + @inject(LoginRequestsRepository) private readonly loginRequestsRepository: LoginRequestsRepository, + ) { } + + async validate(data: SignInEmailDto) { + + } + + async create(data: RegisterEmailDto) { + // generate a token, expiry date, and hash + const { token, expiry, hashedToken } = await this.tokensService.generateTokenWithExpiryAndHash(15, 'm'); + // save the login request to the database - ensuring we save the hashedToken + await this.loginRequestsRepository.create({ email: data.email, hashedToken, expiresAt: expiry }); + // send the login request email + await this.mailerService.sendLoginRequest({ + to: data.email, + props: { token: token } + }); + } + + async verify(data: SignInEmailDto) { + let existingUser = await this.usersRepository.findOneByUsername(data.username); + + if (!existingUser) { + throw BadRequest('User not found'); + } + + + + return this.lucia.createSession(existingUser.id, {}); + } + + // Create a new user and send a welcome email - or other onboarding process + private async handleNewUserRegistration(email: string) { + const newUser = await this.usersRepository.create({ email, verified: true, avatar: null }) + this.mailerService.sendWelcome({ to: email, props: null }); + // TODO: add whatever onboarding process or extra data you need here + return newUser + } + + // Fetch a valid request from the database, verify the token and burn the request if it is valid + private async fetchValidRequest(email: string, token: string) { + return await this.db.transaction(async (trx) => { + // fetch the login request + const loginRequest = await this.loginRequestsRepository.trxHost(trx).findOneByEmail(email) + if (!loginRequest) return null; + + // check if the token is valid + const isValidRequest = await this.tokensService.verifyHashedToken(loginRequest.hashedToken, token); + if (!isValidRequest) return null + + // if the token is valid, burn the request + await this.loginRequestsRepository.trxHost(trx).deleteById(loginRequest.id); + return loginRequest + }) + } +} \ No newline at end of file diff --git a/src/lib/server/api/services/mailer.service.ts b/src/lib/server/api/services/mailer.service.ts index 368fdb9..f644f11 100644 --- a/src/lib/server/api/services/mailer.service.ts +++ b/src/lib/server/api/services/mailer.service.ts @@ -11,14 +11,14 @@ import { injectable } from 'tsyringe'; /* -------------------------------------------------------------------------- */ /* ---------------------------------- About --------------------------------- */ /* -Services are responsible for handling business logic and data manipulation. +Services are responsible for handling business logic and data manipulation. They genreally call on repositories or other services to complete a use-case. */ /* ---------------------------------- Notes --------------------------------- */ /* -Services should be kept as clean and simple as possible. +Services should be kept as clean and simple as possible. -Create private functions to handle complex logic and keep the public methods as +Create private functions to handle complex logic and keep the public methods as simple as possible. This makes the service easier to read, test and understand. */ /* -------------------------------------------------------------------------- */ diff --git a/src/lib/server/api/services/tokens.service.ts b/src/lib/server/api/services/tokens.service.ts index c8ac626..a3f712e 100644 --- a/src/lib/server/api/services/tokens.service.ts +++ b/src/lib/server/api/services/tokens.service.ts @@ -1,9 +1,11 @@ +import { inject, injectable } from "tsyringe"; import { generateRandomString } from "oslo/crypto"; import { TimeSpan, createDate, type TimeSpanUnit } from 'oslo'; import { HashingService } from "./hashing.service"; +@injectable() export class TokensService { - private readonly hashingService = new HashingService(); + constructor(@inject(HashingService) private readonly hashingService: HashingService) { } generateToken() { const alphabet = '23456789ACDEFGHJKLMNPQRSTUVWXYZ'; // alphabet with removed look-alike characters (0, 1, O, I)