diff --git a/.drone.yml b/.drone.yml
index 204f6c3..db1b1e7 100644
--- a/.drone.yml
+++ b/.drone.yml
@@ -9,7 +9,6 @@ steps:
commands:
- git clone $DRONE_REMOTE_URL .
- git checkout $DRONE_SOURCE_BRANCH
- - mv .env.ci .env
- name: run tests
image: node:latest
commands:
diff --git a/package.json b/package.json
index 8e0ba5c..073777c 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,7 @@
"jsonwebtoken": "^8.5.1",
"libphonenumber-js": "^1.9.7",
"mysql": "^2.18.1",
+ "nodemailer": "^6.4.17",
"pg": "^8.5.1",
"reflect-metadata": "^0.1.13",
"routing-controllers": "^0.9.0-alpha.6",
@@ -56,6 +57,7 @@
"@types/jest": "^26.0.16",
"@types/jsonwebtoken": "^8.5.0",
"@types/node": "^14.14.20",
+ "@types/nodemailer": "^6.4.0",
"@types/uuid": "^8.3.0",
"axios": "^0.21.1",
"cp-cli": "^2.0.0",
@@ -75,7 +77,9 @@
"docs": "typedoc --out docs src",
"test": "jest",
"test:watch": "jest --watchAll",
- "test:ci": "start-server-and-test dev http://localhost:4010/api/docs/openapi.json test",
+ "test:ci:generate_env": "ts-node scripts/create_testenv.ts",
+ "test:ci:run": "start-server-and-test dev http://localhost:4010/api/docs/openapi.json test",
+ "test:ci": "npm run test:ci:generate_env && npm run test:ci:run",
"seed": "ts-node ./node_modules/typeorm/cli.js schema:sync && ts-node ./node_modules/typeorm-seeding/dist/cli.js seed",
"openapi:export": "ts-node scripts/openapi_export.ts",
"licenses:export": "license-exporter --md",
@@ -100,4 +104,4 @@
"docs/*"
]
}
-}
+}
\ No newline at end of file
diff --git a/scripts/create_testenv.ts b/scripts/create_testenv.ts
new file mode 100644
index 0000000..1fa6c8c
--- /dev/null
+++ b/scripts/create_testenv.ts
@@ -0,0 +1,37 @@
+import consola from "consola";
+import fs from "fs";
+import nodemailer from "nodemailer";
+
+
+nodemailer.createTestAccount((err, account) => {
+ if (err) {
+ console.error('Failed to create a testing account. ' + err.message);
+ return process.exit(1);
+ }
+
+ const env = `
+APP_PORT=4010
+DB_TYPE=sqlite
+DB_HOST=bla
+DB_PORT=bla
+DB_USER=bla
+DB_PASSWORD=bla
+DB_NAME=./test.sqlite
+NODE_ENV=dev
+POSTALCODE_COUNTRYCODE=DE
+SEED_TEST_DATA=true
+MAIL_SERVER=${account.smtp.host}
+MAIL_PORT=${account.smtp.port}
+MAIL_USER=${account.user}
+MAIL_PASSWORD=${account.pass}
+MAIL_FROM=${account.user}`
+
+ try {
+ fs.writeFileSync("./.env", env, { encoding: "utf-8" });
+ consola.success("Exported ci env to .env");
+ } catch (error) {
+ consola.error("Couldn't export the ci env");
+ }
+
+});
+
diff --git a/src/config.ts b/src/config.ts
index 3bdcd27..d2840da 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -10,7 +10,13 @@ export const config = {
phone_validation_countrycode: getPhoneCodeLocale(),
postalcode_validation_countrycode: getPostalCodeLocale(),
version: process.env.VERSION || require('../package.json').version,
- seedTestData: getDataSeeding()
+ seedTestData: getDataSeeding(),
+ app_url: process.env.APP_URL || "http://localhost:4010",
+ mail_server: process.env.MAIL_SERVER,
+ mail_port: Number(process.env.MAIL_PORT) || 25,
+ mail_user: process.env.MAIL_USER,
+ mail_password: process.env.MAIL_PASSWORD,
+ mail_from: process.env.MAIL_FROM
}
let errors = 0
if (typeof config.internal_port !== "number") {
diff --git a/src/controllers/AuthController.ts b/src/controllers/AuthController.ts
index 507ac38..3545422 100644
--- a/src/controllers/AuthController.ts
+++ b/src/controllers/AuthController.ts
@@ -2,17 +2,23 @@ import { Body, CookieParam, JsonController, Param, Post, Req, Res } from 'routin
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
import { IllegalJWTError, InvalidCredentialsError, JwtNotProvidedError, PasswordNeededError, RefreshTokenCountInvalidError, UsernameOrEmailNeededError } from '../errors/AuthError';
import { UserNotFoundError } from '../errors/UserErrors';
+import { Mailer } from '../mailer';
import { CreateAuth } from '../models/actions/create/CreateAuth';
import { CreateResetToken } from '../models/actions/create/CreateResetToken';
import { HandleLogout } from '../models/actions/HandleLogout';
import { RefreshAuth } from '../models/actions/RefreshAuth';
import { ResetPassword } from '../models/actions/ResetPassword';
import { ResponseAuth } from '../models/responses/ResponseAuth';
+import { ResponseEmpty } from '../models/responses/ResponseEmpty';
import { Logout } from '../models/responses/ResponseLogout';
@JsonController('/auth')
export class AuthController {
+
+ private mailer: Mailer;
+
constructor() {
+ this.mailer = new Mailer();
}
@Post("/login")
@@ -82,13 +88,14 @@ export class AuthController {
}
@Post("/reset")
- @ResponseSchema(ResponseAuth)
- @ResponseSchema(UserNotFoundError)
- @ResponseSchema(UsernameOrEmailNeededError)
+ @ResponseSchema(ResponseEmpty, { statusCode: 200 })
+ @ResponseSchema(UserNotFoundError, { statusCode: 404 })
+ @ResponseSchema(UsernameOrEmailNeededError, { statusCode: 406 })
@OpenAPI({ description: "Request a password reset token. This will provide you with a reset token that you can use by posting to /api/auth/reset/{token}." })
async getResetToken(@Body({ validate: true }) passwordReset: CreateResetToken) {
- //This really shouldn't just get returned, but sent via mail or sth like that. But for dev only this is fine.
- return { "resetToken": await passwordReset.toResetToken() };
+ const reset_token: String = await passwordReset.toResetToken();
+ await this.mailer.sendResetMail(passwordReset.email, reset_token);
+ return new ResponseEmpty();
}
@Post("/reset/:token")
diff --git a/src/errors/MailErrors.ts b/src/errors/MailErrors.ts
new file mode 100644
index 0000000..7772ca9
--- /dev/null
+++ b/src/errors/MailErrors.ts
@@ -0,0 +1,12 @@
+import { IsString } from 'class-validator'
+
+/**
+ * Error to throw when a permission couldn't be found.
+ */
+export class MailServerConfigError extends Error {
+ @IsString()
+ name = "MailServerConfigError"
+
+ @IsString()
+ message = "The SMTP server you provided couldn't be reached!"
+}
\ No newline at end of file
diff --git a/src/mailer.ts b/src/mailer.ts
new file mode 100644
index 0000000..bc2281d
--- /dev/null
+++ b/src/mailer.ts
@@ -0,0 +1,48 @@
+import fs from "fs";
+import nodemailer from 'nodemailer';
+import { MailOptions } from 'nodemailer/lib/json-transport';
+import Mail from 'nodemailer/lib/mailer';
+import { config } from './config';
+import { MailServerConfigError } from './errors/MailErrors';
+/**
+ * This class is responsible for all things mail sending.
+ */
+export class Mailer {
+ private transport: Mail;
+
+ constructor() {
+ this.transport = nodemailer.createTransport({
+ host: config.mail_server,
+ port: config.mail_port,
+ auth: {
+ user: config.mail_user,
+ pass: config.mail_password
+ }
+ });
+
+ this.transport.verify(function (error, success) {
+ if (error) {
+ throw new MailServerConfigError();
+ }
+ });
+ }
+
+ public async sendResetMail(to_address: string, token: String) {
+ const reset_link = `${config.app_url}/reset/${token}`
+ const body_html = fs.readFileSync(__dirname + '/static/mail_templates/pw-reset.html', { encoding: 'utf8' }).replace("{{reset_link}}", reset_link).replace("{{recipient_mail}}", to_address).replace("{{copyright_owner}}", "LfK!").replace("{{link_imprint}}", `${config.app_url}/imprint`).replace("{{link_privacy}}", `${config.app_url}/privacy`);
+ const body_txt = fs.readFileSync(__dirname + '/static/mail_templates/pw-reset.html', { encoding: 'utf8' }).replace("{{reset_link}}", reset_link).replace("{{recipient_mail}}", to_address).replace("{{copyright_owner}}", "LfK!").replace("{{link_imprint}}", `${config.app_url}/imprint`).replace("{{link_privacy}}", `${config.app_url}/privacy`);
+
+ const mail: MailOptions = {
+ to: to_address,
+ subject: "LfK! Password Reset",
+ text: body_txt,
+ html: body_html
+ };
+ await this.sendMail(mail);
+ }
+
+ public async sendMail(mail: MailOptions) {
+ mail.from = config.mail_from;
+ await this.transport.sendMail(mail);
+ }
+}
diff --git a/src/models/actions/create/CreateResetToken.ts b/src/models/actions/create/CreateResetToken.ts
index 81f430a..8194fe4 100644
--- a/src/models/actions/create/CreateResetToken.ts
+++ b/src/models/actions/create/CreateResetToken.ts
@@ -1,39 +1,33 @@
-import { IsEmail, IsOptional, IsString } from 'class-validator';
+import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
import { getConnectionManager } from 'typeorm';
import { ResetAlreadyRequestedError, UserDisabledError, UserNotFoundError } from '../../../errors/AuthError';
-import { UsernameOrEmailNeededError } from '../../../errors/UserErrors';
+import { UserEmailNeededError } from '../../../errors/UserErrors';
import { JwtCreator } from '../../../jwtcreator';
import { User } from '../../entities/User';
/**
- * This calss is used to create password reset tokens for users.
+ * This class is used to create password reset tokens for users.
* These password reset token can be used to set a new password for the user for the next 15mins.
*/
export class CreateResetToken {
- /**
- * The username of the user that wants to reset their password.
- */
- @IsOptional()
- @IsString()
- username?: string;
/**
* The email address of the user that wants to reset their password.
*/
- @IsOptional()
+ @IsNotEmpty()
@IsEmail()
@IsString()
- email?: string;
+ email: string;
/**
* Create a password reset token based on this.
*/
public async toResetToken(): Promise {
- if (this.email === undefined && this.username === undefined) {
- throw new UsernameOrEmailNeededError();
+ if (!this.email) {
+ throw new UserEmailNeededError();
}
- let found_user = await getConnectionManager().get().getRepository(User).findOne({ where: [{ username: this.username }, { email: this.email }] });
+ let found_user = await getConnectionManager().get().getRepository(User).findOne({ where: [{ email: this.email }] });
if (!found_user) { throw new UserNotFoundError(); }
if (found_user.enabled == false) { throw new UserDisabledError(); }
if (found_user.resetRequestedTimestamp > (Math.floor(Date.now() / 1000) - 15 * 60)) { throw new ResetAlreadyRequestedError(); }
diff --git a/src/static/mail_templates/.gitkeep b/src/static/mail_templates/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/static/mail_templates/pw-reset.html b/src/static/mail_templates/pw-reset.html
new file mode 100644
index 0000000..7f672b8
--- /dev/null
+++ b/src/static/mail_templates/pw-reset.html
@@ -0,0 +1,384 @@
+
+
+
+
+ LfK! - Passwort zurücksetzen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ LfK! - Password reset
+
+
+
+
+
+
+
+
+
+
+
+
LfK!
+
+
+
+
+
Password reset
+
A password reset for your account got requested. If you didn't request the reset please ignore this mail. Your password won't be changed until you click the reset link below and set a new one.