#54 user accounts - login/registration flow
This commit is contained in:
parent
642c69ceec
commit
f86bc1d02b
|
@ -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'
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
|
@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
152
server/user.js
152
server/user.js
|
@ -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');
|
||||||
|
|
Reference in New Issue