From 89a9ed4bc29bb661fc1b4836dfd738c56a7ce540 Mon Sep 17 00:00:00 2001 From: Avris Date: Tue, 13 Oct 2020 18:29:42 +0200 Subject: [PATCH 1/9] #54 user accounts - db --- migrations/002-users.sql | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 migrations/002-users.sql diff --git a/migrations/002-users.sql b/migrations/002-users.sql new file mode 100644 index 00000000..13eb33de --- /dev/null +++ b/migrations/002-users.sql @@ -0,0 +1,23 @@ +-- Up + +CREATE TABLE users ( + id TEXT NOT NULL PRIMARY KEY, + username TEXT NOT NULL, + email TEXT NOT NULL, + roles TEXT NOT NULL, + avatarSource TEXT +); + +CREATE TABLE authenticators ( + id TEXT NOT NULL PRIMARY KEY, + userId TEXT NOT NULL, + type TEXT NOT NULL, + payload TEXT NOT NULL, + validUntil INTEGER, + FOREIGN KEY(userId) REFERENCES users(id) +); + +-- Down + +DROP TABLE authenticators; +DROP TABLE users; From f9fe7546552ac177011685acd5f5437e8c656ba4 Mon Sep 17 00:00:00 2001 From: Avris Date: Tue, 13 Oct 2020 21:49:08 +0200 Subject: [PATCH 2/9] #54 user accounts - jwt, page --- .gitignore | 2 + Makefile | 7 ++++ README.md | 15 ++----- components/Header.vue | 4 ++ components/Share.vue | 2 +- locale/en/config.suml | 4 ++ locale/pl/config.suml | 4 ++ locale/pl/translations.suml | 5 +++ nuxt.config.js | 15 ++++--- package.json | 1 + pages/index.vue | 4 +- routes/contact.vue | 2 - routes/user.vue | 50 +++++++++++++++++++++++ server/jwt.js | 32 +++++++++++++++ server/user.js | 19 +++++++++ src/helpers.js | 12 +++++- yarn.lock | 80 +++++++++++++++++++++++++++++++++++++ 17 files changed, 236 insertions(+), 22 deletions(-) create mode 100644 routes/user.vue create mode 100644 server/jwt.js create mode 100644 server/user.js diff --git a/.gitignore b/.gitignore index a8d7aa7f..ddf7ed3d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ /daemonise.json /daemonise.log +/keys + # Created by .ignore support plugin (hsz.mobi) ### Node template # Logs diff --git a/Makefile b/Makefile index 079bf5c9..dd73e743 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,11 @@ +include .env.dist +-include .env + +KEYS_DIR=./keys + install: + -cp -n .env.dist .env + if [ ! -d "${KEYS_DIR}" ]; then mkdir -p ${KEYS_DIR}; openssl genrsa -out ${KEYS_DIR}/private.pem 2048; openssl rsa -in ${KEYS_DIR}/private.pem -outform PEM -pubout -out ${KEYS_DIR}/public.pem; fi yarn node server/migrate.js diff --git a/README.md b/README.md index 4f253cbf..108969f5 100644 --- a/README.md +++ b/README.md @@ -4,27 +4,20 @@ ```bash # install dependencies -$ yarn install +$ make install # configure environment -$ cp .env.dist .env $ nano .env $ make switch LANG=pl -$ node server/initDb.js # serve with hot reload at localhost:3000 -$ yarn dev +$ make run # build for production and launch server -$ yarn build -$ yarn start - -# generate static project -$ yarn generate +$ make deploy +$ nuxt start ``` -For detailed explanation on how things work, check out [Nuxt.js docs](https://nuxtjs.org). - ## Copyright * **Author:** Andrea [(Avris.it)](https://avris.it) diff --git a/components/Header.vue b/components/Header.vue index 39c96356..f810c193 100644 --- a/components/Header.vue +++ b/components/Header.vue @@ -76,6 +76,10 @@ links.push({ link: '/' + this.config.contact.route, icon: 'comment-alt-smile', text: this.$t('contact.header')}); } + if (this.config.user.enabled) { + links.push({ link: '/' + this.config.user.route, icon: 'user', text: this.$t('user.header'), textLong: this.$t('user.headerLong')}); + } + return { links, }; diff --git a/components/Share.vue b/components/Share.vue index 7f71956e..08d266a8 100644 --- a/components/Share.vue +++ b/components/Share.vue @@ -59,7 +59,7 @@ data() { return { preset: { - url: process.env.baseUrl + this.$route.path, + url: process.env.BASE_URL + this.$route.path, title: this.title, extra: { media: '', diff --git a/locale/en/config.suml b/locale/en/config.suml index 360f75bc..7716e4d4 100644 --- a/locale/en/config.suml +++ b/locale/en/config.suml @@ -73,3 +73,7 @@ contact: mail: 'zuzannagrzybowska@protonmail.com' areas: social_media: ~ + +user: + enabled: true + route: 'account' diff --git a/locale/pl/config.suml b/locale/pl/config.suml index 0331f6b5..92c4b91a 100644 --- a/locale/pl/config.suml +++ b/locale/pl/config.suml @@ -202,3 +202,7 @@ support: iconSet: 'b' url: 'https://paypal.me/AndreAvris' headline: 'PayPal' + +user: + enabled: true + route: 'konto' diff --git a/locale/pl/translations.suml b/locale/pl/translations.suml index 77c01641..1c3b0c2e 100644 --- a/locale/pl/translations.suml +++ b/locale/pl/translations.suml @@ -518,6 +518,11 @@ support: Jeśli chcesz się zrzucić na serwer, domeny, wlepki itp., lub zwyczajnie postawić autorzom piwo, możesz skorzystać z poniższego linku (wspomnij o „zaimki.pl” w opisie transakcji): +user: + header: 'Konto' + headerLong: 'Twoje konto' + + share: 'Udostępnij' crud: diff --git a/nuxt.config.js b/nuxt.config.js index 7e7ff13b..ccfe349c 100644 --- a/nuxt.config.js +++ b/nuxt.config.js @@ -1,5 +1,6 @@ import translations from './server/translations'; import config from './server/config'; +import fs from 'fs'; const title = translations.title; const description = translations.description; @@ -78,17 +79,18 @@ export default { }, }, env: { - baseUrl: process.env.BASE_URL, - secret: process.env.SECRET, - lang: process.env.LANG, + BASE_URL: process.env.BASE_URL, + SECRET: process.env.SECRET, + PUBLIC_KEY: fs.readFileSync(__dirname + '/keys/public.pem').toString(), }, serverMiddleware: { '/': bodyParser.json(), - '/nouns': '~/server/nouns.js', '/banner': '~/server/banner.js', + '/api/nouns': '~/server/nouns.js', + '/api/user': '~/server/user.js', }, axios: { - baseURL: process.env.BASE_URL, + baseURL: process.env.BASE_URL + '/api', }, router: { extendRoutes(routes, resolve) { @@ -124,6 +126,9 @@ export default { routes.push({ path: '/' + config.contact.route, component: resolve(__dirname, 'routes/contact.vue') }); } + if (config.user.enabled) { + routes.push({path: '/' + config.user.route, component: resolve(__dirname, 'routes/user.vue')}); + } routes.push({ path: '/' + config.template.any.route, component: resolve(__dirname, 'routes/any.vue') }); routes.push({ name: 'all', path: '*', component: resolve(__dirname, 'routes/template.vue') }); diff --git a/package.json b/package.json index c6a6bd97..a9f9a6c4 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "body-parser": "^1.19.0", "canvas": "^2.6.1", "dotenv": "^8.2.0", + "jsonwebtoken": "^8.5.1", "mailer": "^0.6.7", "nuxt": "^2.13.0", "sql-template-strings": "^2.2.2", diff --git a/pages/index.vue b/pages/index.vue index 92b3e51b..a2d3a482 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -242,14 +242,14 @@ if (!this.selectedTemplate.pronoun()) { return null; } - return this.addSlash(process.env.baseUrl + '/' + (this.usedBaseEquals ? this.usedBase : this.longLink)); + return this.addSlash(process.env.BASE_URL + '/' + (this.usedBaseEquals ? this.usedBase : this.longLink)); }, linkMultiple() { if (!this.multiple.length) { return null; } - return this.addSlash(process.env.baseUrl + '/' + this.multiple.join('&')); + return this.addSlash(process.env.BASE_URL + '/' + this.multiple.join('&')); }, sources() { return getSources(this.selectedTemplate); diff --git a/routes/contact.vue b/routes/contact.vue index ddfd98c2..974ba99a 100644 --- a/routes/contact.vue +++ b/routes/contact.vue @@ -42,10 +42,8 @@ diff --git a/server/jwt.js b/server/jwt.js new file mode 100644 index 00000000..b3b625b4 --- /dev/null +++ b/server/jwt.js @@ -0,0 +1,32 @@ +import jwt from 'jsonwebtoken'; +import fs from 'fs'; + +class Jwt { + constructor(privateKey, publicKey) { + this.privateKey = fs.readFileSync(privateKey); + this.publicKey = fs.readFileSync(publicKey); + } + + sign(payload, expiresIn = '30d') { + return jwt.sign(payload, this.privateKey, { + expiresIn, + algorithm: 'RS256', + audience: process.env.BASE_URL, + issuer: process.env.BASE_URL, + }); + } + + validate(token) { + try { + return jwt.verify(token, this.publicKey, { + algorithm: 'RS256', + audience: process.env.BASE_URL, + issuer: process.env.BASE_URL, + }); + } catch (e) { + console.error(e); + } + } +} + +export default new Jwt(__dirname + '/../keys/private.pem', __dirname + '/../keys/public.pem'); diff --git a/server/user.js b/server/user.js new file mode 100644 index 00000000..e0db553c --- /dev/null +++ b/server/user.js @@ -0,0 +1,19 @@ +import jwt from './jwt'; +import { makeId } from '../src/helpers'; + +export default async function (req, res, next) { + const db = await dbConnection(); + + let result = {error: 'Not found'} + if (req.method === 'GET' && req.url === '/all') { + jwt.sign({ + username: 'andrea', + email: 'andrea@avris.it', + secret: makeId(6, '0123456789'), + }) + } + + res.setHeader('content-type', 'application/json'); + res.write(JSON.stringify(result)); + res.end(); +} diff --git a/src/helpers.js b/src/helpers.js index 5814537a..f76d49d6 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -31,7 +31,7 @@ export const head = ({title, description, banner}) => { } if (banner) { - banner = process.env.baseUrl + '/' + banner; + banner = process.env.BASE_URL + '/' + banner; meta.meta.push({ hid: 'og:logo', property: 'og:logo', content: banner }); meta.meta.push({ hid: 'twitter:image', property: 'twitter:image', content: banner }); } @@ -62,3 +62,13 @@ export const clearUrl = url => { return decodeURIComponent(url); } + +export const makeId = (length, characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789') => { + let result = ''; + const charactersLength = characters.length; + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + } + + return result; +} diff --git a/yarn.lock b/yarn.lock index 25ca3868..6f602b30 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2065,6 +2065,11 @@ browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.6.4, browserslist@^4. escalade "^3.0.1" node-releases "^1.1.58" +buffer-equal-constant-time@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk= + buffer-from@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" @@ -3226,6 +3231,13 @@ ecc-jsbn@~0.1.1: jsbn "~0.1.0" safer-buffer "^2.1.0" +ecdsa-sig-formatter@1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -4871,6 +4883,22 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" +jsonwebtoken@^8.5.1: + version "8.5.1" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d" + integrity sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w== + dependencies: + jws "^3.2.2" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^5.6.0" + jsprim@^1.2.2: version "1.4.1" resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" @@ -4881,6 +4909,23 @@ jsprim@^1.2.2: json-schema "0.2.3" verror "1.10.0" +jwa@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" + integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA== + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" + integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== + dependencies: + jwa "^1.4.1" + safe-buffer "^5.0.1" + kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" @@ -5000,6 +5045,36 @@ lodash._reinterpolate@^3.0.0: resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0= +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" + integrity sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8= + +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" + integrity sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY= + +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" + integrity sha1-YZwK89A/iwTDH1iChAt3sRzWg0M= + +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" + integrity sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w= + +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs= + +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + integrity sha1-1SfftUVuynzJu5XV2ur4i6VKVFE= + lodash.kebabcase@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36" @@ -5010,6 +5085,11 @@ lodash.memoize@^4.1.2: resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w= + lodash.template@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.5.0.tgz#f976195cf3f347d0d5f52483569fe8031ccce8ab" From 642c69ceec09c18ae652bcc887788eb27b49bedd Mon Sep 17 00:00:00 2001 From: Avris Date: Wed, 14 Oct 2020 21:46:39 +0200 Subject: [PATCH 3/9] translation params --- components/T.vue | 5 ++++- src/translator.js | 8 +++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/components/T.vue b/components/T.vue index 9de033c3..d8afb3bd 100644 --- a/components/T.vue +++ b/components/T.vue @@ -13,9 +13,12 @@ import t from '../src/translator'; export default { + props: { + params: {}, + }, data() { return { - txt: t(this.$slots.default[0].text), + txt: t(this.$slots.default[0].text, this.params || {}), } }, } diff --git a/src/translator.js b/src/translator.js index 60224b13..bb76c13f 100644 --- a/src/translator.js +++ b/src/translator.js @@ -1,6 +1,6 @@ import translations from '../data/translations.suml'; -export default key => { +export default (key, params = {}) => { let value = translations; for (let part of key.split('.')) { value = value[part]; @@ -10,5 +10,11 @@ export default key => { } } + for (let k in params) { + if (params.hasOwnProperty(k)) { + value = value.replace(new RegExp('%' + k + '%', 'g'), params[k]) + } + } + return value; } From f86bc1d02b5f7f46fd91dd7a9bd325c0e201321d Mon Sep 17 00:00:00 2001 From: Avris Date: Wed, 14 Oct 2020 21:49:18 +0200 Subject: [PATCH 4/9] #54 user accounts - login/registration flow --- locale/pl/translations.suml | 15 ++++ migrations/002-users.sql | 2 +- routes/user.vue | 84 +++++++++++++++++++- server/mailer.js | 21 +++++ server/notify.js | 20 +---- server/user.js | 152 ++++++++++++++++++++++++++++++++++-- 6 files changed, 264 insertions(+), 30 deletions(-) create mode 100644 server/mailer.js diff --git a/locale/pl/translations.suml b/locale/pl/translations.suml index 1c3b0c2e..d5d3d434 100644 --- a/locale/pl/translations.suml +++ b/locale/pl/translations.suml @@ -521,6 +521,21 @@ support: user: header: 'Konto' headerLong: 'Twoje konto' + tokenExpired: 'Sesja wygasła. Odśwież stronę i spróbuj ponownie.' + login: + placeholder: 'Email (lub nazwa użytkownika, jeśli już posiadasz konto)' + action: 'Zaloguj' + emailSent: 'Na adres %email% wysłałośmy email z sześciocyfrowym kodem. Wpisz go poniżej. Kod jest jednorazowy i ważny przez 15 minut.' + userNotFound: 'Użytkownik nie został znaleziony.' + email: + subject: 'Twój kod logowania to %code%' + content: | + Aby potwierdzić swój adres email, użyj kodu: %code%. + + Jeśli nie zamawiałxś tego kodu, po prostu zignoruj tę wiadomość. + code: + action: 'Sprawdź' + invalid: 'Kod nieprawidłowy.' share: 'Udostępnij' diff --git a/migrations/002-users.sql b/migrations/002-users.sql index 13eb33de..d461d18c 100644 --- a/migrations/002-users.sql +++ b/migrations/002-users.sql @@ -10,7 +10,7 @@ CREATE TABLE users ( CREATE TABLE authenticators ( id TEXT NOT NULL PRIMARY KEY, - userId TEXT NOT NULL, + userId TEXT, type TEXT NOT NULL, payload TEXT NOT NULL, validUntil INTEGER, diff --git a/routes/user.vue b/routes/user.vue index 56035c19..a42f0bee 100644 --- a/routes/user.vue +++ b/routes/user.vue @@ -2,14 +2,61 @@

