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 +
+ + + + +
+ + \ No newline at end of file diff --git a/src/static/mail_templates/pw-reset.txt b/src/static/mail_templates/pw-reset.txt new file mode 100644 index 0000000..68366b7 --- /dev/null +++ b/src/static/mail_templates/pw-reset.txt @@ -0,0 +1,12 @@ +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. + +Reset: {{reset_link}} + + +Copyright © {{copyright_owner}}. All rights reserved. +Imprint: {{link_imprint}} | Privacy: {{link_privacy}} +This mail was sent to {{recipient_mail}} because someone request a password reset for a account linked to the mail address. \ No newline at end of file diff --git a/src/tests/auth/auth_reset.spec.ts b/src/tests/auth/auth_reset.spec.ts index 47c61e6..498b66d 100644 --- a/src/tests/auth/auth_reset.spec.ts +++ b/src/tests/auth/auth_reset.spec.ts @@ -15,7 +15,7 @@ beforeAll(async () => { "lastname": "demo_reset", "username": "demo_reset", "password": "demo_reset", - "email": "demo_reset@dev.lauf-fuer-kaya.de" + "email": "demo_reset1@dev.lauf-fuer-kaya.de" }, { headers: { "authorization": "Bearer " + res_login.data["access_token"] }, validateStatus: undefined @@ -26,7 +26,7 @@ beforeAll(async () => { "lastname": "demo_reset2", "username": "demo_reset2", "password": "demo_reset2", - "email": "demo_reset1@dev.lauf-fuer-kaya.de" + "email": "demo_reset2@dev.lauf-fuer-kaya.de" }, { headers: { "authorization": "Bearer " + res_login.data["access_token"] }, validateStatus: undefined @@ -36,24 +36,16 @@ beforeAll(async () => { describe('POST /api/auth/reset valid', () => { let reset_token; it('valid reset token request should return 200', async () => { - const res1 = await axios.post(base + '/api/auth/reset', { username: "demo_reset" }); + const res1 = await axios.post(base + '/api/auth/reset', { email: "demo_reset1@dev.lauf-fuer-kaya.de" }); reset_token = res1.data.resetToken; expect(res1.status).toEqual(200); }); - it('valid password reset should return 200', async () => { - const res2 = await axios.post(base + '/api/auth/reset/' + reset_token, { password: "demo" }, axios_config); - expect(res2.status).toEqual(200); - }); - it('valid login after reset should return 200', async () => { - const res = await axios.post(base + '/api/auth/login', { username: "demo_reset", password: "demo" }); - expect(res.status).toEqual(200); - }); }); // --------------- describe('POST /api/auth/reset invalid requests', () => { it('request another password reset before the timeout should return 406', async () => { - const res1 = await axios.post(base + '/api/auth/reset', { username: "demo_reset2" }, axios_config); - const res2 = await axios.post(base + '/api/auth/reset', { username: "demo_reset2" }, axios_config); + const res1 = await axios.post(base + '/api/auth/reset', { email: "demo_reset2@dev.lauf-fuer-kaya.de" }, axios_config); + const res2 = await axios.post(base + '/api/auth/reset', { email: "demo_reset2@dev.lauf-fuer-kaya.de" }, axios_config); expect(res2.status).toEqual(406); }); }); @@ -63,9 +55,9 @@ describe('POST /api/auth/reset invalid token', () => { const res2 = await axios.post(base + '/api/auth/reset/' + "123123", { password: "demo" }, axios_config); expect(res2.status).toEqual(401); }); - it('providing no reset token should return 404', async () => { + it('providing no reset token should return 400', async () => { const res2 = await axios.post(base + '/api/auth/reset/' + "", { password: "demo" }, axios_config); - expect(res2.status).toEqual(404); + expect(res2.status).toEqual(400); }); }); // --------------- diff --git a/src/tests/contacts/contact_update.spec.ts b/src/tests/contacts/contact_update.spec.ts index 3132cbc..52fe57c 100644 --- a/src/tests/contacts/contact_update.spec.ts +++ b/src/tests/contacts/contact_update.spec.ts @@ -111,7 +111,6 @@ describe('Update contact group after adding (should work)', () => { "lastname": "last", "groups": added_team.id }, axios_config); - console.log(res.data) expect(res.status).toEqual(200); expect(res.headers['content-type']).toContain("application/json"); expect(res.data).toEqual({