Changing the security auth openapi to be cookie session and documenting more APIs.

This commit is contained in:
Bradley Shellnut 2024-10-13 23:10:35 -07:00
parent eb1d44037e
commit cbcbbed912
8 changed files with 163 additions and 38 deletions

View file

@ -98,7 +98,7 @@
"@types/feather-icons": "^4.29.4", "@types/feather-icons": "^4.29.4",
"bits-ui": "^0.21.16", "bits-ui": "^0.21.16",
"boardgamegeekclient": "^1.9.1", "boardgamegeekclient": "^1.9.1",
"bullmq": "^5.19.0", "bullmq": "^5.19.1",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cookie": "^0.6.0", "cookie": "^0.6.0",
@ -112,7 +112,7 @@
"hono": "^4.6.4", "hono": "^4.6.4",
"hono-pino": "^0.3.0", "hono-pino": "^0.3.0",
"hono-rate-limiter": "^0.4.0", "hono-rate-limiter": "^0.4.0",
"hono-zod-openapi": "^0.2.1", "hono-zod-openapi": "^0.3.0",
"html-entities": "^2.5.2", "html-entities": "^2.5.2",
"iconify-icon": "^2.1.0", "iconify-icon": "^2.1.0",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",

View file

@ -81,8 +81,8 @@ importers:
specifier: ^1.9.1 specifier: ^1.9.1
version: 1.9.1 version: 1.9.1
bullmq: bullmq:
specifier: ^5.19.0 specifier: ^5.19.1
version: 5.19.0 version: 5.19.1
class-variance-authority: class-variance-authority:
specifier: ^0.7.0 specifier: ^0.7.0
version: 0.7.0 version: 0.7.0
@ -123,8 +123,8 @@ importers:
specifier: ^0.4.0 specifier: ^0.4.0
version: 0.4.0(hono@4.6.4) version: 0.4.0(hono@4.6.4)
hono-zod-openapi: hono-zod-openapi:
specifier: ^0.2.1 specifier: ^0.3.0
version: 0.2.1(hono@4.6.4)(zod@3.23.8) version: 0.3.0(hono@4.6.4)(zod@3.23.8)
html-entities: html-entities:
specifier: ^2.5.2 specifier: ^2.5.2
version: 2.5.2 version: 2.5.2
@ -2554,8 +2554,8 @@ packages:
buffer@6.0.3: buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
bullmq@5.19.0: bullmq@5.19.1:
resolution: {integrity: sha512-S6ZxVqPgzvKVkGjUN5Qwi0bDgM2aZPKsgJ8ESe5gUOOt3APDRPfDAzrkUz1FkTd1nfgc3HFBN8MCipWDGTdFGA==} resolution: {integrity: sha512-ziQ2C0ZS39MwerK+C1W88L4niDgrByVTogr8PeLHVEPhSHNCW39a/ROphNAdGWZ4M4hcXolloyAGgdtEz5Fw5A==}
bytes@3.1.2: bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
@ -3337,8 +3337,8 @@ packages:
peerDependencies: peerDependencies:
hono: ^4.1.1 hono: ^4.1.1
hono-zod-openapi@0.2.1: hono-zod-openapi@0.3.0:
resolution: {integrity: sha512-KHxVkmokPzRuXWpner1Hb3Eey/FmZOn8+IMNISWhvMIdvdKOo8qbjNzTUmRMRZZYewqa1SeSy2slFCZrUNBfGw==} resolution: {integrity: sha512-Ledae1WTC+ct7Mj61y+KnrwgPm34CkU1wTt85C2bBoD60b7DR7ExJEUj77tZU7O8Y7G8p9NWwYngfbzGDTBtYA==}
peerDependencies: peerDependencies:
hono: ^4.6.3 hono: ^4.6.3
zod: ^3.21.4 zod: ^3.21.4
@ -7008,7 +7008,7 @@ snapshots:
base64-js: 1.5.1 base64-js: 1.5.1
ieee754: 1.2.1 ieee754: 1.2.1
bullmq@5.19.0: bullmq@5.19.1:
dependencies: dependencies:
cron-parser: 4.9.0 cron-parser: 4.9.0
ioredis: 5.4.1 ioredis: 5.4.1
@ -7860,7 +7860,7 @@ snapshots:
dependencies: dependencies:
hono: 4.6.4 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: dependencies:
'@hono/zod-validator': 0.4.1(hono@4.6.4)(zod@3.23.8) '@hono/zod-validator': 0.4.1(hono@4.6.4)(zod@3.23.8)
hono: 4.6.4 hono: 4.6.4

View file

@ -0,0 +1,12 @@
import { type HonoOpenApiOperation, type HonoOpenApiRequestSchemas, defineOpenApiOperation } from "hono-zod-openapi";
export const taggedAuthRoute = <T extends HonoOpenApiRequestSchemas>(
tag: string,
doc: HonoOpenApiOperation<T>,
) => {
return defineOpenApiOperation({
...doc,
tags: [tag],
security: [{ cookieAuth: [] }],
});
};

View file