- user.header + user.headerLong

+
+

+ + {{error}} +

+
+ +
+ Logged in as {{payload.uid}}. +
+
+
+
+ +
+ +
+
+
+
+
+
+

+ + user.login.emailSent +

+
+ +
+
+ +
+ +
+
+
+
+
{{JSON.stringify(token)}}
{{JSON.stringify(payload, null, 4)}}
-
@@ -21,7 +68,11 @@ data() { return { token: null, - } + usernameOrEmail: '', + code: '', + + error: '', + }; }, computed: { payload() { @@ -38,8 +89,33 @@ }, methods: { async login() { - this.token = await this.$axios.$post(`/user`); + await this.post(`/user/init`, { + usernameOrEmail: this.usernameOrEmail + }); }, + async validate() { + await this.post(`/user/validate`, { + code: this.code + }, { + headers: { + authorization: 'Bearer ' + this.token, + }, + }); + }, + async post(url, data, options = {}) { + this.error = ''; + + const response = await this.$axios.$post(url, data, options); + + if (response.error) { + this.error = response.error; + this.usernameOrEmail = ''; + this.code = ''; + return; + } + + this.token = response.token; + } }, head() { return head({ diff --git a/server/mailer.js b/server/mailer.js new file mode 100644 index 00000000..703350d0 --- /dev/null +++ b/server/mailer.js @@ -0,0 +1,21 @@ +const mailer = require('mailer'); + +module.exports = (to, subject, body) => { + mailer.send({ + host: process.env.MAILER_HOST, + port: parseInt(process.env.MAILER_PORT), + ssl: parseInt(process.env.MAILER_PORT) === 465, + authentication: 'login', + username: process.env.MAILER_USER, + password: process.env.MAILER_PASS, + from: process.env.MAILER_FROM, + to, + subject, + body, + }, + function(err){ + if (err) { + console.error(err); + } + }); +} diff --git a/server/notify.js b/server/notify.js index 2a6faf6d..eac7f0f8 100644 --- a/server/notify.js +++ b/server/notify.js @@ -1,6 +1,6 @@ const dbConnection = require('./db'); -const mailer = require('mailer'); require('dotenv').config({ path:__dirname + '/../.env' }); +const mailer = require('./mailer'); async function notify() { const db = await dbConnection(); @@ -15,23 +15,7 @@ async function notify() { for (let admin of process.env.MAILER_ADMINS.split(',')) { console.log('Sending email to ' + admin) - mailer.send({ - host: process.env.MAILER_HOST, - port: parseInt(process.env.MAILER_PORT), - ssl: parseInt(process.env.MAILER_PORT) === 465, - authentication: 'login', - username: process.env.MAILER_USER, - password: process.env.MAILER_PASS, - from: process.env.MAILER_FROM, - to: admin, - subject: '[Zaimki.pl] Wpisy oczekują na moderację', - body: 'Liczba wpisów: ' + awaitingModeration, - }, - function(err, result){ - if (err) { - console.log(err); - } - }); + mailer(admin, '[Zaimki.pl] Wpisy oczekują na moderację', 'Liczba wpisów: ' + awaitingModeration); } } diff --git a/server/user.js b/server/user.js index e0db553c..61e17664 100644 --- a/server/user.js +++ b/server/user.js @@ -1,16 +1,154 @@ import jwt from './jwt'; import { makeId } from '../src/helpers'; +const dbConnection = require('./db'); +const SQL = require('sql-template-strings'); +import { ulid } from 'ulid'; +import translations from "./translations"; +const mailer = require('./mailer'); + +const now = Math.floor(Date.now() / 1000); + +const getUser = (authorization) => { + if (!authorization || !authorization.startsWith('Bearer ')) { + return null; + } + + return jwt.validate(authorization.substring(7)); +} + +const saveAuthenticator = async (db, type, user, payload, validForMinutes = null) => { + const id = ulid(); + await db.get(SQL`INSERT INTO authenticators (id, userId, type, payload, validUntil) VALUES ( + ${id}, + ${user ? user.id : null}, + ${type}, + ${JSON.stringify(payload)}, + ${validForMinutes ? (now + validForMinutes * 60) : null} + )`); + return id; +} + +const findAuthenticator = async (db, id, type) => { + const authenticator = await db.get(SQL`SELECT * FROM authenticators + WHERE id = ${id} + AND type = ${type} + AND (validUntil IS NULL OR validUntil > ${now}) + `); + + if (authenticator) { + authenticator.payload = JSON.parse(authenticator.payload); + } + + return authenticator +} + +const invalidateAuthenticator = async (db, id) => { + await db.get(SQL`UPDATE authenticators + SET validUntil = ${now} + WHERE id = ${id} + `); +} + +const init = async (db, usernameOrEmail) => { + let user = undefined; + + const isEmail = usernameOrEmail.indexOf('@') > -1; + + if (isEmail) { + user = await db.get(SQL`SELECT * FROM users WHERE email = ${usernameOrEmail}`); + } else { + user = await db.get(SQL`SELECT * FROM users WHERE username = ${usernameOrEmail}`); + } + + if (!user && !isEmail) { + return {error: 'user.login.userNotFound'} + } + + const payload = { + username: isEmail ? (user ? user.username : null) : usernameOrEmail, + email: isEmail ? usernameOrEmail : user.email, + code: makeId(6, '0123456789'), + } + + const codeKey = await saveAuthenticator(db, 'email', user, payload, 15); + + mailer( + payload.email, + `[${translations.title}] ${translations.user.login.email.subject.replace('%code%', payload.code)}`, + translations.user.login.email.content.replace('%code%', payload.code), + ) + + return { + token: jwt.sign({...payload, code: null, codeKey}, '15m'), + }; +} + +const validate = async (db, user, code) => { + if (!user || !user.codeKey) { + return {error: 'user.tokenExpired'}; + } + + const authenticator = await findAuthenticator(db, user.codeKey, 'email'); + if (!authenticator) { + return {error: 'user.tokenExpired'}; + } + + if (authenticator.payload.code !== code) { + return {error: 'user.code.invalid'}; + } + + return await authenticate(db, user, authenticator); +} + +const defaultUsername = async (db, email) => { + const base = email.substring(0, email.indexOf('@')) + .padEnd(4, '0') + .replace(/[^A-Za-z0-9._-]/g, '_'); + + let c = 0; + while (true) { + let proposal = base + (c || ''); + let dbUser = await db.get(SQL`SELECT id FROM users WHERE username = ${proposal}`); + if (!dbUser) { + return proposal; + } + c++; + } +} + +const authenticate = async (db, user, authenticator) => { + let dbUser = await db.get(SQL`SELECT * FROM users WHERE email = ${user.email}`); + if (!dbUser) { + dbUser = { + id: ulid(), + username: await defaultUsername(db, user.email), + email: user.email, + roles: 'user', + avatarSource: null, + } + } + + invalidateAuthenticator(db, authenticator); + + return { + token: jwt.sign({ + ...dbUser, + authenticated: true, + }), + }; +} export default async function (req, res, next) { const db = await dbConnection(); - let result = {error: 'Not found'} - if (req.method === 'GET' && req.url === '/all') { - jwt.sign({ - username: 'andrea', - email: 'andrea@avris.it', - secret: makeId(6, '0123456789'), - }) + let result = {error: 'notfound'} + + const user = getUser(req.headers.authorization); + + if (req.method === 'POST' && req.url === '/init' && req.body.usernameOrEmail) { + result = await init(db, req.body.usernameOrEmail) + } else if (req.method === 'POST' && req.url === '/validate' && req.body.code) { + result = await validate(db, user, req.body.code); } res.setHeader('content-type', 'application/json'); From 7ac9348396023c57a5f5a9f8523630caa4f8c622 Mon Sep 17 00:00:00 2001 From: Avris Date: Thu, 15 Oct 2020 18:50:05 +0200 Subject: [PATCH 5/9] #54 user accounts - test login --- server/user.js | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/server/user.js b/server/user.js index 61e17664..bc2f7a0f 100644 --- a/server/user.js +++ b/server/user.js @@ -53,6 +53,12 @@ const init = async (db, usernameOrEmail) => { let user = undefined; const isEmail = usernameOrEmail.indexOf('@') > -1; + let isTest = false; + + if (process.env.NODE_ENV === 'development' && usernameOrEmail.endsWith('+')) { + isTest = true; + usernameOrEmail = usernameOrEmail.substring(0, usernameOrEmail.length - 1); + } if (isEmail) { user = await db.get(SQL`SELECT * FROM users WHERE email = ${usernameOrEmail}`); @@ -67,16 +73,18 @@ const init = async (db, usernameOrEmail) => { const payload = { username: isEmail ? (user ? user.username : null) : usernameOrEmail, email: isEmail ? usernameOrEmail : user.email, - code: makeId(6, '0123456789'), + code: isTest ? '999999' : makeId(6, '0123456789'), } const codeKey = await saveAuthenticator(db, 'email', user, payload, 15); - mailer( - payload.email, - `[${translations.title}] ${translations.user.login.email.subject.replace('%code%', payload.code)}`, - translations.user.login.email.content.replace('%code%', payload.code), - ) + if (!isTest) { + mailer( + payload.email, + `[${translations.title}] ${translations.user.login.email.subject.replace('%code%', payload.code)}`, + translations.user.login.email.content.replace('%code%', payload.code), + ) + } return { token: jwt.sign({...payload, code: null, codeKey}, '15m'), @@ -103,6 +111,7 @@ const validate = async (db, user, code) => { const defaultUsername = async (db, email) => { const base = email.substring(0, email.indexOf('@')) .padEnd(4, '0') + .substring(0, 12) .replace(/[^A-Za-z0-9._-]/g, '_'); let c = 0; From 7453a26773068021b55ca29130ca5a6ed231978b Mon Sep 17 00:00:00 2001 From: Avris Date: Thu, 15 Oct 2020 18:50:32 +0200 Subject: [PATCH 6/9] #54 user accounts - vuex store and localStorage --- components/Header.vue | 122 ++++++++++++++++++++++++++++++------------ nuxt.config.js | 1 + plugins/auth.js | 10 ++++ routes/user.vue | 107 +++++++++++++++++++----------------- src/helpers.js | 24 +++++++++ store/index.js | 35 ++++++++++++ 6 files changed, 217 insertions(+), 82 deletions(-) create mode 100644 plugins/auth.js create mode 100644 store/index.js diff --git a/components/Header.vue b/components/Header.vue index f810c193..ca321cd4 100644 --- a/components/Header.vue +++ b/components/Header.vue @@ -39,50 +39,106 @@ diff --git a/components/Alert.vue b/components/Alert.vue new file mode 100644 index 00000000..983a5cfd --- /dev/null +++ b/components/Alert.vue @@ -0,0 +1,17 @@ + + + diff --git a/components/Login.vue b/components/Login.vue new file mode 100644 index 00000000..ef0ada9b --- /dev/null +++ b/components/Login.vue @@ -0,0 +1,106 @@ + + + diff --git a/locale/pl/translations.suml b/locale/pl/translations.suml index caf00045..7e7dded6 100644 --- a/locale/pl/translations.suml +++ b/locale/pl/translations.suml @@ -596,7 +596,17 @@ user: code: action: 'Sprawdź' invalid: 'Kod nieprawidłowy.' - + account: + changeUsername: + header: 'Nazwa użytkownika' + action: 'Zmień' + invalid: 'Nazwa użytkownika musi mieć od 4 do 16 znaków i zawierać wyłącznie cyfry, litery, kropkę, myślnik i podłogę.' + taken: 'Ta nazwa użytkownika jest zajęta.' + changeEmail: + header: 'Adres email' + action: 'Zmień' + admin: 'Adminię' + logout: 'Wyloguj' share: 'Udostępnij' diff --git a/plugins/auth.js b/plugins/auth.js index 952e8de0..0cfe8787 100644 --- a/plugins/auth.js +++ b/plugins/auth.js @@ -1,3 +1,6 @@ +import Vue from 'vue'; +import t from "../src/translator"; + export default ({app, store}) => { const token = app.$cookies.get('token'); if (token) { @@ -6,4 +9,11 @@ export default ({app, store}) => { app.$cookies.removeAll(); } } + + Vue.prototype.$user = _ => store.state.user; + Vue.prototype.$auth = _ => { + return store.state.token ? { + authorization: 'Bearer ' + store.state.token, + } : {}; + }; } diff --git a/routes/user.vue b/routes/user.vue index 8c66a109..f4f8605b 100644 --- a/routes/user.vue +++ b/routes/user.vue @@ -5,129 +5,15 @@ user.headerLong -
-
-

- - {{error}} -

-
- -
- Logged in as @{{$store.state.user.username}}. - - -
-
-
-
- -
- -
-
-
-
-
-
-

- - user.login.emailSent -

-
- -
-
- -
- -
-
-
-
-
+ +