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:
Tessa Walsh 2023-10-20 17:10:56 -04:00 committed by GitHub
parent 733809b5a8
commit 5c5ef68a8a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 221 additions and 14 deletions

View File

@ -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)

View File

@ -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"""

View File

@ -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)
# ============================================================================

View File

@ -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 = {

View File

@ -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 {