Require that all passwords are between 8 and 64 characters (#1239)

- Require that all passwords are between 8 and 64 characters
- Fixes account settings password reset form to only trigger
logged-in event after successful password change.
- Password validation can be extended within the UserManager's
validate_password method to add or modify requirements.
- Add tests for password validation
This commit is contained in:
Tessa Walsh 2023-10-03 21:57:46 -04:00 committed by GitHub
parent b1ead614ee
commit bbdb7f8ce5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 200 additions and 19 deletions

View File

@ -6,7 +6,7 @@ import os
import uuid import uuid
import asyncio import asyncio
from typing import Optional from typing import Optional, Union
from pydantic import UUID4 from pydantic import UUID4
import passlib.pwd import passlib.pwd
@ -17,7 +17,7 @@ from fastapi.security import OAuth2PasswordBearer
from pymongo.errors import DuplicateKeyError from pymongo.errors import DuplicateKeyError
from fastapi_users import FastAPIUsers, BaseUserManager from fastapi_users import FastAPIUsers, BaseUserManager
from fastapi_users.manager import UserAlreadyExists from fastapi_users.manager import UserAlreadyExists, InvalidPasswordException
from fastapi_users.authentication import ( from fastapi_users.authentication import (
AuthenticationBackend, AuthenticationBackend,
BearerTransport, BearerTransport,
@ -96,6 +96,23 @@ class UserManager(BaseUserManager[UserCreate, UserDB]):
await self.on_after_register_custom(created_user, user, request) await self.on_after_register_custom(created_user, user, request)
return created_user return created_user
async def validate_password(
self, password: str, user: Union[UserCreate, UserDB]
) -> None:
"""
Validate a password.
Overloaded to set password requirements.
:param password: The password to validate.
:param user: The user associated to this password.
:raises InvalidPasswordException: The password is invalid.
:return: None if the password is valid.
"""
pw_length = len(password)
if not 8 <= pw_length <= 64:
raise InvalidPasswordException(reason="invalid_password_length")
async def get_user_names_by_ids(self, user_ids): async def get_user_names_by_ids(self, user_ids):
"""return list of user names for given ids""" """return list of user names for given ids"""
user_ids = [UUID4(id_) for id_ in user_ids] user_ids = [UUID4(id_) for id_ in user_ids]
@ -147,9 +164,11 @@ class UserManager(BaseUserManager[UserCreate, UserDB]):
) )
print(f"Super user {email} created", flush=True) print(f"Super user {email} created", flush=True)
print(res, flush=True) print(res, flush=True)
except (DuplicateKeyError, UserAlreadyExists): except (DuplicateKeyError, UserAlreadyExists):
print(f"User {email} already exists", flush=True) print(f"User {email} already exists", flush=True)
# pylint: disable=raise-missing-from
except InvalidPasswordException:
raise HTTPException(status_code=422, detail="invalid_password")
async def create_non_super_user( async def create_non_super_user(
self, self,
@ -174,9 +193,17 @@ class UserManager(BaseUserManager[UserCreate, UserDB]):
newOrg=False, newOrg=False,
is_verified=True, is_verified=True,
) )
created_user = await super().create(user_create, safe=False, request=None) try:
await self.on_after_register_custom(created_user, user_create, request=None) created_user = await super().create(
return created_user user_create, safe=False, request=None
)
await self.on_after_register_custom(
created_user, user_create, request=None
)
return created_user
# pylint: disable=raise-missing-from
except InvalidPasswordException:
raise HTTPException(status_code=422, detail="invalid_password")
except (DuplicateKeyError, UserAlreadyExists): except (DuplicateKeyError, UserAlreadyExists):
print(f"User {email} already exists", flush=True) print(f"User {email} already exists", flush=True)

View File

@ -226,7 +226,7 @@ def test_send_and_accept_org_invite(
json={ json={
"name": "accepted", "name": "accepted",
"email": expected_stored_email, "email": expected_stored_email,
"password": "testpw", "password": "testingpassword",
"inviteToken": token, "inviteToken": token,
"newOrg": False, "newOrg": False,
}, },

View File

@ -1,6 +1,10 @@
import requests import requests
import time
from .conftest import API_PREFIX, CRAWLER_USERNAME from .conftest import API_PREFIX, CRAWLER_USERNAME, ADMIN_PW, ADMIN_USERNAME
VALID_USER_EMAIL = "validpassword@example.com"
VALID_USER_PW = "validpassw0rd!"
def test_create_super_user(admin_auth_headers): def test_create_super_user(admin_auth_headers):
@ -42,3 +46,139 @@ def test_me_with_orgs(crawler_auth_headers, default_org_id):
assert default_org["name"] assert default_org["name"]
assert default_org["default"] assert default_org["default"]
assert default_org["role"] == 20 assert default_org["role"] == 20
def test_add_user_to_org_invalid_password(admin_auth_headers, default_org_id):
r = requests.post(
f"{API_PREFIX}/orgs/{default_org_id}/add-user",
json={
"email": "invalidpassword@example.com",
"password": "pw",
"name": "invalid pw user",
"description": "test invalid password",
"role": 20,
},
headers=admin_auth_headers,
)
assert r.status_code == 422
assert r.json()["detail"] == "invalid_password"
def test_register_user_invalid_password(admin_auth_headers, default_org_id):
email = "invalidpassword@example.com"
# Send invite
r = requests.post(
f"{API_PREFIX}/orgs/{default_org_id}/invite",
headers=admin_auth_headers,
json={"email": email, "role": 20},
)
assert r.status_code == 200
data = r.json()
assert data["invited"] == "new_user"
# Look up token
r = requests.get(
f"{API_PREFIX}/orgs/{default_org_id}/invites",
headers=admin_auth_headers,
)
assert r.status_code == 200
data = r.json()
invites_matching_email = [
invite for invite in data["items"] if invite["email"] == email
]
token = invites_matching_email[0]["id"]
# Create user with invite
r = requests.post(
f"{API_PREFIX}/auth/register",
headers=admin_auth_headers,
json={
"name": "invalid",
"email": email,
"password": "passwd",
"inviteToken": token,
"newOrg": False,
},
)
assert r.status_code == 400
detail = r.json()["detail"]
assert detail["code"] == "REGISTER_INVALID_PASSWORD"
assert detail["reason"] == "invalid_password_length"
def test_register_user_valid_password(admin_auth_headers, default_org_id):
# Send invite
r = requests.post(
f"{API_PREFIX}/orgs/{default_org_id}/invite",
headers=admin_auth_headers,
json={"email": VALID_USER_EMAIL, "role": 20},
)
assert r.status_code == 200
data = r.json()
assert data["invited"] == "new_user"
# Look up token
r = requests.get(
f"{API_PREFIX}/orgs/{default_org_id}/invites",
headers=admin_auth_headers,
)
assert r.status_code == 200
data = r.json()
invites_matching_email = [
invite for invite in data["items"] if invite["email"] == VALID_USER_EMAIL
]
token = invites_matching_email[0]["id"]
# Create user with invite
r = requests.post(
f"{API_PREFIX}/auth/register",
headers=admin_auth_headers,
json={
"name": "valid",
"email": VALID_USER_EMAIL,
"password": VALID_USER_PW,
"inviteToken": token,
"newOrg": False,
},
)
assert r.status_code == 201
def test_reset_invalid_password(admin_auth_headers):
r = requests.patch(
f"{API_PREFIX}/users/me",
headers=admin_auth_headers,
json={"email": ADMIN_USERNAME, "password": "12345"},
)
assert r.status_code == 400
detail = r.json()["detail"]
assert detail["code"] == "UPDATE_USER_INVALID_PASSWORD"
assert detail["reason"] == "invalid_password_length"
def test_reset_valid_password(admin_auth_headers, default_org_id):
valid_user_headers = {}
while True:
r = requests.post(
f"{API_PREFIX}/auth/jwt/login",
data={
"username": VALID_USER_EMAIL,
"password": VALID_USER_PW,
"grant_type": "password",
},
)
data = r.json()
try:
valid_user_headers = {"Authorization": f"Bearer {data['access_token']}"}
break
except:
print("Waiting for valid user auth headers")
time.sleep(5)
r = requests.patch(
f"{API_PREFIX}/users/me",
headers=valid_user_headers,
json={"email": VALID_USER_EMAIL, "password": "new!password"},
)
assert r.status_code == 200
assert r.json()["email"] == VALID_USER_EMAIL

View File

@ -311,6 +311,8 @@ export class AccountSettings extends LiteElement {
name="newPassword" name="newPassword"
type="password" type="password"
label="${msg("New password")}" label="${msg("New password")}"
help-text=${msg("Must be between 8-64 characters")}
minlength="8"
autocomplete="new-password" autocomplete="new-password"
password-toggle password-toggle
required required
@ -351,8 +353,6 @@ export class AccountSettings extends LiteElement {
email: this.authState.username, email: this.authState.username,
password: formData.get("password") as string, password: formData.get("password") as string,
}); });
this.dispatchEvent(AuthService.createLoggedInEvent(nextAuthState));
} catch (e: any) { } catch (e: any) {
console.debug(e); console.debug(e);
} }
@ -371,6 +371,7 @@ export class AccountSettings extends LiteElement {
const params = { const params = {
password: formData.get("newPassword"), password: formData.get("newPassword"),
email: this.userInfo?.email,
}; };
try { try {
@ -385,13 +386,17 @@ export class AccountSettings extends LiteElement {
successMessage: "Successfully updated password", successMessage: "Successfully updated password",
}, },
}); });
this.dispatchEvent(AuthService.createLoggedInEvent(nextAuthState));
} catch (e) { } catch (e) {
console.error(e); console.error(e);
this.formStateService.send({ this.formStateService.send({
type: "ERROR", type: "ERROR",
detail: { detail: {
serverError: msg("Something went wrong changing password"), serverError: msg(
"Something went wrong changing password. Verify that new password meets requirements (8-64 characters)."
),
}, },
}); });
} }

