Skip to content

Commit

Permalink
fix(api): case-insensitive survey alias login (V4-1455)
Browse files Browse the repository at this point in the history
  • Loading branch information
lukashroch committed Feb 6, 2025
1 parent eda13c7 commit c32a091
Show file tree
Hide file tree
Showing 9 changed files with 62 additions and 19 deletions.
19 changes: 19 additions & 0 deletions apps/api/__tests__/integration/authentication/login-alias.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,23 @@ export default () => {
),
).toBeTrue();
});

it('valid credentials should return 200, access token & refresh cookie (case-insensitive)', async () => {
const res = await request(suite.app).post(url).set('Accept', 'application/json').send({
username: 'testRespondent',
password: 'testRespondentPassword',
survey: 'Test-Survey',
captcha: 'test-captcha',
});

expect(res.status).toBe(200);
expect(res.body).toContainAllKeys(['accessToken']);

expect(res.get('Set-Cookie')?.length).toBeGreaterThanOrEqual(1);
expect(
(res.get('Set-Cookie') ?? []).some(
cookie => cookie.split('=')[0] === securityConfig.jwt.survey.cookie.name,
),
).toBeTrue();
});
};
19 changes: 19 additions & 0 deletions apps/api/__tests__/integration/authentication/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,23 @@ export default () => {
),
).toBeTrue();
});

it('valid credentials should return 200, access token & refresh cookie (case-insensitive)', async () => {
const res = await request(suite.app).post(url).set('Accept', 'application/json').send({
email: '[email protected]',
password: 'testUserPassword',
survey: 'tEst-sUrvey',
captcha: 'test-captcha',
});

expect(res.status).toBe(200);
expect(res.body).toContainAllKeys(['accessToken']);

expect(res.get('Set-Cookie')?.length).toBeGreaterThanOrEqual(1);
expect(
(res.get('Set-Cookie') ?? []).some(
cookie => cookie.split('=')[0] === securityConfig.jwt.survey.cookie.name,
),
).toBeTrue();
});
};
5 changes: 2 additions & 3 deletions apps/api/src/http/routers/password.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import ms from 'ms';
import { ValidationError } from '@intake24/api/http/errors';
import ioc from '@intake24/api/ioc';
import { contract } from '@intake24/common/contracts';
import { Op, User, UserPasswordReset } from '@intake24/db';
import { Op, UserPasswordReset } from '@intake24/db';

import { captchaCheck } from '../rules';

