Skip to content
Snippets Groups Projects
Commit e5893403 authored by Léopold Chappuis's avatar Léopold Chappuis
Browse files

auth-methods: Prevent Brute Force Attacks

Introduce a new class to block users after multiple failed login attempts and restrict frequent login attempts from the same IP address using express-rate-limit

Gitlab: #213

Change-Id: I84dfbaabe71bb62b480fde221533faf4d4ea1c06
parent b9459db7
Branches
No related tags found
No related merge requests found
...@@ -116,6 +116,7 @@ type AlertMessageKeys = ...@@ -116,6 +116,7 @@ type AlertMessageKeys =
| 'getting_oauth_clients_error' | 'getting_oauth_clients_error'
| 'getting_oauth_provider_info_error' | 'getting_oauth_provider_info_error'
| 'file_not_downloaded' | 'file_not_downloaded'
| 'too_many_requests'
export const AlertSnackbarContext = createContext<IAlertSnackbarContext>(defaultAlertSnackbarContext) export const AlertSnackbarContext = createContext<IAlertSnackbarContext>(defaultAlertSnackbarContext)
...@@ -217,6 +218,8 @@ const AlertSnackbarProvider = ({ children }: WithChildren) => { ...@@ -217,6 +218,8 @@ const AlertSnackbarProvider = ({ children }: WithChildren) => {
return t('getting_oauth_provider_info_error') return t('getting_oauth_provider_info_error')
case 'file_not_downloaded': case 'file_not_downloaded':
return t('file_not_downloaded') return t('file_not_downloaded')
case 'too_many_requests':
return t('too_many_requests')
default: default:
return t('unknown_error_alert') return t('unknown_error_alert')
} }
......
...@@ -94,6 +94,12 @@ export const useLoginAdminMutation = () => { ...@@ -94,6 +94,12 @@ export const useLoginAdminMutation = () => {
severity: 'error', severity: 'error',
alertOpen: true, alertOpen: true,
}) })
} else if (e.response?.status === HttpStatusCode.TooManyRequests) {
setAlertContent({
messageI18nKey: 'too_many_requests',
severity: 'error',
alertOpen: true,
})
} else { } else {
setAlertContent({ setAlertContent({
messageI18nKey: 'unknown_error_alert', messageI18nKey: 'unknown_error_alert',
......
...@@ -164,6 +164,8 @@ export const useLoginMutation = () => { ...@@ -164,6 +164,8 @@ export const useLoginMutation = () => {
//continue when the auth flow is clear //continue when the auth flow is clear
} else if (status === HttpStatusCode.Unauthorized) { } else if (status === HttpStatusCode.Unauthorized) {
setAlertContent({ messageI18nKey: 'login_invalid_credentials', severity: 'error', alertOpen: true }) setAlertContent({ messageI18nKey: 'login_invalid_credentials', severity: 'error', alertOpen: true })
} else if (status === HttpStatusCode.TooManyRequests) {
setAlertContent({ messageI18nKey: 'too_many_requests', severity: 'error', alertOpen: true })
} else { } else {
setAlertContent({ messageI18nKey: 'unknown_error_alert', severity: 'error', alertOpen: true }) setAlertContent({ messageI18nKey: 'unknown_error_alert', severity: 'error', alertOpen: true })
} }
......
This diff is collapsed.
...@@ -47,8 +47,8 @@ ...@@ -47,8 +47,8 @@
"prettier": "^3.3.3" "prettier": "^3.3.3"
}, },
"dependencies": { "dependencies": {
"vcard4-ts": "^0.4.1",
"byte-size": "^9.0.0", "byte-size": "^9.0.0",
"multer": "^1.4.5-lts.1" "multer": "^1.4.5-lts.1",
"vcard4-ts": "^0.4.1"
} }
} }
...@@ -296,6 +296,7 @@ ...@@ -296,6 +296,7 @@
"share_window": "Share window", "share_window": "Share window",
"size": "File size:", "size": "File size:",
"submit": "Submit", "submit": "Submit",
"too_many_requests": "Too many attemps, please try again later.",
"transfer_message": "Transfer message", "transfer_message": "Transfer message",
"unauthorized_access": "Unauthorized access", "unauthorized_access": "Unauthorized access",
"unknown_error_alert": "An unexpected error occurred. Please try again.", "unknown_error_alert": "An unexpected error occurred. Please try again.",
......
...@@ -31,7 +31,8 @@ ...@@ -31,7 +31,8 @@
"typedi": "^0.10.0", "typedi": "^0.10.0",
"vcard4-ts": "^0.4.1", "vcard4-ts": "^0.4.1",
"whatwg-url": "^14.0.0", "whatwg-url": "^14.0.0",
"ws": "^8.18.0" "ws": "^8.18.0",
"express-rate-limit": "7.5.0"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
......
...@@ -19,6 +19,7 @@ import argon2 from 'argon2' ...@@ -19,6 +19,7 @@ import argon2 from 'argon2'
import envPaths from 'env-paths' import envPaths from 'env-paths'
import { Router } from 'express' import { Router } from 'express'
import asyncHandler from 'express-async-handler' import asyncHandler from 'express-async-handler'
import rateLimit from 'express-rate-limit'
import { ParamsDictionary, Request, Response } from 'express-serve-static-core' import { ParamsDictionary, Request, Response } from 'express-serve-static-core'
import fs from 'fs' import fs from 'fs'
import { readdir, stat } from 'fs/promises' import { readdir, stat } from 'fs/promises'
...@@ -96,8 +97,16 @@ adminRouter.post('/adminWizardState', (req: Request<ParamsDictionary, AdminWizar ...@@ -96,8 +97,16 @@ adminRouter.post('/adminWizardState', (req: Request<ParamsDictionary, AdminWizar
res.sendStatus(HttpStatusCode.Ok) res.sendStatus(HttpStatusCode.Ok)
}) })
const loginlimiter = rateLimit({
windowMs: 10 * 60 * 5000, // 5 minutes
max: 3, // Limit each IP to 3 requests per `window` (here, per 5 minutes)
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
})
adminRouter.post( adminRouter.post(
'/login', '/login',
loginlimiter,
asyncHandler(async (req: Request<ParamsDictionary, AccessToken | string, Partial<AdminCredentials>>, res) => { asyncHandler(async (req: Request<ParamsDictionary, AccessToken | string, Partial<AdminCredentials>>, res) => {
const { password } = req.body const { password } = req.body
if (password === undefined) { if (password === undefined) {
......
...@@ -18,21 +18,26 @@ ...@@ -18,21 +18,26 @@
import argon2 from 'argon2' import argon2 from 'argon2'
import { Router } from 'express' import { Router } from 'express'
import asyncHandler from 'express-async-handler' import asyncHandler from 'express-async-handler'
import rateLimit from 'express-rate-limit'
import { ParamsDictionary, Request } from 'express-serve-static-core' import { ParamsDictionary, Request } from 'express-serve-static-core'
import { AccessToken, AccountDetails, HttpStatusCode, UserCredentials } from 'jami-web-common' import { AccessToken, AccountDetails, HttpStatusCode, UserCredentials } from 'jami-web-common'
import { Container } from 'typedi' import { Container } from 'typedi'
import { Jamid } from '../jamid/jamid.js' import { Jamid } from '../jamid/jamid.js'
import { NameRegistrationEndedState, RegistrationState } from '../jamid/state-enums.js' import { NameRegistrationEndedState, RegistrationState } from '../jamid/state-enums.js'
import { LoginGuard } from '../services/LoginGuard.js'
import { Accounts } from '../storage/accounts.js' import { Accounts } from '../storage/accounts.js'
import { AdminAccount } from '../storage/admin-account.js' import { AdminAccount } from '../storage/admin-account.js'
import { OpenIdProviders } from '../storage/openid-providers.js' import { OpenIdProviders } from '../storage/openid-providers.js'
import { clearGuest, clearGuests } from '../utils/guests.js' import { clearGuest, clearGuests } from '../utils/guests.js'
import { signJwt, signJwtWithExpiration } from '../utils/jwt.js' import { signJwt, signJwtWithExpiration } from '../utils/jwt.js'
import { exchangeCodeForToken, exchangeTokenForUserInfo, getBase64Picture, getJwt } from '../utils/openid.js' import { exchangeCodeForToken, exchangeTokenForUserInfo, getBase64Picture, getJwt } from '../utils/openid.js'
const jamid = Container.get(Jamid) const jamid = Container.get(Jamid)
const accounts = Container.get(Accounts) const accounts = Container.get(Accounts)
const guard = new LoginGuard()
const config = Container.get(AdminAccount) const config = Container.get(AdminAccount)
const providers = Container.get(OpenIdProviders) const providers = Container.get(OpenIdProviders)
export const authRouter = Router() export const authRouter = Router()
...@@ -106,8 +111,17 @@ authRouter.post( ...@@ -106,8 +111,17 @@ authRouter.post(
} }
}), }),
) )
const loginlimiter = rateLimit({
windowMs: 10 * 60 * 5000, // 5 minutes
max: 10, // Limit each IP to 3 requests per `window` (here, per 5 minutes)
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
})
authRouter.post( authRouter.post(
'/login', '/login',
loginlimiter,
asyncHandler(async (req: Request<ParamsDictionary, AccessToken | string, Partial<UserCredentials>>, res) => { asyncHandler(async (req: Request<ParamsDictionary, AccessToken | string, Partial<UserCredentials>>, res) => {
const { username, password, authMethod } = req.body const { username, password, authMethod } = req.body
...@@ -193,18 +207,25 @@ authRouter.post( ...@@ -193,18 +207,25 @@ authRouter.post(
res.status(HttpStatusCode.NotFound).send('Password not found') res.status(HttpStatusCode.NotFound).send('Password not found')
return return
} }
const JAMSAccountId = data.accountId
if (guard.isBlocked(JAMSAccountId)) {
res.status(HttpStatusCode.TooManyRequests).send('Too many login attempts')
return
}
const isPasswordVerified = await argon2.verify(data.password, password) const isPasswordVerified = await argon2.verify(data.password, password)
if (!isPasswordVerified) { if (!isPasswordVerified) {
guard.setFailedLogin(JAMSAccountId)
res.status(HttpStatusCode.Unauthorized).send('Incorrect password') res.status(HttpStatusCode.Unauthorized).send('Incorrect password')
return return
} }
const JAMSAccountId = data.accountId
if (JAMSAccountId === undefined) { if (JAMSAccountId === undefined) {
res.status(HttpStatusCode.NotFound).send('Username not found') res.status(HttpStatusCode.NotFound).send('Username not found')
return return
} }
const jwt = await signJwt(JAMSAccountId) const jwt = await signJwt(JAMSAccountId)
guard.resetAttempts(JAMSAccountId)
res.status(HttpStatusCode.Ok).send({ accessToken: jwt }) res.status(HttpStatusCode.Ok).send({ accessToken: jwt })
return return
} else if (authMethod === 'local') { } else if (authMethod === 'local') {
...@@ -225,6 +246,11 @@ authRouter.post( ...@@ -225,6 +246,11 @@ authRouter.post(
return return
} }
if (guard.isBlocked(accountId)) {
res.status(HttpStatusCode.TooManyRequests).send('Too many login attempts')
return
}
if (!('password' in data)) { if (!('password' in data)) {
res.status(HttpStatusCode.NotFound).send('Password not found') res.status(HttpStatusCode.NotFound).send('Password not found')
return return
...@@ -240,10 +266,12 @@ authRouter.post( ...@@ -240,10 +266,12 @@ authRouter.post(
} }
const isPasswordVerified = await argon2.verify(hashedPassword, password) const isPasswordVerified = await argon2.verify(hashedPassword, password)
if (!isPasswordVerified) { if (!isPasswordVerified) {
guard.setFailedLogin(accountId)
res.status(HttpStatusCode.Unauthorized).send('Incorrect password') res.status(HttpStatusCode.Unauthorized).send('Incorrect password')
return return
} }
const jwt = await signJwt(accountId) const jwt = await signJwt(accountId)
guard.resetAttempts(accountId)
res.status(HttpStatusCode.Ok).send({ accessToken: jwt }) res.status(HttpStatusCode.Ok).send({ accessToken: jwt })
} }
}), }),
......
/*
* Copyright (C) 2022-2025 Savoir-faire Linux Inc.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public
* License along with this program. If not, see
* <https://www.gnu.org/licenses/>.
*/
export class LoginGuard {
attempts: Map<string, { attempts: number; lastAttemptTime: number }>
maxLoginAttempts = 5
timeWindow = 60 * 25 * 1000 // 25 minutes in milliseconds
constructor() {
this.attempts = new Map<string, { attempts: number; lastAttemptTime: number }>()
}
setFailedLogin(account: string) {
if (this.isBlocked(account)) {
return
}
if (this.attempts.has(account)) {
this.attempts.get(account)!.attempts += 1
this.attempts.get(account)!.lastAttemptTime = Date.now()
} else {
this.attempts.set(account, { attempts: 1, lastAttemptTime: Date.now() })
}
}
isBlocked(account: string): boolean {
if (!this.attempts.has(account)) {
return false
}
const timeLeft = this.timeWindow - (Date.now() - this.attempts.get(account)!.lastAttemptTime)
if (timeLeft < 0) {
this.attempts.delete(account)
return false
}
if (this.attempts.get(account)!.attempts < this.maxLoginAttempts) {
return false
}
return true
}
resetAttempts(account: string) {
this.attempts.delete(account)
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please to comment