From f86bc1d02b5f7f46fd91dd7a9bd325c0e201321d Mon Sep 17 00:00:00 2001 From: Avris Date: Wed, 14 Oct 2020 21:49:18 +0200 Subject: [PATCH] #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');