From a77f68940e408d697745a0867567eabcdf7ffc4c Mon Sep 17 00:00:00 2001 From: Georgon Date: Thu, 13 Aug 2026 21:11:00 +0300 Subject: [PATCH] Add password validation for email auth --- Makefile | 8 +-- README.md | 20 +++++-- auth_backend/auth_plugins/email.py | 7 ++- auth_backend/cli/user.py | 2 + auth_backend/schemas/types/password.py | 56 ++++++++++++++++++ tests/test_routes/conftest.py | 4 +- tests/test_routes/test_change_password.py | 44 +++++++++++--- tests/test_routes/test_email_message_delay.py | 4 +- tests/test_routes/test_login.py | 8 +-- tests/test_routes/test_logout.py | 2 +- tests/test_routes/test_oidc.py | 4 +- tests/test_routes/test_registration.py | 58 +++++++++++-------- tests/test_unit/test_password.py | 54 +++++++++++++++++ 13 files changed, 215 insertions(+), 56 deletions(-) create mode 100644 auth_backend/schemas/types/password.py create mode 100644 tests/test_unit/test_password.py diff --git a/Makefile b/Makefile index e754ba75..e9763c63 100644 --- a/Makefile +++ b/Makefile @@ -31,10 +31,10 @@ test: source ./venv/bin/activate && python3 -m pytest --verbosity=2 --showlocals --log-level=DEBUG create-user: - python -m auth_backend user create --email test-user@profcomff.com --password string + python -m auth_backend user create --email test-user@profcomff.com --password string12 create-admin: - source ./venv/bin/activate && python -m auth_backend user create --email test-admin@profcomff.com --password string + source ./venv/bin/activate && python -m auth_backend user create --email test-admin@profcomff.com --password string12 source ./venv/bin/activate && python -m auth_backend scope create --name auth.group.create --comment auth.group.create --creator_email test-admin@profcomff.com source ./venv/bin/activate && python -m auth_backend scope create --name auth.group.delete --comment auth.group.delete --creator_email test-admin@profcomff.com source ./venv/bin/activate && python -m auth_backend scope create --name auth.group.read --comment auth.group.read --creator_email test-admin@profcomff.com @@ -61,7 +61,7 @@ create-admin: source ./venv/bin/activate && python -m auth_backend user_group create --email test-admin@profcomff.com login-user: - curl -X 'POST' 'http://localhost:8000/email/login' -H 'accept: application/json' -H 'Content-Type: application/json' -d '{"email": "test-user@profcomff.com", "password": "string"}' + curl -X 'POST' 'http://localhost:8000/email/login' -H 'accept: application/json' -H 'Content-Type: application/json' -d '{"email": "test-user@profcomff.com", "password": "string12"}' login-admin: - curl -X 'POST' 'http://localhost:8000/email/login' -H 'accept: application/json' -H 'Content-Type: application/json' -d '{"email": "test-admin@profcomff.com", "password": "string"}' + curl -X 'POST' 'http://localhost:8000/email/login' -H 'accept: application/json' -H 'Content-Type: application/json' -d '{"email": "test-admin@profcomff.com", "password": "string12"}' diff --git a/README.md b/README.md index c2808571..5e22e1b1 100644 --- a/README.md +++ b/README.md @@ -96,13 +96,21 @@ foo@bar:~$ python -m auth_backend start ## Сценарий использования ### Email: регистрация нового аккаунта -1. Дернуть ручку `POST /email/registrate` . Вы передаете +1. Дернуть ручку `POST /email/registration`. Вы передаете ```json { - "email": "string", // Почта - "password": "string" // Пароль + "email": "user@example.com", + "password": "Password1!" } ``` + +Требования к новому паролю: +- длина от 8 до 32 символов; +- разрешены латинские буквы `A-Z`, `a-z`, цифры `0-9` и стандартные ASCII-спецсимволы; +- пробелы, управляющие символы и символы вне ASCII (например, кириллица) запрещены. + +Эти же требования применяются при смене и восстановлении пароля. При нарушении требований API возвращает `422 Unprocessable Entity`. + 3. На почту приходит письмо с линком на `GET /email/approve?token='...'`, если по ней перейти то почта будет подтверждена и регистрацию можно считать завершенной. ### Email: вход в аккаунт @@ -118,7 +126,7 @@ foo@bar:~$ python -m auth_backend start 3. Вам придет письмо, где будет ссылка НА ФРОНТ(надо сделать это), в ссылке будет reset_token 4. Токен надо передать в ручку `POST /email/reset/password` в заголовках, вместе с ```json -{"new_password": ""} +{"new_password": "NewPassword1!"} ``` и пароль будет изменен @@ -126,8 +134,8 @@ foo@bar:~$ python -m auth_backend start 1. Если пароль не забыт, а просто надо его поменять. Тогда в `POST /email/reset/password/request` передается токен авторизации, в теле вы передаете ```json { -"password": "string", // старый пароль -"new_password": "string" // новый пароль +"password": "CurrentPassword1!", // старый пароль +"new_password": "NewPassword1!" // новый пароль } ``` 3. Отправляете запрос и всё, пароль изменен, вам придет письмо с уведомлением о смене пароляю diff --git a/auth_backend/auth_plugins/email.py b/auth_backend/auth_plugins/email.py index 799188aa..1bca731b 100644 --- a/auth_backend/auth_plugins/email.py +++ b/auth_backend/auth_plugins/email.py @@ -16,6 +16,7 @@ from auth_backend.exceptions import AlreadyExists, AuthFailed, IncorrectUserAuthType, SessionExpired from auth_backend.kafka.kafka import get_kafka_producer from auth_backend.models.db import AuthMethod, User, UserSession +from auth_backend.schemas.types.password import Password from auth_backend.schemas.types.scopes import Scope from auth_backend.settings import get_settings from auth_backend.utils.security import UnionAuth @@ -70,7 +71,7 @@ class EmailLogin(Base): class EmailRegister(Base): email: Annotated[str, MinLen(1)] - password: Annotated[str, MinLen(1)] + password: Password email_validator = field_validator("email")(check_email) @@ -82,7 +83,7 @@ class EmailChange(Base): class ResetPassword(Base): password: Annotated[str, MinLen(1)] - new_password: Annotated[str, MinLen(1)] + new_password: Password @model_validator(mode="after") def check_passwords_dont_match(self) -> Self: @@ -99,7 +100,7 @@ class RequestResetForgottenPassword(Base): class ResetForgottenPassword(Base): - new_password: Annotated[str, MinLen(1)] + new_password: Password class Email(UserdataMixin, LoginableMixin, RegistrableMixin, AuthPluginMeta): diff --git a/auth_backend/cli/user.py b/auth_backend/cli/user.py index e5f08e63..2ada3cd5 100644 --- a/auth_backend/cli/user.py +++ b/auth_backend/cli/user.py @@ -4,10 +4,12 @@ from auth_backend.auth_plugins import Email from auth_backend.models import AuthMethod, User +from auth_backend.schemas.types.password import validate_password from auth_backend.utils.string import random_string def create_user(email: str, password: str, session: Session) -> None: + password = validate_password(password) if ( AuthMethod.query(session=session) .filter(AuthMethod.value == email, AuthMethod.auth_method == "email") diff --git a/auth_backend/schemas/types/password.py b/auth_backend/schemas/types/password.py new file mode 100644 index 00000000..1dee6015 --- /dev/null +++ b/auth_backend/schemas/types/password.py @@ -0,0 +1,56 @@ +import string +from typing import Any + +from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler +from pydantic.json_schema import JsonSchemaValue +from pydantic_core import core_schema + +PASSWORD_MIN_LENGTH = 8 +PASSWORD_MAX_LENGTH = 32 +PASSWORD_ALLOWED_CHARACTERS = string.ascii_letters + string.digits + string.punctuation +PASSWORD_PATTERN = r"^[\x21-\x7E]+$" +PASSWORD_REQUIREMENTS = ( + f"Password must be {PASSWORD_MIN_LENGTH}-{PASSWORD_MAX_LENGTH} characters long and contain only " + "ASCII letters, digits and punctuation. Spaces and non-ASCII characters are not allowed." +) + + +def validate_password(value: str) -> str: + """Validate a newly created password according to the Auth API password policy.""" + if len(value) < PASSWORD_MIN_LENGTH: + raise ValueError(f"Password must be at least {PASSWORD_MIN_LENGTH} characters long") + if len(value) > PASSWORD_MAX_LENGTH: + raise ValueError(f"Password must be at most {PASSWORD_MAX_LENGTH} characters long") + if any(character not in PASSWORD_ALLOWED_CHARACTERS for character in value): + raise ValueError( + "Password may contain only ASCII letters, digits and punctuation; " + "spaces and non-ASCII characters are not allowed" + ) + return value + + +class Password: + """Pydantic type for a password that is being created or replaced.""" + + @classmethod + def __get_pydantic_core_schema__( + cls, + source: type[Any], + handler: GetCoreSchemaHandler, + ) -> core_schema.CoreSchema: + return core_schema.no_info_after_validator_function(validate_password, core_schema.str_schema()) + + @classmethod + def __get_pydantic_json_schema__( + cls, core_schema_: core_schema.CoreSchema, handler: GetJsonSchemaHandler + ) -> JsonSchemaValue: + field_schema = handler(core_schema_) + field_schema.update( + type="string", + format="password", + minLength=PASSWORD_MIN_LENGTH, + maxLength=PASSWORD_MAX_LENGTH, + pattern=PASSWORD_PATTERN, + description=PASSWORD_REQUIREMENTS, + ) + return field_schema diff --git a/tests/test_routes/conftest.py b/tests/test_routes/conftest.py index 1f42b388..b86867ef 100644 --- a/tests/test_routes/conftest.py +++ b/tests/test_routes/conftest.py @@ -58,7 +58,7 @@ def dbsession(): @pytest.fixture() def user_id(client_auth: TestClient, dbsession): time = datetime.datetime.utcnow() - body = {"email": f"user{time}@example.com", "password": "string"} + body = {"email": f"user{time}@example.com", "password": "string12"} client_auth.post("/email/registration", json=body) db_user: AuthMethod = ( dbsession.query(AuthMethod).filter(AuthMethod.value == body['email'], AuthMethod.param == 'email').one() @@ -78,7 +78,7 @@ def user_id(client_auth: TestClient, dbsession): def user(client_auth: TestClient, dbsession): url = "/email/login" time = datetime.datetime.utcnow() - body = {"email": f"user{time}@example.com", "password": "string", "scopes": []} + body = {"email": f"user{time}@example.com", "password": "string12", "scopes": []} response = client_auth.post("/email/registration", json=body) db_user: AuthMethod = ( dbsession.query(AuthMethod).filter(AuthMethod.value == body['email'], AuthMethod.param == 'email').one() diff --git a/tests/test_routes/test_change_password.py b/tests/test_routes/test_change_password.py index df03504f..d31abef5 100644 --- a/tests/test_routes/test_change_password.py +++ b/tests/test_routes/test_change_password.py @@ -62,14 +62,14 @@ def test_unprocessable_jsons_with_token(client_auth: TestClient, dbsession: Sess response = client_auth.post( f"{url}/request", headers={"Authorization": auth_token}, - json={"password": "", "new_password": "changed"}, + json={"password": "", "new_password": "changed12"}, ) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY response = client_auth.post( f"{url}/request", headers={"Authorization": auth_token}, - json={"password": "", "new_password": "changed"}, + json={"password": "", "new_password": "changed12"}, ) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY @@ -83,7 +83,21 @@ def test_unprocessable_jsons_with_token(client_auth: TestClient, dbsession: Sess response = client_auth.post( f"{url}/request", headers={"Authorization": auth_token}, - json={"password": body["password"], "new_password": "changed"}, + json={"password": body["password"], "new_password": "short7"}, + ) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + response = client_auth.post( + f"{url}/request", + headers={"Authorization": auth_token}, + json={"password": body["password"], "new_password": "пароль123"}, + ) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + response = client_auth.post( + f"{url}/request", + headers={"Authorization": auth_token}, + json={"password": body["password"], "new_password": "changed12"}, ) assert response.status_code == status.HTTP_200_OK @@ -113,6 +127,20 @@ def test_no_token(client_auth: TestClient, dbsession: Session, user_id: int): assert reset_token auth_params = Email.get_auth_method_params(user_id, session=dbsession) + response = client_auth.post( + f"{url}", + headers={"reset-token": reset_token.value}, + json={"new_password": "short7"}, + ) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + response = client_auth.post( + f"{url}", + headers={"reset-token": reset_token.value}, + json={"new_password": "пароль123"}, + ) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + response = client_auth.post( f"{url}", headers={"reset-token": reset_token.value + "x"}, @@ -129,7 +157,7 @@ def test_no_token(client_auth: TestClient, dbsession: Session, user_id: int): response = client_auth.post( "/email/login", - json={"email": auth_params["email"].value, "password": "string", "scopes": []}, + json={"email": auth_params["email"].value, "password": "string12", "scopes": []}, ) assert response.status_code == status.HTTP_401_UNAUTHORIZED @@ -147,21 +175,21 @@ def test_with_token(client_auth: TestClient, dbsession: Session, user): response = client_auth.post( f"{url}/request", headers={"Authorization": auth_token}, - json={"password": "wrong", "new_password": "changed"}, + json={"password": "wrong", "new_password": "changed12"}, ) assert response.status_code == status.HTTP_401_UNAUTHORIZED response = client_auth.post( f"{url}/request", headers={"Authorization": auth_token + "wrong"}, - json={"password": body["password"], "new_password": "changed"}, + json={"password": body["password"], "new_password": "changed12"}, ) assert response.status_code == status.HTTP_403_FORBIDDEN response = client_auth.post( f"{url}/request", headers={"Authorization": auth_token}, - json={"password": body["password"], "new_password": "changed"}, + json={"password": body["password"], "new_password": "changed12"}, ) assert response.status_code == status.HTTP_200_OK reset_token = ( @@ -175,7 +203,7 @@ def test_with_token(client_auth: TestClient, dbsession: Session, user): ) assert response.status_code == status.HTTP_401_UNAUTHORIZED - response = client_auth.post("/email/login", json={"email": body["email"], "password": "changed", "scopes": []}) + response = client_auth.post("/email/login", json={"email": body["email"], "password": "changed12", "scopes": []}) assert response.status_code == status.HTTP_200_OK diff --git a/tests/test_routes/test_email_message_delay.py b/tests/test_routes/test_email_message_delay.py index 9d6cd3a0..1e5a9d0e 100644 --- a/tests/test_routes/test_email_message_delay.py +++ b/tests/test_routes/test_email_message_delay.py @@ -16,11 +16,11 @@ def test_message_delay(client_auth_email_delay: TestClient, dbsession: Session): settings_.EMAIL_DELAY_TIME_IN_MINUTES = 1 for i in range(settings.IP_DELAY_COUNT): response = client_auth_email_delay.post( - "/email/registration", json={"email": f"test-user@profcomff.com", "password": "string"} + "/email/registration", json={"email": f"test-user@profcomff.com", "password": "string12"} ) assert response.status_code == status.HTTP_200_OK delay_response = client_auth_email_delay.post( - "/email/registration", json={"email": f"test-user@profcomff.com", "password": "string"} + "/email/registration", json={"email": f"test-user@profcomff.com", "password": "string12"} ) assert delay_response.status_code == status.HTTP_429_TOO_MANY_REQUESTS settings_.IP_DELAY_TIME_IN_MINUTES = ip_delay diff --git a/tests/test_routes/test_login.py b/tests/test_routes/test_login.py index c4e4fcce..4a803465 100644 --- a/tests/test_routes/test_login.py +++ b/tests/test_routes/test_login.py @@ -10,7 +10,7 @@ def test_invalid_email(client: TestClient): - body = {"email": "some_string", "password": "string"} + body = {"email": "some_string", "password": "string12"} response = client.post(url, json=body) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY @@ -19,7 +19,7 @@ def test_main_scenario(client_auth: TestClient, dbsession: Session, user): user_id, body, response = user["user_id"], user["body"], user["login_json"] body_with_uppercase = { "email": body["email"].replace("u", "U"), - "password": "string", + "password": "string12", "scopes": [], "session_name": "name", } @@ -28,8 +28,8 @@ def test_main_scenario(client_auth: TestClient, dbsession: Session, user): def test_incorrect_data(client_auth: TestClient, dbsession: Session): - body1 = {"email": f"user{datetime.datetime.utcnow()}@example.com", "password": "string", "scopes": []} - body2 = {"email": "wrong@example.com", "password": "string", "scopes": []} + body1 = {"email": f"user{datetime.datetime.utcnow()}@example.com", "password": "string12", "scopes": []} + body2 = {"email": "wrong@example.com", "password": "string12", "scopes": []} body3 = {"email": "some@example.com", "password": "strong", "scopes": []} body4 = {"email": "wrong@example.com", "password": "strong", "scopes": []} client_auth.post("/email/registration", json=body1) diff --git a/tests/test_routes/test_logout.py b/tests/test_routes/test_logout.py index 9a979a34..59285eeb 100644 --- a/tests/test_routes/test_logout.py +++ b/tests/test_routes/test_logout.py @@ -10,7 +10,7 @@ def test_main_scenario(client_auth: TestClient, dbsession: Session): - body = {"email": f"user{datetime.utcnow()}@example.com", "password": "string", "scopes": []} + body = {"email": f"user{datetime.utcnow()}@example.com", "password": "string12", "scopes": []} user_response = client_auth.post("/email/registration", json=body) query = ( dbsession.query(AuthMethod) diff --git a/tests/test_routes/test_oidc.py b/tests/test_routes/test_oidc.py index 2037308d..091f07a8 100644 --- a/tests/test_routes/test_oidc.py +++ b/tests/test_routes/test_oidc.py @@ -39,7 +39,7 @@ def test_jwks(client_auth: TestClient): def test_token_from_token_ok(client_auth: TestClient, dbsession: Session): # Подготовка к тесту - body = {"email": f"user{datetime.utcnow()}@example.com", "password": "string", "scopes": []} + body = {"email": f"user{datetime.utcnow()}@example.com", "password": "string12", "scopes": []} user_response = client_auth.post("/email/registration", json=body) query = ( dbsession.query(AuthMethod) @@ -108,7 +108,7 @@ def test_token_from_creds_ok(client_auth: TestClient, user): "grant_type": "client_credentials", "client_id": "app", "username": user["body"]["email"], - "password": "string", + "password": "string12", }, ) assert response.status_code == status.HTTP_200_OK diff --git a/tests/test_routes/test_registration.py b/tests/test_routes/test_registration.py index 169f44a6..b37b9933 100644 --- a/tests/test_routes/test_registration.py +++ b/tests/test_routes/test_registration.py @@ -12,33 +12,25 @@ def test_invalid_email(client_auth: TestClient, dbsession: Session): - body1 = {"email": f"notEmailForSure", "password": "string"} - body2 = {"email": f"EmailForSure{datetime.datetime.utcnow()}@mail.gtg", "password": ""} - body3 = { - "email": f"EmailForSure{datetime.datetime.utcnow()}@mail.gtg", - "password": "&%@#$@322îïíīįì3@##EFWed}efvef{}{}{}[èéêëēėę'", - } - body4 = {"email": f"EmailFor+ _Sur{datetime.datetime.utcnow()}e@mail.gtg", "password": "string2222"} - body5 = {"email": f"Email For Sure {datetime.datetime.utcnow()} @ mail. gtg", "password": "string"} - body6 = { - "email": f"roman@dyakov.space\nContent-Type: text/html; charset=utf-8;\n\nАхаха,лох