diff --git a/.env.prod.sample b/.env.prod.sample index c200152..c76dc20 100644 --- a/.env.prod.sample +++ b/.env.prod.sample @@ -24,6 +24,9 @@ ADMIN_PORT=3001 # TRUSTED_PROXIES=1.2.3.4 # valid configurations see isIP & isIPRange in https://github.com/validatorjs/validator.js?tab=readme-ov-file#validators +# RATE_LIMIT_MAX_FAILED_ATTEMPTS=5 +# RATE_LIMIT_LOCKOUT_TIME=10 + # GOOGLE_CLIENT_ID= # GOOGLE_CLIENT_SECRET= # GITHUB_CLIENT_ID= @@ -31,9 +34,6 @@ ADMIN_PORT=3001 # SURVEY_URL= # BUG_REPORT_URL= -# SURVEY_URL= -# BUG_REPORT_URL= - # Docker related config # DOCKER_NETWORK=host # QUEST_IMAGE=linuxodyssey/quest-base diff --git a/.env.sample b/.env.sample index c5c7643..16a135b 100644 --- a/.env.sample +++ b/.env.sample @@ -12,6 +12,9 @@ DOCKER_NETWORK=linux-odyssey-players-dev # TRUSTED_PROXIES=1.2.3.4 # valid configurations see isIP & isIPRange in https://github.com/validatorjs/validator.js?tab=readme-ov-file#validators +# RATE_LIMIT_MAX_FAILED_ATTEMPTS=5 +# RATE_LIMIT_LOCKOUT_TIME=10 + # GOOGLE_CLIENT_ID= # GOOGLE_CLIENT_SECRET= # GITHUB_CLIENT_ID= @@ -19,9 +22,6 @@ DOCKER_NETWORK=linux-odyssey-players-dev # SURVEY_URL= # BUG_REPORT_URL= -# SURVEY_URL= -# BUG_REPORT_URL= - # Docker related config # DOCKER_NETWORK=host # QUEST_IMAGE=linuxodyssey/quest-base diff --git a/packages/models/index.ts b/packages/models/index.ts index c812f75..339ff98 100644 --- a/packages/models/index.ts +++ b/packages/models/index.ts @@ -4,6 +4,7 @@ export { User, IUser } from './user.js' export { Session, ISession } from './session.js' export { Command, ICommand } from './command.js' export { UserProfile, IUserProfile, IProgress } from './userProfile.js' +export { ILoginAttempt, LoginAttempt } from './loginAttempt.js' export async function connectDB(uri: string) { const db = await mongoose.connect(uri) diff --git a/packages/models/loginAttempt.ts b/packages/models/loginAttempt.ts new file mode 100644 index 0000000..4fde053 --- /dev/null +++ b/packages/models/loginAttempt.ts @@ -0,0 +1,29 @@ +import { model, Schema, Document } from 'mongoose' + +export interface ILoginAttempt { + username: string + ip: string + userAgent: string + time: Date + success: boolean + message: string +} + +export type LoginAttemptDocument = ILoginAttempt & Document + +const LoginAttemptSchema = new Schema( + { + username: { type: String, required: true }, + ip: { type: String, required: true }, + userAgent: { type: String, required: true }, + time: { type: Date, default: Date.now, required: true }, + success: { type: Boolean, required: true }, + message: { type: String }, + }, + { timestamps: true } +) + +export const LoginAttempt = model( + 'LoginAttempt', + LoginAttemptSchema +) diff --git a/server/src/api/routes/authRouter.ts b/server/src/api/routes/authRouter.ts index 318af99..d234dae 100644 --- a/server/src/api/routes/authRouter.ts +++ b/server/src/api/routes/authRouter.ts @@ -1,6 +1,7 @@ -import { Router, Request, Response } from 'express' +import { Router, Request, Response, Express } from 'express' import passport from 'passport' - +import { NextFunction } from 'express-serve-static-core' +import { LoginAttempt } from '../../../../packages/models' import enabledMethods from '../../auth/passport.js' import { @@ -17,20 +18,59 @@ import { registerFromSessionValidators, registerValidators, } from '../validators/authValidator.js' -import { authenticateRateLimit } from '../../middleware/rateLimiter.js' +import { + authenticateRateLimit, + rateLimitByAccount, +} from '../../middleware/rateLimiter.js' const router = Router() router.post( '/login', authenticateRateLimit, + rateLimitByAccount, loginValidators, - passport.authenticate('local', { failureMessage: true }), - (req: Request, res: Response) => { - res.json({ message: 'success' }) + (req: Request, res: Response, next: NextFunction): void => { + passport.authenticate( + 'local', + async (err: unknown, user: Express.User | false, info: any) => { + if (err) { + await recordLogin(req, false, 'passport error') + return next(err) + } + + if (!user) { + const msg = info?.message || 'invalid credentials' + await recordLogin(req, false, msg) + return res.status(401).json({ message: msg }) + } + + req.logIn(user, async (loginErr: unknown) => { + if (loginErr) { + await recordLogin(req, false, 'login session failed') + return next(loginErr) + } + + await recordLogin(req, true, 'login success') + return res.json({ message: 'success' }) + }) + return null + } + )(req, res, next) } ) +async function recordLogin(req: Request, success: boolean, message: string) { + await LoginAttempt.create({ + ip: req.ip, + userAgent: req.headers['user-agent'] || '', + success, + time: new Date(), + username: req.body.username, + message, + }) +} + router.post('/register', authenticateRateLimit, registerValidators, register) router.post('/register-guest', authenticateRateLimit, registerGuest) diff --git a/server/src/config.ts b/server/src/config.ts index 117909b..38b2114 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -68,6 +68,13 @@ function createConfig() { trustedProxies: getTrustProxies('TRUSTED_PROXIES'), surveyUrl: getUrl('SURVEY_URL'), bugReportUrl: getUrl('BUG_REPORT_URL'), + rateLimit: { + maxFailedAttempts: Number( + process.env.RATE_LIMIT_MAX_FAILED_ATTEMPTS ?? 5 + ), + lockoutTime: + Number(process.env.RATE_LIMIT_LOCKOUT_TIME ?? 10) * 60 * 1000, + }, projectRoot, diff --git a/server/src/middleware/rateLimiter.ts b/server/src/middleware/rateLimiter.ts index 88ac95f..4a5fb04 100644 --- a/server/src/middleware/rateLimiter.ts +++ b/server/src/middleware/rateLimiter.ts @@ -2,6 +2,8 @@ import rateLimit from 'express-rate-limit' import type { Options } from 'express-rate-limit' import type { Request, Response, NextFunction } from 'express' import config from '../config.js' +import { LoginAttempt } from '../../../packages/models' +import logger from '../utils/logger.js' function createRateLimiter(options: Partial) { if (config.testing.enabled) { @@ -11,12 +13,12 @@ function createRateLimiter(options: Partial) { } export const globalRateLimit = createRateLimiter({ - windowMs: 60 * 1000, // 1 minute + windowMs: 60 * 1000, max: 100, }) export const authenticateRateLimit = createRateLimiter({ - windowMs: 2 * 60 * 1000, + windowMs: 10 * 1000, max: 5, }) @@ -27,3 +29,34 @@ export const sessionRateLimit = createRateLimiter({ return req.user ? (req.user as any)._id : req.ip || '' }, }) + +export async function rateLimitByAccount( + req: Request, + res: Response, + next: NextFunction +) { + const username = req.body.username?.trim() + if (!username) { + return next() + } + + const since = new Date(Date.now() - config.rateLimit.lockoutTime) + + const recentFailedCount = await LoginAttempt.countDocuments({ + username, + success: false, + ip: req.ip, + time: { $gte: since }, + }) + + if (recentFailedCount >= config.rateLimit.maxFailedAttempts) { + logger.warn( + `Too many failed login attempts for "${username}" from ${req.ip}` + ) + return res.status(429).json({ + error: 'Too many failed login attempts. Please try again later.', + }) + } + + return next() +}