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:
parent
b1ead614ee
commit
bbdb7f8ce5
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)."
|
||||||
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user