Merge pull request 'selfservice forgotten mails feature/154-selfservice_forgotten' (#155) from feature/154-selfservice_forgotten into dev
continuous-integration/drone/push Build is passing Details

Reviewed-on: #155
This commit is contained in:
Nicolai Ort 2021-03-06 13:22:16 +00:00
commit cb6e78fc17
11 changed files with 192 additions and 8 deletions

View File

@ -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

View File

@ -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

View File

@ -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}`

View File

@ -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(),

View File

@ -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;
}

View File

@ -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.
*/

View File

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

View File

@ -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.

View File

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

View File

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

View File

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