diff --git a/.gitignore b/.gitignore index a72f5ed..9fe646a 100644 --- a/.gitignore +++ b/.gitignore @@ -127,4 +127,5 @@ dist .pnp.* .vscode -yarn.lock \ No newline at end of file +yarn.lock +*.env \ No newline at end of file diff --git a/README.md b/README.md index bb05201..50a5d26 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,103 @@ # captcha-microservice REST based captcha generating microservice + +Consists of the following types: +- Image To Text + + ![Math Challenge](./assets/image.png) + + (Ans: dfl22V) + +- Math Challenge + + ![Math Challenge](./assets/math.png) + + (Ans: 64) + +## Workflow +- Request captcha using one of the methods +- Response body is the raw body of the captcha (image/sound) and Captcha Id will be provided in `X-Captcha-Session-Id` header +- Answer can be verified using the `/verify` endpoint providing a JWT token +- JWT token can be used to confirm authenticity on server side or client side with `/verify` endpoint +## **Methods** + +## Random Captcha +## `/captcha/captcha` + +Get a random captcha from all the methods supported + +Header: `X-Captcha-Session-Id` - contains the captcha id + +Body is raw content of the captcha (image/sound) + +## Image Captcha +## `/captcha/imageCaptcha` + +Get image captcha + +Header: `X-Captcha-Session-Id` - contains the captcha id + +Body is raw image in png format + +![Image Captcha](./assets/image.png) + +## Math Captcha +## `/captcha/mathCaptcha` + +Get math challenge which gives a math equation + +Header: `X-Captcha-Session-Id` - contains the captcha id + +Body is raw image in png format + +![Math Challenge](./assets/math.png) + +## Verify +## `/verify?sessionId=&solution=` + +Verify captcha answer. A JWT token is returned if the answer is correct (`200` status code), else `401` status code is returned + +## Validate +## `/validate?sessionId=&token=` + +Can be used server side to validate the verification + +# **Usage** + +## As a service +## *Environment Variables* +``` +PORT= Application port +JWT_SECRET= JWT signing secret +HOST= Hostname to use in JWT token +PROMETHEUS_SECRET= Prometheus secret (Use with header + Authorization: Bearer ) +RATE_LIMIT_DURATION= Rate limit duration in seconds +RATE_LIMIT_POINTS= Rate limit points for given duration +REDIS_HOST= Redis host +REDIS_PORT= Redis port +REDIS_PASSWORD= Redis password +REDIS_DB= Redis database +REDIS_USERNAME= Redis username +CAPTCHA_TIMEOUT= Captcha timeout in seconds +``` + +## Docker +`docker run -d --name email-microservice --env-file app.env -p 5555:5555 crossphoton/email-microservice:v1.0.0` + +## Kubernetes +> TODO + +## Locally +1. Clone repository +2. Run `npm install` +3. Run `dotenv -e app.env -- npm start` + +## Additional Parts +- **Prometheus** : `/metrics` endpoint with proper authorization can be used to collect metrics +- **Logging** : is done using [winston](https://www.npmjs.com/package/winston). +- **Shutdown management** : done using [lightship](https://www.npmjs.com/package/lightship) + + +## License +MIT License diff --git a/api.js b/api.js index 5ebdbba..add07ea 100644 --- a/api.js +++ b/api.js @@ -1,52 +1,68 @@ const express = require("express"); const app = express(); -const { image_text, verify } = require("./src"); -const logger = require("./src/logging"); +const compression = require("compression"); +const { image_text, math_text, verify } = require("./src"); +const { logger, middleware: logMiddleWare } = require("./src/logging"); const { verifyJWTToken } = require("./src/jwt"); app.disable("x-powered-by"); -app.use(express.json()); app.use(express.urlencoded({ extended: true })); +app.enable("trust proxy"); +app.use(logMiddleWare); +app.use( + compression({ + filter: shouldCompress, + level: 9, + }) +); -app.get("/captcha/imageCaptcha.png", async (_req, res) => { - var captcha; - try { - captcha = await image_text.generate(); - } catch (error) { - return res.sendStatus(500); - } - - res.setHeader("Content-Type", "image/png"); - res.setHeader("X-Captcha-Session-Id", captcha.sessionId); +function shouldCompress(req, res) { + if (req.headers["x-no-compression"]) return false; + return compression.filter(req, res); +} - res.send(captcha.captcha); -}); - -app.get("/verify", async (req, res) => { +app.get("/verify", (req, res) => { const { sessionId, solution } = req.query; + if (!sessionId || !solution) { + return res.sendStatus(400); + } try { - const valid = await verify(sessionId, solution); - valid ? res.send(valid) : res.sendStatus(401); + verify(sessionId, solution).then((valid) => { + valid ? res.send(valid) : res.sendStatus(401); + }); } catch (err) { - logger.error(err); + logger.error(err.message); res.send(null).statusCode(401); } }); -app.get("/validate", async (req, res) => { +app.get("/validate", (req, res) => { const { sessionId, token } = req.query; if (!token || !sessionId) { return res.sendStatus(400); } try { - const valid = await verifyJWTToken(token); - valid && valid.sessionId === sessionId - ? res.sendStatus(200) - : res.sendStatus(401); + verifyJWTToken(token).then((valid) => { + valid && valid.sessionId === sessionId + ? res.sendStatus(200) + : res.sendStatus(401); + }); } catch (err) { logger.error(err.message); res.sendStatus(500); } }); +app.get("/captcha/imageCaptcha", image_text.middleware); + +app.get("/captcha/mathCaptcha", math_text.middleware); + +// Pick a random captcha method +const randomCaptcha = (req, res, next) => { + const map = [image_text, math_text]; + return map[Math.floor(Math.random() * map.length)].middleware(req, res, next); +}; + +app.get("/captcha/captcha", randomCaptcha); + module.exports = app; diff --git a/assets/image.png b/assets/image.png new file mode 100644 index 0000000..0879a39 Binary files /dev/null and b/assets/image.png differ diff --git a/assets/math.png b/assets/math.png new file mode 100644 index 0000000..ceeb69e Binary files /dev/null and b/assets/math.png differ diff --git a/package.json b/package.json index a11e744..fd9f4f6 100644 --- a/package.json +++ b/package.json @@ -26,11 +26,17 @@ "homepage": "https://github.com/crossphoton/captcha-microservice#readme", "dependencies": { "captchagen": "^1.2.0", + "compression": "^1.7.4", "express": "^4.17.2", "express-prometheus-middleware": "^1.2.0", + "express-winston": "^4.2.0", + "helmet": "^4.6.0", "ioredis": "^4.28.2", "jsonwebtoken": "^8.5.1", + "lightship": "^6.8.0", "prom-client": "^14.0.1", + "rate-limiter-flexible": "^2.3.6", + "text-to-image": "^4.1.1", "uuid": "^8.3.2", "winston": "^3.3.3" }, diff --git a/server.js b/server.js index 1b78947..4296aa4 100644 --- a/server.js +++ b/server.js @@ -1,10 +1,15 @@ const expressApp = require("./api"); -const logger = require("./src/logging"); +const helmet = require("helmet"); +const { createLightship } = require("lightship"); +const rateLimiter = require("./src/rateLimit"); +const { logger } = require("./src/logging"); const prometheus = require("./src/prometheus"); const PORT = process.env.PORT || 5000; -// Add prometheus middleware -prometheus(expressApp); +// Add middleware +prometheus(expressApp); // Add prometheus middleware +expressApp.use(helmet()); // Add helmet middleware +expressApp.use(rateLimiter); // Add rate limiter middleware // Health check expressApp.get("/healthz", (_req, res) => { @@ -12,4 +17,18 @@ expressApp.get("/healthz", (_req, res) => { }); // Start the server -expressApp.listen(PORT, () => logger.info(`listening on ${PORT}`)); +const server = expressApp + .listen(PORT, () => { + logger.info(`listening on ${PORT}`); + lightship.signalReady(); + }) + .on("error", () => { + logger.info(`shutting down server`); + lightship.shutdown(); + }); + +const lightship = createLightship(); + +lightship.registerShutdownHandler(() => { + server.close(); +}); diff --git a/src/imageCaptcha.js b/src/imageCaptcha.js new file mode 100644 index 0000000..310e710 --- /dev/null +++ b/src/imageCaptcha.js @@ -0,0 +1,29 @@ +var captchagen = require("captchagen"); +const { store } = require("./store"); + +const generate = async () => { + const captcha = captchagen.create(); + captcha.generate(); + const sessionId = await store(captcha.text()); + + return { + /** @type {Buffer} */ captcha: captcha.buffer(), + /** @type {string} */ sessionId, + }; +}; + +const middleware = (_req, res) => { + try { + generate().then((captcha) => { + res.setHeader("Content-Type", "image/png"); + res.setHeader("X-Captcha-Session-Id", captcha.sessionId); + + res.send(captcha.captcha); + }); + } catch (error) { + logger.error(error.message); + res.sendStatus(500); + } +}; + +module.exports = { middleware }; diff --git a/src/imageToText.js b/src/imageToText.js deleted file mode 100644 index 2a82072..0000000 --- a/src/imageToText.js +++ /dev/null @@ -1,15 +0,0 @@ -var captchagen = require("captchagen"); -const { store } = require("./store"); - -const generate = async () => { - const captcha = captchagen.create(); - captcha.generate(); - const sessionId = await store(captcha.text()); - - return { - captcha: captcha.buffer(), - sessionId, - }; -}; - -module.exports = { generate }; diff --git a/src/index.js b/src/index.js index a7d4da8..b912f7f 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,5 @@ module.exports = { - image_text: require("./imageToText"), + image_text: require("./imageCaptcha"), + math_text: require("./mathCaptcha"), verify: require("./store").verify, }; diff --git a/src/jwt.js b/src/jwt.js index c7fa327..652ae83 100644 --- a/src/jwt.js +++ b/src/jwt.js @@ -1,8 +1,9 @@ const jwt = require("jsonwebtoken"); +// create a token from a payload function createJWTToken(payload) { return jwt.sign(payload, process.env.JWT_SECRET || "secret", { - expiresIn: "30m", + expiresIn: Number(process.env.CAPTCHA_TIMEOUT) || "30m", header: { alg: "HS256", typ: "JWT", @@ -13,6 +14,7 @@ function createJWTToken(payload) { }); } +// verify a token function verifyJWTToken(token) { try { return jwt.verify(token, process.env.JWT_SECRET || "secret", { diff --git a/src/logging.js b/src/logging.js index 2c621b0..7804650 100644 --- a/src/logging.js +++ b/src/logging.js @@ -1,4 +1,5 @@ const winston = require("winston"); +const expressWinston = require("express-winston"); const consoleTransport = new winston.transports.Console({ format: winston.format.combine( @@ -9,9 +10,10 @@ const consoleTransport = new winston.transports.Console({ /** @type {winston.LoggerOptions} */ const myWinstonOptions = { transports: [consoleTransport], - defaultMeta: { service: "my-service-name" }, + defaultMeta: { service: "captcha-service" }, }; const logger = winston.createLogger(myWinstonOptions); +const middleware = expressWinston.logger(myWinstonOptions); -module.exports = logger; +module.exports = { logger, middleware }; diff --git a/src/mathCaptcha.js b/src/mathCaptcha.js new file mode 100644 index 0000000..43d31aa --- /dev/null +++ b/src/mathCaptcha.js @@ -0,0 +1,69 @@ +const text_to_image = require("text-to-image"); +const { store } = require("./store"); +const { logger } = require("./logging"); +const map = [addition, subtraction, multiplication]; + +async function generate() { + // Select a method from map + const method = map[Math.floor(Math.random() * map.length)]; + const challenge = method(); + const sessionId = await store(challenge.answer.toString()); + + // Generate image + var image; + try { + image = await text_to_image.generate(challenge.question, { + textAlign: "center", + fontWeight: "bold", + maxWidth: 150, + }); + } catch (error) { + logger.error(error.message); + } + + return { captcha: Buffer.from(image.split(",")[1], "base64"), sessionId }; +} + +function addition() { + var a = Math.floor(Math.random() * 100), + b = Math.floor(Math.random() * 100); + + return { question: `${a} + ${b}`, answer: a + b }; +} + +function subtraction() { + var a = Math.floor(Math.random() * 100) + 100, + b = Math.floor(Math.random() * 100); + + return { question: `${a} - ${b}`, answer: a - b }; +} + +function multiplication() { + var a = Math.floor(Math.random() * 30), + b = Math.floor(Math.random() * 10); + + return { question: `${a} x ${b}`, answer: a * b }; +} + +// function division() { +// var a = Math.floor(Math.random() * 30), +// b = Math.floor(Math.random() * 10); + +// return { question: `${a} / ${b}`, answer: a / b }; +// } + +const middleware = (_req, res) => { + try { + generate().then((captcha) => { + res.setHeader("Content-Type", "image/png"); + res.setHeader("X-Captcha-Session-Id", captcha.sessionId); + + res.send(captcha.captcha); + }); + } catch (error) { + logger.error(error.message); + res.sendStatus(500); + } +}; + +module.exports = { middleware }; diff --git a/src/prometheus.js b/src/prometheus.js index 2424889..7abe25a 100644 --- a/src/prometheus.js +++ b/src/prometheus.js @@ -7,6 +7,22 @@ var prom_middleware = prometheusMiddleware({ defaultLabels: { version: "1.0.0", }, + authenticate: function (req) { + const authHeader = req.header("Authorization"); + if (!authHeader) { + return false; + } + const authHeaderParts = authHeader.split(" "); + if (authHeaderParts.length !== 2) { + return false; + } + const scheme = authHeaderParts[0]; + const credentials = authHeaderParts[1]; + if (scheme !== "Bearer") { + return false; + } + return credentials === process.env.PROMETHEUS_SECRET || "secret"; + }, }); function addPrometheus(app) { diff --git a/src/rateLimit.js b/src/rateLimit.js new file mode 100644 index 0000000..7178dee --- /dev/null +++ b/src/rateLimit.js @@ -0,0 +1,24 @@ +const { RateLimiterRedis } = require("rate-limiter-flexible"); +const { redisClient } = require("./store"); + +// Rate limiter +const rateLimiter = new RateLimiterRedis({ + storeClient: redisClient, + keyPrefix: "captcha-rate-limit", + points: process.env.RATE_LIMIT_POINTS || 5, + duration: process.env.RATE_LIMIT_DURATION || 1, +}); + +// Rate limit middleware +const rateLimiterMiddleware = (req, res, next) => { + rateLimiter + .consume(req.ip) + .then(() => { + next(); + }) + .catch(() => { + res.status(429).send("Too Many Requests"); + }); +}; + +module.exports = rateLimiterMiddleware; diff --git a/src/store.js b/src/store.js index 4e0ed7f..a544c03 100644 --- a/src/store.js +++ b/src/store.js @@ -23,4 +23,4 @@ async function verify(/** @type {string} */ sessionId, solution) { return storedSolution === solution ? createJWTToken({ sessionId }) : null; } -module.exports = { store, verify }; +module.exports = { store, verify, redisClient: redis };