Skip to content

Commit

Permalink
server centralize request validation for co-location and reduced redu…
Browse files Browse the repository at this point in the history
…ndancy (medplum#3228)

* server centralize request validation for co-location and reduced redundancy

* more appropriate type, variable, and function names

* prettier

* remove unused imports

* fix manual execution of validators

* use existing type

* prettier

---------

Co-authored-by: dillon streator <[email protected]>
  • Loading branch information
dillonstreator and dillon streator committed Nov 14, 2023
1 parent 8207e9c commit fe4cec8
Show file tree
Hide file tree
Showing 21 changed files with 138 additions and 205 deletions.
13 changes: 5 additions & 8 deletions packages/server/src/admin/bot.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { ContentType, createReference, getReferenceString } from '@medplum/core';
import { AccessPolicy, Binary, Bot, Project, ProjectMembership, Reference } from '@medplum/fhirtypes';
import { Request, Response } from 'express';
import { body, validationResult } from 'express-validator';
import { body } from 'express-validator';
import { Readable } from 'stream';
import { invalidRequest, sendOutcome } from '../fhir/outcomes';
import { Repository, systemRepo } from '../fhir/repo';
import { getBinaryStorage } from '../fhir/storage';
import { getAuthenticatedContext } from '../context';
import { makeValidationMiddleware } from '../util/validator';

export const createBotValidators = [body('name').notEmpty().withMessage('Bot name is required')];
export const createBotValidator = makeValidationMiddleware([
body('name').notEmpty().withMessage('Bot name is required'),
]);

const defaultBotCode = `import { BotEvent, MedplumClient } from '@medplum/core';
Expand All @@ -19,11 +21,6 @@ export async function handler(medplum: MedplumClient, event: BotEvent): Promise<

export async function createBotHandler(req: Request, res: Response): Promise<void> {
const ctx = getAuthenticatedContext();
const errors = validationResult(req);
if (!errors.isEmpty()) {
sendOutcome(res, invalidRequest(errors));
return;
}

const bot = await createBot(ctx.repo, {
...req.body,
Expand Down
14 changes: 5 additions & 9 deletions packages/server/src/admin/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,17 @@ import {
Reference,
} from '@medplum/fhirtypes';
import { Request, Response } from 'express';
import { body, validationResult } from 'express-validator';
import { invalidRequest, sendOutcome } from '../fhir/outcomes';
import { body } from 'express-validator';
import { Repository, systemRepo } from '../fhir/repo';
import { generateSecret } from '../oauth/keys';
import { getAuthenticatedContext } from '../context';
import { makeValidationMiddleware } from '../util/validator';

export const createClientValidators = [body('name').notEmpty().withMessage('Client name is required')];
export const createClientValidator = makeValidationMiddleware([
body('name').notEmpty().withMessage('Client name is required'),
]);

export async function createClientHandler(req: Request, res: Response): Promise<void> {
const errors = validationResult(req);
if (!errors.isEmpty()) {
sendOutcome(res, invalidRequest(errors));
return;
}

let project: Project;
const { project: localsProject, repo } = getAuthenticatedContext();
if (localsProject.superAdmin) {
Expand Down
13 changes: 4 additions & 9 deletions packages/server/src/admin/invite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,19 @@ import {
} from '@medplum/core';
import { Practitioner, Project, ProjectMembership, Reference, User } from '@medplum/fhirtypes';
import { Request, Response } from 'express';
import { body, oneOf, validationResult } from 'express-validator';
import { body, oneOf } from 'express-validator';
import Mail from 'nodemailer/lib/mailer';
import { resetPassword } from '../auth/resetpassword';
import { bcryptHashPassword, createProfile, createProjectMembership } from '../auth/utils';
import { getConfig } from '../config';
import { getAuthenticatedContext } from '../context';
import { sendEmail } from '../email/email';
import { invalidRequest, sendOutcome } from '../fhir/outcomes';
import { systemRepo } from '../fhir/repo';
import { generateSecret } from '../oauth/keys';
import { getUserByEmailInProject, getUserByEmailWithoutProject } from '../oauth/utils';
import { makeValidationMiddleware } from '../util/validator';

export const inviteValidators = [
export const inviteValidator = makeValidationMiddleware([
body('resourceType').isIn(['Patient', 'Practitioner', 'RelatedPerson']).withMessage('Resource type is required'),
body('firstName').notEmpty().withMessage('First name is required'),
body('lastName').notEmpty().withMessage('Last name is required'),
Expand All @@ -32,15 +32,10 @@ export const inviteValidators = [
],
{ message: 'Either email or externalId is required' }
),
];
]);

export async function inviteHandler(req: Request, res: Response): Promise<void> {
const ctx = getAuthenticatedContext();
const errors = validationResult(req);
if (!errors.isEmpty()) {
sendOutcome(res, invalidRequest(errors));
return;
}

const inviteRequest = { ...req.body } as ServerInviteRequest;
const { projectId } = req.params;
Expand Down
12 changes: 6 additions & 6 deletions packages/server/src/admin/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,18 @@ import { asyncWrap } from '../async';
import { sendOutcome } from '../fhir/outcomes';
import { systemRepo } from '../fhir/repo';
import { authenticateRequest } from '../oauth/middleware';
import { createBotHandler, createBotValidators } from './bot';
import { createClientHandler, createClientValidators } from './client';
import { inviteHandler, inviteValidators } from './invite';
import { createBotHandler, createBotValidator } from './bot';
import { createClientHandler, createClientValidator } from './client';
import { inviteHandler, inviteValidator } from './invite';
import { verifyProjectAdmin } from './utils';
import { getAuthenticatedContext } from '../context';

export const projectAdminRouter = Router();
projectAdminRouter.use(authenticateRequest);
projectAdminRouter.use(verifyProjectAdmin);
projectAdminRouter.post('/:projectId/bot', createBotValidators, asyncWrap(createBotHandler));
projectAdminRouter.post('/:projectId/client', createClientValidators, asyncWrap(createClientHandler));
projectAdminRouter.post('/:projectId/invite', inviteValidators, asyncWrap(inviteHandler));
projectAdminRouter.post('/:projectId/bot', createBotValidator, asyncWrap(createBotHandler));
projectAdminRouter.post('/:projectId/client', createClientValidator, asyncWrap(createClientHandler));
projectAdminRouter.post('/:projectId/invite', inviteValidator, asyncWrap(inviteHandler));

/**
* Handles requests to "/admin/projects/{projectId}"
Expand Down
15 changes: 5 additions & 10 deletions packages/server/src/auth/changepassword.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,22 @@ import { allOk, badRequest, OperationOutcomeError } from '@medplum/core';
import { Reference, User } from '@medplum/fhirtypes';
import bcrypt from 'bcryptjs';
import { Request, Response } from 'express';
import { body, validationResult } from 'express-validator';
import { body } from 'express-validator';
import { pwnedPassword } from 'hibp';
import { invalidRequest, sendOutcome } from '../fhir/outcomes';
import { sendOutcome } from '../fhir/outcomes';
import { systemRepo } from '../fhir/repo';
import { bcryptHashPassword } from './utils';
import { getAuthenticatedContext } from '../context';
import { makeValidationMiddleware } from '../util/validator';

export const changePasswordValidators = [
export const changePasswordValidator = makeValidationMiddleware([
body('oldPassword').notEmpty().withMessage('Missing oldPassword'),
body('newPassword').isLength({ min: 8 }).withMessage('Invalid password, must be at least 8 characters'),
];
]);

export async function changePasswordHandler(req: Request, res: Response): Promise<void> {
const ctx = getAuthenticatedContext();

const errors = validationResult(req);
if (!errors.isEmpty()) {
sendOutcome(res, invalidRequest(errors));
return;
}

const user = await systemRepo.readReference<User>(ctx.membership.user as Reference<User>);

await changePassword({
Expand Down
14 changes: 4 additions & 10 deletions packages/server/src/auth/exchange.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { OAuthTokenType } from '@medplum/core';
import { Request, Response } from 'express';
import { body, validationResult } from 'express-validator';
import { invalidRequest, sendOutcome } from '../fhir/outcomes';
import { body } from 'express-validator';
import { exchangeExternalAuthToken } from '../oauth/token';
import { makeValidationMiddleware } from '../util/validator';

/*
* Exchange an access token from an external auth provider for a Medplum access token.
Expand All @@ -11,18 +11,12 @@ import { exchangeExternalAuthToken } from '../oauth/token';
* Deprecated. Use /oauth2/token with grant_type of "urn:ietf:params:oauth:grant-type:token-exchange" instead.
*/

export const exchangeValidators = [
export const exchangeValidator = makeValidationMiddleware([
body('externalAccessToken').notEmpty().withMessage('Missing externalAccessToken'),
body('clientId').notEmpty().withMessage('Missing clientId'),
];
]);

export const exchangeHandler = async (req: Request, res: Response): Promise<void> => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
sendOutcome(res, invalidRequest(errors));
return Promise.resolve();
}

return exchangeExternalAuthToken(
req,
res,
Expand Down
15 changes: 5 additions & 10 deletions packages/server/src/auth/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@ import { badRequest, Operator } from '@medplum/core';
import { Project, ResourceType, User } from '@medplum/fhirtypes';
import { randomUUID } from 'crypto';
import { Request, Response } from 'express';
import { body, validationResult } from 'express-validator';
import { body } from 'express-validator';
import { createRemoteJWKSet, jwtVerify, JWTVerifyOptions } from 'jose';
import { URL } from 'url';
import { getConfig } from '../config';
import { invalidRequest, sendOutcome } from '../fhir/outcomes';
import { sendOutcome } from '../fhir/outcomes';
import { systemRepo } from '../fhir/repo';
import { getUserByEmail, GoogleCredentialClaims, tryLogin } from '../oauth/utils';
import { isExternalAuth } from './method';
import { getProjectIdByClientId, sendLoginResult } from './utils';
import { makeValidationMiddleware } from '../util/validator';

/*
* Integrating Google Sign-In into your web app
Expand All @@ -28,10 +29,10 @@ const JWKS = createRemoteJWKSet(new URL('https://www.googleapis.com/oauth2/v3/ce
* A request to the /auth/google endpoint is expected to satisfy these validators.
* These values are obtained from the Google Sign-in button.
*/
export const googleValidators = [
export const googleValidator = makeValidationMiddleware([
body('googleClientId').notEmpty().withMessage('Missing googleClientId'),
body('googleCredential').notEmpty().withMessage('Missing googleCredential'),
];
]);

/**
* Google authentication request handler.
Expand All @@ -40,12 +41,6 @@ export const googleValidators = [
* @param res - The response.
*/
export async function googleHandler(req: Request, res: Response): Promise<void> {
const errors = validationResult(req);
if (!errors.isEmpty()) {
sendOutcome(res, invalidRequest(errors));
return;
}

// Resource type can optionally be specified.
// If specified, only memberships of that type will be returned.
// If not specified, all memberships will be considered.
Expand Down
14 changes: 4 additions & 10 deletions packages/server/src/auth/login.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,17 @@
import { ResourceType } from '@medplum/fhirtypes';
import { randomUUID } from 'crypto';
import { Request, Response } from 'express';
import { body, validationResult } from 'express-validator';
import { invalidRequest, sendOutcome } from '../fhir/outcomes';
import { body } from 'express-validator';
import { tryLogin } from '../oauth/utils';
import { getProjectIdByClientId, sendLoginResult } from './utils';
import { makeValidationMiddleware } from '../util/validator';

export const loginValidators = [
export const loginValidator = makeValidationMiddleware([
body('email').isEmail().withMessage('Valid email address is required'),
body('password').isLength({ min: 5 }).withMessage('Invalid password, must be at least 5 characters'),
];
]);

export async function loginHandler(req: Request, res: Response): Promise<void> {
const errors = validationResult(req);
if (!errors.isEmpty()) {
sendOutcome(res, invalidRequest(errors));
return;
}

// Resource type can optionally be specified.
// If specified, only memberships of that type will be returned.
// If not specified, all memberships will be considered.
Expand Down
14 changes: 5 additions & 9 deletions packages/server/src/auth/method.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Operator } from '@medplum/core';
import { DomainConfiguration } from '@medplum/fhirtypes';
import { Request, Response } from 'express';
import { body, validationResult } from 'express-validator';
import { body } from 'express-validator';
import { getConfig } from '../config';
import { invalidRequest, sendOutcome } from '../fhir/outcomes';
import { systemRepo } from '../fhir/repo';
import { makeValidationMiddleware } from '../util/validator';

/*
* The method handler is used to determine available login methods.
Expand All @@ -13,15 +13,11 @@ import { systemRepo } from '../fhir/repo';
* For example, an unauthenticated user could determine if "foo.com" has a domain configuration.
*/

export const methodValidators = [body('email').isEmail().withMessage('Valid email address is required')];
export const methodValidator = makeValidationMiddleware([
body('email').isEmail().withMessage('Valid email address is required'),
]);

export async function methodHandler(req: Request, res: Response): Promise<void> {
const errors = validationResult(req);
if (!errors.isEmpty()) {
sendOutcome(res, invalidRequest(errors));
return;
}

const externalAuth = await isExternalAuth(req.body.email);
if (externalAuth) {
// Return the authorization URL
Expand Down
15 changes: 5 additions & 10 deletions packages/server/src/auth/newpatient.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { badRequest, createReference, OperationOutcomeError } from '@medplum/core';
import { Login, Patient, Project, ProjectMembership, Reference, User } from '@medplum/fhirtypes';
import { Request, Response } from 'express';
import { body, validationResult } from 'express-validator';
import { invalidRequest, sendOutcome } from '../fhir/outcomes';
import { body } from 'express-validator';
import { sendOutcome } from '../fhir/outcomes';
import { systemRepo } from '../fhir/repo';
import { setLoginMembership } from '../oauth/utils';
import { createProfile, createProjectMembership } from './utils';
import { makeValidationMiddleware } from '../util/validator';

export const newPatientValidators = [
export const newPatientValidator = makeValidationMiddleware([
body('login').notEmpty().withMessage('Missing login'),
body('projectId').notEmpty().withMessage('Project ID is required'),
];
]);

/**
* Handles a HTTP request to /auth/newpatient.
Expand All @@ -19,12 +20,6 @@ export const newPatientValidators = [
* @param res - The HTTP response.
*/
export async function newPatientHandler(req: Request, res: Response): Promise<void> {
const errors = validationResult(req);
if (!errors.isEmpty()) {
sendOutcome(res, invalidRequest(errors));
return;
}

const login = await systemRepo.readResource<Login>('Login', req.body.login);

if (login.membership) {
Expand Down
15 changes: 5 additions & 10 deletions packages/server/src/auth/newproject.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
import { badRequest, createReference, ProfileResource } from '@medplum/core';
import { ClientApplication, Login, Project, ProjectMembership, Reference, User } from '@medplum/fhirtypes';
import { Request, Response } from 'express';
import { body, validationResult } from 'express-validator';
import { body } from 'express-validator';
import { createClient } from '../admin/client';
import { invalidRequest, sendOutcome } from '../fhir/outcomes';
import { sendOutcome } from '../fhir/outcomes';
import { systemRepo } from '../fhir/repo';
import { setLoginMembership } from '../oauth/utils';
import { createProfile, createProjectMembership } from './utils';
import { getRequestContext } from '../context';
import { makeValidationMiddleware } from '../util/validator';

export interface NewProjectRequest {
readonly loginId: string;
readonly projectName: string;
}

export const newProjectValidators = [
export const newProjectValidator = makeValidationMiddleware([
body('login').notEmpty().withMessage('Missing login'),
body('projectName').notEmpty().withMessage('Project name is required'),
];
]);

/**
* Handles a HTTP request to /auth/newproject.
Expand All @@ -27,12 +28,6 @@ export const newProjectValidators = [
* @param res - The HTTP response.
*/
export async function newProjectHandler(req: Request, res: Response): Promise<void> {
const errors = validationResult(req);
if (!errors.isEmpty()) {
sendOutcome(res, invalidRequest(errors));
return;
}

const login = await systemRepo.readResource<Login>('Login', req.body.login);

if (login.membership) {
Expand Down
Loading

0 comments on commit fe4cec8

Please sign in to comment.