diff --git a/src/api/app.ts b/src/api/app.ts index d71ff2db2..7e2875044 100644 --- a/src/api/app.ts +++ b/src/api/app.ts @@ -4,9 +4,8 @@ import "reflect-metadata"; import { RoleType } from "@beabee/beabee-common"; import cookie from "cookie-parser"; import cors from "cors"; -import express, { ErrorRequestHandler } from "express"; +import express, { ErrorRequestHandler, Request } from "express"; import { - Action, HttpError, InternalServerError, NotFoundError, @@ -32,6 +31,8 @@ import { UploadController } from "./controllers/UploadController"; import { ValidateResponseInterceptor } from "./interceptors/ValidateResponseInterceptor"; +import { AuthMiddleware } from "./middlewares/AuthMiddleware"; + import { log as mainLogger, requestErrorLogger, @@ -40,22 +41,21 @@ import { import sessions from "@core/sessions"; import { initApp, startServer } from "@core/server"; -import AuthService from "@core/services/AuthService"; +import Contact from "@models/Contact"; import config from "@config"; -async function currentUserChecker(action: Action) { - const apiKeyOrContact = await AuthService.check(action.request); - // API key isn't a user - return apiKeyOrContact === true ? undefined : apiKeyOrContact; +function currentUserChecker(action: { request: Request }): Contact | undefined { + return action.request.auth?.entity instanceof Contact + ? action.request.auth.entity + : undefined; } -async function authorizationChecker(action: Action, roles: RoleType[]) { - const apiKeyOrContact = await AuthService.check(action.request); - // API key has superadmin abilities - return apiKeyOrContact === true - ? true - : roles.every((role) => apiKeyOrContact?.hasRole(role)); +function authorizationChecker( + action: { request: Request }, + roles: RoleType[] +): boolean { + return roles.every((r) => action.request.auth?.roles.includes(r)); } const app = express(); @@ -91,6 +91,7 @@ initApp() UploadController ], interceptors: [ValidateResponseInterceptor], + middlewares: [AuthMiddleware], currentUserChecker, authorizationChecker, validation: { diff --git a/src/api/controllers/ApiKeyController.ts b/src/api/controllers/ApiKeyController.ts index 2084a949e..3026fcf9c 100644 --- a/src/api/controllers/ApiKeyController.ts +++ b/src/api/controllers/ApiKeyController.ts @@ -18,7 +18,9 @@ import { generateApiKey } from "@core/utils/auth"; import ApiKey from "@models/ApiKey"; import Contact from "@models/Contact"; +import UnauthorizedError from "@api/errors/UnauthorizedError"; +import { CurrentAuth } from "@api/decorators/CurrentAuth"; import { CreateApiKeyDto, GetApiKeyDto, @@ -28,30 +30,39 @@ import { import { PaginatedDto } from "@api/dto/PaginatedDto"; import ApiKeyTransformer from "@api/transformers/ApiKeyTransformer"; +import { AuthInfo } from "@type/auth-info"; + @JsonController("/api-key") @Authorized("admin") export class ApiKeyController { @Get("/") async getApiKeys( - @CurrentUser({ required: true }) caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @QueryParams() query: ListApiKeysDto ): Promise> { - return await ApiKeyTransformer.fetch(caller, query); + return await ApiKeyTransformer.fetch(auth, query); } @Get("/:id") async getApiKey( - @CurrentUser({ required: true }) caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @Param("id") id: string ): Promise { - return await ApiKeyTransformer.fetchOneById(caller, id); + return await ApiKeyTransformer.fetchOneById(auth, id); } @Post("/") async createApiKey( - @Body() data: CreateApiKeyDto, - @CurrentUser({ required: true }) creator: Contact + @CurrentAuth({ required: true }) auth: AuthInfo, + @CurrentUser({ required: true }) creator: Contact, + @Body() data: CreateApiKeyDto ): Promise { + if (auth.method === "api-key") { + throw new UnauthorizedError({ + message: "API key cannot create API keys" + }); + } + const { id, secretHash, token } = generateApiKey(); await getRepository(ApiKey).save({ diff --git a/src/api/controllers/CalloutController.ts b/src/api/controllers/CalloutController.ts index 68dbad662..510ec16a3 100644 --- a/src/api/controllers/CalloutController.ts +++ b/src/api/controllers/CalloutController.ts @@ -42,6 +42,7 @@ import { import { CreateCalloutTagDto, GetCalloutTagDto } from "@api/dto/CalloutTagDto"; import { PaginatedDto } from "@api/dto/PaginatedDto"; +import { CurrentAuth } from "@api/decorators/CurrentAuth"; import PartialBody from "@api/decorators/PartialBody"; import DuplicateId from "@api/errors/DuplicateId"; import InvalidCalloutResponse from "@api/errors/InvalidCalloutResponse"; @@ -57,14 +58,16 @@ import CalloutResponse from "@models/CalloutResponse"; import CalloutResponseTag from "@models/CalloutResponseTag"; import CalloutTag from "@models/CalloutTag"; +import { AuthInfo } from "@type/auth-info"; + @JsonController("/callout") export class CalloutController { @Get("/") async getCallouts( - @CurrentUser({ required: false }) caller: Contact | undefined, + @CurrentAuth() auth: AuthInfo | undefined, @QueryParams() query: ListCalloutsDto ): Promise> { - return CalloutTransformer.fetch(caller, query); + return CalloutTransformer.fetch(auth, query); } @Authorized("admin") @@ -82,11 +85,11 @@ export class CalloutController { @Get("/:slug") async getCallout( - @CurrentUser({ required: false }) caller: Contact | undefined, + @CurrentAuth() auth: AuthInfo | undefined, @Param("slug") slug: string, @QueryParams() query: GetCalloutOptsDto ): Promise { - return CalloutTransformer.fetchOneById(caller, slug, { + return CalloutTransformer.fetchOneById(auth, slug, { ...query, showHiddenForAll: true }); @@ -95,7 +98,7 @@ export class CalloutController { @Authorized("admin") @Patch("/:slug") async updateCallout( - @CurrentUser({ required: true }) caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @Param("slug") slug: string, @PartialBody() data: CreateCalloutDto // Should be Partial ): Promise { @@ -122,7 +125,7 @@ export class CalloutController { data.formSchema as QueryDeepPartialEntity }) }); - return await CalloutTransformer.fetchOneById(caller, newSlug); + return await CalloutTransformer.fetchOneById(auth, newSlug); } catch (err) { throw isDuplicateIndex(err, "slug") ? new DuplicateId(newSlug) : err; } @@ -141,26 +144,22 @@ export class CalloutController { @Get("/:slug/responses") async getCalloutResponses( - @CurrentUser() caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @Param("slug") slug: string, @QueryParams() query: ListCalloutResponsesDto ): Promise> { - return await CalloutResponseTransformer.fetchForCallout( - caller, - slug, - query - ); + return await CalloutResponseTransformer.fetchForCallout(auth, slug, query); } @Get("/:slug/responses.csv") async exportCalloutResponses( - @CurrentUser() caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @Param("slug") slug: string, @QueryParams() query: GetExportQuery, @Res() res: Response ): Promise { const [exportName, exportData] = await CalloutResponseExporter.export( - caller, + auth, slug, query ); @@ -170,12 +169,12 @@ export class CalloutController { @Get("/:slug/responses/map") async getCalloutResponsesMap( - @CurrentUser({ required: false }) caller: Contact | undefined, + @CurrentAuth() auth: AuthInfo | undefined, @Param("slug") slug: string, @QueryParams() query: ListCalloutResponsesDto ): Promise> { return await CalloutResponseMapTransformer.fetchForCallout( - caller, + auth, slug, query ); @@ -184,7 +183,7 @@ export class CalloutController { @Post("/:slug/responses") @OnUndefined(204) async createCalloutResponse( - @CurrentUser({ required: false }) caller: Contact | undefined, + @CurrentUser() caller: Contact | undefined, @Param("slug") slug: string, @Body() data: CreateCalloutResponseDto ): Promise { @@ -213,10 +212,10 @@ export class CalloutController { @Authorized("admin") @Get("/:slug/tags") async getCalloutTags( - @CurrentUser() caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @Param("slug") slug: string ): Promise { - const result = await CalloutTagTransformer.fetch(caller, { + const result = await CalloutTagTransformer.fetch(auth, { rules: { condition: "AND", rules: [{ field: "calloutSlug", operator: "equal", value: [slug] }] @@ -245,16 +244,16 @@ export class CalloutController { @Authorized("admin") @Get("/:slug/tags/:tag") async getCalloutTag( - @CurrentUser() caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @Param("tag") tagId: string ): Promise { - return CalloutTagTransformer.fetchOneById(caller, tagId); + return CalloutTagTransformer.fetchOneById(auth, tagId); } @Authorized("admin") @Patch("/:slug/tags/:tag") async updateCalloutTag( - @CurrentUser() caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @Param("slug") slug: string, @Param("tag") tagId: string, @PartialBody() data: CreateCalloutTagDto // Partial @@ -264,7 +263,7 @@ export class CalloutController { data ); - return CalloutTagTransformer.fetchOneById(caller, tagId); + return CalloutTagTransformer.fetchOneById(auth, tagId); } @Authorized("admin") diff --git a/src/api/controllers/CalloutResponseCommentController.ts b/src/api/controllers/CalloutResponseCommentController.ts index 395b69261..ad71ca5a8 100644 --- a/src/api/controllers/CalloutResponseCommentController.ts +++ b/src/api/controllers/CalloutResponseCommentController.ts @@ -15,6 +15,7 @@ import { import { getRepository } from "@core/database"; +import { CurrentAuth } from "@api/decorators/CurrentAuth"; import PartialBody from "@api/decorators/PartialBody"; import { CreateCalloutResponseCommentDto, @@ -29,6 +30,8 @@ import CalloutResponseCommentTransformer from "@api/transformers/CalloutResponse import CalloutResponseComment from "@models/CalloutResponseComment"; import Contact from "@models/Contact"; +import { AuthInfo } from "@type/auth-info"; + @JsonController("/callout-response-comments") @Authorized("admin") export class CalloutResponseCommentController { @@ -40,8 +43,8 @@ export class CalloutResponseCommentController { const comment: CalloutResponseComment = await getRepository( CalloutResponseComment ).save({ + contact, text: data.text, - contact: contact, response: { id: data.responseId } }); return CalloutResponseCommentTransformer.convert(comment); @@ -49,28 +52,28 @@ export class CalloutResponseCommentController { @Get("/") async getCalloutResponseComments( - @CurrentUser({ required: true }) caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @QueryParams() query: ListCalloutResponseCommentsDto ): Promise> { - return await CalloutResponseCommentTransformer.fetch(caller, query); + return await CalloutResponseCommentTransformer.fetch(auth, query); } @Get("/:id") async getCalloutResponseComment( - @CurrentUser({ required: true }) caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @Params() { id }: UUIDParams ): Promise { - return await CalloutResponseCommentTransformer.fetchOneById(caller, id); + return await CalloutResponseCommentTransformer.fetchOneById(auth, id); } @Patch("/:id") async updateCalloutResponseComment( - @CurrentUser({ required: true }) caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @Params() { id }: UUIDParams, @PartialBody() data: CreateCalloutResponseCommentDto ): Promise { await getRepository(CalloutResponseComment).update(id, data); - return await CalloutResponseCommentTransformer.fetchOneById(caller, id); + return await CalloutResponseCommentTransformer.fetchOneById(auth, id); } @OnUndefined(204) diff --git a/src/api/controllers/CalloutResponseController.ts b/src/api/controllers/CalloutResponseController.ts index 22a6b6982..f1b65fbd4 100644 --- a/src/api/controllers/CalloutResponseController.ts +++ b/src/api/controllers/CalloutResponseController.ts @@ -1,7 +1,6 @@ import { plainToInstance } from "class-transformer"; import { Authorized, - CurrentUser, Get, JsonController, Params, @@ -9,6 +8,7 @@ import { QueryParams } from "routing-controllers"; +import { CurrentAuth } from "@api/decorators/CurrentAuth"; import PartialBody from "@api/decorators/PartialBody"; import { UUIDParams } from "@api/params/UUIDParams"; @@ -23,44 +23,44 @@ import { import { PaginatedDto } from "@api/dto/PaginatedDto"; import CalloutResponseTransformer from "@api/transformers/CalloutResponseTransformer"; -import Contact from "@models/Contact"; +import { AuthInfo } from "@type/auth-info"; @JsonController("/callout-responses") export class CalloutResponseController { @Get("/") async getCalloutResponses( - @CurrentUser() caller: Contact, + @CurrentAuth() auth: AuthInfo | undefined, @QueryParams() query: ListCalloutResponsesDto ): Promise> { - return CalloutResponseTransformer.fetch(caller, query); + return CalloutResponseTransformer.fetch(auth, query); } @Authorized("admin") @Patch("/") async updateCalloutResponses( - @CurrentUser() caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @PartialBody() data: BatchUpdateCalloutResponseDto ): Promise { - const affected = await CalloutResponseTransformer.update(caller, data); + const affected = await CalloutResponseTransformer.update(auth, data); return plainToInstance(BatchUpdateCalloutResponseResultDto, { affected }); } @Get("/:id") async getCalloutResponse( - @CurrentUser() caller: Contact, + @CurrentAuth() auth: AuthInfo | undefined, @Params() { id }: UUIDParams, @QueryParams() query: GetCalloutResponseOptsDto ): Promise { - return await CalloutResponseTransformer.fetchOneById(caller, id, query); + return await CalloutResponseTransformer.fetchOneById(auth, id, query); } @Authorized("admin") @Patch("/:id") async updateCalloutResponse( - @CurrentUser() caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @Params() { id }: UUIDParams, @PartialBody() data: CreateCalloutResponseDto // Should be Partial ): Promise { - await CalloutResponseTransformer.updateOneById(caller, id, data); - return await CalloutResponseTransformer.fetchOneById(caller, id); + await CalloutResponseTransformer.updateOneById(auth, id, data); + return await CalloutResponseTransformer.fetchOneById(auth, id); } } diff --git a/src/api/controllers/ContactController.ts b/src/api/controllers/ContactController.ts index eec8ce646..2c94f3535 100644 --- a/src/api/controllers/ContactController.ts +++ b/src/api/controllers/ContactController.ts @@ -6,7 +6,6 @@ import { BadRequestError, Body, createParamDecorator, - CurrentUser, Delete, Get, JsonController, @@ -20,7 +19,6 @@ import { Res } from "routing-controllers"; -import AuthService from "@core/services/AuthService"; import ContactsService from "@core/services/ContactsService"; import OptionsService from "@core/services/OptionsService"; import PaymentFlowService from "@core/services/PaymentFlowService"; @@ -58,7 +56,9 @@ import { import { CompleteJoinFlowDto, StartJoinFlowDto } from "@api/dto/JoinFlowDto"; import { PaginatedDto } from "@api/dto/PaginatedDto"; import { GetPaymentDto, ListPaymentsDto } from "@api/dto/PaymentDto"; +import { GetPaymentFlowDto } from "@api/dto/PaymentFlowDto"; +import { CurrentAuth } from "@api/decorators/CurrentAuth"; import PartialBody from "@api/decorators/PartialBody"; import { UnauthorizedError } from "@api/errors/UnauthorizedError"; import CantUpdateContribution from "@api/errors/CantUpdateContribution"; @@ -74,7 +74,8 @@ import ContactRoleTransformer from "@api/transformers/ContactRoleTransformer"; import PaymentTransformer from "@api/transformers/PaymentTransformer"; import { GetContactWith } from "@enums/get-contact-with"; -import { GetPaymentFlowDto } from "@api/dto/PaymentFlowDto"; + +import { AuthInfo } from "@type/auth-info"; /** * The target user can either be the current user or for admins @@ -87,13 +88,13 @@ function TargetUser() { value: async (action): Promise => { const request: Request = action.request; - const auth = await AuthService.check(request); + const auth = request.auth; if (!auth) { throw new UnauthorizedError(); } const id = request.params.id; - if (auth === true || (id !== "me" && auth.hasRole("admin"))) { + if (id !== "me" && auth.roles.includes("admin")) { const uuid = new UUIDParams(); uuid.id = id; await validateOrReject(uuid); @@ -104,8 +105,11 @@ function TargetUser() { } else { throw new NotFoundError(); } - } else if (id === "me" || id === auth.id) { - return auth; + } else if ( + auth.entity instanceof Contact && + (id === "me" || id === auth.entity.id) + ) { + return auth.entity; } else { throw new UnauthorizedError(); } @@ -119,7 +123,7 @@ export class ContactController { @Authorized("admin") @Post("/") async createContact( - @CurrentUser() caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @Body() data: CreateContactDto ): Promise { const contact = await ContactsService.createContact( @@ -162,46 +166,43 @@ export class ContactController { ...(data.roles ? [GetContactWith.Roles] : []) ] }, - caller + auth ); } @Authorized("admin") @Get("/") async getContacts( - @CurrentUser() caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @QueryParams() query: ListContactsDto ): Promise> { - return await ContactTransformer.fetch(caller, query); + return await ContactTransformer.fetch(auth, query); } @Authorized("admin") @Get(".csv") async exportContacts( - @CurrentUser() caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @QueryParams() query: GetExportQuery, @Res() res: Response ): Promise { - const [exportName, exportData] = await ContactExporter.export( - caller, - query - ); + const [exportName, exportData] = await ContactExporter.export(auth, query); res.attachment(exportName).send(exportData); return res; } @Get("/:id") async getContact( - @CurrentUser() caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @TargetUser() target: Contact, @QueryParams() query: GetContactOptsDto ): Promise { - return await ContactTransformer.fetchOneById(caller, target.id, query); + return await ContactTransformer.fetchOneById(auth, target.id, query); } @Patch("/:id") async updateContact( - @CurrentUser() caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @TargetUser() target: Contact, @PartialBody() data: UpdateContactDto // Should be Partial ): Promise { @@ -218,7 +219,7 @@ export class ContactController { if (data.profile) { if ( - !caller.hasRole("admin") && + !auth.roles.includes("admin") && (data.profile.tags || data.profile.notes || data.profile.description) ) { throw new UnauthorizedError(); @@ -227,7 +228,7 @@ export class ContactController { await ContactsService.updateContactProfile(target, data.profile); } - return await ContactTransformer.fetchOneById(caller, target.id, { + return await ContactTransformer.fetchOneById(auth, target.id, { with: data.profile ? [GetContactWith.Profile] : [] }); } @@ -348,11 +349,11 @@ export class ContactController { @Get("/:id/payment") async getPayments( - @CurrentUser() caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @TargetUser() target: Contact, @QueryParams() query: ListPaymentsDto ): Promise> { - return PaymentTransformer.fetch(caller, { + return PaymentTransformer.fetch(auth, { ...query, rules: mergeRules([ query.rules, @@ -451,7 +452,7 @@ export class ContactController { @Authorized("admin") @Put("/:id/role/:roleType") async updateRole( - @CurrentUser() caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @TargetUser() target: Contact, @Params() { roleType }: ContactRoleParams, @Body() data: UpdateContactRoleDto @@ -460,7 +461,7 @@ export class ContactController { throw new BadRequestError(); } - if (roleType === "superadmin" && !caller.hasRole("superadmin")) { + if (roleType === "superadmin" && !auth.roles.includes("superadmin")) { throw new UnauthorizedError(); } @@ -476,11 +477,11 @@ export class ContactController { @Delete("/:id/role/:roleType") @OnUndefined(201) async deleteRole( - @CurrentUser() caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @TargetUser() target: Contact, @Params() { roleType }: ContactRoleParams ): Promise { - if (roleType === "superadmin" && !caller.hasRole("superadmin")) { + if (roleType === "superadmin" && !auth.roles.includes("superadmin")) { throw new UnauthorizedError(); } diff --git a/src/api/controllers/NoticeController.ts b/src/api/controllers/NoticeController.ts index 412c27a9b..9075184c6 100644 --- a/src/api/controllers/NoticeController.ts +++ b/src/api/controllers/NoticeController.ts @@ -1,7 +1,6 @@ import { Authorized, Body, - CurrentUser, Delete, Get, JsonController, @@ -15,9 +14,7 @@ import { import { getRepository } from "@core/database"; -import Contact from "@models/Contact"; -import Notice from "@models/Notice"; - +import { CurrentAuth } from "@api/decorators/CurrentAuth"; import PartialBody from "@api/decorators/PartialBody"; import { CreateNoticeDto, @@ -28,23 +25,27 @@ import { PaginatedDto } from "@api/dto/PaginatedDto"; import { UUIDParams } from "@api/params/UUIDParams"; import NoticeTransformer from "@api/transformers/NoticeTransformer"; +import Notice from "@models/Notice"; + +import { AuthInfo } from "@type/auth-info"; + @JsonController("/notice") @Authorized() export class NoticeController { @Get("/") async getNotices( - @CurrentUser() caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @QueryParams() query: ListNoticesDto ): Promise> { - return await NoticeTransformer.fetch(caller, query); + return await NoticeTransformer.fetch(auth, query); } @Get("/:id") async getNotice( - @CurrentUser() caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @Params() { id }: UUIDParams ): Promise { - return await NoticeTransformer.fetchOneById(caller, id); + return await NoticeTransformer.fetchOneById(auth, id); } @Post("/") @@ -57,12 +58,12 @@ export class NoticeController { @Patch("/:id") @Authorized("admin") async updateNotice( - @CurrentUser() caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @Params() { id }: UUIDParams, @PartialBody() data: CreateNoticeDto ): Promise { await getRepository(Notice).update(id, data); - return await NoticeTransformer.fetchOneById(caller, id); + return await NoticeTransformer.fetchOneById(auth, id); } @OnUndefined(204) diff --git a/src/api/controllers/PaymentController.ts b/src/api/controllers/PaymentController.ts index b07059ce7..2ed709d0d 100644 --- a/src/api/controllers/PaymentController.ts +++ b/src/api/controllers/PaymentController.ts @@ -1,12 +1,12 @@ import { Authorized, - CurrentUser, Get, JsonController, Param, QueryParams } from "routing-controllers"; +import { CurrentAuth } from "@api/decorators/CurrentAuth"; import { PaginatedDto } from "@api/dto/PaginatedDto"; import { GetPaymentDto, @@ -15,25 +15,25 @@ import { } from "@api/dto/PaymentDto"; import PaymentTransformer from "@api/transformers/PaymentTransformer"; -import Contact from "@models/Contact"; +import { AuthInfo } from "@type/auth-info"; @JsonController("/payment") @Authorized() export class PaymentController { @Get("/") async getPayments( - @CurrentUser() caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @QueryParams() query: ListPaymentsDto ): Promise> { - return await PaymentTransformer.fetch(caller, query); + return await PaymentTransformer.fetch(auth, query); } @Get("/:id") async getPayment( - @CurrentUser() caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @Param("id") id: string, @QueryParams() query: GetPaymentOptsDto ): Promise { - return await PaymentTransformer.fetchOneById(caller, id, query); + return await PaymentTransformer.fetchOneById(auth, id, query); } } diff --git a/src/api/controllers/SegmentController.ts b/src/api/controllers/SegmentController.ts index 59e04a0b1..7434ba406 100644 --- a/src/api/controllers/SegmentController.ts +++ b/src/api/controllers/SegmentController.ts @@ -1,7 +1,6 @@ import { Authorized, Body, - CurrentUser, Delete, Get, JsonController, @@ -15,6 +14,8 @@ import { import { getRepository } from "@core/database"; +import { CurrentAuth } from "@api/decorators/CurrentAuth"; +import PartialBody from "@api/decorators/PartialBody"; import { GetContactDto, ListContactsDto } from "@api/dto/ContactDto"; import { GetSegmentDto, @@ -24,31 +25,31 @@ import { GetSegmentOptsDto } from "@api/dto/SegmentDto"; import { PaginatedDto } from "@api/dto/PaginatedDto"; -import PartialBody from "@api/decorators/PartialBody"; import { UUIDParams } from "@api/params/UUIDParams"; import ContactTransformer from "@api/transformers/ContactTransformer"; import SegmentTransformer from "@api/transformers/SegmentTransformer"; -import Contact from "@models/Contact"; import Segment from "@models/Segment"; import SegmentContact from "@models/SegmentContact"; import SegmentOngoingEmail from "@models/SegmentOngoingEmail"; +import { AuthInfo } from "@type/auth-info"; + @JsonController("/segments") @Authorized("admin") export class SegmentController { @Get("/") async getSegments( - @CurrentUser() caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @QueryParams() query: ListSegmentsDto ): Promise { - const result = await SegmentTransformer.fetch(caller, query); + const result = await SegmentTransformer.fetch(auth, query); return result.items; // TODO: return paginated } @Post("/") async createSegment( - @CurrentUser() caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @Body() data: CreateSegmentDto ): Promise { // Default to inserting new segment at the bottom @@ -63,28 +64,28 @@ export class SegmentController { const segment = await getRepository(Segment).save(data); // Use fetchOne to ensure that the segment has a contactCount - return await SegmentTransformer.fetchOneByIdOrFail(caller, segment.id, { + return await SegmentTransformer.fetchOneByIdOrFail(auth, segment.id, { with: [GetSegmentWith.contactCount] }); } @Get("/:id") async getSegment( - @CurrentUser() caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @Params() { id }: UUIDParams, @QueryParams() opts: GetSegmentOptsDto ): Promise { - return await SegmentTransformer.fetchOneById(caller, id, opts); + return await SegmentTransformer.fetchOneById(auth, id, opts); } @Patch("/:id") async updateSegment( - @CurrentUser() caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @Params() { id }: UUIDParams, @PartialBody() data: CreateSegmentDto ): Promise { await getRepository(Segment).update(id, data); - return await SegmentTransformer.fetchOneById(caller, id, { + return await SegmentTransformer.fetchOneById(auth, id, { with: [GetSegmentWith.contactCount] }); } @@ -102,13 +103,13 @@ export class SegmentController { @Get("/:id/contacts") async getSegmentContacts( - @CurrentUser() caller: Contact, + @CurrentAuth({ required: true }) auth: AuthInfo, @Params() { id }: UUIDParams, @QueryParams() query: ListContactsDto ): Promise | undefined> { const segment = await getRepository(Segment).findOneBy({ id }); if (segment) { - return await ContactTransformer.fetch(caller, { + return await ContactTransformer.fetch(auth, { ...query, rules: query.rules ? { diff --git a/src/api/controllers/UploadController.ts b/src/api/controllers/UploadController.ts index 1bb67f738..ad41d1537 100644 --- a/src/api/controllers/UploadController.ts +++ b/src/api/controllers/UploadController.ts @@ -36,7 +36,7 @@ async function canUploadOrFail(ipAddress: string, date: Date, max: number) { export class UploadController { @Post("/") async create( - @CurrentUser({ required: false }) contact: Contact | undefined, + @CurrentUser() contact: Contact | undefined, @Req() req: Request ): Promise { if (!req.ip) { diff --git a/src/api/decorators/CurrentAuth.ts b/src/api/decorators/CurrentAuth.ts new file mode 100644 index 000000000..3e8e4a7c6 --- /dev/null +++ b/src/api/decorators/CurrentAuth.ts @@ -0,0 +1,13 @@ +import { Request } from "express"; +import { createParamDecorator } from "routing-controllers"; + +import { AuthInfo } from "@type/auth-info"; + +export function CurrentAuth(options?: { required?: boolean }) { + return createParamDecorator({ + required: options && options.required ? true : false, + value: (action: { request: Request }): AuthInfo | undefined => { + return action.request.auth; + } + }); +} diff --git a/src/api/middlewares/AuthMiddleware.ts b/src/api/middlewares/AuthMiddleware.ts new file mode 100644 index 000000000..9a33834e9 --- /dev/null +++ b/src/api/middlewares/AuthMiddleware.ts @@ -0,0 +1,69 @@ +import crypto from "node:crypto"; + +import { Request, Response } from "express"; +import { Middleware, ExpressMiddlewareInterface } from "routing-controllers"; + +import { getRepository } from "@core/database"; + +import ContactsService from "@core/services/ContactsService"; + +import Contact from "@models/Contact"; +import ApiKey from "@models/ApiKey"; +import { AuthInfo } from "@type/auth-info"; + +@Middleware({ type: "before" }) +export class AuthMiddleware implements ExpressMiddlewareInterface { + async use( + req: Request, + res: Response, + next: (err?: any) => any + ): Promise { + req.auth = await getAuth(req); + next(); + } +} + +async function getAuth(request: Request): Promise { + const headers = request.headers; + const authHeader = headers.authorization; + + // If there's a bearer key check API key + if (authHeader?.startsWith("Bearer ")) { + const apiKey = await getValidApiKey(authHeader.substring(7)); + if (apiKey) { + // API key can act as a user + const contactId = headers["x-contact-id"]?.toString(); + if (contactId) { + const contact = await ContactsService.findOneBy({ id: contactId }); + if (contact) { + return { + method: "api-key", + entity: contact, + roles: contact.activeRoles + }; + } + } else { + return { + method: "api-key", + entity: apiKey, + roles: apiKey.activeRoles + }; + } + } + } else if (request.user) { + return { + method: "user", + entity: request.user, + roles: request.user.activeRoles + }; + } +} + +async function getValidApiKey(key: string): Promise { + const [_, secret] = key.split("_"); + const secretHash = crypto.createHash("sha256").update(secret).digest("hex"); + const apiKey = await getRepository(ApiKey).findOneBy({ secretHash }); + return !!apiKey && (!apiKey.expires || apiKey.expires > new Date()) + ? apiKey + : undefined; +} diff --git a/src/api/params/CalloutResponseParams.ts b/src/api/params/CalloutResponseParams.ts index 879548d3a..45e80ec23 100644 --- a/src/api/params/CalloutResponseParams.ts +++ b/src/api/params/CalloutResponseParams.ts @@ -1,5 +1,4 @@ import { IsString, IsUUID } from "class-validator"; -import { UUIDParams } from "@api/params/UUIDParams"; export class CalloutResponseParams { @IsUUID("4") diff --git a/src/api/transformers/BaseCalloutResponseTransformer.ts b/src/api/transformers/BaseCalloutResponseTransformer.ts index f8f9d8258..bb079889a 100644 --- a/src/api/transformers/BaseCalloutResponseTransformer.ts +++ b/src/api/transformers/BaseCalloutResponseTransformer.ts @@ -15,8 +15,8 @@ import { mergeRules } from "@api/utils/rules"; import CalloutResponse from "@models/CalloutResponse"; import CalloutResponseTag from "@models/CalloutResponseTag"; -import Contact from "@models/Contact"; +import { AuthInfo } from "@type/auth-info"; import { FilterHandler, FilterHandlers } from "@type/filter-handlers"; export abstract class BaseCalloutResponseTransformer< @@ -57,14 +57,14 @@ export abstract class BaseCalloutResponseTransformer< protected transformQuery( query: T, - caller: Contact | undefined + auth: AuthInfo | undefined ): T { return { ...query, rules: mergeRules([ query.rules, // Non admins can only see their own responses - !caller?.hasRole("admin") && { + !auth?.roles.includes("admin") && { field: "contact", operator: "equal", value: ["me"] diff --git a/src/api/transformers/BaseTransformer.ts b/src/api/transformers/BaseTransformer.ts index 8188461c3..3455c328d 100644 --- a/src/api/transformers/BaseTransformer.ts +++ b/src/api/transformers/BaseTransformer.ts @@ -18,6 +18,7 @@ import { convertRulesToWhereClause } from "@api/utils/rules"; import Contact from "@models/Contact"; +import { AuthInfo } from "@type/auth-info"; import { FilterHandlers } from "@type/filter-handlers"; /** @@ -38,7 +39,7 @@ export abstract class BaseTransformer< protected allowedRoles: RoleType[] | undefined; - abstract convert(model: Model, opts: GetDtoOpts, caller?: Contact): GetDto; + abstract convert(model: Model, opts: GetDtoOpts, auth?: AuthInfo): GetDto; /** * Transform the query before the results are fetched. @@ -52,7 +53,7 @@ export abstract class BaseTransformer< */ protected transformQuery( query: T, - caller: Contact | undefined + auth: AuthInfo | undefined ): T { return query; } @@ -68,7 +69,7 @@ export abstract class BaseTransformer< */ protected transformFilters( query: Query, - caller: Contact | undefined + auth: AuthInfo | undefined ): [Partial>, FilterHandlers] { return [{}, {}]; } @@ -89,7 +90,7 @@ export abstract class BaseTransformer< qb: SelectQueryBuilder, fieldPrefix: string, query: Query, - caller: Contact | undefined + auth: AuthInfo | undefined ): void {} /** @@ -105,7 +106,7 @@ export abstract class BaseTransformer< protected async modifyItems( items: Model[], query: Query, - caller: Contact | undefined + auth: AuthInfo | undefined ): Promise {} /** @@ -113,23 +114,23 @@ export abstract class BaseTransformer< * filters and filter handlers. * * @param query The query - * @param caller The contact who is requesting the results + * @param auth The contact who is requesting the results */ protected preFetch( query: T, - caller: Contact | undefined + auth: AuthInfo | undefined ): [T, Filters, FilterHandlers] { if ( this.allowedRoles && - !this.allowedRoles.some((r) => caller?.hasRole(r)) + !this.allowedRoles.some((r) => auth?.roles.includes(r)) ) { throw new UnauthorizedError(); } - const [filters, filterHandlers] = this.transformFilters(query, caller); + const [filters, filterHandlers] = this.transformFilters(query, auth); return [ - this.transformQuery(query, caller), + this.transformQuery(query, auth), { ...this.filters, ...filters }, { ...this.filterHandlers, ...filterHandlers } ]; @@ -138,15 +139,15 @@ export abstract class BaseTransformer< /** * Fetch a list of items * - * @param caller The contact who is requesting the results + * @param auth The contact who is requesting the results * @param query_ The query * @returns A list of items that match the query */ async fetch( - caller: Contact | undefined, + auth: AuthInfo | undefined, query_: Query ): Promise> { - const [query, filters, filterHandlers] = this.preFetch(query_, caller); + const [query, filters, filterHandlers] = this.preFetch(query_, auth); const limit = query.limit || 50; const offset = query.offset || 0; @@ -164,7 +165,7 @@ export abstract class BaseTransformer< qb.where( ...convertRulesToWhereClause( ruleGroup, - caller, + auth?.entity instanceof Contact ? auth.entity : undefined, filterHandlers, "item." ) @@ -175,17 +176,17 @@ export abstract class BaseTransformer< qb.orderBy(`item."${query.sort}"`, query.order || "ASC", "NULLS LAST"); } - this.modifyQueryBuilder(qb, "item.", query, caller); + this.modifyQueryBuilder(qb, "item.", query, auth); const [items, total] = await qb.getManyAndCount(); - await this.modifyItems(items, query, caller); + await this.modifyItems(items, query, auth); return plainToInstance(PaginatedDto, { total, offset, count: items.length, - items: items.map((item) => this.convert(item, query, caller)) + items: items.map((item) => this.convert(item, query, auth)) }); } catch (err) { throw err instanceof InvalidRule @@ -197,15 +198,15 @@ export abstract class BaseTransformer< /** * Fetch a single item * - * @param caller The contact who is requesting the results + * @param auth The contact who is requesting the results * @param query The query * @returns A single item or undefined if not found */ async fetchOne( - caller: Contact | undefined, + auth: AuthInfo | undefined, query: Query ): Promise { - const result = await this.fetch(caller, { ...query, limit: 1 }); + const result = await this.fetch(auth, { ...query, limit: 1 }); return result.items[0]; } @@ -218,7 +219,7 @@ export abstract class BaseTransformer< * @returns A single item or undefined if not found */ async fetchOneById( - caller: Contact | undefined, + auth: AuthInfo | undefined, id: string, opts?: GetDtoOpts ): Promise { @@ -230,23 +231,23 @@ export abstract class BaseTransformer< } } as Query; - return await this.fetchOne(caller, query); + return await this.fetchOne(auth, query); } /** * Fetch a single item by it's primary key or throw an error if not found * - * @param caller The contact who is requesting the results + * @param auth The contact who is requesting the results * @param id The primary key of the item * @param opts Additional options to pass to the query * @returns A single item */ async fetchOneByIdOrFail( - caller: Contact | undefined, + auth: AuthInfo | undefined, id: string, opts?: GetDtoOpts ): Promise { - const result = await this.fetchOneById(caller, id, opts); + const result = await this.fetchOneById(auth, id, opts); if (!result) { throw new NotFoundError(); } @@ -256,11 +257,11 @@ export abstract class BaseTransformer< /** * Fetch the number of items that match the query * - * @param caller The contact who is requesting the results + * @param auth The contact who is requesting the results * @param query The query * @returns The number of items that match the query */ - async count(caller: Contact | undefined, query: Query): Promise { - return (await this.fetch(caller, { ...query, limit: 0 })).total; + async count(auth: AuthInfo | undefined, query: Query): Promise { + return (await this.fetch(auth, { ...query, limit: 0 })).total; } } diff --git a/src/api/transformers/CalloutResponseCommentTransformer.ts b/src/api/transformers/CalloutResponseCommentTransformer.ts index a9edfa7c0..aa66d8d8f 100644 --- a/src/api/transformers/CalloutResponseCommentTransformer.ts +++ b/src/api/transformers/CalloutResponseCommentTransformer.ts @@ -16,7 +16,8 @@ import ContactTransformer, { import { mergeRules } from "@api/utils/rules"; import CalloutResponseComment from "@models/CalloutResponseComment"; -import Contact from "@models/Contact"; + +import { AuthInfo } from "@type/auth-info"; class CalloutResponseCommentTransformer extends BaseTransformer< CalloutResponseComment, @@ -40,13 +41,13 @@ class CalloutResponseCommentTransformer extends BaseTransformer< protected transformQuery( query: T, - caller: Contact | undefined + auth: AuthInfo | undefined ): T { return { ...query, rules: mergeRules([ query.rules, - !caller?.hasRole("admin") && { + !auth?.roles.includes("admin") && { field: "contact", operator: "equal", value: ["me"] diff --git a/src/api/transformers/CalloutResponseExporter.ts b/src/api/transformers/CalloutResponseExporter.ts index 9f2296ba5..919f53100 100644 --- a/src/api/transformers/CalloutResponseExporter.ts +++ b/src/api/transformers/CalloutResponseExporter.ts @@ -20,9 +20,10 @@ import { groupBy } from "@api/utils"; import CalloutResponse from "@models/CalloutResponse"; import CalloutResponseComment from "@models/CalloutResponseComment"; -import Contact from "@models/Contact"; import Callout from "@models/Callout"; +import { AuthInfo } from "@type/auth-info"; + class CalloutResponseExporter extends BaseCalloutResponseTransformer< ExportCalloutResponseDto, ExportCalloutResponsesOptsDto @@ -86,7 +87,7 @@ class CalloutResponseExporter extends BaseCalloutResponseTransformer< } async export( - caller: Contact | undefined, + auth: AuthInfo | undefined, calloutSlug: string, query: GetExportQuery ): Promise<[string, string]> { @@ -101,7 +102,7 @@ class CalloutResponseExporter extends BaseCalloutResponseTransformer< (c) => c.input ); - const result = await this.fetch(caller, { + const result = await this.fetch(auth, { limit: -1, ...query, callout, diff --git a/src/api/transformers/CalloutResponseMapTransformer.ts b/src/api/transformers/CalloutResponseMapTransformer.ts index 5cb3f89cc..ca2455405 100644 --- a/src/api/transformers/CalloutResponseMapTransformer.ts +++ b/src/api/transformers/CalloutResponseMapTransformer.ts @@ -22,7 +22,8 @@ import { mergeRules } from "@api/utils/rules"; import Callout, { CalloutResponseViewSchema } from "@models/Callout"; import CalloutResponse from "@models/CalloutResponse"; -import Contact from "@models/Contact"; + +import { AuthInfo } from "@type/auth-info"; class CalloutResponseMapTransformer extends BaseCalloutResponseTransformer< GetCalloutResponseMapDto, @@ -99,7 +100,7 @@ class CalloutResponseMapTransformer extends BaseCalloutResponseTransformer< } async fetchForCallout( - caller: Contact | undefined, + auth: AuthInfo | undefined, calloutSlug: string, query: ListCalloutResponsesDto ): Promise> { @@ -114,7 +115,7 @@ class CalloutResponseMapTransformer extends BaseCalloutResponseTransformer< responseViewSchema: CalloutResponseViewSchema; }; - return await this.fetch(caller, { ...query, callout: calloutWithSchema }); + return await this.fetch(auth, { ...query, callout: calloutWithSchema }); } } diff --git a/src/api/transformers/CalloutResponseTransformer.ts b/src/api/transformers/CalloutResponseTransformer.ts index d32018a39..25176a410 100644 --- a/src/api/transformers/CalloutResponseTransformer.ts +++ b/src/api/transformers/CalloutResponseTransformer.ts @@ -29,6 +29,8 @@ import CalloutResponseComment from "@models/CalloutResponseComment"; import CalloutResponseTag from "@models/CalloutResponseTag"; import Contact from "@models/Contact"; +import { AuthInfo } from "@type/auth-info"; + export class CalloutResponseTransformer extends BaseCalloutResponseTransformer< GetCalloutResponseDto, GetCalloutResponseOptsDto @@ -136,7 +138,7 @@ export class CalloutResponseTransformer extends BaseCalloutResponseTransformer< } async fetchForCallout( - caller: Contact | undefined, + auth: AuthInfo | undefined, calloutSlug: string, query: ListCalloutResponsesDto ): Promise> { @@ -146,14 +148,14 @@ export class CalloutResponseTransformer extends BaseCalloutResponseTransformer< if (!callout) { throw new NotFoundError(); } - return await this.fetch(caller, { ...query, callout }); + return await this.fetch(auth, { ...query, callout }); } async update( - caller: Contact | undefined, + auth: AuthInfo | undefined, query: BatchUpdateCalloutResponseDto ): Promise { - const [query2, filters, filterHandlers] = this.preFetch(query, caller); + const [query2, filters, filterHandlers] = this.preFetch(query, auth); const { tagUpdates, responseUpdates } = getUpdateData(query2.updates); const result = await batchUpdate( @@ -161,7 +163,7 @@ export class CalloutResponseTransformer extends BaseCalloutResponseTransformer< filters, query2.rules, responseUpdates, - caller, + auth?.entity instanceof Contact ? auth.entity : undefined, filterHandlers, (qb) => qb.returning(["id"]) ); @@ -179,7 +181,7 @@ export class CalloutResponseTransformer extends BaseCalloutResponseTransformer< } async updateOneById( - caller: Contact | undefined, + auth: AuthInfo | undefined, id: string, updates: CreateCalloutResponseDto ): Promise { @@ -190,7 +192,7 @@ export class CalloutResponseTransformer extends BaseCalloutResponseTransformer< }, updates }; - const affected = await this.update(caller, query); + const affected = await this.update(auth, query); return affected !== 0; } } diff --git a/src/api/transformers/CalloutTransformer.ts b/src/api/transformers/CalloutTransformer.ts index ae730b36f..c8e89fd61 100644 --- a/src/api/transformers/CalloutTransformer.ts +++ b/src/api/transformers/CalloutTransformer.ts @@ -24,6 +24,7 @@ import Contact from "@models/Contact"; import Callout from "@models/Callout"; import CalloutResponse from "@models/CalloutResponse"; +import { AuthInfo } from "@type/auth-info"; import { FilterHandlers } from "@type/filter-handlers"; class CalloutTransformer extends BaseTransformer< @@ -39,7 +40,7 @@ class CalloutTransformer extends BaseTransformer< protected transformFilters( query: GetCalloutOptsDto & PaginatedQuery, - caller: Contact | undefined + auth: AuthInfo | undefined ): [Partial>, FilterHandlers] { return [ {}, @@ -51,8 +52,8 @@ class CalloutTransformer extends BaseTransformer< } if ( - !caller || - (args.value[0] !== caller.id && !caller.hasRole("admin")) + !auth?.roles.includes("admin") && + args.value[0] !== auth?.entity.id ) { throw new UnauthorizedError(); } @@ -113,9 +114,9 @@ class CalloutTransformer extends BaseTransformer< protected transformQuery( query: T, - caller: Contact | undefined + auth: AuthInfo | undefined ): T { - if (caller?.hasRole("admin")) { + if (auth?.roles.includes("admin")) { return query; } @@ -169,19 +170,19 @@ class CalloutTransformer extends BaseTransformer< protected async modifyItems( callouts: Callout[], query: ListCalloutsDto, - caller: Contact | undefined + auth: AuthInfo | undefined ): Promise { if ( - caller && callouts.length > 0 && + auth?.entity instanceof Contact && query.with?.includes(GetCalloutWith.HasAnswered) ) { const answeredCallouts = await createQueryBuilder(CalloutResponse, "cr") .select("cr.calloutSlug", "slug") .distinctOn(["cr.calloutSlug"]) .where("cr.calloutSlug IN (:...slugs) AND cr.contactId = :id", { - slugs: callouts.map((item) => item.slug), - id: caller.id + slugs: callouts.map((c) => c.slug), + id: auth.entity.id }) .orderBy("cr.calloutSlug") .getRawMany<{ slug: string }>(); diff --git a/src/api/transformers/ContactExporter.ts b/src/api/transformers/ContactExporter.ts index a987cef9f..d4c724d43 100644 --- a/src/api/transformers/ContactExporter.ts +++ b/src/api/transformers/ContactExporter.ts @@ -3,6 +3,7 @@ import { RoleType, contactFilters } from "@beabee/beabee-common"; +import { stringify } from "csv-stringify/sync"; import { SelectQueryBuilder } from "typeorm"; import { getMembershipStatus } from "@core/services/PaymentService"; @@ -13,7 +14,8 @@ import { BaseTransformer } from "@api/transformers/BaseTransformer"; import ContactTransformer from "@api/transformers/ContactTransformer"; import Contact from "@models/Contact"; -import { stringify } from "csv-stringify/sync"; + +import { AuthInfo } from "@type/auth-info"; class ContactExporter extends BaseTransformer< Contact, @@ -64,10 +66,10 @@ class ContactExporter extends BaseTransformer< } async export( - caller: Contact | undefined, + auth: AuthInfo | undefined, query?: GetExportQuery ): Promise<[string, string]> { - const result = await this.fetch(caller, { limit: -1, ...query }); + const result = await this.fetch(auth, { limit: -1, ...query }); const exportName = `contacts-${new Date().toISOString()}.csv`; return [exportName, stringify(result.items, { header: true })]; diff --git a/src/api/transformers/ContactProfileTransformer.ts b/src/api/transformers/ContactProfileTransformer.ts index 262d3cbfc..ee3d7d4d7 100644 --- a/src/api/transformers/ContactProfileTransformer.ts +++ b/src/api/transformers/ContactProfileTransformer.ts @@ -4,9 +4,10 @@ import { GetContactProfileDto } from "@api/dto/ContactProfileDto"; import AddressTransformer from "@api/transformers/AddressTransformer"; import { BaseTransformer } from "@api/transformers/BaseTransformer"; -import Contact from "@models/Contact"; import ContactProfile from "@models/ContactProfile"; +import { AuthInfo } from "@type/auth-info"; + class ContactProfileTransformer extends BaseTransformer< ContactProfile, GetContactProfileDto @@ -18,7 +19,7 @@ class ContactProfileTransformer extends BaseTransformer< convert( profile: ContactProfile, opts: unknown, - caller: Contact | undefined + auth: AuthInfo | undefined ): GetContactProfileDto { return { telephone: profile.telephone, @@ -30,7 +31,7 @@ class ContactProfileTransformer extends BaseTransformer< AddressTransformer.convert(profile.deliveryAddress), newsletterStatus: profile.newsletterStatus, newsletterGroups: profile.newsletterGroups, - ...(caller?.hasRole("admin") && { + ...(auth?.roles.includes("admin") && { tags: profile.tags, notes: profile.notes, description: profile.description diff --git a/src/api/transformers/ContactTransformer.ts b/src/api/transformers/ContactTransformer.ts index 58236b669..9e200acf8 100644 --- a/src/api/transformers/ContactTransformer.ts +++ b/src/api/transformers/ContactTransformer.ts @@ -21,6 +21,7 @@ import { mergeRules } from "@api/utils/rules"; import { GetContactWith } from "@enums/get-contact-with"; +import { AuthInfo } from "@type/auth-info"; import { FilterHandler, FilterHandlers } from "@type/filter-handlers"; class ContactTransformer extends BaseTransformer< @@ -38,19 +39,15 @@ class ContactTransformer extends BaseTransformer< convert( contact: Contact, opts?: GetContactOptsDto, - caller?: Contact | undefined + auth?: AuthInfo | undefined ): GetContactDto { - const activeRoles = [...contact.activeRoles]; - if (activeRoles.includes("superadmin")) { - activeRoles.push("admin"); - } - return { id: contact.id, email: contact.email, firstname: contact.firstname, lastname: contact.lastname, joined: contact.joined, + activeRoles: contact.activeRoles, ...(contact.lastSeen && { lastSeen: contact.lastSeen }), @@ -60,13 +57,12 @@ class ContactTransformer extends BaseTransformer< ...(contact.contributionPeriod && { contributionPeriod: contact.contributionPeriod }), - activeRoles, ...(opts?.with?.includes(GetContactWith.Profile) && contact.profile && { profile: ContactProfileTransformer.convert( contact.profile, undefined, - caller + auth ) }), ...(opts?.with?.includes(GetContactWith.Roles) && { @@ -80,13 +76,13 @@ class ContactTransformer extends BaseTransformer< protected transformQuery( query: T, - caller: Contact | undefined + auth: AuthInfo | undefined ): T { return { ...query, rules: mergeRules([ query.rules, - !caller?.hasRole("admin") && { + !auth?.roles.includes("admin") && { field: "id", operator: "equal", value: ["me"] diff --git a/src/api/transformers/NoticeTransformer.ts b/src/api/transformers/NoticeTransformer.ts index fec122c56..655585eb4 100644 --- a/src/api/transformers/NoticeTransformer.ts +++ b/src/api/transformers/NoticeTransformer.ts @@ -9,9 +9,10 @@ import { BaseTransformer } from "@api/transformers/BaseTransformer"; import { GetNoticeDto, ListNoticesDto } from "@api/dto/NoticeDto"; import { mergeRules, statusFilterHandler } from "@api/utils/rules"; -import Contact from "@models/Contact"; import Notice from "@models/Notice"; +import { AuthInfo } from "@type/auth-info"; + export class NoticeTransformer extends BaseTransformer< Notice, GetNoticeDto, @@ -39,14 +40,14 @@ export class NoticeTransformer extends BaseTransformer< protected transformQuery( query: T, - caller: Contact | undefined + auth: AuthInfo | undefined ): T { return { ...query, rules: mergeRules([ query.rules, // Non-admins can only see open notices - !caller?.hasRole("admin") && { + !auth?.roles.includes("admin") && { field: "status", operator: "equal", value: [ItemStatus.Open] diff --git a/src/api/transformers/PaymentTransformer.ts b/src/api/transformers/PaymentTransformer.ts index 7228efe55..7069613c4 100644 --- a/src/api/transformers/PaymentTransformer.ts +++ b/src/api/transformers/PaymentTransformer.ts @@ -17,6 +17,8 @@ import { mergeRules } from "@api/utils/rules"; import Contact from "@models/Contact"; import Payment from "@models/Payment"; +import { AuthInfo } from "@type/auth-info"; + class PaymentTransformer extends BaseTransformer< Payment, GetPaymentDto, @@ -40,13 +42,13 @@ class PaymentTransformer extends BaseTransformer< protected transformQuery( query: T, - caller: Contact | undefined + auth: AuthInfo | undefined ): T { return { ...query, rules: mergeRules([ query.rules, - !caller?.hasRole("admin") && { + !auth?.roles.includes("admin") && { field: "contact", operator: "equal", value: ["me"] diff --git a/src/api/transformers/SegmentTransformer.ts b/src/api/transformers/SegmentTransformer.ts index bd407d471..2221fc1ea 100644 --- a/src/api/transformers/SegmentTransformer.ts +++ b/src/api/transformers/SegmentTransformer.ts @@ -10,9 +10,10 @@ import { import { BaseTransformer } from "@api/transformers/BaseTransformer"; import ContactTransformer from "@api/transformers/ContactTransformer"; -import Contact from "@models/Contact"; import Segment from "@models/Segment"; +import { AuthInfo } from "@type/auth-info"; + class SegmentTransformer extends BaseTransformer< Segment, GetSegmentDto, @@ -41,11 +42,11 @@ class SegmentTransformer extends BaseTransformer< protected async modifyItems( segments: Segment[], query: ListSegmentsDto, - caller: Contact | undefined + auth: AuthInfo | undefined ): Promise { if (query.with?.includes(GetSegmentWith.contactCount)) { for (const segment of segments) { - const result = await ContactTransformer.fetch(caller, { + const result = await ContactTransformer.fetch(auth, { limit: 0, rules: segment.ruleGroup }); diff --git a/src/apps/members/app.ts b/src/apps/members/app.ts index 19138ad8f..7d9406f14 100644 --- a/src/apps/members/app.ts +++ b/src/apps/members/app.ts @@ -4,7 +4,7 @@ import queryString from "query-string"; import { getRepository } from "@core/database"; import { isAdmin } from "@core/middleware"; -import { wrapAsync } from "@core/utils"; +import { userToAuth, wrapAsync } from "@core/utils"; import OptionsService from "@core/services/OptionsService"; import SegmentService from "@core/services/SegmentService"; @@ -109,8 +109,10 @@ app.get( const { query } = req; const availableTags = await getAvailableTags(); + const auth = userToAuth(req.user!); + const totalMembers = await getRepository(Contact).count(); - const segments = await SegmentService.getSegmentsWithCount(req.user); + const segments = await SegmentService.getSegmentsWithCount(auth); const activeSegment = query.segment ? segments.find((s) => s.id === query.segment) : undefined; @@ -127,7 +129,7 @@ app.get( const sort = (query.sort as string) || "lastname_ASC"; const [sortId, sortDir] = sort.split("_"); - const result = await ContactTransformer.fetch(req.user, { + const result = await ContactTransformer.fetch(auth, { offset: limit * (page - 1), limit, sort: sortOptions[sortId].sort, diff --git a/src/apps/members/apps/segments/app.ts b/src/apps/members/apps/segments/app.ts index 743135469..3e44e954c 100644 --- a/src/apps/members/apps/segments/app.ts +++ b/src/apps/members/apps/segments/app.ts @@ -2,7 +2,7 @@ import express from "express"; import { getRepository } from "@core/database"; import { hasNewModel } from "@core/middleware"; -import { wrapAsync } from "@core/utils"; +import { userToAuth, wrapAsync } from "@core/utils"; import SegmentService from "@core/services/SegmentService"; @@ -23,7 +23,9 @@ app.set("views", __dirname + "/views"); app.get( "/", wrapAsync(async (req, res) => { - const segments = await SegmentService.getSegmentsWithCount(req.user); + const segments = await SegmentService.getSegmentsWithCount( + userToAuth(req.user!) + ); res.render("index", { segments }); }) ); @@ -97,9 +99,10 @@ app.get( hasNewModel(Segment, "id"), wrapAsync(async (req, res) => { const segment = req.model as Segment; - segment.contactCount = await ContactTransformer.count(req.user, { - rules: segment.ruleGroup - }); + segment.contactCount = await ContactTransformer.count( + userToAuth(req.user!), + { rules: segment.ruleGroup } + ); res.render("email", { segment, diff --git a/src/apps/tools/apps/polls/app.ts b/src/apps/tools/apps/polls/app.ts index d9d853185..70b9add0e 100644 --- a/src/apps/tools/apps/polls/app.ts +++ b/src/apps/tools/apps/polls/app.ts @@ -4,7 +4,7 @@ import { createQueryBuilder } from "typeorm"; import { getRepository } from "@core/database"; import { hasNewModel, hasSchema, isAdmin } from "@core/middleware"; -import { createDateTime, wrapAsync } from "@core/utils"; +import { createDateTime, userToAuth, wrapAsync } from "@core/utils"; import Callout from "@models/Callout"; import CalloutResponse from "@models/CalloutResponse"; @@ -179,7 +179,7 @@ app.post( res.redirect(req.originalUrl); } else { const [exportName, exportData] = await CalloutResponseExporter.export( - req.user, + userToAuth(req.user!), callout.slug, {} ); diff --git a/src/core/services/AuthService.ts b/src/core/services/AuthService.ts deleted file mode 100644 index 62321b1de..000000000 --- a/src/core/services/AuthService.ts +++ /dev/null @@ -1,42 +0,0 @@ -import crypto from "crypto"; -import { Request } from "express"; - -import { getRepository } from "@core/database"; - -import ContactsService from "@core/services/ContactsService"; - -import ApiKey from "@models/ApiKey"; -import Contact from "@models/Contact"; - -class AuthService { - /** - * Check if the request is authenticated. - */ - async check(request: Request): Promise { - const headers = request.headers; - const authHeader = headers.authorization; - - // If there's a bearer key check API key - if (authHeader?.startsWith("Bearer ")) { - if (await this.isValidApiKey(authHeader.substring(7))) { - // API key can act as a user - const contactId = headers["x-contact-id"]?.toString(); - return contactId - ? await ContactsService.findOneBy({ id: contactId }) - : true; - } - return undefined; // Invalid key, not authenticated - } - - // Otherwise use logged in user - return request.user; - } - private async isValidApiKey(key: string): Promise { - const [_, secret] = key.split("_"); - const secretHash = crypto.createHash("sha256").update(secret).digest("hex"); - const apiKey = await getRepository(ApiKey).findOneBy({ secretHash }); - return !!apiKey && (!apiKey.expires || apiKey.expires > new Date()); - } -} - -export default new AuthService(); diff --git a/src/core/services/SegmentService.ts b/src/core/services/SegmentService.ts index 5f5b1bc77..cbc800547 100644 --- a/src/core/services/SegmentService.ts +++ b/src/core/services/SegmentService.ts @@ -8,6 +8,8 @@ import { buildSelectQuery } from "@api/utils/rules"; import Contact from "@models/Contact"; import Segment from "@models/Segment"; +import { AuthInfo } from "@type/auth-info"; + class SegmentService { async createSegment( name: string, @@ -20,12 +22,12 @@ class SegmentService { } /** @deprecated */ - async getSegmentsWithCount(caller: Contact | undefined): Promise { + async getSegmentsWithCount(auth: AuthInfo | undefined): Promise { const segments = await getRepository(Segment).find({ order: { order: "ASC" } }); for (const segment of segments) { - const result = await ContactTransformer.fetch(caller, { + const result = await ContactTransformer.fetch(auth, { limit: 0, rules: segment.ruleGroup }); diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index 871ea6529..df692106e 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -1,4 +1,5 @@ import { ContributionPeriod } from "@beabee/beabee-common"; +import { AuthInfo } from "@type/auth-info"; import { NextFunction, Request, RequestHandler, Response } from "express"; import { QueryFailedError } from "typeorm"; @@ -31,6 +32,14 @@ export function wrapAsync(fn: RequestHandler): RequestHandler { }; } +export function userToAuth(user: Express.User): AuthInfo { + return { + method: "user", + entity: user, + roles: user.activeRoles + }; +} + export interface RequestWithUser extends Request { user: Express.User; } diff --git a/src/models/ApiKey.ts b/src/models/ApiKey.ts index 7a3eb1ef3..1282475ff 100644 --- a/src/models/ApiKey.ts +++ b/src/models/ApiKey.ts @@ -1,3 +1,4 @@ +import { RoleType } from "@beabee/beabee-common"; import { Column, CreateDateColumn, @@ -5,6 +6,7 @@ import { ManyToOne, PrimaryColumn } from "typeorm"; + import type Contact from "./Contact"; @Entity() @@ -26,4 +28,8 @@ export default class ApiKey { @Column() description!: string; + + get activeRoles(): RoleType[] { + return ["admin", "superadmin"]; + } } diff --git a/src/models/Contact.ts b/src/models/Contact.ts index 3d0dfeb26..657d2c451 100644 --- a/src/models/Contact.ts +++ b/src/models/Contact.ts @@ -80,7 +80,11 @@ export default class Contact { contribution?: ContributionInfo; get activeRoles(): RoleType[] { - return this.roles.filter((p) => p.isActive).map((p) => p.type); + const ret = this.roles.filter((p) => p.isActive).map((p) => p.type); + if (ret.includes("superadmin")) { + ret.push("admin"); + } + return ret; } hasRole(roleType: RoleType): boolean { diff --git a/src/type/auth-info.ts b/src/type/auth-info.ts new file mode 100644 index 000000000..4b1f06fc4 --- /dev/null +++ b/src/type/auth-info.ts @@ -0,0 +1,9 @@ +import { RoleType } from "@beabee/beabee-common"; +import type ApiKey from "@models/ApiKey"; +import Contact from "@models/Contact"; + +export interface AuthInfo { + method: "user" | "api-key"; + entity: Contact | ApiKey; + roles: RoleType[]; +} diff --git a/src/typings/index.d.ts b/src/typings/index.d.ts index 1546f0fe3..a67e22923 100644 --- a/src/typings/index.d.ts +++ b/src/typings/index.d.ts @@ -1,7 +1,10 @@ import { ParamsDictionary } from "express-serve-static-core"; -import Contact from "@models/Contact"; +import ApiKey from "@models/ApiKey"; import { CalloutResponseAnswers } from "@models/CalloutResponse"; +import Contact from "@models/Contact"; + +import { AuthInfo as AuthInfo2 } from "@type/auth-info"; declare global { type HTMLElement = never; @@ -20,6 +23,7 @@ declare global { model: unknown; allParams: ParamsDictionary; answers?: CalloutResponseAnswers; + auth: AuthInfo2 | undefined; } } }