@ -44,13 +44,13 @@ export default function configureOpenAPI(app: AppOpenAPI) {
type: 'http', type: 'http',
scheme: 'bearer', scheme: 'bearer',
}, },
cookieAuth: {
type: 'apiKey',
name: 'session',
in: 'cookie',
}
}, },
}, },
security: [
{
bearerAuth: [],
},
],
}); });
app.get( app.get(

View file

@ -1,6 +1,5 @@
import { StatusCodes } from '$lib/constants/status-codes'; import { StatusCodes } from '$lib/constants/status-codes';
import { Controller } from '$lib/server/api/common/types/controller'; 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 { changePasswordDto } from '$lib/server/api/dtos/change-password.dto';
import { updateEmailDto } from '$lib/server/api/dtos/update-email.dto'; import { updateEmailDto } from '$lib/server/api/dtos/update-email.dto';
import { updateProfileDto } from '$lib/server/api/dtos/update-profile.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 { setCookie } from 'hono/cookie';
import { inject, injectable } from 'tsyringe'; import { inject, injectable } from 'tsyringe';
import { requireAuth } from '../middleware/require-auth.middleware'; import { requireAuth } from '../middleware/require-auth.middleware';
import { iam, logout, updateEmail, updatePassword, updateProfile, verifyPassword } from './iam.routes';
@injectable() @injectable()
export class IamController extends Controller { export class IamController extends Controller {
@ -42,26 +42,26 @@ export class IamController extends Controller {
const { firstName, lastName, username } = c.req.valid('json'); const { firstName, lastName, username } = c.req.valid('json');
const updatedUser = await this.iamService.updateProfile(user.id, { firstName, lastName, username }); const updatedUser = await this.iamService.updateProfile(user.id, { firstName, lastName, username });
if (!updatedUser) { 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); 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 user = c.var.user;
const { password } = c.req.valid('json'); const { password } = c.req.valid('json');
const passwordVerified = await this.iamService.verifyPassword(user.id, { password }); const passwordVerified = await this.iamService.verifyPassword(user.id, { password });
if (!passwordVerified) { if (!passwordVerified) {
console.log('Incorrect password'); 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); 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 user = c.var.user;
const { password, confirm_password } = c.req.valid('json'); const { password, confirm_password } = c.req.valid('json');
if (password !== confirm_password) { 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 { try {
await this.iamService.updatePassword(user.id, { password, confirm_password }); await this.iamService.updatePassword(user.id, { password, confirm_password });
@ -80,19 +80,19 @@ export class IamController extends Controller {
return c.json({ status: 'success' }); return c.json({ status: 'success' });
} catch (error) { } catch (error) {
console.error('Error updating password', 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 user = c.var.user;
const { email } = c.req.valid('json'); const { email } = c.req.valid('json');
const updatedUser = await this.iamService.updateEmail(user.id, { email }); const updatedUser = await this.iamService.updateEmail(user.id, { email });
if (!updatedUser) { 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); 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; const sessionId = c.var.session.id;
await this.iamService.logout(sessionId); await this.iamService.logout(sessionId);
const sessionCookie = this.luciaService.lucia.createBlankSessionCookie(); const sessionCookie = this.luciaService.lucia.createBlankSessionCookie();

View file

@ -1,16 +1,15 @@
import { StatusCodes } from '$lib/constants/status-codes'; import { StatusCodes } from '$lib/constants/status-codes';
import { unauthorizedSchema } from '$lib/server/api/common/exceptions'; 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 { selectUserSchema } from '$lib/server/api/databases/tables/users.table';
import { updateProfileDto } from '$lib/server/api/dtos/update-profile.dto'; 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 { 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({ export const iam = taggedAuthRoute(tag, {
tags,
responses: { responses: {
[StatusCodes.OK]: { [StatusCodes.OK]: {
description: 'User profile', description: 'User profile',
@ -25,9 +24,7 @@ export const iam = defineOpenApiOperation({
}, },
}); });
export const updateProfile = defineOpenApiOperation({ export const updateProfile = taggedAuthRoute(tag, {
tags,
security: [{ bearerAuth: [] }],
request: { request: {
json: updateProfileDto, json: updateProfileDto,
}, },
@ -37,11 +34,104 @@ export const updateProfile = defineOpenApiOperation({
schema: selectUserSchema, schema: selectUserSchema,
mediaType: 'application/json', mediaType: 'application/json',
}, },
[StatusCodes.UNPROCESSABLE_ENTITY]: { [StatusCodes.BAD_REQUEST]: {
description: 'The validation error(s)', description: 'The validation error(s)',
schema: createErrorSchema(updateProfileDto), schema: createErrorSchema(updateProfileDto),
mediaType: 'application/json', 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]: { [StatusCodes.UNAUTHORIZED]: {
description: 'Unauthorized', description: 'Unauthorized',
schema: createErrorSchema(unauthorizedSchema), schema: createErrorSchema(unauthorizedSchema),

View file

@ -5,9 +5,11 @@ import { LuciaService } from '$lib/server/api/services/lucia.service'
import { zValidator } from '@hono/zod-validator' import { zValidator } from '@hono/zod-validator'
import { setCookie } from 'hono/cookie' import { setCookie } from 'hono/cookie'
import { TimeSpan } from 'oslo' import { TimeSpan } from 'oslo'
import { openApi } from 'hono-zod-openapi';
import { inject, injectable } from 'tsyringe' import { inject, injectable } from 'tsyringe'
import { limiter } from '../middleware/rate-limiter.middleware' import { limiter } from '../middleware/rate-limiter.middleware'
import { LoginRequestsService } from '../services/loginrequest.service' import { LoginRequestsService } from '../services/loginrequest.service'
import { signinUsername } from './login.routes'
@injectable() @injectable()
export class LoginController extends Controller { export class LoginController extends Controller {
@ -19,7 +21,7 @@ export class LoginController extends Controller {
} }
routes() { 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 { username, password } = c.req.valid('json')
const session = await this.loginRequestsService.verify({ username, password }, c.req) const session = await this.loginRequestsService.verify({ username, password }, c.req)
const sessionCookie = this.luciaService.lucia.createSessionCookie(session.id) const sessionCookie = this.luciaService.lucia.createSessionCookie(session.id)

View file

@ -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',
}
}
});