feat: add reset password
This commit is contained in:
@@ -38,6 +38,10 @@ class IUserRepository(ABC):
|
||||
async def email_exists(self, email: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_user_by_email(self, email: str) -> UserEntity | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def set_avatar_link(self, user_id: str, avatar_link: str | None) -> UserEntity:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -8,6 +8,8 @@ from src.application.commands.update_bank_details_start import UpdateBankDetails
|
||||
from src.application.commands.update_bank_details_complete import UpdateBankDetailsCompleteCommand
|
||||
from src.application.commands.change_password_start import ChangePasswordStartCommand
|
||||
from src.application.commands.change_password_complete import ChangePasswordCompleteCommand
|
||||
from src.application.commands.forgot_password_start import ForgotPasswordStartCommand
|
||||
from src.application.commands.forgot_password_complete import ForgotPasswordCompleteCommand
|
||||
from src.application.commands.change_email_start import ChangeEmailStartCommand
|
||||
from src.application.commands.change_email_confirm_old import ChangeEmailConfirmOldCommand
|
||||
from src.application.commands.change_email_complete import ChangeEmailCompleteCommand
|
||||
|
||||
83
src/application/commands/forgot_password_complete.py
Normal file
83
src/application/commands/forgot_password_complete.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import IHashService, ILogger, ICache
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
|
||||
|
||||
class ForgotPasswordCompleteCommand:
|
||||
def __init__(
|
||||
self,
|
||||
unit_of_work: IUnitOfWork,
|
||||
hash_service: IHashService,
|
||||
cache: ICache,
|
||||
logger: ILogger,
|
||||
):
|
||||
self._unit_of_work = unit_of_work
|
||||
self._hash_service = hash_service
|
||||
self._cache = cache
|
||||
self._logger = logger
|
||||
|
||||
@staticmethod
|
||||
def _normalize_email(email: str) -> str:
|
||||
return email.strip().lower()
|
||||
|
||||
@transactional
|
||||
async def __call__(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
code: str,
|
||||
new_password: str,
|
||||
confirm_password: str,
|
||||
) -> bool:
|
||||
code = (code or '').strip()
|
||||
normalized = self._normalize_email(email)
|
||||
|
||||
EMAIL_PREFIX = 'forgot_password:email:'
|
||||
CODE_PREFIX = 'forgot_password:code:'
|
||||
|
||||
if new_password != confirm_password:
|
||||
self._logger.info('Forgot password failed: passwords do not match')
|
||||
raise ApplicationException(400, 'Passwords do not match')
|
||||
|
||||
code_key = f'{CODE_PREFIX}{code}'
|
||||
cached_email = await self._cache.get(code_key)
|
||||
if not cached_email:
|
||||
self._logger.info('Forgot password failed: code not found')
|
||||
raise ApplicationException(400, 'Invalid or expired code')
|
||||
|
||||
if cached_email != normalized:
|
||||
self._logger.info('Forgot password failed: code-email mismatch')
|
||||
raise ApplicationException(400, 'Invalid or expired code')
|
||||
|
||||
email_key = f'{EMAIL_PREFIX}{normalized}'
|
||||
code_hash = await self._cache.get(email_key)
|
||||
if not code_hash:
|
||||
self._logger.info('Forgot password failed: email key missing')
|
||||
raise ApplicationException(400, 'Invalid or expired code')
|
||||
|
||||
ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code)
|
||||
if not ok:
|
||||
self._logger.info('Forgot password failed: code hash mismatch')
|
||||
raise ApplicationException(400, 'Invalid or expired code')
|
||||
|
||||
user = await self._unit_of_work.user_repository.get_user_by_email(normalized)
|
||||
if user is None:
|
||||
self._logger.info('Forgot password failed: user not found after valid code')
|
||||
raise ApplicationException(400, 'Invalid or expired code')
|
||||
|
||||
new_password_hash = await self._hash_service.hash(new_password)
|
||||
user = await self._unit_of_work.user_repository.set_password(
|
||||
user_id=user.id,
|
||||
password_hash=new_password_hash,
|
||||
)
|
||||
await self._cache.set_user(user.id, user)
|
||||
|
||||
try:
|
||||
await self._cache.delete(code_key)
|
||||
await self._cache.delete(email_key)
|
||||
except Exception as e:
|
||||
self._logger.warning(f'Forgot password cleanup failed (user_id={user.id}): {e}')
|
||||
|
||||
self._logger.info(f'Password reset via forgot flow for user_id={user.id}')
|
||||
return True
|
||||
132
src/application/commands/forgot_password_start.py
Normal file
132
src/application/commands/forgot_password_start.py
Normal file
@@ -0,0 +1,132 @@
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from ulid import ULID
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
from src.infrastructure.config import settings
|
||||
from src.infrastructure.context_vars import trace_id_var
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
|
||||
|
||||
class ForgotPasswordStartCommand:
|
||||
def __init__(
|
||||
self,
|
||||
hash_service: IHashService,
|
||||
cache: ICache,
|
||||
unit_of_work: IUnitOfWork,
|
||||
logger: ILogger,
|
||||
messanger: IQueueMessanger,
|
||||
):
|
||||
self._hash_service = hash_service
|
||||
self._unit_of_work = unit_of_work
|
||||
self._cache = cache
|
||||
self._logger = logger
|
||||
self._messanger = messanger
|
||||
|
||||
@staticmethod
|
||||
def _normalize_email(email: str) -> str:
|
||||
return email.strip().lower()
|
||||
|
||||
@transactional
|
||||
async def __call__(self, email: str) -> bool:
|
||||
TTL = 300
|
||||
LOCK_TTL = 30
|
||||
MAX_ATTEMPTS = 20
|
||||
|
||||
EMAIL_PREFIX = 'forgot_password:email:'
|
||||
CODE_PREFIX = 'forgot_password:code:'
|
||||
LOCK_PREFIX = 'forgot_password:lock:'
|
||||
|
||||
normalized = self._normalize_email(email)
|
||||
user = await self._unit_of_work.user_repository.get_user_by_email(normalized)
|
||||
if user is None:
|
||||
self._logger.info(f'Forgot password start: no user for email hash lookup')
|
||||
return True
|
||||
|
||||
trace_id = trace_id_var.get()
|
||||
if not trace_id or trace_id == 'N/A':
|
||||
trace_id = None
|
||||
|
||||
lock_key = f'{LOCK_PREFIX}{normalized}'
|
||||
locked = await self._cache.set_nx(lock_key, '1', ttl=LOCK_TTL)
|
||||
if not locked:
|
||||
self._logger.info(f'Forgot password throttled by lock (user_id={user.id})')
|
||||
raise ApplicationException(429, 'Too many requests. Please wait.')
|
||||
|
||||
try:
|
||||
email_key = f'{EMAIL_PREFIX}{normalized}'
|
||||
|
||||
existing = await self._cache.get(email_key)
|
||||
if existing:
|
||||
self._logger.info(f'Forgot password denied: code already exists for user_id={user.id}')
|
||||
raise ApplicationException(429, 'Code already sent. Please wait before retrying.')
|
||||
|
||||
for _ in range(MAX_ATTEMPTS):
|
||||
code = f'{secrets.randbelow(1_000_000):06d}'
|
||||
code_key = f'{CODE_PREFIX}{code}'
|
||||
|
||||
code_hash = await self._hash_service.hash(code)
|
||||
|
||||
reserved = await self._cache.set_nx(code_key, normalized, ttl=TTL)
|
||||
if not reserved:
|
||||
continue
|
||||
|
||||
saved = await self._cache.set(email_key, code_hash, ttl=TTL)
|
||||
if not saved:
|
||||
await self._cache.delete(code_key)
|
||||
self._logger.error(f'Forgot password failed: cannot save code hash for user_id={user.id}')
|
||||
raise ApplicationException(503, 'Temporary error. Please try again.')
|
||||
|
||||
message_id = str(ULID())
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
metadata = {
|
||||
'trace_id': trace_id,
|
||||
'source': 'user-service',
|
||||
'timestamp': now,
|
||||
'message_id': message_id,
|
||||
}
|
||||
|
||||
payload = {
|
||||
'email': normalized,
|
||||
'code': code,
|
||||
'ttl_seconds': TTL,
|
||||
}
|
||||
|
||||
message = {
|
||||
'event': 'forgot_password',
|
||||
'payload': payload,
|
||||
'metadata': metadata,
|
||||
}
|
||||
|
||||
self._logger.info(f'Forgot password code created for user_id={user.id}')
|
||||
|
||||
try:
|
||||
await self._messanger.publish_to_queue(
|
||||
queue=settings.RABBIT_EMAIL_CODE_QUEUE,
|
||||
message=message,
|
||||
persist=True,
|
||||
correlation_id=trace_id,
|
||||
message_id=message_id,
|
||||
headers={'trace_id': trace_id} if trace_id else None,
|
||||
)
|
||||
except Exception as exception:
|
||||
try:
|
||||
await self._cache.delete(email_key)
|
||||
await self._cache.delete(code_key)
|
||||
except Exception as rollback_err:
|
||||
self._logger.error(
|
||||
f'Publish failed and rollback cache failed for user_id={user.id}: {str(rollback_err)}'
|
||||
)
|
||||
|
||||
self._logger.error(f'Failed to publish forgot password email for user_id={user.id}: {str(exception)}')
|
||||
raise ApplicationException(503, 'Temporary error. Please try again.')
|
||||
|
||||
return True
|
||||
|
||||
self._logger.error(f'Forgot password failed: code space exhausted for user_id={user.id}')
|
||||
raise ApplicationException(503, 'Temporary error. Please try again.')
|
||||
|
||||
finally:
|
||||
await self._cache.delete(lock_key)
|
||||
21
src/application/domain/password_policy.py
Normal file
21
src/application/domain/password_policy.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import re
|
||||
|
||||
SPECIAL_CHARS = '!@#$%^&*()_+-=.,:;?/[]{}<>'
|
||||
|
||||
|
||||
def validate_password_strength(password: str) -> str:
|
||||
if re.search(r'\s', password):
|
||||
raise ValueError('Password must not contain whitespace')
|
||||
if len(password) < 12:
|
||||
raise ValueError('Password must be at least 12 characters')
|
||||
if not re.search(r'[a-z]', password):
|
||||
raise ValueError('Password must contain at least one lowercase letter')
|
||||
if not re.search(r'[A-Z]', password):
|
||||
raise ValueError('Password must contain at least one uppercase letter')
|
||||
if not re.search(r'\d', password):
|
||||
raise ValueError('Password must contain at least one digit')
|
||||
if not any(c in SPECIAL_CHARS for c in password):
|
||||
raise ValueError(
|
||||
'Password must contain at least one special character from: !@#$%^&*()_+-=.,:;?/[]{}<>'
|
||||
)
|
||||
return password
|
||||
@@ -122,3 +122,21 @@ class UserRepository(IUserRepository):
|
||||
except SQLAlchemyError as exception:
|
||||
self._logger.exception(str(exception))
|
||||
raise InternalException(message=f'Database error: {str(exception)}')
|
||||
|
||||
async def get_user_by_email(self, email: str) -> UserEntity | None:
|
||||
try:
|
||||
stmt = (
|
||||
select(UserModel)
|
||||
.where(
|
||||
UserModel.email == email,
|
||||
UserModel.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
user: UserModel | None = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
return None
|
||||
return self._to_entity(user)
|
||||
except SQLAlchemyError as exception:
|
||||
self._logger.exception(str(exception))
|
||||
raise InternalException(message=f'Database error: {str(exception)}')
|
||||
|
||||
@@ -23,6 +23,7 @@ from src.presentation.handlers import (
|
||||
)
|
||||
from src.presentation.middleware import TraceIDMiddleware, SecurityHeadersMiddleware
|
||||
from src.presentation.routing import me_router
|
||||
from src.presentation.routing.password import password_router
|
||||
|
||||
security = HTTPBasic()
|
||||
|
||||
@@ -90,6 +91,7 @@ app.add_exception_handler(ApplicationException, application_exception_handler)
|
||||
app.add_exception_handler(Exception, unhandled_exception_handler)
|
||||
|
||||
app.include_router(me_router)
|
||||
app.include_router(password_router)
|
||||
# app.include_router(me_devices_router)
|
||||
# app.include_router(me_deals_router)
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ from src.presentation.dependencies.commands import (
|
||||
get_update_bank_details_complete_command,
|
||||
get_change_password_start_command,
|
||||
get_change_password_complete_command,
|
||||
get_forgot_password_start_command,
|
||||
get_forgot_password_complete_command,
|
||||
get_change_email_start_command,
|
||||
get_change_email_confirm_old_command,
|
||||
get_change_email_complete_command,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import Depends
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.commands import GetMeCommand, SetPhoneCommand, SetAvatarCommand, DeleteAvatarCommand, SetEncryptedMnemonicStartCommand, SetEncryptedMnemonicCompleteCommand, UpdateBankDetailsStartCommand, UpdateBankDetailsCompleteCommand, ChangePasswordStartCommand, ChangePasswordCompleteCommand, ChangeEmailStartCommand, ChangeEmailConfirmOldCommand, ChangeEmailCompleteCommand
|
||||
from src.application.commands import GetMeCommand, SetPhoneCommand, SetAvatarCommand, DeleteAvatarCommand, SetEncryptedMnemonicStartCommand, SetEncryptedMnemonicCompleteCommand, UpdateBankDetailsStartCommand, UpdateBankDetailsCompleteCommand, ChangePasswordStartCommand, ChangePasswordCompleteCommand, ForgotPasswordStartCommand, ForgotPasswordCompleteCommand, ChangeEmailStartCommand, ChangeEmailConfirmOldCommand, ChangeEmailCompleteCommand
|
||||
from src.application.contracts import ILogger, ICache, IQueueMessanger, IHashService, IS3
|
||||
from src.presentation.dependencies.cache import get_cache
|
||||
from src.presentation.dependencies.logger import get_logger
|
||||
@@ -104,6 +104,36 @@ def get_change_password_complete_command(
|
||||
)
|
||||
|
||||
|
||||
def get_forgot_password_start_command(
|
||||
logger: ILogger = Depends(get_logger),
|
||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||
cache: ICache = Depends(get_cache),
|
||||
messanger: IQueueMessanger = Depends(get_rabbit),
|
||||
hash_service: IHashService = Depends(get_hash_service),
|
||||
) -> ForgotPasswordStartCommand:
|
||||
return ForgotPasswordStartCommand(
|
||||
logger=logger,
|
||||
unit_of_work=unit_of_work,
|
||||
cache=cache,
|
||||
messanger=messanger,
|
||||
hash_service=hash_service,
|
||||
)
|
||||
|
||||
|
||||
def get_forgot_password_complete_command(
|
||||
logger: ILogger = Depends(get_logger),
|
||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||
cache: ICache = Depends(get_cache),
|
||||
hash_service: IHashService = Depends(get_hash_service),
|
||||
) -> ForgotPasswordCompleteCommand:
|
||||
return ForgotPasswordCompleteCommand(
|
||||
logger=logger,
|
||||
unit_of_work=unit_of_work,
|
||||
cache=cache,
|
||||
hash_service=hash_service,
|
||||
)
|
||||
|
||||
|
||||
def get_change_email_start_command(
|
||||
logger: ILogger = Depends(get_logger),
|
||||
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
|
||||
|
||||
@@ -191,7 +191,7 @@ async def change_password_start(
|
||||
summary='Подтвердить смену пароля',
|
||||
description=(
|
||||
'Принимает код из письма, новый пароль и его подтверждение. '
|
||||
'Новый пароль должен отличаться от текущего (минимум 8 символов).'
|
||||
'Новый пароль должен отличаться от текущего и соответствовать политике сложности (минимум 12 символов).'
|
||||
),
|
||||
responses=_PASSWORD_ERROR_RESPONSES,
|
||||
)
|
||||
|
||||
81
src/presentation/routing/password.py
Normal file
81
src/presentation/routing/password.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from starlette import status
|
||||
from src.application.commands import ForgotPasswordStartCommand, ForgotPasswordCompleteCommand
|
||||
from src.presentation.decorators import rate_limit, email_rl_key
|
||||
from src.presentation.dependencies import (
|
||||
get_forgot_password_start_command,
|
||||
get_forgot_password_complete_command,
|
||||
)
|
||||
from src.presentation.schemas import ForgotPasswordStartRequest, ForgotPasswordCompleteRequest
|
||||
from src.presentation.schemas.api_errors import ApiErrorPayload, ApiValidationErrorsPayload
|
||||
|
||||
|
||||
password_router = APIRouter(prefix='/password', tags=['Password'])
|
||||
|
||||
|
||||
_FORGOT_PASSWORD_ERROR_RESPONSES: dict[int, dict[str, object]] = {
|
||||
status.HTTP_400_BAD_REQUEST: {
|
||||
'description': 'Неверный или просроченный код, пароли не совпадают.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY: {
|
||||
'description': 'Тело запроса не соответствует схеме.',
|
||||
'model': ApiValidationErrorsPayload,
|
||||
},
|
||||
status.HTTP_429_TOO_MANY_REQUESTS: {
|
||||
'description': 'Код уже отправлен или слишком частые запросы.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE: {
|
||||
'description': 'Временная ошибка отправки кода или сохранения в кеш.',
|
||||
'model': ApiErrorPayload,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@password_router.post(
|
||||
path='/forgot/start',
|
||||
response_class=ORJSONResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
summary='Запросить код для восстановления пароля',
|
||||
description=(
|
||||
'Принимает email. Если учётная запись существует, отправляет шестизначный код. '
|
||||
'Ответ всегда успешный при валидном email (без раскрытия наличия аккаунта).'
|
||||
),
|
||||
responses=_FORGOT_PASSWORD_ERROR_RESPONSES,
|
||||
)
|
||||
@rate_limit(limit=5, window_seconds=300, scope='key', key_prefix='rl', key_builder=email_rl_key)
|
||||
async def forgot_password_start(
|
||||
request: Request,
|
||||
body: ForgotPasswordStartRequest,
|
||||
command: ForgotPasswordStartCommand = Depends(get_forgot_password_start_command),
|
||||
):
|
||||
result = await command(email=body.email)
|
||||
return ORJSONResponse(status_code=status.HTTP_200_OK, content={'success': result})
|
||||
|
||||
|
||||
@password_router.post(
|
||||
path='/forgot/complete',
|
||||
response_class=ORJSONResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
summary='Установить новый пароль по коду из письма',
|
||||
description=(
|
||||
'Принимает email, код из письма, новый пароль и подтверждение. '
|
||||
'Пароль: минимум 12 символов, строчная и заглавная буква, цифра, спецсимвол, без пробелов.'
|
||||
),
|
||||
responses=_FORGOT_PASSWORD_ERROR_RESPONSES,
|
||||
)
|
||||
@rate_limit(limit=10, window_seconds=300, scope='key', key_prefix='rl', key_builder=email_rl_key)
|
||||
async def forgot_password_complete(
|
||||
request: Request,
|
||||
body: ForgotPasswordCompleteRequest,
|
||||
command: ForgotPasswordCompleteCommand = Depends(get_forgot_password_complete_command),
|
||||
):
|
||||
result = await command(
|
||||
email=body.email,
|
||||
code=body.code,
|
||||
new_password=body.new_password,
|
||||
confirm_password=body.confirm_password,
|
||||
)
|
||||
return ORJSONResponse(status_code=status.HTTP_200_OK, content={'success': result})
|
||||
@@ -2,5 +2,9 @@ from src.presentation.schemas.avatar import SetAvatarRequest
|
||||
from src.presentation.schemas.phone import SetPhoneRequest
|
||||
from src.presentation.schemas.bank import BankUpdateRequest, BankConfirmRequest
|
||||
from src.presentation.schemas.encrypted_mnemonic import EncryptedMnemonicConfirmRequest
|
||||
from src.presentation.schemas.password import ChangePasswordConfirmRequest
|
||||
from src.presentation.schemas.password import (
|
||||
ChangePasswordConfirmRequest,
|
||||
ForgotPasswordStartRequest,
|
||||
ForgotPasswordCompleteRequest,
|
||||
)
|
||||
from src.presentation.schemas.email import ChangeEmailConfirmOldRequest, ChangeEmailCompleteRequest
|
||||
@@ -1,6 +1,53 @@
|
||||
import re
|
||||
from typing import Self
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
from src.application.domain.password_policy import validate_password_strength
|
||||
|
||||
|
||||
class ForgotPasswordStartRequest(BaseModel):
|
||||
email: str
|
||||
|
||||
@field_validator('email')
|
||||
@classmethod
|
||||
def validate_email(cls, v: str) -> str:
|
||||
v = v.strip().lower()
|
||||
if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', v):
|
||||
raise ValueError('Invalid email address')
|
||||
return v
|
||||
|
||||
|
||||
class ForgotPasswordCompleteRequest(BaseModel):
|
||||
email: str
|
||||
code: str
|
||||
new_password: str
|
||||
confirm_password: str
|
||||
|
||||
@field_validator('email')
|
||||
@classmethod
|
||||
def validate_email(cls, v: str) -> str:
|
||||
v = v.strip().lower()
|
||||
if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', v):
|
||||
raise ValueError('Invalid email address')
|
||||
return v
|
||||
|
||||
@model_validator(mode='after')
|
||||
def passwords_match(self) -> Self:
|
||||
if self.new_password != self.confirm_password:
|
||||
raise ValueError('Passwords do not match')
|
||||
return self
|
||||
|
||||
@field_validator('code')
|
||||
@classmethod
|
||||
def validate_code(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not re.match(r'^\d{6}$', v):
|
||||
raise ValueError('Code must be exactly 6 digits')
|
||||
return v
|
||||
|
||||
@field_validator('new_password')
|
||||
@classmethod
|
||||
def validate_new_password(cls, v: str) -> str:
|
||||
return validate_password_strength(v)
|
||||
|
||||
|
||||
class ChangePasswordConfirmRequest(BaseModel):
|
||||
@@ -25,6 +72,4 @@ class ChangePasswordConfirmRequest(BaseModel):
|
||||
@field_validator('new_password')
|
||||
@classmethod
|
||||
def validate_new_password(cls, v: str) -> str:
|
||||
if len(v) < 8:
|
||||
raise ValueError('Password must be at least 8 characters')
|
||||
return v
|
||||
return validate_password_strength(v)
|
||||
|
||||
Reference in New Issue
Block a user