Prevent user from logging in after 5 consecutive failed login attempts until pw is reset (#1281)
Fixes #1270 After 5 consecutive failed logins from the same user, we now prevent the user from logging in even with the correct password until they reset it via their email, or wait an hour. - After failure threshold is reached, all further login attempts are rejected - Attempts for invalid email addresses are also tracked - On 6th try, a reset password email is automatically sent, only once - Failed login counter resets after an hour of no further logins after last attempted login. --------- Co-authored-by: Ilya Kreymer <ikreymer@gmail.com>
This commit is contained in:
parent
733809b5a8
commit
5c5ef68a8a
@ -2,6 +2,7 @@
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Tuple, List
|
||||
from passlib import pwd
|
||||
@ -44,6 +45,8 @@ AUTH_ALLOW_AUD = [AUTH_AUD, "fastapi-users:auth"]
|
||||
RESET_ALLOW_AUD = [RESET_AUD, "fastapi-users:reset"]
|
||||
VERIFY_ALLOW_AUD = [VERIFY_AUD, "fastapi-users:verify"]
|
||||
|
||||
MAX_FAILED_LOGINS = 5
|
||||
|
||||
|
||||
# ============================================================================
|
||||
class BearerResponse(BaseModel):
|
||||
@ -170,21 +173,63 @@ def init_jwt_auth(user_manager):
|
||||
@auth_jwt_router.post("/login", response_model=BearerResponse)
|
||||
async def login(
|
||||
credentials: OAuth2PasswordRequestForm = Depends(),
|
||||
):
|
||||
user = await user_manager.authenticate(
|
||||
credentials.username, credentials.password
|
||||
)
|
||||
) -> BearerResponse:
|
||||
"""Prevent brute force password attacks.
|
||||
|
||||
After 5 or more consecutive failed login attempts for the same user,
|
||||
lock the user account and send an email to reset their password.
|
||||
On successful login when user is not already locked, reset count to 0.
|
||||
"""
|
||||
login_email = credentials.username
|
||||
|
||||
failed_count = await user_manager.get_failed_logins_count(login_email)
|
||||
|
||||
if failed_count > 0:
|
||||
print(
|
||||
f"Consecutive failed login count for {login_email}: {failed_count}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# first, check if failed count exceeds max failed logins
|
||||
# if so, don't try logging in
|
||||
if failed_count >= MAX_FAILED_LOGINS:
|
||||
# only send reset email on first failure to avoid spamming user
|
||||
if failed_count == MAX_FAILED_LOGINS:
|
||||
# do this async to avoid hinting at any delay if user exists
|
||||
async def send_reset_if_needed():
|
||||
attempted_user = await user_manager.get_by_email(login_email)
|
||||
if attempted_user:
|
||||
await user_manager.forgot_password(attempted_user)
|
||||
print(
|
||||
f"Password reset email sent after too many attempts for {login_email}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
asyncio.create_task(send_reset_if_needed())
|
||||
|
||||
# any further attempt is a failure, increment to track further attempts
|
||||
# and avoid sending email again
|
||||
await user_manager.inc_failed_logins(login_email)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="too_many_login_attempts",
|
||||
)
|
||||
|
||||
# attempt login
|
||||
user = await user_manager.authenticate(login_email, credentials.password)
|
||||
|
||||
if not user:
|
||||
print(f"Failed login attempt for {login_email}", flush=True)
|
||||
await user_manager.inc_failed_logins(login_email)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="login_bad_credentials",
|
||||
)
|
||||
# if requires_verification and not user.is_verified:
|
||||
# raise HTTPException(
|
||||
# status_code=400,
|
||||
# detail="login_user_not_verified",
|
||||
# )
|
||||
|
||||
# successfully logged in, reset failed logins, return user
|
||||
await user_manager.reset_failed_logins(login_email)
|
||||
return get_bearer_response(user)
|
||||
|
||||
@auth_jwt_router.post("/refresh", response_model=BearerResponse)
|
||||
|
@ -96,6 +96,22 @@ class User(BaseModel):
|
||||
return super().dict(*a, **kw)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
class FailedLogin(BaseMongoModel):
|
||||
"""
|
||||
Failed login model
|
||||
"""
|
||||
|
||||
attempted: datetime = datetime.now()
|
||||
email: str
|
||||
|
||||
# Consecutive failed logins, reset to 0 on successful login or after
|
||||
# password is reset. On failed_logins >= 5 within the hour before this
|
||||
# object is deleted, the user is unable to log in until they reset their
|
||||
# password.
|
||||
count: int = 1
|
||||
|
||||
|
||||
# ============================================================================
|
||||
class UserOrgInfoOut(BaseModel):
|
||||
"""org per user"""
|
||||
|
@ -31,6 +31,7 @@ from .models import (
|
||||
UserRole,
|
||||
Organization,
|
||||
PaginatedResponse,
|
||||
FailedLogin,
|
||||
)
|
||||
from .pagination import DEFAULT_PAGE_SIZE, paginated_format
|
||||
from .utils import is_bool
|
||||
@ -57,6 +58,7 @@ class UserManager:
|
||||
|
||||
def __init__(self, mdb, email, invites):
|
||||
self.users = mdb.get_collection("users")
|
||||
self.failed_logins = mdb.get_collection("logins")
|
||||
self.crawl_config_ops = None
|
||||
self.base_crawl_ops = None
|
||||
self.email = email
|
||||
@ -76,6 +78,8 @@ class UserManager:
|
||||
"""init lookup index"""
|
||||
await self.users.create_index("id", unique=True)
|
||||
await self.users.create_index("email", unique=True)
|
||||
# Expire failed logins object after one hour
|
||||
await self.failed_logins.create_index("attempted", expireAfterSeconds=3600)
|
||||
|
||||
async def register(
|
||||
self, user: UserCreateIn, request: Optional[Request] = None
|
||||
@ -516,6 +520,8 @@ class UserManager:
|
||||
"""Update hashed_password for user, overwriting previous password hash
|
||||
|
||||
Internal method, use change_password() for password verification first
|
||||
|
||||
Method also ensures user is not locked after password change
|
||||
"""
|
||||
await self.validate_password(new_password)
|
||||
hashed_password = get_password_hash(new_password)
|
||||
@ -523,8 +529,38 @@ class UserManager:
|
||||
return
|
||||
user.hashed_password = hashed_password
|
||||
await self.users.find_one_and_update(
|
||||
{"id": user.id}, {"$set": {"hashed_password": hashed_password}}
|
||||
{"id": user.id},
|
||||
{"$set": {"hashed_password": hashed_password}},
|
||||
)
|
||||
await self.reset_failed_logins(user.email)
|
||||
|
||||
async def reset_failed_logins(self, email: str) -> None:
|
||||
"""Reset consecutive failed login attempts by deleting FailedLogin object"""
|
||||
await self.failed_logins.delete_one({"email": email})
|
||||
|
||||
async def inc_failed_logins(self, email: str) -> None:
|
||||
"""Inc consecutive failed login attempts for user by 1
|
||||
|
||||
If a FailedLogin object doesn't already exist, create it
|
||||
"""
|
||||
failed_login = FailedLogin(id=uuid.uuid4(), email=email)
|
||||
|
||||
await self.failed_logins.find_one_and_update(
|
||||
{"email": email},
|
||||
{
|
||||
"$setOnInsert": failed_login.to_dict(exclude={"count", "attempted"}),
|
||||
"$set": {"attempted": failed_login.dict(include={"attempted"})},
|
||||
"$inc": {"count": 1},
|
||||
},
|
||||
upsert=True,
|
||||
)
|
||||
|
||||
async def get_failed_logins_count(self, email: str) -> int:
|
||||
"""Get failed login attempts for user, falling back to 0"""
|
||||
failed_login = await self.failed_logins.find_one({"email": email})
|
||||
if not failed_login:
|
||||
return 0
|
||||
return failed_login.get("count", 0)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
@ -11,9 +11,12 @@ from .conftest import (
|
||||
|
||||
VALID_USER_EMAIL = "validpassword@example.com"
|
||||
VALID_USER_PW = "validpassw0rd!"
|
||||
VALID_USER_PW_RESET = "new!password"
|
||||
VALID_USER_PW_RESET_AGAIN = "new!password1"
|
||||
|
||||
|
||||
my_id = None
|
||||
valid_user_headers = None
|
||||
|
||||
|
||||
def test_create_super_user(admin_auth_headers):
|
||||
@ -199,7 +202,6 @@ def test_reset_password_invalid_current(admin_auth_headers):
|
||||
|
||||
|
||||
def test_reset_valid_password(admin_auth_headers, default_org_id):
|
||||
valid_user_headers = {}
|
||||
count = 0
|
||||
while True:
|
||||
r = requests.post(
|
||||
@ -212,6 +214,7 @@ def test_reset_valid_password(admin_auth_headers, default_org_id):
|
||||
)
|
||||
data = r.json()
|
||||
try:
|
||||
global valid_user_headers
|
||||
valid_user_headers = {"Authorization": f"Bearer {data['access_token']}"}
|
||||
break
|
||||
except:
|
||||
@ -228,7 +231,7 @@ def test_reset_valid_password(admin_auth_headers, default_org_id):
|
||||
json={
|
||||
"email": VALID_USER_EMAIL,
|
||||
"password": VALID_USER_PW,
|
||||
"newPassword": "new!password",
|
||||
"newPassword": VALID_USER_PW_RESET,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
@ -236,6 +239,108 @@ def test_reset_valid_password(admin_auth_headers, default_org_id):
|
||||
assert r.json()["updated"] == True
|
||||
|
||||
|
||||
def test_lock_out_user_after_failed_logins():
|
||||
# Almost lock out user by making 5 consecutive failed login attempts
|
||||
for _ in range(5):
|
||||
requests.post(
|
||||
f"{API_PREFIX}/auth/jwt/login",
|
||||
data={
|
||||
"username": VALID_USER_EMAIL,
|
||||
"password": "incorrect",
|
||||
"grant_type": "password",
|
||||
},
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
# Ensure we get a 429 response on the 5th failed attempt
|
||||
r = requests.post(
|
||||
f"{API_PREFIX}/auth/jwt/login",
|
||||
data={
|
||||
"username": VALID_USER_EMAIL,
|
||||
"password": "incorrect",
|
||||
"grant_type": "password",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 429
|
||||
assert r.json()["detail"] == "too_many_login_attempts"
|
||||
|
||||
# Try again with correct password and ensure we still can't log in
|
||||
r = requests.post(
|
||||
f"{API_PREFIX}/auth/jwt/login",
|
||||
data={
|
||||
"username": VALID_USER_EMAIL,
|
||||
"password": VALID_USER_PW_RESET,
|
||||
"grant_type": "password",
|
||||
},
|
||||
)
|
||||
assert r.status_code in (400, 429)
|
||||
|
||||
# Reset password
|
||||
r = requests.put(
|
||||
f"{API_PREFIX}/users/me/password-change",
|
||||
headers=valid_user_headers,
|
||||
json={
|
||||
"email": VALID_USER_EMAIL,
|
||||
"password": VALID_USER_PW_RESET,
|
||||
"newPassword": VALID_USER_PW_RESET_AGAIN,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
time.sleep(5)
|
||||
|
||||
# Try once more again with invalid password and ensure we no longer get a
|
||||
# 429 response since password reset unlocked user
|
||||
r = requests.post(
|
||||
f"{API_PREFIX}/auth/jwt/login",
|
||||
data={
|
||||
"username": VALID_USER_EMAIL,
|
||||
"password": "incorrect",
|
||||
"grant_type": "password",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"] == "login_bad_credentials"
|
||||
|
||||
# Try again with correct reset password and this time it should work
|
||||
r = requests.post(
|
||||
f"{API_PREFIX}/auth/jwt/login",
|
||||
data={
|
||||
"username": VALID_USER_EMAIL,
|
||||
"password": VALID_USER_PW_RESET_AGAIN,
|
||||
"grant_type": "password",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_lock_out_unregistered_user_after_failed_logins():
|
||||
unregistered_email = "notregistered@example.com"
|
||||
# Almost lock out email by making 5 consecutive failed login attempts
|
||||
for _ in range(5):
|
||||
requests.post(
|
||||
f"{API_PREFIX}/auth/jwt/login",
|
||||
data={
|
||||
"username": unregistered_email,
|
||||
"password": "incorrect",
|
||||
"grant_type": "password",
|
||||
},
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
# Ensure we get a 429 response on the 5th failed attempt
|
||||
r = requests.post(
|
||||
f"{API_PREFIX}/auth/jwt/login",
|
||||
data={
|
||||
"username": unregistered_email,
|
||||
"password": "incorrect",
|
||||
"grant_type": "password",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 429
|
||||
assert r.json()["detail"] == "too_many_login_attempts"
|
||||
|
||||
|
||||
def test_patch_me_endpoint(admin_auth_headers, default_org_id, admin_userid):
|
||||
# Start a new crawl
|
||||
crawl_data = {
|
||||
|
@ -371,11 +371,16 @@ export class LogInPage extends LiteElement {
|
||||
// will result in a route change
|
||||
} catch (e: any) {
|
||||
if (e.isApiError) {
|
||||
// TODO check error details
|
||||
let message = msg("Sorry, invalid username or password");
|
||||
if (e.message == "Too Many Requests") {
|
||||
message = msg(
|
||||
"Sorry, too many failed login attempts. A reset password link has been sent to your email."
|
||||
);
|
||||
}
|
||||
this.formStateService.send({
|
||||
type: "ERROR",
|
||||
detail: {
|
||||
serverError: msg("Sorry, invalid username or password"),
|
||||
serverError: message,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
|
Loading…
Reference in New Issue
Block a user