#54 user accounts - login/registration flow

This commit is contained in:
Avris 2020-10-14 21:49:18 +02:00
parent 642c69ceec
commit f86bc1d02b
6 changed files with 264 additions and 30 deletions

View File

@ -521,6 +521,21 @@ support:
user: user:
header: 'Konto' header: 'Konto'
headerLong: 'Twoje 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 <strong>%email%</strong> 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' share: 'Udostępnij'

View File

@ -10,7 +10,7 @@ CREATE TABLE users (
CREATE TABLE authenticators ( CREATE TABLE authenticators (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
userId TEXT NOT NULL, userId TEXT,
type TEXT NOT NULL, type TEXT NOT NULL,
payload TEXT NOT NULL, payload TEXT NOT NULL,
validUntil INTEGER, validUntil INTEGER,

View File

@ -2,14 +2,61 @@
<div class="container"> <div class="container">
<h2> <h2>
<Icon v="user"/> <Icon v="user"/>
<T>user.header</T> <T>user.headerLong</T>
</h2> </h2>
<div v-if="error" class="alert alert-danger">
<p class="mb-0">
<Icon v="exclamation-triangle"/>
<T>{{error}}</T>
</p>
</div>
<div v-if="payload && payload.authenticated">
Logged in as <strong>{{payload.uid}}</strong>.
</div>
<div v-else-if="token === null">
<form @submit.prevent="login">
<div class="input-group mb-3">
<input type="text" class="form-control" v-model="usernameOrEmail"
:placeholder="$t('user.login.placeholder')" autofocus required/>
<div class="input-group-append">
<button class="btn btn-primary">
<Icon v="sign-in"/>
<T>user.login.action</T>
</button>
</div>
</div>
</form>
</div>
<div v-else-if="payload && !payload.code">
<div class="alert alert-success">
<p class="mb-0">
<Icon v="envelope-open-text"/>
<T :params="{email: payload.email}">user.login.emailSent</T>
</p>
</div>
<form @submit.prevent="validate">
<div class="input-group mb-3">
<input type="text" class="form-control text-center" v-model="code"
placeholder="000000" autofocus required minlength="0" maxlength="6"
inputmode="numeric" pattern="[0-9]{6}" autocomplete="one-time-code"
/>
<div class="input-group-append">
<button class="btn btn-primary">
<Icon v="key"/>
<T>user.code.action</T>
</button>
</div>
</div>
</form>
</div>
<div v-if="token"> <div v-if="token">
<pre><code>{{JSON.stringify(token)}}</code></pre> <pre><code>{{JSON.stringify(token)}}</code></pre>
<pre>{{JSON.stringify(payload, null, 4)}}</pre> <pre>{{JSON.stringify(payload, null, 4)}}</pre>
</div> </div>
<button v-else class="btn btn-primary" @click="login">Zaloguj</button>
</div> </div>
</template> </template>
@ -21,7 +68,11 @@
data() { data() {
return { return {
token: null, token: null,
} usernameOrEmail: '',
code: '',
error: '',
};
}, },
computed: { computed: {
payload() { payload() {
@ -38,8 +89,33 @@
}, },
methods: { methods: {
async login() { 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() { head() {
return head({ return head({

21
server/mailer.js Normal file
View File

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

View File

@ -1,6 +1,6 @@
const dbConnection = require('./db'); const dbConnection = require('./db');
const mailer = require('mailer');
require('dotenv').config({ path:__dirname + '/../.env' }); require('dotenv').config({ path:__dirname + '/../.env' });
const mailer = require('./mailer');
async function notify() { async function notify() {
const db = await dbConnection(); const db = await dbConnection();
@ -15,23 +15,7 @@ async function notify() {
for (let admin of process.env.MAILER_ADMINS.split(',')) { for (let admin of process.env.MAILER_ADMINS.split(',')) {
console.log('Sending email to ' + admin) console.log('Sending email to ' + admin)
mailer.send({ mailer(admin, '[Zaimki.pl] Wpisy oczekują na moderację', 'Liczba wpisów: ' + awaitingModeration);
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);
}
});
} }
} }

View File

@ -1,16 +1,154 @@
import jwt from './jwt'; import jwt from './jwt';
import { makeId } from '../src/helpers'; 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) { export default async function (req, res, next) {
const db = await dbConnection(); const db = await dbConnection();
let result = {error: 'Not found'} let result = {error: 'notfound'}
if (req.method === 'GET' && req.url === '/all') {
jwt.sign({ const user = getUser(req.headers.authorization);
username: 'andrea',
email: 'andrea@avris.it', if (req.method === 'POST' && req.url === '/init' && req.body.usernameOrEmail) {
secret: makeId(6, '0123456789'), 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'); res.setHeader('content-type', 'application/json');