diff --git a/shared/constants.ts b/shared/constants.ts index 204f551..ae8726f 100644 --- a/shared/constants.ts +++ b/shared/constants.ts @@ -1 +1,3 @@ export const NON_NEGATIVE_REAL_NUMBER_REGEX = /^\d+(\.\d+)?$/; + +export const AUTH_MESSAGE_EXPIRATION_TIME_MS = 5 * 60 * 1000; // 5 minutes diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index 20dacc9..dfec3ee 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -1,13 +1,39 @@ import jwt from 'jsonwebtoken'; import dotenv from 'dotenv'; import { Request, Response } from 'express'; +import crypto from 'crypto'; + import AuthData from '@/interfaces/bodies/user/AuthData.js'; +import RequestAuthBody from '@/interfaces/bodies/auth/RequestAuthBody.js'; +import authMessagesModel from '@/models/AuthMessages.js'; +import { AUTH_MESSAGE_EXPIRATION_TIME_MS } from 'shared/constants.js'; +import RequestAuthRes from '@/interfaces/responses/auth/RequestAuthRes.js'; +import sequelize from '@/sequelize.js'; import validateWallet from '../methods/validateWallet.js'; import userModel from '../models/User.js'; dotenv.config(); class AuthController { + requestAuth = async (req: Request, res: Response) => { + const { address, alias } = req.body as RequestAuthBody; + + const message = crypto.randomUUID(); + const expiresAt = new Date(Date.now() + AUTH_MESSAGE_EXPIRATION_TIME_MS); + + const authMessageRow = await authMessagesModel.create({ + address, + alias, + message, + expiresAt, + }); + + return res.status(200).send({ + success: true, + data: authMessageRow.message, + }); + }; + async auth(req: Request, res: Response) { try { const userData: AuthData = req.body.data; @@ -18,6 +44,19 @@ class AuthController { return res.status(400).send({ success: false, data: 'Invalid auth data' }); } + const authMessageRow = await authMessagesModel.findOne({ + address, + alias, + message, + }); + + const isAuthMessageValid = + !!authMessageRow && authMessageRow.expires_at.getTime() > Date.now(); + + if (!isAuthMessageValid) { + return res.status(400).send({ success: false, data: 'Invalid auth message' }); + } + const dataValid = !!( userData && userData.address && @@ -28,17 +67,33 @@ class AuthController { return res.status(400).send({ success: false, data: 'Invalid auth data' }); } - const success = await userModel.add(userData); + let token: string | undefined; - if (success) { - const token = jwt.sign( - { ...userData }, - process.env.JWT_SECRET || '', - neverExpires ? undefined : { expiresIn: '24h' }, - ); - res.status(200).send({ success, data: token }); + await sequelize.transaction(async (transaction) => { + const success = await userModel.add(userData, { transaction }); + + if (success) { + await authMessagesModel.deleteOne( + { + address, + alias, + message, + }, + { transaction }, + ); + + token = jwt.sign( + { ...userData }, + process.env.JWT_SECRET || '', + neverExpires ? undefined : { expiresIn: '24h' }, + ); + } + }); + + if (token !== undefined) { + res.status(200).send({ success: true, data: token }); } else { - res.status(500).send({ success, data: 'Internal error' }); + res.status(500).send({ success: false, data: 'Internal error' }); } } catch (err) { console.log(err); diff --git a/src/interfaces/bodies/auth/RequestAuthBody.ts b/src/interfaces/bodies/auth/RequestAuthBody.ts new file mode 100644 index 0000000..6f1d368 --- /dev/null +++ b/src/interfaces/bodies/auth/RequestAuthBody.ts @@ -0,0 +1,13 @@ +import { body } from 'express-validator'; + +interface RequestAuthBody { + address: string; + alias: string; +} + +export const requestAuthBodyValidator = [ + body('address').isString().notEmpty(), + body('alias').isString().notEmpty(), +]; + +export default RequestAuthBody; diff --git a/src/interfaces/responses/auth/RequestAuthRes.ts b/src/interfaces/responses/auth/RequestAuthRes.ts new file mode 100644 index 0000000..a200f5f --- /dev/null +++ b/src/interfaces/responses/auth/RequestAuthRes.ts @@ -0,0 +1,7 @@ +interface RequestAuthRes { + success: true; + // Auth message + data: string; +} + +export default RequestAuthRes; diff --git a/src/models/AuthMessages.ts b/src/models/AuthMessages.ts new file mode 100644 index 0000000..0cbee63 --- /dev/null +++ b/src/models/AuthMessages.ts @@ -0,0 +1,89 @@ +import { Op, Transaction } from 'sequelize'; + +import AuthMessage from '@/schemes/AuthMessage'; + +class AuthMessagesModel { + create = async ( + { + address, + alias, + message, + expiresAt, + }: { + address: string; + alias: string; + message: string; + expiresAt: Date; + }, + { transaction }: { transaction?: Transaction } = {}, + ): Promise => { + const authMessage = await AuthMessage.create( + { + address, + alias, + message, + expires_at: expiresAt, + }, + { transaction }, + ); + + return authMessage; + }; + + findOne = async ({ + address, + alias, + message, + }: { + address: string; + alias: string; + message: string; + }): Promise => + AuthMessage.findOne({ + where: { + address, + alias, + message, + }, + }); + + deleteOne = async ( + { + address, + alias, + message, + }: { + address: string; + alias: string; + message: string; + }, + { transaction }: { transaction?: Transaction } = {}, + ): Promise => { + await AuthMessage.destroy({ + where: { + address, + alias, + message, + }, + transaction, + }); + }; + + deleteAllExpired = async ( + { now }: { now: Date }, + { transaction }: { transaction?: Transaction } = {}, + ): Promise => { + await AuthMessage.destroy({ + where: { + expires_at: { + [Op.lt]: now, + }, + }, + transaction, + }); + }; +} + +const authMessagesModel = new AuthMessagesModel(); + +export default authMessagesModel; diff --git a/src/models/User.ts b/src/models/User.ts index 999ab65..83135fc 100644 --- a/src/models/User.ts +++ b/src/models/User.ts @@ -1,4 +1,4 @@ -import { Op } from 'sequelize'; +import { Op, Transaction } from 'sequelize'; import Offer from '../schemes/Offer'; import GetUserBody from '../interfaces/bodies/user/GetUserBody'; import SetFavouriteCurrsBody from '../interfaces/bodies/user/SetFavouriteCurrsBody'; @@ -15,7 +15,7 @@ class UserModel { return selected; } - async add(userData: UserData) { + async add(userData: UserData, { transaction }: { transaction?: Transaction } = {}) { try { const userRow = await this.getUserRow(userData.address); if (userRow) return true; @@ -27,12 +27,15 @@ class UserModel { if (oldAddressOfCurrentAlias) { await User.update( { address: userData.address }, - { where: { alias: userData.alias } }, + { where: { alias: userData.alias }, transaction }, ); return true; } - await User.create({ alias: userData.alias, address: userData.address }); + await User.create( + { alias: userData.alias, address: userData.address }, + { transaction }, + ); return true; } catch (err) { diff --git a/src/routes/auth.router.ts b/src/routes/auth.router.ts index 77dfc5b..fd7d8ca 100644 --- a/src/routes/auth.router.ts +++ b/src/routes/auth.router.ts @@ -1,8 +1,16 @@ import express from 'express'; + +import middleware from '@/middleware/middleware.js'; +import { requestAuthBodyValidator } from '@/interfaces/bodies/auth/RequestAuthBody.js'; import authController from '../controllers/auth.controller.js'; const authRouter = express.Router(); +authRouter.post( + '/auth/request-auth', + middleware.expressValidator(requestAuthBodyValidator), + authController.requestAuth.bind(authController), +); authRouter.post('/auth', authController.auth); export default authRouter; diff --git a/src/schemes/AuthMessage.ts b/src/schemes/AuthMessage.ts new file mode 100644 index 0000000..ef8ab38 --- /dev/null +++ b/src/schemes/AuthMessage.ts @@ -0,0 +1,45 @@ +import sequelize from '@/sequelize'; +import { DataTypes, Model } from 'sequelize'; + +class AuthMessage extends Model { + declare readonly id: number; + declare address: string; + declare alias: string; + declare message: string; + declare expires_at: Date; + + declare readonly createdAt: Date; + declare readonly updatedAt: Date; +} + +AuthMessage.init( + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + address: { + type: DataTypes.STRING, + allowNull: false, + }, + alias: { + type: DataTypes.STRING, + allowNull: false, + }, + message: { + type: DataTypes.STRING, + allowNull: false, + }, + expires_at: { + type: DataTypes.DATE, + allowNull: false, + }, + }, + { + sequelize, + modelName: 'AuthMessage', + }, +); + +export default AuthMessage; diff --git a/src/server.ts b/src/server.ts index 4c20936..2c5ba0e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,6 +3,7 @@ import express from 'express'; import http from 'http'; import { Server } from 'socket.io'; +import authMessagesCleanService from '@/workers/authMessagesCleanService'; import authRouter from './routes/auth.router'; import offersRouter from './routes/offers.router'; import userRouter from './routes/user.router'; @@ -73,6 +74,7 @@ process.on('unhandledRejection', (reason, promise) => { assetsUpdateChecker.run(); ordersModerationService.run(); + authMessagesCleanService.run(); exchangeModel.runPairStatsDaemon(); statsModel.init(); diff --git a/src/workers/authMessagesCleanService.ts b/src/workers/authMessagesCleanService.ts new file mode 100644 index 0000000..aa26c9a --- /dev/null +++ b/src/workers/authMessagesCleanService.ts @@ -0,0 +1,41 @@ +import authMessagesModel from '@/models/AuthMessages'; + +const CLEAN_INTERVAL = 60 * 60 * 1000; // 1 hour + +class AuthMessagesCleanService { + run = async () => { + console.log( + `Auth messages clean service is running. Cleaning interval: ${CLEAN_INTERVAL / 1000} sec.`, + ); + + async function clean() { + console.log(`[${new Date()}] Cleaning auth messages...`); + + await authMessagesModel.deleteAllExpired({ now: new Date() }); + + console.log(`[${new Date()}] Auth messages cleaned.`); + } + + // eslint-disable-next-line no-constant-condition + while (true) { + try { + await clean(); + } catch (error) { + console.log( + `[${new Date()}] Error while cleaning auth messages. Continuing on next iteration. Error:`, + ); + console.error(error); + } + + console.log( + `[${new Date()}] Auth messages cleaned. Next cleaning in ${CLEAN_INTERVAL / 1000} sec.`, + ); + + await new Promise((resolve) => setTimeout(resolve, CLEAN_INTERVAL)); + } + }; +} + +const authMessagesCleanService = new AuthMessagesCleanService(); + +export default authMessagesCleanService;