View File

@ -79,7 +79,9 @@ export class SignUpForm extends LiteElement {
id="password" id="password"
name="password" name="password"
type="password" type="password"
label=${msg("Create a password")} label="${msg("New password")}"
help-text=${msg("Must be between 8-64 characters")}
minlength="8"
autocomplete="new-password" autocomplete="new-password"
passwordToggle passwordToggle
required required
@ -172,9 +174,12 @@ export class SignUpForm extends LiteElement {
const { detail } = await resp.json(); const { detail } = await resp.json();
if (detail === "REGISTER_USER_ALREADY_EXISTS") { if (detail === "REGISTER_USER_ALREADY_EXISTS") {
shouldLogIn = true; shouldLogIn = true;
} else if (detail.code && detail.code === "REGISTER_INVALID_PASSWORD") {
this.serverError = msg(
"Invalid password. Must be between 8 and 64 characters"
);
} else { } else {
// TODO show validation details this.serverError = msg("Invalid email or password");
this.serverError = msg("Invalid email address or password");
} }
break; break;
default: default:

View File

@ -38,6 +38,8 @@ export class ResetPassword extends LiteElement {
name="password" name="password"
type="password" type="password"
label="${msg("New password")}" label="${msg("New password")}"
help-text=${msg("Must be between 8-64 characters")}
minlength="8"
autocomplete="new-password" autocomplete="new-password"
passwordToggle passwordToggle
required required
@ -95,17 +97,19 @@ export class ResetPassword extends LiteElement {
case 400: case 400:
case 422: case 422:
const { detail } = await resp.json(); const { detail } = await resp.json();
if (detail === "RESET_PASSWORD_BAD_TOKEN") { if (detail === "RESET_PASSWORD_BAD_TOKEN") {
// TODO password validation details // TODO password validation details
this.serverError = msg( this.serverError = msg(
"Password reset email is not valid. Request a new password reset email" "Password reset email is not valid. Request a new password reset email"
); );
} else { } else if (
// TODO password validation details detail.code &&
this.serverError = msg("Invalid password"); detail.code === "RESET_PASSWORD_INVALID_PASSWORD"
) {
this.serverError = msg(
"Invalid password. Must be between 8 and 64 characters"
);
} }
break; break;
default: default:
this.serverError = msg("Something unexpected went wrong"); this.serverError = msg("Something unexpected went wrong");