#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:
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 <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'

View File

@ -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,

View File

@ -2,14 +2,61 @@
<div class="container">
<h2>
<Icon v="user"/>
<T>user.header</T>
<T>user.headerLong</T>
</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">
<pre><code>{{JSON.stringify(token)}}</code></pre>
<pre>{{JSON.stringify(payload, null, 4)}}</pre>
</div>
<button v-else class="btn btn-primary" @click="login">Zaloguj</button>
</div>
</template>
@ -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({

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

View File

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