From eff3ed1914f0b4ab5c7ed61d8162c3ef68fa236a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Gomo=C5=82a?= Date: Wed, 13 Mar 2024 21:40:24 +0100 Subject: [PATCH] Working MySQL-based auth --- index.js | 135 +++++++++++++++++++--- utils/auth.js | 167 ++++++++++++++++++++++++++++ utils/mail/auth-code-firsttime.html | 22 ++++ utils/mail/auth-code.html | 22 ++++ views/auth.handlebars | 24 ++++ views/login.handlebars | 23 ++++ views/setup.handlebars | 9 +- 7 files changed, 383 insertions(+), 19 deletions(-) create mode 100644 utils/auth.js create mode 100644 utils/mail/auth-code-firsttime.html create mode 100644 utils/mail/auth-code.html create mode 100644 views/auth.handlebars create mode 100644 views/login.handlebars diff --git a/index.js b/index.js index 8bffdc4..62fbd65 100644 --- a/index.js +++ b/index.js @@ -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) { - res.redirect('/'); +app.get('/login', (req, res) => { + let login = loginState(req); + if (!login) { + res.render('login'); + } else if (login == 1) { + res.redirect('/auth'); } else { - res.render("setup"); + res.redirect('/'); } }); -app.post('/api/setup-profile', (req, res) => { - if (req.session.nickname == null && 3 <= req.body.nickname.length && req.body.nickname.length <= 16) { +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.sendStatus(401); + } + } else { + res.sendStatus(403); + } +}); + +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'); @@ -375,4 +460,20 @@ function AFKEnd(gameId) { function roomMemberIterator(id) { return io.sockets.adapter.rooms.get(id) == undefined ? null : io.sockets.adapter.rooms.get(id).entries(); -} \ No newline at end of file +} + +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,}))$/ + ); +}; \ No newline at end of file diff --git a/utils/auth.js b/utils/auth.js new file mode 100644 index 0000000..e73f34f --- /dev/null +++ b/utils/auth.js @@ -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(); +} \ No newline at end of file diff --git a/utils/mail/auth-code-firsttime.html b/utils/mail/auth-code-firsttime.html new file mode 100644 index 0000000..b2003f9 --- /dev/null +++ b/utils/mail/auth-code-firsttime.html @@ -0,0 +1,22 @@ + + + + + + + + +
+

Cześć!

+

Ktoś próbował utworzyć konto w Statkach za pomocą tego e-maila, jeśli to nie byłeś ty - zignoruj tę wiadomość.
Poniżej znajduje się kod autoryzacyjny, pamiętaj by nie udostępniać go nikomu.

+
+ {{ CODE }} +
+

Powyższy kod wygasa po 10 minutach

+
+

Copyright © 2024 MCJK | statki.maciejka.xyz
Ta wiadomość została wysłana automatycznie, nie odpowiadaj na nią.

+ + \ No newline at end of file diff --git a/utils/mail/auth-code.html b/utils/mail/auth-code.html new file mode 100644 index 0000000..a136bda --- /dev/null +++ b/utils/mail/auth-code.html @@ -0,0 +1,22 @@ + + + + + + + + +
+

Hej, {{ NICKNAME }}!

+

Ktoś próbował się zalogować na twoje konto w Statkach, jeśli to nie byłeś ty - zignoruj tę wiadomość.
Poniżej znajduje się kod autoryzacyjny, pamiętaj by nie udostępniać go nikomu.

+
+ {{ CODE }} +
+

Powyższy kod wygasa po 10 minutach

+
+

Copyright © 2024 MCJK | statki.maciejka.xyz
Ta wiadomość została wysłana automatycznie, nie odpowiadaj na nią.

+ + \ No newline at end of file diff --git a/views/auth.handlebars b/views/auth.handlebars new file mode 100644 index 0000000..e3c6db3 --- /dev/null +++ b/views/auth.handlebars @@ -0,0 +1,24 @@ + +
+
+

Statki

+

Autoryzacja

+
+
+

Podaj kod wysłany do Twojej skrzynki e-mail

+
+ + +
+
+
+
+
\ No newline at end of file diff --git a/views/login.handlebars b/views/login.handlebars new file mode 100644 index 0000000..1e37de9 --- /dev/null +++ b/views/login.handlebars @@ -0,0 +1,23 @@ + +
+
+

Statki

+

Logowanie

+
+
+
+ + +
+
+
+
+
\ No newline at end of file diff --git a/views/setup.handlebars b/views/setup.handlebars index b8fdd15..2d795d6 100644 --- a/views/setup.handlebars +++ b/views/setup.handlebars @@ -2,6 +2,10 @@ .container { display: flex; } + + #pvpJoinView .modes div { + height: 11rem; + }
@@ -9,8 +13,9 @@

Konfiguracja profilu

-
- +

Twój nickname będzie widoczny dla innych graczy

+ +