feat: email and password login

This commit is contained in:
3252a8
2026-05-19 15:21:02 +03:00
parent 3152631911
commit f5006af6c0
26 changed files with 1273 additions and 90 deletions
+224
View File
@@ -10,6 +10,7 @@ from aiohttp import web
from bot.app.web import admin_api, subscription_webapp
from bot.app.web.admin_api_impl import settings as admin_settings_routes
from bot.app.web.webapp import account as account_routes
from bot.app.web.webapp_auth import (
create_telegram_oauth_nonce,
create_webapp_session_token,
@@ -19,8 +20,10 @@ from bot.payment_providers.cryptopay import CryptoPayService
from bot.payment_providers.freekassa import FreeKassaService
from bot.payment_providers.heleket import HeleketConfig, HeleketService, _compute_signature
from bot.payment_providers.yookassa import yookassa_webhook_route
from bot.services.email_templates import render_login_code
from bot.utils.request_security import request_client_ip
from config.settings import Settings
from db.dal import security_dal
from db.database_setup import redacted_database_url
@@ -197,6 +200,25 @@ class HeleketServiceTests(unittest.TestCase):
class WebAppSecurityTests(unittest.IsolatedAsyncioTestCase):
class _AsyncSessionFactory:
def __call__(self):
return self
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def commit(self):
return None
async def rollback(self):
return None
async def flush(self):
return None
def test_auth_response_sets_cookies_and_does_not_return_session_token(self):
settings = SimpleNamespace(
WEBAPP_SESSION_SECRET="session-secret",
@@ -306,6 +328,208 @@ class WebAppSecurityTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(response.status, 400)
self.assertIn("email_too_long", response.text)
def test_email_password_hash_round_trips_without_plaintext(self):
password = "correct horse battery staple"
stored_hash = subscription_webapp._hash_email_password(password)
self.assertNotIn(password, stored_hash)
self.assertTrue(subscription_webapp._verify_email_password(password, stored_hash))
self.assertFalse(subscription_webapp._verify_email_password("wrong-password", stored_hash))
def test_set_password_email_code_copy_mentions_password_creation(self):
settings = SimpleNamespace(
DEFAULT_LANGUAGE="ru",
EMAIL_CODE_TTL_SECONDS=600,
WEBAPP_LOGO_URL="",
WEBAPP_LOGO_USE_EMOJI=False,
WEBAPP_PRIMARY_COLOR="#00fe7a",
WEBAPP_TITLE="Remnawave",
)
content = render_login_code(
settings,
code="123456",
language_code="ru",
magic_link="https://example.com/login",
purpose="set_password",
)
self.assertIn("создания пароля", content.subject)
self.assertIn("создание пароля", content.html)
self.assertIn("код для создания пароля", content.text)
self.assertNotIn("Подтвердите вход", content.html)
self.assertNotIn("https://example.com/login", content.text)
async def test_email_password_login_success_sets_session_cookie(self):
settings = SimpleNamespace(
WEBAPP_SESSION_SECRET="session-secret",
WEBAPP_SESSION_TTL_SECONDS=3600,
email_auth_configured=True,
BRUTE_FORCE_MAX_FAILURES=5,
BRUTE_FORCE_WINDOW_SECONDS=60,
BRUTE_FORCE_LOCK_SECONDS=300,
)
stored_hash = subscription_webapp._hash_email_password("secret-password")
db_user = SimpleNamespace(
user_id=42,
email_verified_at=object(),
password_hash=stored_hash,
is_banned=False,
telegram_id=None,
)
request = SimpleNamespace(
app={"settings": settings, "async_session_factory": self._AsyncSessionFactory()},
json=AsyncMock(
return_value={"email": "user@example.com", "password": "secret-password"}
),
)
with (
patch.object(
subscription_webapp.security_dal,
"check_throttle",
AsyncMock(return_value=security_dal.ThrottleDecision(locked=False)),
),
patch.object(
subscription_webapp.security_dal,
"clear_throttle_state",
AsyncMock(return_value=None),
),
patch.object(
subscription_webapp.user_dal,
"get_user_by_email",
AsyncMock(return_value=db_user),
),
):
response = await subscription_webapp.email_password_auth_route(request)
payload = json.loads(response.text)
self.assertTrue(payload["ok"])
self.assertEqual(payload["user_id"], 42)
self.assertIn("rw_webapp_session", response.cookies)
async def test_email_password_login_failures_use_same_fallback_error(self):
settings = SimpleNamespace(
email_auth_configured=True,
BRUTE_FORCE_MAX_FAILURES=5,
BRUTE_FORCE_WINDOW_SECONDS=60,
BRUTE_FORCE_LOCK_SECONDS=300,
)
cases = [
None,
SimpleNamespace(
user_id=42,
email_verified_at=object(),
password_hash=None,
is_banned=False,
),
SimpleNamespace(
user_id=42,
email_verified_at=object(),
password_hash=subscription_webapp._hash_email_password("other-password"),
is_banned=False,
),
]
for db_user in cases:
request = SimpleNamespace(
app={"settings": settings, "async_session_factory": self._AsyncSessionFactory()},
json=AsyncMock(
return_value={"email": "user@example.com", "password": "secret-password"}
),
)
with (
patch.object(
subscription_webapp.security_dal,
"check_throttle",
AsyncMock(return_value=security_dal.ThrottleDecision(locked=False)),
),
patch.object(
subscription_webapp.security_dal,
"record_throttle_failure",
AsyncMock(return_value=security_dal.ThrottleDecision(locked=False)),
),
patch.object(
subscription_webapp.user_dal,
"get_user_by_email",
AsyncMock(return_value=db_user),
),
):
response = await subscription_webapp.email_password_auth_route(request)
payload = json.loads(response.text)
self.assertEqual(response.status, 401)
self.assertEqual(payload["error"], "password_login_failed")
self.assertEqual(payload["fallback"], "email_code")
async def test_account_password_confirm_requires_matching_passwords(self):
request = SimpleNamespace(
app={},
json=AsyncMock(
return_value={
"password": "secret-password",
"password_confirm": "other-password",
"code": "123456",
}
),
)
with patch.object(account_routes, "_require_user_id", return_value=42):
response = await account_routes.account_password_confirm_route(request)
self.assertEqual(response.status, 400)
self.assertIn("password_mismatch", response.text)
async def test_account_password_confirm_sets_hash_after_email_code(self):
settings = SimpleNamespace(
REDIS_URL=None,
REDIS_KEY_PREFIX="test",
)
db_user = SimpleNamespace(
user_id=42,
email="user@example.com",
email_verified_at=object(),
is_banned=False,
password_hash=None,
password_set_at=None,
)
email_service = SimpleNamespace(
verify_code=AsyncMock(
return_value=SimpleNamespace(ok=True, error=None, retry_after=None)
)
)
request = SimpleNamespace(
app={
"settings": settings,
"email_auth_service": email_service,
"async_session_factory": self._AsyncSessionFactory(),
},
json=AsyncMock(
return_value={
"password": "secret-password",
"password_confirm": "secret-password",
"code": "123456",
}
),
)
with (
patch.object(account_routes, "_require_user_id", return_value=42),
patch.object(
account_routes.user_dal,
"get_user_by_id",
AsyncMock(return_value=db_user),
),
):
response = await account_routes.account_password_confirm_route(request)
self.assertEqual(response.status, 200)
self.assertTrue(
subscription_webapp._verify_email_password("secret-password", db_user.password_hash)
)
self.assertIsNotNone(db_user.password_set_at)
def test_payment_payload_rejects_overlong_description(self):
model, response = subscription_webapp._validate_model_payload(
subscription_webapp.WebAppPaymentCreatePayload,
+4
View File
@@ -48,6 +48,7 @@ class WebAppRouteContractTests(unittest.TestCase):
routes = _route_map(app)
expected = {
("GET", "/"): "index_route",
("GET", "/login/password"): "index_route",
("GET", "/home"): "index_route",
("GET", "/invite"): "index_route",
("GET", "/devices"): "index_route",
@@ -70,12 +71,15 @@ class WebAppRouteContractTests(unittest.TestCase):
("POST", "/api/auth/email/request"): "email_auth_request_route",
("POST", "/api/auth/email/verify"): "email_auth_verify_route",
("POST", "/api/auth/email/magic"): "email_auth_magic_route",
("POST", "/api/auth/email/password"): "email_password_auth_route",
("POST", "/api/auth/logout"): "logout_route",
("GET", "/api/me"): "me_route",
("GET", "/api/account/avatar"): "account_avatar_route",
("POST", "/api/account/language"): "account_language_route",
("POST", "/api/account/email/request"): "account_email_request_route",
("POST", "/api/account/email/verify"): "account_email_verify_route",
("POST", "/api/account/password/request"): "account_password_request_route",
("POST", "/api/account/password/confirm"): "account_password_confirm_route",
("POST", "/api/account/telegram/link"): "account_telegram_link_route",
("POST", "/api/promo/apply"): "apply_promo_route",
("POST", "/api/trial/activate"): "activate_trial_route",