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