Expand Down Expand Up @@ -39,12 +39,11 @@ export function password() {
const expiredAt = new Date(
Date.now() - ms(req.scope.cradle.securityConfig.passwords.expiresIn),
);
const op = User.sequelize?.getDialect() === 'postgres' ? Op.iLike : Op.eq;

const passwordReset = await UserPasswordReset.findOne({
attributes: ['id', 'userId'],
where: { token, createdAt: { [Op.gt]: expiredAt } },
include: [{ association: 'user', where: { email: { [op]: email } } }],
include: [{ association: 'user', where: { email: { [UserPasswordReset.op('ciEq')]: email } } }],
});

if (!passwordReset) {
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/http/rules/unique.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default async ({ model, condition, options = { attributes: ['id'] } }: Un
const cModel = model as BaseModelCtor<BaseModel>;

const { field, value, ci } = mergedCondition;
const op = ci && cModel.sequelize?.getDialect() === 'postgres' ? Op.iLike : Op.eq;
const op = ci ? cModel.op('ciEq') : Op.eq;

const findOptions: FindOptions = merge(options, { where: { [field]: { [op]: value } } });

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Job } from 'bullmq';
import ms from 'ms';
import nunjucks from 'nunjucks';
import { Op } from 'sequelize';

import type { IoC } from '@intake24/api/ioc';
import { getFrontEndUrl, getUAInfo } from '@intake24/api/util';
Expand Down Expand Up @@ -58,9 +57,8 @@ export default class UserEmailVerificationNotification extends BaseJob<'UserEmai

private async getUser(): Promise<User | null> {
const { email } = this.params;
const op = User.sequelize?.getDialect() === 'postgres' ? Op.iLike : Op.eq;

return User.findOne({ attributes: ['id', 'name'], where: { email: { [op]: email } } });
return User.findOne({ attributes: ['id', 'name'], where: { email: { [User.op('ciEq')]: email } } });
}

private async sendEmail(user: User) {
Expand Down
4 changes: 1 addition & 3 deletions apps/api/src/jobs/user/user-password-reset-notification.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Job } from 'bullmq';
import ms from 'ms';
import nunjucks from 'nunjucks';
import { Op } from 'sequelize';

import type { IoC } from '@intake24/api/ioc';
import { getFrontEndUrl, getUAInfo } from '@intake24/api/util';
Expand Down Expand Up @@ -59,9 +58,8 @@ export default class UserPasswordResetNotification extends BaseJob<'UserPassword

private async getUser(): Promise<User | null> {
const { email } = this.params;
const op = User.sequelize?.getDialect() === 'postgres' ? Op.iLike : Op.eq;

return User.findOne({ attributes: ['id', 'name'], where: { email: { [op]: email } } });
return User.findOne({ attributes: ['id', 'name'], where: { email: { [User.op('ciEq')]: email } } });
}

private async sendEmail(user: User) {
Expand Down
14 changes: 8 additions & 6 deletions apps/api/src/services/core/auth/authentication.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import type {
} from '@intake24/common/types/http';
import type { SurveyAttributes, UserPassword } from '@intake24/db';

import { MFADevice, Op, Survey, User } from '@intake24/db';
import { MFADevice, Survey, User } from '@intake24/db';

export type LoginCredentials<T extends FrontEnd = FrontEnd> = {
user: User | null;
Expand Down Expand Up @@ -271,9 +271,8 @@ function authenticationService({
): Promise<Tokens | MFAAuthResponse> => {
const { email, password } = credentials;

const op = User.sequelize?.getDialect() === 'postgres' ? Op.iLike : Op.eq;
const user = await User.findOne({
where: { email: { [op]: email } },
where: { email: { [User.op('ciEq')]: email } },
include: [{ association: 'password', required: true }],
});

Expand All @@ -295,10 +294,9 @@ function authenticationService({
): Promise<ChallengeResponse | Tokens> => {
const { email, password, survey: slug, captcha } = credentials;

const op = User.sequelize?.getDialect() === 'postgres' ? Op.iLike : Op.eq;
const [user, survey] = await Promise.all([
User.findOne({
where: { email: { [op]: email } },
where: { email: { [User.op('ciEq')]: email } },
include: [
{ association: 'password', required: true },
{
Expand Down Expand Up @@ -340,7 +338,11 @@ function authenticationService({
association: 'aliases',
where: { username },
include: [
{ association: 'survey', attributes: ['authCaptcha', 'slug'], where: { slug } },
{
association: 'survey',
attributes: ['authCaptcha', 'slug'],
where: { slug: { [User.op('ciEq')]: slug } },
},
],
},
{ association: 'password' },
Expand Down
10 changes: 10 additions & 0 deletions packages/db/src/models/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ export default class Model<
TModelAttributes extends {} = any,
TCreationAttributes extends {} = TModelAttributes,
> extends BaseModel<TModelAttributes, TCreationAttributes> {
public static op(op: 'ciEq') {
const isPostgres = this.sequelize?.getDialect() === 'postgres';

const ops = {
ciEq: isPostgres ? Op.iLike : Op.eq,
};

return ops[op];
}

/**
* Paginate results of Model.findAll
*
Expand Down
4 changes: 1 addition & 3 deletions packages/db/src/models/system/survey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import type {
InferCreationAttributes,
NonAttribute,
} from 'sequelize';
import { Op } from 'sequelize';
import {
AfterCreate,
AfterDestroy,
Expand Down Expand Up @@ -373,9 +372,8 @@ export default class Survey extends BaseModel<
options: FindOptions<SurveyAttributes> = {},
): Promise<Survey | null> {
const { where, ...rest } = options;
const op = Survey.sequelize?.getDialect() === 'postgres' ? Op.iLike : Op.eq;

return Survey.findOne({ where: { ...where, slug: { [op]: slug } }, ...rest });
return Survey.findOne({ where: { ...where, slug: { [this.op('ciEq')]: slug } }, ...rest });
}

// TODO: add BulkAfterCreate & BulkAfterDestroy if/when implemented in system
Expand Down

0 comments on commit c32a091

Please sign in to comment.