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..8125c51 --- /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..93ef92a 100644 --- a/server/src/api/routes/authRouter.ts +++ b/server/src/api/routes/authRouter.ts @@ -1,6 +1,6 @@ import { Router, Request, Response } from 'express' import passport from 'passport' - +import { recordLogin } from '../../middleware/recordLogin.js' import enabledMethods from '../../auth/passport.js' import { @@ -18,16 +18,41 @@ import { registerValidators, } from '../validators/authValidator.js' import { authenticateRateLimit } from '../../middleware/rateLimiter.js' - +import { NextFunction } from 'express-serve-static-core' +import { rateLimitByAccount } from '../../middleware/rateLimiter.js' const router = Router() router.post( '/login', + rateLimitByAccount, authenticateRateLimit, 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 (err: unknown) => { + if (err) { + await recordLogin(req, false, 'login session failed') + return next(err) + } + + await recordLogin(req, true, 'login success') + return res.json({ message: 'success' }) + }) + } + )(req, res, next) } ) diff --git a/server/src/index.ts b/server/src/index.ts index f9c703b..49fc8ac 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -85,6 +85,7 @@ async function main() { server.listen(config.port, '0.0.0.0', () => { logger.info(`Server listening at ${config.baseUrl}`) }) + } main().catch((err) => logger.error(err)) diff --git a/server/src/middleware/rateLimiter.ts b/server/src/middleware/rateLimiter.ts index 88ac95f..3422d6e 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 { recordLogin } from './recordLogin.js' +import { LoginAttempt } from '@linux-odyssey/models' function createRateLimiter(options: Partial) { if (config.testing.enabled) { @@ -11,14 +13,21 @@ 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, - max: 5, -}) +export const authenticateRateLimit = config.testing.enabled + ? (req: Request, res: Response, next: NextFunction) => next() + : rateLimit({ + windowMs: 1 * 1000, + max: 5, + handler: async (req: Request, res: Response, _next: NextFunction) => { + recordLogin(req, false, 'rate limit') + res.status(429).json({ + error: 'Too many login attempts. Please try again later.', + }) + }, + }) export const sessionRateLimit = createRateLimiter({ windowMs: 10 * 1000, @@ -27,3 +36,30 @@ 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() - 10 * 60 * 1000) + + const recentFailedCount = await LoginAttempt.countDocuments({ + username, + success: false, + time: { $gte: since }, + }) + + if (recentFailedCount >= 10) { + return res.status(429).json({ + error: 'Too many failed login attempts. Please try again later.', + }) + } + + return next() +} diff --git a/server/src/middleware/recordLogin.ts b/server/src/middleware/recordLogin.ts new file mode 100644 index 0000000..d400422 --- /dev/null +++ b/server/src/middleware/recordLogin.ts @@ -0,0 +1,17 @@ +import { Request, Response, NextFunction } from 'express' +import { LoginAttempt } from '../../../packages/models' + +export 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, + }) +} diff --git a/server/src/middleware/validator.ts b/server/src/middleware/validator.ts index 54dfd3b..cbe2b6b 100644 --- a/server/src/middleware/validator.ts +++ b/server/src/middleware/validator.ts @@ -1,5 +1,7 @@ import { validationResult } from 'express-validator' import type { Request, Response, NextFunction } from 'express' +import { recordLogin } from './recordLogin.js' + export function noError(req: Request, res: Response, next: NextFunction) { const result = validationResult(req) @@ -7,6 +9,7 @@ export function noError(req: Request, res: Response, next: NextFunction) { next() return } + recordLogin(req, false, 'valid error'); res.status(400).json({ message: 'Validation failed', errors: result.array(),