From cbcbbed9120af49c65fd3d16e8171fb405e5d105 Mon Sep 17 00:00:00 2001 From: Bradley Shellnut Date: Sun, 13 Oct 2024 23:10:35 -0700 Subject: [PATCH] Changing the security auth openapi to be cookie session and documenting more APIs. --- package.json | 4 +- pnpm-lock.yaml | 20 ++-- .../api/common/openapi/create-auth-route.ts | 12 ++ src/lib/server/api/configure-open-api.ts | 10 +- .../server/api/controllers/iam.controller.ts | 20 ++-- src/lib/server/api/controllers/iam.routes.ts | 110 ++++++++++++++++-- .../api/controllers/login.controller.ts | 4 +- .../server/api/controllers/login.routes.ts | 21 ++++ 8 files changed, 163 insertions(+), 38 deletions(-) create mode 100644 src/lib/server/api/common/openapi/create-auth-route.ts create mode 100644 src/lib/server/api/controllers/login.routes.ts diff --git a/package.json b/package.json index 48a16c9..16e01d6 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,7 @@ "@types/feather-icons": "^4.29.4", "bits-ui": "^0.21.16", "boardgamegeekclient": "^1.9.1", - "bullmq": "^5.19.0", + "bullmq": "^5.19.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cookie": "^0.6.0", @@ -112,7 +112,7 @@ "hono": "^4.6.4", "hono-pino": "^0.3.0", "hono-rate-limiter": "^0.4.0", - "hono-zod-openapi": "^0.2.1", + "hono-zod-openapi": "^0.3.0", "html-entities": "^2.5.2", "iconify-icon": "^2.1.0", "ioredis": "^5.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acf4611..e31267e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,8 +81,8 @@ importers: specifier: ^1.9.1 version: 1.9.1 bullmq: - specifier: ^5.19.0 - version: 5.19.0 + specifier: ^5.19.1 + version: 5.19.1 class-variance-authority: specifier: ^0.7.0 version: 0.7.0 @@ -123,8 +123,8 @@ importers: specifier: ^0.4.0 version: 0.4.0(hono@4.6.4) hono-zod-openapi: - specifier: ^0.2.1 - version: 0.2.1(hono@4.6.4)(zod@3.23.8) + specifier: ^0.3.0 + version: 0.3.0(hono@4.6.4)(zod@3.23.8) html-entities: specifier: ^2.5.2 version: 2.5.2 @@ -2554,8 +2554,8 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - bullmq@5.19.0: - resolution: {integrity: sha512-S6ZxVqPgzvKVkGjUN5Qwi0bDgM2aZPKsgJ8ESe5gUOOt3APDRPfDAzrkUz1FkTd1nfgc3HFBN8MCipWDGTdFGA==} + bullmq@5.19.1: + resolution: {integrity: sha512-ziQ2C0ZS39MwerK+C1W88L4niDgrByVTogr8PeLHVEPhSHNCW39a/ROphNAdGWZ4M4hcXolloyAGgdtEz5Fw5A==} bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} @@ -3337,8 +3337,8 @@ packages: peerDependencies: hono: ^4.1.1 - hono-zod-openapi@0.2.1: - resolution: {integrity: sha512-KHxVkmokPzRuXWpner1Hb3Eey/FmZOn8+IMNISWhvMIdvdKOo8qbjNzTUmRMRZZYewqa1SeSy2slFCZrUNBfGw==} + hono-zod-openapi@0.3.0: + resolution: {integrity: sha512-Ledae1WTC+ct7Mj61y+KnrwgPm34CkU1wTt85C2bBoD60b7DR7ExJEUj77tZU7O8Y7G8p9NWwYngfbzGDTBtYA==} peerDependencies: hono: ^4.6.3 zod: ^3.21.4 @@ -7008,7 +7008,7 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 - bullmq@5.19.0: + bullmq@5.19.1: dependencies: cron-parser: 4.9.0 ioredis: 5.4.1 @@ -7860,7 +7860,7 @@ snapshots: dependencies: hono: 4.6.4 - hono-zod-openapi@0.2.1(hono@4.6.4)(zod@3.23.8): + hono-zod-openapi@0.3.0(hono@4.6.4)(zod@3.23.8): dependencies: '@hono/zod-validator': 0.4.1(hono@4.6.4)(zod@3.23.8) hono: 4.6.4 diff --git a/src/lib/server/api/common/openapi/create-auth-route.ts b/src/lib/server/api/common/openapi/create-auth-route.ts new file mode 100644 index 0000000..0b562d5 --- /dev/null +++ b/src/lib/server/api/common/openapi/create-auth-route.ts @@ -0,0 +1,12 @@ +import { type HonoOpenApiOperation, type HonoOpenApiRequestSchemas, defineOpenApiOperation } from "hono-zod-openapi"; + +export const taggedAuthRoute = ( + tag: string, + doc: HonoOpenApiOperation, +) => { + return defineOpenApiOperation({ + ...doc, + tags: [tag], + security: [{ cookieAuth: [] }], + }); +}; diff --git a/src/lib/server/api/configure-open-api.ts b/src/lib/server/api/configure-open-api.ts index 488ee3e..5d225cc 100644 --- a/src/lib/server/api/configure-open-api.ts +++ b/src/lib/server/api/configure-open-api.ts @@ -44,13 +44,13 @@ export default function configureOpenAPI(app: AppOpenAPI) { type: 'http', scheme: 'bearer', }, + cookieAuth: { + type: 'apiKey', + name: 'session', + in: 'cookie', + } }, }, - security: [ - { - bearerAuth: [], - }, - ], }); app.get( diff --git a/src/lib/server/api/controllers/iam.controller.ts b/src/lib/server/api/controllers/iam.controller.ts index 4554385..cc8a26e 100644 --- a/src/lib/server/api/controllers/iam.controller.ts +++ b/src/lib/server/api/controllers/iam.controller.ts @@ -1,6 +1,5 @@ import { StatusCodes } from '$lib/constants/status-codes'; import { Controller } from '$lib/server/api/common/types/controller'; -import { iam, updateProfile } from '$lib/server/api/controllers/iam.routes'; import { changePasswordDto } from '$lib/server/api/dtos/change-password.dto'; import { updateEmailDto } from '$lib/server/api/dtos/update-email.dto'; import { updateProfileDto } from '$lib/server/api/dtos/update-profile.dto'; @@ -14,6 +13,7 @@ import { openApi } from 'hono-zod-openapi'; import { setCookie } from 'hono/cookie'; import { inject, injectable } from 'tsyringe'; import { requireAuth } from '../middleware/require-auth.middleware'; +import { iam, logout, updateEmail, updatePassword, updateProfile, verifyPassword } from './iam.routes'; @injectable() export class IamController extends Controller { @@ -42,26 +42,26 @@ export class IamController extends Controller { const { firstName, lastName, username } = c.req.valid('json'); const updatedUser = await this.iamService.updateProfile(user.id, { firstName, lastName, username }); if (!updatedUser) { - return c.json('Username already in use', StatusCodes.BAD_REQUEST); + return c.json('Username already in use', StatusCodes.UNPROCESSABLE_ENTITY); } return c.json({ user: updatedUser }, StatusCodes.OK); }, ) - .post('/verify/password', requireAuth, zValidator('json', verifyPasswordDto), limiter({ limit: 10, minutes: 60 }), async (c) => { + .post('/verify/password', requireAuth, zValidator('json', verifyPasswordDto), openApi(verifyPassword), limiter({ limit: 10, minutes: 60 }), async (c) => { const user = c.var.user; const { password } = c.req.valid('json'); const passwordVerified = await this.iamService.verifyPassword(user.id, { password }); if (!passwordVerified) { console.log('Incorrect password'); - return c.json('Incorrect password', StatusCodes.BAD_REQUEST); + return c.json('Incorrect password', StatusCodes.BAD_GATEWAY); } return c.json({}, StatusCodes.OK); }) - .put('/update/password', requireAuth, zValidator('json', changePasswordDto), limiter({ limit: 10, minutes: 60 }), async (c) => { + .put('/update/password', requireAuth, openApi(updatePassword), zValidator('json', changePasswordDto), limiter({ limit: 10, minutes: 60 }), async (c) => { const user = c.var.user; const { password, confirm_password } = c.req.valid('json'); if (password !== confirm_password) { - return c.json('Passwords do not match', StatusCodes.BAD_REQUEST); + return c.json('Passwords do not match', StatusCodes.UNPROCESSABLE_ENTITY); } try { await this.iamService.updatePassword(user.id, { password, confirm_password }); @@ -80,19 +80,19 @@ export class IamController extends Controller { return c.json({ status: 'success' }); } catch (error) { console.error('Error updating password', error); - return c.json('Error updating password', StatusCodes.BAD_REQUEST); + return c.json('Error updating password', StatusCodes.UNPROCESSABLE_ENTITY); } }) - .post('/update/email', requireAuth, zValidator('json', updateEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => { + .post('/update/email', requireAuth, openApi(updateEmail), zValidator('json', updateEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => { const user = c.var.user; const { email } = c.req.valid('json'); const updatedUser = await this.iamService.updateEmail(user.id, { email }); if (!updatedUser) { - return c.json('Email already in use', StatusCodes.BAD_REQUEST); + return c.json('Cannot change email address', StatusCodes.BAD_REQUEST); } return c.json({ user: updatedUser }, StatusCodes.OK); }) - .post('/logout', requireAuth, async (c) => { + .post('/logout', requireAuth, openApi(logout), async (c) => { const sessionId = c.var.session.id; await this.iamService.logout(sessionId); const sessionCookie = this.luciaService.lucia.createBlankSessionCookie(); diff --git a/src/lib/server/api/controllers/iam.routes.ts b/src/lib/server/api/controllers/iam.routes.ts index bef4396..14daba5 100644 --- a/src/lib/server/api/controllers/iam.routes.ts +++ b/src/lib/server/api/controllers/iam.routes.ts @@ -1,16 +1,15 @@ import { StatusCodes } from '$lib/constants/status-codes'; import { unauthorizedSchema } from '$lib/server/api/common/exceptions'; -import { createAuthCookieSchema } from '$lib/server/api/common/openapi/create-cookie-schema'; import { selectUserSchema } from '$lib/server/api/databases/tables/users.table'; import { updateProfileDto } from '$lib/server/api/dtos/update-profile.dto'; -import { z } from '@hono/zod-openapi'; -import { defineOpenApiOperation } from 'hono-zod-openapi'; import { createErrorSchema } from 'stoker/openapi/schemas'; +import { taggedAuthRoute } from '../common/openapi/create-auth-route'; +import { changePasswordDto } from '../dtos/change-password.dto'; +import { verifyPasswordDto } from '../dtos/verify-password.dto'; -const tags = ['IAM']; +const tag = 'IAM'; -export const iam = defineOpenApiOperation({ - tags, +export const iam = taggedAuthRoute(tag, { responses: { [StatusCodes.OK]: { description: 'User profile', @@ -25,9 +24,7 @@ export const iam = defineOpenApiOperation({ }, }); -export const updateProfile = defineOpenApiOperation({ - tags, - security: [{ bearerAuth: [] }], +export const updateProfile = taggedAuthRoute(tag, { request: { json: updateProfileDto, }, @@ -37,11 +34,104 @@ export const updateProfile = defineOpenApiOperation({ schema: selectUserSchema, mediaType: 'application/json', }, - [StatusCodes.UNPROCESSABLE_ENTITY]: { + [StatusCodes.BAD_REQUEST]: { description: 'The validation error(s)', schema: createErrorSchema(updateProfileDto), mediaType: 'application/json', }, + [StatusCodes.UNPROCESSABLE_ENTITY]: { + description: 'Username already in use', + mediaType: 'application/json', + }, + [StatusCodes.UNAUTHORIZED]: { + description: 'Unauthorized', + schema: createErrorSchema(unauthorizedSchema), + mediaType: 'application/json', + }, + }, +}); + +export const verifyPassword = taggedAuthRoute(tag, { + request: { + json: verifyPasswordDto, + }, + responses: { + [StatusCodes.OK]: { + description: 'Password verified', + mediaType: 'application/json', + }, + [StatusCodes.BAD_REQUEST]: { + description: 'The validation error(s)', + schema: createErrorSchema(verifyPasswordDto), + mediaType: 'application/json', + }, + [StatusCodes.BAD_REQUEST]: { + description: 'Incorrect password', + mediaType: 'application/json', + }, + [StatusCodes.UNAUTHORIZED]: { + description: 'Unauthorized', + schema: createErrorSchema(unauthorizedSchema), + mediaType: 'application/json', + }, + }, +}); + +export const updatePassword = taggedAuthRoute(tag, { + request: { + json: changePasswordDto, + }, + responses: { + [StatusCodes.OK]: { + description: 'Password updated', + mediaType: 'application/json', + }, + [StatusCodes.BAD_REQUEST]: { + description: 'The validation error(s)', + schema: createErrorSchema(changePasswordDto), + mediaType: 'application/json', + }, + [StatusCodes.BAD_REQUEST]: { + description: 'Incorrect password', + mediaType: 'application/json', + }, + [StatusCodes.UNAUTHORIZED]: { + description: 'Unauthorized', + schema: createErrorSchema(unauthorizedSchema), + mediaType: 'application/json', + }, + }, +}); + +export const updateEmail = taggedAuthRoute(tag, { + responses: { + [StatusCodes.OK]: { + description: 'Email updated', + mediaType: 'application/json', + }, + [StatusCodes.BAD_REQUEST]: { + description: 'The validation error(s)', + schema: createErrorSchema(changePasswordDto), + mediaType: 'application/json', + }, + [StatusCodes.BAD_REQUEST]: { + description: "Cannot change email address", + mediaType: 'application/json', + }, + [StatusCodes.UNAUTHORIZED]: { + description: 'Unauthorized', + schema: createErrorSchema(unauthorizedSchema), + mediaType: 'application/json', + }, + }, +}); + +export const logout = taggedAuthRoute(tag, { + responses: { + [StatusCodes.OK]: { + description: 'Logged out', + mediaType: 'application/json', + }, [StatusCodes.UNAUTHORIZED]: { description: 'Unauthorized', schema: createErrorSchema(unauthorizedSchema), diff --git a/src/lib/server/api/controllers/login.controller.ts b/src/lib/server/api/controllers/login.controller.ts index ea12bf3..37d32ed 100644 --- a/src/lib/server/api/controllers/login.controller.ts +++ b/src/lib/server/api/controllers/login.controller.ts @@ -5,9 +5,11 @@ import { LuciaService } from '$lib/server/api/services/lucia.service' import { zValidator } from '@hono/zod-validator' import { setCookie } from 'hono/cookie' import { TimeSpan } from 'oslo' +import { openApi } from 'hono-zod-openapi'; import { inject, injectable } from 'tsyringe' import { limiter } from '../middleware/rate-limiter.middleware' import { LoginRequestsService } from '../services/loginrequest.service' +import { signinUsername } from './login.routes' @injectable() export class LoginController extends Controller { @@ -19,7 +21,7 @@ export class LoginController extends Controller { } routes() { - return this.controller.post('/', zValidator('json', signinUsernameDto), limiter({ limit: 10, minutes: 60 }), async (c) => { + return this.controller.post('/', openApi(signinUsername), zValidator('json', signinUsernameDto), limiter({ limit: 10, minutes: 60 }), async (c) => { const { username, password } = c.req.valid('json') const session = await this.loginRequestsService.verify({ username, password }, c.req) const sessionCookie = this.luciaService.lucia.createSessionCookie(session.id) diff --git a/src/lib/server/api/controllers/login.routes.ts b/src/lib/server/api/controllers/login.routes.ts new file mode 100644 index 0000000..d6c8424 --- /dev/null +++ b/src/lib/server/api/controllers/login.routes.ts @@ -0,0 +1,21 @@ +import { defineOpenApiOperation } from "hono-zod-openapi"; +import { StatusCodes } from '$lib/constants/status-codes'; +import { signinUsernameDto } from "../dtos/signin-username.dto"; +import { createErrorSchema } from "stoker/openapi/schemas"; + +export const signinUsername = defineOpenApiOperation({ + tags: ['Login'], + summary: 'Sign in with username', + description: 'Sign in with username', + responses: { + [StatusCodes.OK]: { + description: 'Sign in with username', + schema: signinUsernameDto, + }, + [StatusCodes.UNPROCESSABLE_ENTITY]: { + description: 'The validation error(s)', + schema: createErrorSchema(signinUsernameDto), + mediaType: 'application/json', + } + } +});