diff --git a/api/auth.ts b/api/auth.ts index c7e7e7d..24436ff 100644 --- a/api/auth.ts +++ b/api/auth.ts @@ -1,84 +1,155 @@ -import express, { Request, Response, NextFunction } from 'express' -import { execFile } from 'child_process' -import { cliStderrResponse, unautorizedResponse } from './handlers/util' -import * as crypto from '@shardus/crypto-utils'; -import rateLimit from 'express-rate-limit'; -const yaml = require('js-yaml') -const jwt = require('jsonwebtoken') +import express, { Request, Response, NextFunction, response } from "express"; +import { execFile } from "child_process"; +import { cliStderrResponse, unautorizedResponse } from "./handlers/util"; +import * as crypto from "@shardus/crypto-utils"; +import rateLimit from "express-rate-limit"; +const yaml = require("js-yaml"); +const jwt = require("jsonwebtoken"); function isValidSecret(secret: unknown) { - return typeof secret === 'string' && secret.length >= 32; + return typeof secret === "string" && secret.length >= 32; } function generateRandomSecret() { - return Buffer.from(crypto.randomBytes(32)).toString('hex'); + return Buffer.from(crypto.randomBytes(32)).toString("hex"); } -const jwtSecret = (isValidSecret(process.env.JWT_SECRET)) +const jwtSecret = isValidSecret(process.env.JWT_SECRET) ? process.env.JWT_SECRET : generateRandomSecret(); -crypto.init('64f152869ca2d473e4ba64ab53f49ccdb2edae22da192c126850970e788af347'); +crypto.init("64f152869ca2d473e4ba64ab53f49ccdb2edae22da192c126850970e788af347"); -export const loginHandler = (req: Request, res: Response) => { - const password = req.body && req.body.password +const MAX_CONCURRENT_EXEC = 1; +let currentExecCount = 0; +export const loginHandler = async (req: Request, res: Response) => { + if (currentExecCount >= MAX_CONCURRENT_EXEC) { + res + .status(429) + .send({ error: "Server is too busy. Please try again later." }); + return; + } + const password = req.body && req.body.password; const hashedPass = crypto.hash(password); + const ip = String(req.socket.remoteAddress); + // Exec the CLI validator login command - execFile('operator-cli', ['gui', 'login', hashedPass], (err, stdout, stderr) => { - if (err) { - cliStderrResponse(res, 'Unable to check login', err.message) - return - } - if (stderr) { - cliStderrResponse(res, 'Unable to check login', stderr) - return - } + execFile( + "operator-cli", + ["gui", "login", hashedPass, ip], + (err, stdout, stderr) => { + currentExecCount--; + if (err) { + cliStderrResponse(res, "Unable to check login", err.message); + return; + } + if (stderr) { + cliStderrResponse(res, "Unable to check login", stderr); + return; + } - const cliResponse = yaml.load(stdout) + const cliResponse = yaml.load(stdout); + if (cliResponse.login === "blocked") { + res.status(403).json({ errorMessage: "Blocked" }); + // Set a timeout to unlock the IP after 30 minutes + setTimeout( () => { + execFile( + "operator-cli", + ["gui", "unlock", ip], + (err, stdout, stderr) => { + console.log("executing operator-cli gui unlock"); + if (err) { + console.error("Unable to unlock IP", err.message); + } else if (stderr) { + console.error("Unable to unlock IP", stderr); + } else { + console.log("IP unlocked successfully"); + } + } + ); + }, 30 * 60 * 1000); // 30 minutes in milliseconds - if (cliResponse.login !== 'authorized') { - unautorizedResponse(req, res) - return - } - const accessToken = jwt.sign({ nodeId: '' /** add unique node id */ }, jwtSecret, { expiresIn: '8h' }) + return; + } + if (cliResponse.login !== "authorized") { + unautorizedResponse(req, res); + return; + } + const accessToken = jwt.sign( + { nodeId: "" /** add unique node id */ }, + jwtSecret, + { expiresIn: "8h" } + ); - res.cookie("accessToken", accessToken, { - httpOnly: true, - secure: true, - sameSite: "strict", - }); - res.send({ status : 'ok' }) - }) - console.log('executing operator-cli gui login...') -} + res.cookie("accessToken", accessToken, { + httpOnly: true, + secure: true, + sameSite: "strict", + }); + res.send({ status: "ok" }); + } + ); + console.log("executing operator-cli gui login..."); +}; export const logoutHandler = (req: Request, res: Response) => { res.clearCookie("accessToken"); - res.send({ status: 'ok' }) -} + res.send({ status: "ok" }); +}; export const apiLimiter = rateLimit({ windowMs: 10 * 60 * 1000, // 10 minutes max: 1500, // Limit each IP to 1500 requests per windowMs - message: 'Too many requests from this IP, please try again after 10 minutes', + message: "Too many requests from this IP, please try again after 10 minutes", }); +export const checkIpHandler = (req:Request,res:Response) =>{ + const ip = String(req.socket.remoteAddress); +// Exec the CLI validator check-ip command +execFile( + "operator-cli", + ["gui", "ipStatus", ip], + (err, stdout, stderr) => { + currentExecCount--; + if (err) { + cliStderrResponse(res, "Unable to check ip", err.message); + return; + } + if (stderr) { + cliStderrResponse(res, "Unable to check ip", stderr); + return; + } -export const httpBodyLimiter = express.json({ limit: '100kb' }) - + const cliResponse = yaml.load(stdout); + if(cliResponse.status === "blocked"){ + res.status(200).json({ip: "blocked"}) + } + else{ + res.status(200).json({ip: "unblocked"}) + } + } +); +console.log("executing operator-cli gui ipStatus"); +} +export const httpBodyLimiter = express.json({ limit: "100kb" }); -export const jwtMiddleware = (req: Request, res: Response, next: NextFunction) => { +export const jwtMiddleware = ( + req: Request, + res: Response, + next: NextFunction +) => { const token = req.cookies.accessToken; if (!token) { - unautorizedResponse(req, res) - return + unautorizedResponse(req, res); + return; } jwt.verify(token, jwtSecret, (err: any, jwtData: any) => { - if (err) {// invalid token - unautorizedResponse(req, res) - return + if (err) { + // invalid token + unautorizedResponse(req, res); + return; } - next() - }) -} + next(); + }); +}; diff --git a/api/index.ts b/api/index.ts index 5dd2afc..66ea78a 100644 --- a/api/index.ts +++ b/api/index.ts @@ -1,5 +1,5 @@ import apiRouter from './api' -import { apiLimiter, httpBodyLimiter, jwtMiddleware, loginHandler, logoutHandler } from './auth' +import { apiLimiter, checkIpHandler, httpBodyLimiter, jwtMiddleware, loginHandler, logoutHandler } from './auth' import * as https from 'https'; import * as fs from 'fs'; import path from 'path'; @@ -24,6 +24,7 @@ if (isDev) { app.use(cookieParser()); app.post('/auth/login', loginHandler) app.post('/auth/logout', logoutHandler) + app.post('/auth/check',checkIpHandler) app.use('/api', jwtMiddleware, apiRouter) app.get('*', (req: any, res: any) => nextHandler(req, res)) app.use(errorMiddleware(isDev)) diff --git a/hooks/useNodeLogs.ts b/hooks/useNodeLogs.ts index 3661a42..2c36928 100644 --- a/hooks/useNodeLogs.ts +++ b/hooks/useNodeLogs.ts @@ -25,7 +25,7 @@ export const useNodeLogs = (): NodeLogsResponse => { const downloadLog = (logName: string): void => { fetch(`${apiBase}/api/node/logs/${logName}`, { method: "GET", - credentials: 'include', + credentials: 'include' }) .then((response) => response.blob()) .then((blob) => { diff --git a/package-lock.json b/package-lock.json index 8893424..bfe7c81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -195,11 +195,11 @@ } }, "node_modules/@babel/runtime": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.5.tgz", - "integrity": "sha512-ecjvYlnAaZ/KVneE/OdKYBYfgXV3Ptu6zQWmgEF7vwKhQnvVS6bjMD2XYgj+SNvQ1GfK/pjgokfPkC/2CO8CuA==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.4.tgz", + "integrity": "sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==", "dependencies": { - "regenerator-runtime": "^0.13.11" + "regenerator-runtime": "^0.14.0" }, "engines": { "node": ">=6.9.0" @@ -1552,27 +1552,49 @@ } }, "node_modules/@solana/web3.js": { - "version": "1.78.0", - "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.78.0.tgz", - "integrity": "sha512-CSjCjo+RELJ5puoZALfznN5EF0YvL1V8NQrQYovsdjE1lCV6SqbKAIZD0+9LlqCBoa1ibuUaR7G2SooYzvzmug==", - "dependencies": { - "@babel/runtime": "^7.22.3", - "@noble/curves": "^1.0.0", - "@noble/hashes": "^1.3.0", - "@solana/buffer-layout": "^4.0.0", - "agentkeepalive": "^4.2.1", + "version": "1.91.7", + "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.91.7.tgz", + "integrity": "sha512-HqljZKDwk6Z4TajKRGhGLlRsbGK4S8EY27DA7v1z6yakewiUY3J7ZKDZRxcqz2MYV/ZXRrJ6wnnpiHFkPdv0WA==", + "dependencies": { + "@babel/runtime": "^7.23.4", + "@noble/curves": "^1.4.0", + "@noble/hashes": "^1.3.3", + "@solana/buffer-layout": "^4.0.1", + "agentkeepalive": "^4.5.0", "bigint-buffer": "^1.1.5", - "bn.js": "^5.0.0", + "bn.js": "^5.2.1", "borsh": "^0.7.0", "bs58": "^4.0.1", "buffer": "6.0.3", "fast-stable-stringify": "^1.0.0", "jayson": "^4.1.0", - "node-fetch": "^2.6.11", + "node-fetch": "^2.7.0", "rpc-websockets": "^7.5.1", "superstruct": "^0.14.2" } }, + "node_modules/@solana/web3.js/node_modules/@noble/curves": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.4.0.tgz", + "integrity": "sha512-p+4cb332SFCrReJkCYe8Xzm0OWi4Jji5jVdIZRL/PmacmDkFNw6MrrV+gGpiPxLHbV+zKFRywUWbaseT+tZRXg==", + "dependencies": { + "@noble/hashes": "1.4.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@solana/web3.js/node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@stablelib/aead": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@stablelib/aead/-/aead-1.0.1.tgz", @@ -3108,12 +3130,10 @@ "integrity": "sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==" }, "node_modules/agentkeepalive": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.3.0.tgz", - "integrity": "sha512-7Epl1Blf4Sy37j4v9f9FjICCh4+KAQOyXgHEwlyBiAQLbhKdq/i2QQU3amQalS/wPhdPzDXPL5DMR5bkn+YeWg==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz", + "integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==", "dependencies": { - "debug": "^4.1.0", - "depd": "^2.0.0", "humanize-ms": "^1.2.1" }, "engines": { @@ -5084,9 +5104,9 @@ } }, "node_modules/eslint-plugin-jsx-a11y/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -5172,9 +5192,9 @@ } }, "node_modules/eslint-plugin-node/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -5284,9 +5304,9 @@ } }, "node_modules/eslint-plugin-react/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -8087,9 +8107,9 @@ "integrity": "sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==" }, "node_modules/node-fetch": { - "version": "2.6.12", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz", - "integrity": "sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "dependencies": { "whatwg-url": "^5.0.0" }, @@ -9139,9 +9159,9 @@ } }, "node_modules/read-pkg/node_modules/semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "dev": true, "bin": { "semver": "bin/semver" @@ -9202,9 +9222,9 @@ } }, "node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "node_modules/regexp-tree": { "version": "0.1.27", diff --git a/pages/login/index.tsx b/pages/login/index.tsx index 8730493..2ea426b 100644 --- a/pages/login/index.tsx +++ b/pages/login/index.tsx @@ -1,78 +1,129 @@ -import { ReactElement, SetStateAction, useState } from 'react' -import { useEffect } from 'react' -import { useRouter } from 'next/router' -import { FieldValues, useForm } from 'react-hook-form' -import { ArrowPathIcon } from '@heroicons/react/20/solid' -import { authService } from '../../services/auth.service'; -import Head from 'next/head'; +import { ReactElement, SetStateAction, useState } from "react"; +import { useEffect } from "react"; +import { useRouter } from "next/router"; +import { FieldValues, useForm } from "react-hook-form"; +import { ArrowPathIcon } from "@heroicons/react/20/solid"; +import { authService } from "../../services/auth.service"; +import { useGlobals } from "../../utils/globals"; + +import Head from "next/head"; const Login = () => { - const router = useRouter() - const login = authService.useLogin() + const router = useRouter(); + const { apiBase } = useGlobals(); + const { register, handleSubmit, formState } = useForm(); + + const [apiError, setApiError] = useState(null); + const [isDisabled, setIsDisabled] = useState(false); + const login = authService.useLogin(); useEffect(() => { // redirect to home if already logged in if (authService.isLogged) { - router.push('/') + router.push("/"); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + // check if ip is blocked + const intervalId = setInterval(async () => { + try { + const status = await authService.checkIp(apiBase); + if(status.ip === "blocked"){ + setIsDisabled(true) + setApiError(Error("IpAddress has been blocked for too many failed Attempts!")) + } + else{ + setIsDisabled(false) + setApiError(null) + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + setApiError(err as SetStateAction); + } + }, 60 * 1000); // 1 minute in milliseconds - const {register, handleSubmit, formState} = useForm() + // Clear interval on component unmount + return () => clearInterval(intervalId); - const [apiError, setApiError] = useState(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - async function onSubmit({password}: FieldValues) { + + async function onSubmit({ password }: FieldValues) { setApiError(null); - try{ - await login(password) - router.push('/') - } - catch(error){ - setApiError(error as SetStateAction) + try { + await login(password); + router.push("/"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + if ( + error.message === + "IpAddress has been blocked for too many failed Attempts!" + ) { + setIsDisabled(true); + } + setApiError(error as SetStateAction); } } return ( <> {/* eslint-disable-next-line @next/next/no-img-element */} - Logo + Logo
-

Connect to Validator Dashboard

+

+ Connect to Validator Dashboard +

- Connect to your validator dashboard to see the performance of your node, check rewards and run - maintenance tasks! + Connect to your validator dashboard to see the performance of your + node, check rewards and run maintenance tasks!

- + {apiError && (
{apiError.message}
)} -
- ) -} + ); +}; Login.getLayout = function getLayout(page: ReactElement) { - return <> - - Shardeum Dashboard - - - - -
- {page} -
- -} + return ( + <> + + Shardeum Dashboard + + + + +
{page}
+ + ); +}; -export default Login +export default Login; diff --git a/services/auth.service.ts b/services/auth.service.ts index 80ee2b1..e0304dc 100644 --- a/services/auth.service.ts +++ b/services/auth.service.ts @@ -1,46 +1,64 @@ -import Router from 'next/router' -import {useGlobals} from '../utils/globals' -import {hashSha256} from '../utils/sha256-hash'; -import {useCallback} from "react"; +import Router from "next/router"; +import { useGlobals } from "../utils/globals"; +import { hashSha256 } from "../utils/sha256-hash"; +import { useCallback } from "react"; -const isLoggedInKey = 'isLoggedIn' +const isLoggedInKey = "isLoggedIn"; function useLogin() { const { apiBase } = useGlobals(); - return useCallback(async (password: string) => { - const sha256digest = await hashSha256(password); - const res = await fetch(`${apiBase}/auth/login`, { - headers: {'Content-Type': 'application/json'}, - method: 'POST', - body: JSON.stringify({password: sha256digest}), - }); - await res.json(); - if (!res.ok) { - if (res.status === 403) { - throw new Error('Invalid password!'); + return useCallback( + async (password: string) => { + const sha256digest = await hashSha256(password); + const res = await fetch(`${apiBase}/auth/login`, { + headers: { "Content-Type": "application/json" }, + method: "POST", + body: JSON.stringify({ password: sha256digest }), + }); + const data = await res.json(); + if (!res.ok) { + if (res.status === 403 && data?.errorMessage === "Blocked") { + throw new Error( + "IpAddress has been blocked for too many failed Attempts!" + ); + } else if (res.status === 403) { + throw new Error("Invalid password!"); + } + } else { + localStorage.setItem(isLoggedInKey, "true"); } - } - localStorage.setItem(isLoggedInKey, 'true'); - }, [apiBase]); + }, + [apiBase] + ); } -async function logout(apiBase : string) { +async function logout(apiBase: string) { const res = await fetch(`${apiBase}/auth/logout`, { - headers: {'Content-Type': 'application/json'}, - method: 'POST', - }) + headers: { "Content-Type": "application/json" }, + method: "POST", + }); if (res.status != 200) { - throw new Error('Error logging out!'); + throw new Error("Error logging out!"); } - localStorage.removeItem(isLoggedInKey) - Router.push('/login') + localStorage.removeItem(isLoggedInKey); + Router.push("/login"); +} + +async function checkIp(apiBase: string) { + const res = await fetch(`${apiBase}/auth/check`, { + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + const data = await res.json(); + return data } export const authService = { get isLogged(): boolean { - return !!localStorage.getItem(isLoggedInKey) + return !!localStorage.getItem(isLoggedInKey); }, useLogin, logout, -} + checkIp +};