Working MySQL-based auth

This commit is contained in:
Maciej Gomoła 2024-03-13 21:40:24 +01:00
parent dde5280c98
commit eff3ed1914
7 changed files with 383 additions and 19 deletions

131
index.js
View File

@ -10,7 +10,8 @@ import { v4 as uuidv4, validate } from 'uuid';
import session from "express-session";
import { engine } from 'express-handlebars';
import { createClient } from 'redis';
import * as bships from './utils/battleships.js'
import * as bships from './utils/battleships.js';
import { MailAuth } from './utils/auth.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@ -31,6 +32,17 @@ redis.on('error', err => console.log('Redis Client Error', err));
await redis.connect();
const GInfo = new bships.GameInfo(redis, io);
const auth = new MailAuth(redis, {
host: process.env.mail_host,
user: process.env.mail_user,
pass: process.env.mail_pass
},
{
host: process.env.db_host,
user: process.env.db_user,
password: process.env.db_pass,
database: 'statki'
});
app.set('trust proxy', 1);
const sessionMiddleware = session({
@ -45,37 +57,110 @@ app.use(express.static(path.join(__dirname, 'public')));
io.engine.use(sessionMiddleware);
app.get("/", async (req, res) => {
if (req.session.nickname == null) {
res.redirect("/setup");
app.get('/', async (req, res) => {
let login = loginState(req);
if (login != 2) {
res.redirect('/login');
} else if (req.session.nickname == null) {
auth.getNickname(req.session.userId).then(nickname => {
if (nickname != null) {
req.session.nickname = nickname;
res.render('index');
} else {
res.redirect('/nickname');
}
});
} else {
res.render('index');
}
});
app.get("/setup", (req, res) => {
if (req.session.nickname != null) {
app.get('/login', (req, res) => {
let login = loginState(req);
if (!login) {
res.render('login');
} else if (login == 1) {
res.redirect('/auth');
} else {
res.redirect('/');
}
});
app.get('/auth', (req, res) => {
let login = loginState(req);
if (!login) { // Niezalogowany
res.redirect('/login');
} else if (login == 1) { // W trakcie autoryzacji
res.render('auth');
} else { // Zalogowany
res.redirect('/auth');
}
});
app.get('/nickname', (req, res) => {
let login = loginState(req);
if (!login) { // Niezalogowany
res.redirect('/login');
} else {
res.render('setup');
}
});
app.post('/api/login', (req, res) => {
let login = loginState(req);
if (login == 2) {
res.redirect('/');
} else if (login == 0 && req.body.email != null && validateEmail(req.body.email)) {
auth.startVerification(req.body.email).then(result => {
if (result.status) {
req.session.userId = result.uid;
req.session.loggedIn = 1;
res.redirect('/auth');
} else {
res.sendStatus(500);
}
});
} else {
res.sendStatus(403);
}
});
app.post('/api/auth', async (req, res) => {
let login = loginState(req);
if (login == 2) {
res.redirect('/');
} else if (login == 1 && req.body.code != null && req.body.code.length <= 10 && req.body.code.length >= 8) {
let finishResult = await auth.finishVerification(req.session.userId, req.body.code);
if (finishResult) {
req.session.loggedIn = 2;
res.redirect('/');
} else {
res.render("setup");
res.sendStatus(401);
}
} else {
res.sendStatus(403);
}
});
app.post('/api/setup-profile', (req, res) => {
if (req.session.nickname == null && 3 <= req.body.nickname.length && req.body.nickname.length <= 16) {
app.post('/api/nickname', (req, res) => {
if (loginState(req) == 2 && req.session.nickname == null && req.body.nickname != null && 3 <= req.body.nickname.length && req.body.nickname.length <= 16) {
req.session.nickname = req.body.nickname;
req.session.playerID = uuidv4();
req.session.activeGame = null;
auth.setNickname(req.session.userId, req.body.nickname).then(() => {
res.redirect('/');
});
} else {
res.sendStatus(400);
}
res.redirect("/")
});
app.get("/game", async (req, res) => {
app.get('/game', async (req, res) => {
const game = await redis.json.get(`game:${req.query.id}`);
if (req.session.nickname == null) {
res.redirect("/setup");
} else if (req.query.id == null || game == null || game.state == "expired" || req.session.activeGame == null) {
res.redirect('/setup');
} else if (req.query.id == null || game == null || game.state == 'expired' || req.session.activeGame == null) {
res.status(400).send('badGameId');
} else {
res.render('board');
@ -376,3 +461,19 @@ function AFKEnd(gameId) {
function roomMemberIterator(id) {
return io.sockets.adapter.rooms.get(id) == undefined ? null : io.sockets.adapter.rooms.get(id).entries();
}
function loginState(req) {
if (req.session.loggedIn == null) {
return 0;
} else {
return req.session.loggedIn;
}
}
function validateEmail(email) {
return String(email)
.toLowerCase()
.match(
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|.(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
);
};

167
utils/auth.js Normal file
View File

@ -0,0 +1,167 @@
import nodemailer from 'nodemailer';
import uuid4 from 'uuid4';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import mysql from 'mysql';
import { createClient } from 'redis';
import readline from "node:readline";
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const saltRounds = 10;
export class MailAuth {
constructor(redis, options, mysqlOptions) {
this.redis = redis;
this.mysqlOptions = mysqlOptions;
this.mail = nodemailer.createTransport({
host: options.host ? options.host : "localhost",
port: options.port ? options.port : "465",
secure: options.secure ? options.secure : true,
auth: {
user: options.user,
pass: options.pass,
},
});
this.mailFrom = `"Statki" <${options.user}>`
}
async timer(tId, time, callback) {
await this.redis.set(`timer:${tId}`, new Date().getTime() / 1000);
let localLastUpdate = await this.redis.get(`timer:${tId}`);
let timeout = setTimeout(callback, time * 1000);
let interval = setInterval(async () => {
if (timeout._destroyed) {
clearInterval(interval);
return;
}
let lastUpdate = await this.redis.get(`timer:${tId}`);
if (localLastUpdate != lastUpdate) {
clearTimeout(timeout);
clearInterval(interval);
return;
}
}, 200);
}
async resetTimer(tId) {
let lastUpdate = await this.redis.get(`timer:${tId}`);
await this.redis.set(`timer:${tId}`, -lastUpdate);
}
startVerification(email) {
return new Promise((resolve, reject) => {
const conn = mysql.createConnection(this.mysqlOptions);
conn.query(`SELECT user_id, nickname FROM accounts WHERE email = ${conn.escape(email)}`, async (error, response) => {
if (error) reject(error);
if (response.length === 0 || response[0].nickname == null) {
conn.query(`DELETE FROM accounts WHERE email = ${conn.escape(email)};`, (error, response) => { if (error) reject(error) });
conn.query(`INSERT INTO accounts(email) VALUES (${conn.escape(email)});`, (error, response) => { if (error) reject(error) });
conn.query(`SELECT user_id, nickname FROM accounts WHERE email = ${conn.escape(email)}`, async (error, response) => {
if (error) reject(error);
const row = response[0];
const html = fs.readFileSync(path.join(__dirname, 'mail/auth-code-firsttime.html'), 'utf8');
let authCode = genCode();
let tId = uuid4();
await this.redis.json.set(`code_auth:${authCode}`, "$", { uid: row.user_id, tid: tId });
await this.timer(tId, 600, async () => {
await this.redis.json.del(`code_auth:${authCode}`);
});
authCode = authCode.slice(0, 4) + " " + authCode.slice(4);
try {
await this.mail.sendMail({
from: this.mailFrom,
to: email,
subject: `${authCode} to twój kod autoryzacji do Statków`,
html: html.replace("{{ CODE }}", authCode),
});
} catch (e) {
reject(e);
}
resolve({ status: 1, uid: row.user_id });
});
return;
}
const row = response[0];
const html = fs.readFileSync(path.join(__dirname, 'mail/auth-code.html'), 'utf8');
let authCode = genCode();
let tId = uuid4();
await this.redis.json.set(`code_auth:${authCode}`, "$", { uid: row.user_id, tid: tId });
await this.timer(tId, 600, async () => {
await this.redis.json.del(`code_auth:${authCode}`);
});
authCode = authCode.slice(0, 4) + " " + authCode.slice(4);
await this.mail.sendMail({
from: this.mailFrom,
to: email,
subject: `${authCode} to twój kod logowania do Statków`,
html: html.replace("{{ NICKNAME }}", row.nickname).replace("{{ CODE }}", authCode),
});
resolve({ status: 1, uid: row.user_id });
});
});
}
async finishVerification(uid, authCode) {
authCode = authCode.replace(/\s+/g, "");
let redisRes = await this.redis.json.get(`code_auth:${authCode}`);
if (redisRes != null && redisRes.uid === uid) {
this.resetTimer(redisRes.tid);
await this.redis.del(`code_auth:${authCode}`);
return true;
} else {
return false;
}
}
setNickname(uid, nickname) {
return new Promise((resolve, reject) => {
const conn = mysql.createConnection(this.mysqlOptions);
conn.query(`UPDATE accounts SET nickname = ${conn.escape(nickname)} WHERE user_id = ${conn.escape(uid)}`, (error) => {
if (error) reject(error);
resolve();
});
});
}
getNickname(uid) {
return new Promise((resolve, reject) => {
const conn = mysql.createConnection(this.mysqlOptions);
conn.query(`SELECT nickname FROM accounts WHERE user_id = ${conn.escape(uid)}`, (error, response) => {
if (error) reject(error);
resolve(response[0].nickname);
});
});
}
}
function genCode() {
return Math.floor(10000000 + Math.random() * 90000000).toString();
}

View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,400;0,700;1,400;1,700&family=Poppins:ital,wght@0,600;1,600&family=Roboto+Mono:ital@0;1&display=swap" rel="stylesheet">
</head>
<body style="background: rgb(0, 0, 0);color: white;;font-family: 'Lato', sans-serif;padding: 30px;">
<div
style="margin:30px;text-align: center;display:flex;flex-direction: column;justify-content: center;font-size:1.3em;margin: auto;width: 35%;min-width:25em;padding:20px;">
<h2 style="font-family: 'Poppins', sans-serif;">Cześć!</h2>
<p>Ktoś próbował utworzyć konto w Statkach za pomocą tego e-maila, jeśli to nie byłeś ty - zignoruj tę wiadomość.<br>Poniżej znajduje się kod autoryzacyjny, pamiętaj by nie udostępniać go nikomu.</p>
<div style="border: solid white 1px; border-radius: 15px;font-family: 'Roboto Mono', monospace;padding:15px;font-size: 1.5em;">
{{ CODE }}
</div>
<p>Powyższy kod wygasa po 10 minutach</p>
</div>
<p style="color:white;margin-bottom: 20px;margin-top: 20px;text-align: center;"><a href="https://maciejka.xyz/"
style="text-decoration: none;color:white;">Copyright © 2024 MCJK</a> | <a href="https://statki.maciejka.xyz/"
style="text-decoration: none;color: rgb(85, 111, 255);">statki.maciejka.xyz</a><br>Ta wiadomość została wysłana automatycznie, nie odpowiadaj na nią.</p>
</body>
</html>

22
utils/mail/auth-code.html Normal file
View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,400;0,700;1,400;1,700&family=Poppins:ital,wght@0,600;1,600&family=Roboto+Mono:ital@0;1&display=swap" rel="stylesheet">
</head>
<body style="background: rgb(0, 0, 0);color: white;;font-family: 'Lato', sans-serif;padding: 30px;">
<div
style="margin:30px;text-align: center;display:flex;flex-direction: column;justify-content: center;font-size:1.3em;margin: auto;width: 35%;min-width:25em;padding:20px;">
<h2 style="font-family: 'Poppins', sans-serif;">Hej, {{ NICKNAME }}!</h2>
<p>Ktoś próbował się zalogować na twoje konto w Statkach, jeśli to nie byłeś ty - zignoruj tę wiadomość.<br>Poniżej znajduje się kod autoryzacyjny, pamiętaj by nie udostępniać go nikomu.</p>
<div style="border: solid white 1px; border-radius: 15px;font-family: 'Roboto Mono', monospace;padding:15px;font-size: 1.5em;">
{{ CODE }}
</div>
<p>Powyższy kod wygasa po 10 minutach</p>
</div>
<p style="color:white;margin-bottom: 20px;margin-top: 20px;text-align: center;"><a href="https://maciejka.xyz/"
style="text-decoration: none;color:white;">Copyright © 2024 MCJK</a> | <a href="https://statki.maciejka.xyz/"
style="text-decoration: none;color: rgb(85, 111, 255);">statki.maciejka.xyz</a><br>Ta wiadomość została wysłana automatycznie, nie odpowiadaj na nią.</p>
</body>
</html>

24
views/auth.handlebars Normal file
View File

@ -0,0 +1,24 @@
<style>
.container {
display: flex;
}
#pvpJoinView .modes div {
height: 13rem;
}
</style>
<div class="container" id="pvpJoinView">
<div>
<h1>Statki</h1>
<h2>Autoryzacja</h2>
<div class="modes">
<div>
<p>Podaj kod wysłany do Twojej skrzynki e-mail</p>
<form action="/api/auth" method="post">
<input type="text" name="code" placeholder="Kod" style="font-size: 1rem;" minlength="8" maxlength="10" autocomplete="off">
<input type="submit" value="Zaloguj się">
</form>
</div>
</div>
</div>
</div>

23
views/login.handlebars Normal file
View File

@ -0,0 +1,23 @@
<style>
.container {
display: flex;
}
#pvpJoinView .modes div {
height: 7rem;
}
</style>
<div class="container" id="pvpJoinView">
<div>
<h1>Statki</h1>
<h2>Logowanie</h2>
<div class="modes">
<div>
<form action="/api/login" method="post">
<input type="email" name="email" placeholder="Adres e-mail" style="font-size: 1rem;">
<input type="submit" value="Przejdź dalej">
</form>
</div>
</div>
</div>
</div>

View File

@ -2,6 +2,10 @@
.container {
display: flex;
}
#pvpJoinView .modes div {
height: 11rem;
}
</style>
<div class="container" id="pvpJoinView">
<div>
@ -9,8 +13,9 @@
<h2>Konfiguracja profilu</h2>
<div class="modes">
<div>
<form action="/api/setup-profile" method="post">
<input type="text" name="nickname" placeholder="Nickname" style="font-size: 1rem;">
<p>Twój nickname będzie widoczny dla innych graczy</p>
<form action="/api/nickname" method="post">
<input type="text" name="nickname" placeholder="Nazwa użytkownika" style="font-size: 1rem;">
<input type="submit" value="Zatwierdź">
</form>
</div>