diff --git a/README.md b/README.md index b2ba341..06a44b1 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ yarn docs | DB_USER | String | N/A | The user for accessing the db | DB_PASSWORD | String | N/A | The user's password for accessing the db | DB_NAME | String | N/A | The db's name -| NODE_ENV | String | dev | The apps env - influences debug info. +| NODE_ENV | String | dev | The apps env - influences debug info. Also when the env is set to "test", mailing errors get ignored. | POSTALCODE_COUNTRYCODE | String/CountryCode | N/A | The countrycode used to validate address's postal codes | PHONE_COUNTRYCODE | String/CountryCode | null (international) | The countrycode used to validate phone numers | SEED_TEST_DATA | Boolean | False | If you want the app to seed some example data set this to true diff --git a/scripts/create_testenv.ts b/scripts/create_testenv.ts index da08dea..ed18169 100644 --- a/scripts/create_testenv.ts +++ b/scripts/create_testenv.ts @@ -10,7 +10,7 @@ DB_PORT=bla DB_USER=bla DB_PASSWORD=bla DB_NAME=./test.sqlite -NODE_ENV=dev +NODE_ENV=test POSTALCODE_COUNTRYCODE=DE SEED_TEST_DATA=true MAILER_URL=https://dev.lauf-fuer-kaya.de/mailer diff --git a/src/app.ts b/src/app.ts index 6e814b3..9bb6803 100644 --- a/src/app.ts +++ b/src/app.ts @@ -20,6 +20,9 @@ const app = createExpressServer({ async function main() { await loaders(app); + if (config.testing) { + consola.info("🛠[config]: Discovered testing env. Mailing errors will get ignored!") + } app.listen(config.internal_port, () => { consola.success( `⚡️[server]: Server is running at http://localhost:${config.internal_port}` diff --git a/src/config.ts b/src/config.ts index 08af1ac..28b687e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,7 @@ configDotenv(); export const config = { internal_port: parseInt(process.env.APP_PORT) || 4010, development: process.env.NODE_ENV === "production", + testing: process.env.NODE_ENV === "test", jwt_secret: process.env.JWT_SECRET || "secretjwtsecret", phone_validation_countrycode: getPhoneCodeLocale(), postalcode_validation_countrycode: getPostalCodeLocale(), diff --git a/src/controllers/RunnerSelfServiceController.ts b/src/controllers/RunnerSelfServiceController.ts index 8b1f30c..28f7493 100644 --- a/src/controllers/RunnerSelfServiceController.ts +++ b/src/controllers/RunnerSelfServiceController.ts @@ -1,17 +1,20 @@ import * as jwt from "jsonwebtoken"; -import { Body, Get, JsonController, OnUndefined, Param, Post } from 'routing-controllers'; +import { Body, Get, JsonController, OnUndefined, Param, Post, QueryParam } from 'routing-controllers'; import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; import { getConnectionManager, Repository } from 'typeorm'; import { config } from '../config'; import { InvalidCredentialsError, JwtNotProvidedError } from '../errors/AuthError'; -import { RunnerEmailNeededError, RunnerNotFoundError } from '../errors/RunnerErrors'; +import { MailSendingError } from '../errors/MailErrors'; +import { RunnerEmailNeededError, RunnerNotFoundError, RunnerSelfserviceTimeoutError } from '../errors/RunnerErrors'; import { RunnerOrganizationNotFoundError } from '../errors/RunnerOrganizationErrors'; import { JwtCreator } from '../jwtcreator'; +import { Mailer } from '../mailer'; import { CreateSelfServiceCitizenRunner } from '../models/actions/create/CreateSelfServiceCitizenRunner'; import { CreateSelfServiceRunner } from '../models/actions/create/CreateSelfServiceRunner'; import { Runner } from '../models/entities/Runner'; import { RunnerGroup } from '../models/entities/RunnerGroup'; import { RunnerOrganization } from '../models/entities/RunnerOrganization'; +import { ResponseEmpty } from '../models/responses/ResponseEmpty'; import { ResponseSelfServiceOrganisation } from '../models/responses/ResponseSelfServiceOrganisation'; import { ResponseSelfServiceRunner } from '../models/responses/ResponseSelfServiceRunner'; import { ResponseSelfServiceScan } from '../models/responses/ResponseSelfServiceScan'; @@ -53,6 +56,32 @@ export class RunnerSelfServiceController { return responseScans; } + @Post('/runners/forgot') + @ResponseSchema(RunnerNotFoundError, { statusCode: 404 }) + @OnUndefined(ResponseEmpty) + @OpenAPI({ description: 'TODO' }) + async requestNewToken(@QueryParam('mail') mail: string) { + if (!mail) { + throw new RunnerNotFoundError(); + } + const runner = await this.runnerRepository.findOne({ email: mail }); + if (!runner) { throw new RunnerNotFoundError(); } + + if (runner.resetRequestedTimestamp > (Math.floor(Date.now() / 1000) - 60 * 60 * 24)) { throw new RunnerSelfserviceTimeoutError(); } + const token = JwtCreator.createSelfService(runner); + + try { + await Mailer.sendSelfserviceForgottenMail(runner.email, token, "en") + } catch (error) { + throw new MailSendingError(); + } + + runner.resetRequestedTimestamp = Math.floor(Date.now() / 1000); + await this.runnerRepository.save(runner); + + return { token }; + } + @Post('/runners/register') @ResponseSchema(ResponseSelfServiceRunner) @ResponseSchema(RunnerEmailNeededError, { statusCode: 406 }) @@ -63,6 +92,13 @@ export class RunnerSelfServiceController { runner = await this.runnerRepository.save(runner); let response = new ResponseSelfServiceRunner(await this.runnerRepository.findOne(runner, { relations: ['scans', 'group', 'group.parentGroup', 'scans.track', 'cards', 'distanceDonations', 'distanceDonations.donor', 'distanceDonations.runner', 'distanceDonations.runner.scans', 'distanceDonations.runner.scans.track'] })); response.token = JwtCreator.createSelfService(runner); + + try { + await Mailer.sendSelfserviceWelcomeMail(runner.email, response.token, "en") + } catch (error) { + throw new MailSendingError(); + } + return response; } @@ -78,6 +114,13 @@ export class RunnerSelfServiceController { let response = new ResponseSelfServiceRunner(await this.runnerRepository.findOne(runner, { relations: ['scans', 'group', 'group.parentGroup', 'scans.track', 'cards', 'distanceDonations', 'distanceDonations.donor', 'distanceDonations.runner', 'distanceDonations.runner.scans', 'distanceDonations.runner.scans.track'] })); response.token = JwtCreator.createSelfService(runner); + + try { + await Mailer.sendSelfserviceWelcomeMail(runner.email, response.token, "en") + } catch (error) { + throw new MailSendingError(); + } + return response; } diff --git a/src/errors/RunnerErrors.ts b/src/errors/RunnerErrors.ts index 7be9048..f5eea84 100644 --- a/src/errors/RunnerErrors.ts +++ b/src/errors/RunnerErrors.ts @@ -46,6 +46,17 @@ export class RunnerEmailNeededError extends NotAcceptableError { message = "Citizenrunners have to provide an email address for verification and contacting." } +/** + * Error to throw when a runner already requested a new selfservice link in the last 24hrs. + */ +export class RunnerSelfserviceTimeoutError extends NotAcceptableError { + @IsString() + name = "RunnerSelfserviceTimeoutError" + + @IsString() + message = "You can only reqest a new token every 24hrs." +} + /** * Error to throw when a runner still has distance donations associated. */ diff --git a/src/mailer.ts b/src/mailer.ts index 33d7f24..fae23d1 100644 --- a/src/mailer.ts +++ b/src/mailer.ts @@ -9,6 +9,7 @@ import { MailSendingError } from './errors/MailErrors'; export class Mailer { public static base: string = config.mailer_url; public static key: string = config.mailer_key; + public static testing: boolean = config.testing; /** * Function for sending a password reset mail. @@ -22,6 +23,41 @@ export class Mailer { resetKey: token }); } catch (error) { + if (Mailer.testing) { return true; } + throw new MailSendingError(); + } + } + + /** + * Function for sending a runner selfservice welcome mail. + * @param to_address The address the mail will be sent to. Should always get pulled from a runner object. + * @param token The requested selfservice token - will be combined with the app_url to generate a selfservice profile link. + */ + public static async sendSelfserviceWelcomeMail(to_address: string, token: string, locale: string = "en") { + try { + await axios.post(`${Mailer.base}/registration?locale=${locale}&key=${Mailer.key}`, { + address: to_address, + selfserviceToken: token + }); + } catch (error) { + if (Mailer.testing) { return true; } + throw new MailSendingError(); + } + } + + /** + * Function for sending a runner selfservice link forgotten mail. + * @param to_address The address the mail will be sent to. Should always get pulled from a runner object. + * @param token The requested selfservice token - will be combined with the app_url to generate a selfservice profile link. + */ + public static async sendSelfserviceForgottenMail(to_address: string, token: string, locale: string = "en") { + try { + await axios.post(`${Mailer.base}/registration_forgot?locale=${locale}&key=${Mailer.key}`, { + address: to_address, + selfserviceToken: token + }); + } catch (error) { + if (Mailer.testing) { return true; } throw new MailSendingError(); } } diff --git a/src/models/entities/Runner.ts b/src/models/entities/Runner.ts index ad16eae..87ef38d 100644 --- a/src/models/entities/Runner.ts +++ b/src/models/entities/Runner.ts @@ -1,5 +1,5 @@ -import { IsInt, IsNotEmpty } from "class-validator"; -import { ChildEntity, ManyToOne, OneToMany } from "typeorm"; +import { IsInt, IsNotEmpty, IsOptional, IsString } from "class-validator"; +import { ChildEntity, Column, ManyToOne, OneToMany } from "typeorm"; import { ResponseRunner } from '../responses/ResponseRunner'; import { DistanceDonation } from "./DistanceDonation"; import { Participant } from "./Participant"; @@ -43,6 +43,15 @@ export class Runner extends Participant { @OneToMany(() => Scan, scan => scan.runner, { nullable: true }) scans: Scan[]; + /** + * The last time the runner requested a selfservice link. + * Used to prevent spamming of the selfservice link forgotten route. + */ + @Column({ nullable: true, unique: false }) + @IsString() + @IsOptional() + resetRequestedTimestamp?: number; + /** * Returns all valid scans associated with this runner. * This is implemented here to avoid duplicate code in other files. diff --git a/src/tests/auth/auth_reset.spec.ts b/src/tests/auth/auth_reset.spec.ts index 64aa790..59da15a 100644 --- a/src/tests/auth/auth_reset.spec.ts +++ b/src/tests/auth/auth_reset.spec.ts @@ -38,7 +38,7 @@ describe('POST /api/auth/reset valid', () => { it('valid reset token request should return 200 (500 w/o correct auth)', async () => { const res1 = await axios.post(base + '/api/auth/reset', { email: "demo_reset1@dev.lauf-fuer-kaya.de" }, axios_config); reset_token = res1.data.resetToken; - expect(res1.status).toEqual(500); + expect(res1.status).toEqual(200); }); }); // --------------- diff --git a/src/tests/selfservice/selfservice_forgotten.spec.ts b/src/tests/selfservice/selfservice_forgotten.spec.ts new file mode 100644 index 0000000..8f11d48 --- /dev/null +++ b/src/tests/selfservice/selfservice_forgotten.spec.ts @@ -0,0 +1,81 @@ +import axios from 'axios'; +import { config } from '../../config'; +const base = "http://localhost:" + config.internal_port + +let access_token; +let axios_config; + +beforeAll(async () => { + const res = await axios.post(base + '/api/auth/login', { username: "demo", password: "demo" }); + access_token = res.data["access_token"]; + axios_config = { + headers: { "authorization": "Bearer " + access_token }, + validateStatus: undefined + }; +}); + +describe('POST /api/runners/me/forgot invalid syntax/mail should fail', () => { + it('get without mail return 404', async () => { + const res = await axios.post(base + '/api/runners/forgot', null, axios_config); + expect(res.status).toEqual(404); + expect(res.headers['content-type']).toContain("application/json"); + }); + it('get without bs mail return 404', async () => { + const res = await axios.post(base + '/api/runners/forgot?mail=asdasdasdasdasd@tester.test.dev.lauf-fuer-kaya.de', null, axios_config); + expect(res.status).toEqual(404); + expect(res.headers['content-type']).toContain("application/json"); + }); +}); +// --------------- +describe('POST /api/runners/me/forgot 2 times within timeout should fail', () => { + let added_runner; + it('registering as citizen should return 200', async () => { + const res = await axios.post(base + '/api/runners/register', { + "firstname": "string", + "middlename": "string", + "lastname": "string", + "email": "citizen420@dev.lauf-fuer-kaya.de" + }, axios_config); + expect(res.status).toEqual(200); + expect(res.headers['content-type']).toContain("application/json"); + added_runner = res.data; + }); + it('post with valid mail should return 200', async () => { + const res = await axios.post(base + '/api/runners/forgot?mail=' + added_runner.email, null, axios_config); + expect(res.status).toEqual(200); + expect(res.headers['content-type']).toContain("application/json"); + }); + it('2nd post with valid mail should return 406', async () => { + const res = await axios.post(base + '/api/runners/forgot?mail=' + added_runner.email, null, axios_config); + expect(res.status).toEqual(406); + expect(res.headers['content-type']).toContain("application/json"); + }); +}); + +// --------------- +describe('POST /api/runners/me/forgot valid should return 200', () => { + let added_runner; + let new_token; + it('registering as citizen should return 200', async () => { + const res = await axios.post(base + '/api/runners/register', { + "firstname": "string", + "middlename": "string", + "lastname": "string", + "email": "citizen69@dev.lauf-fuer-kaya.de" + }, axios_config); + expect(res.status).toEqual(200); + expect(res.headers['content-type']).toContain("application/json"); + added_runner = res.data; + }); + it('post with valid mail should return 200', async () => { + const res = await axios.post(base + '/api/runners/forgot?mail=' + added_runner.email, null, axios_config); + expect(res.status).toEqual(200); + expect(res.headers['content-type']).toContain("application/json"); + new_token = res.data.token; + }); + it('get infos with valid jwt should return 200', async () => { + const res = await axios.get(base + '/api/runners/me/' + new_token, axios_config); + expect(res.status).toEqual(200); + expect(res.headers['content-type']).toContain("application/json"); + }); +}); diff --git a/src/tests/selfservice/selfservice_org.ts b/src/tests/selfservice/selfservice_org.spec.ts similarity index 97% rename from src/tests/selfservice/selfservice_org.ts rename to src/tests/selfservice/selfservice_org.spec.ts index f269285..f718fac 100644 --- a/src/tests/selfservice/selfservice_org.ts +++ b/src/tests/selfservice/selfservice_org.spec.ts @@ -49,6 +49,6 @@ describe('get valid org w/teams', () => { expect(res.status).toEqual(200); expect(res.headers['content-type']).toContain("application/json"); expect(res.data.name).toEqual(added_org.name); - expect(res.data.teams[0]).toEqual({ name: added_team.name, id: added_team.id }); + expect(res.data.teams[0]).toEqual({ name: added_team.name, id: added_team.id, responseType: "SELFSERVICETEAM" }); }); }); \ No newline at end of file