feat: email and password login
This commit is contained in:
@@ -41,7 +41,7 @@ from bot.app.web.webapp_auth import (
|
||||
verify_telegram_oauth_nonce,
|
||||
verify_webapp_session_token,
|
||||
)
|
||||
from bot.infra.redis import cache_get_json, cache_set_json, get_redis, redis_key
|
||||
from bot.infra.redis import cache_delete, cache_get_json, cache_set_json, get_redis, redis_key
|
||||
from bot.services.email_auth_service import EmailAuthService, normalize_email
|
||||
from bot.services.email_templates import render_account_merged
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
@@ -51,7 +51,7 @@ from bot.utils.config_link import prepare_config_links
|
||||
from bot.utils.request_security import parse_ip_entries, request_client_ip
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal, subscription_dal, user_dal
|
||||
from db.dal import payment_dal, security_dal, subscription_dal, user_dal
|
||||
from db.dal.user_dal import UserMergeConflictError
|
||||
from db.models import Payment, User, UserTelegramAvatar
|
||||
|
||||
@@ -103,6 +103,7 @@ WEBAPP_CSRF_EXEMPT_PATHS = {
|
||||
"/api/auth/email/request",
|
||||
"/api/auth/email/verify",
|
||||
"/api/auth/email/magic",
|
||||
"/api/auth/email/password",
|
||||
"/api/auth/logout",
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .auth import _hash_email_password
|
||||
|
||||
|
||||
async def account_email_request_route(request: web.Request) -> web.Response:
|
||||
@@ -201,6 +202,84 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
return _build_webapp_auth_response(settings, response_payload, token=token)
|
||||
|
||||
|
||||
async def account_password_request_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user or db_user.is_banned:
|
||||
return _json_error(403, "access_denied", "Access denied")
|
||||
if not db_user.email or not db_user.email_verified_at:
|
||||
return _json_error(400, "email_not_linked", "Email is not linked")
|
||||
email = db_user.email
|
||||
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
|
||||
|
||||
return await _request_email_code(
|
||||
request,
|
||||
email=email,
|
||||
purpose="set_password",
|
||||
language_code=lang,
|
||||
target_user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
async def account_password_confirm_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
payload = await _read_json(request)
|
||||
password_payload, validation_error = _validate_model_payload(WebAppSetPasswordPayload, payload)
|
||||
if validation_error:
|
||||
return validation_error
|
||||
if password_payload.password != password_payload.password_confirm:
|
||||
return _json_error(400, "password_mismatch", "Passwords do not match")
|
||||
|
||||
settings: Settings = request.app["settings"]
|
||||
email_service: EmailAuthService = request.app["email_auth_service"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user or db_user.is_banned:
|
||||
await session.rollback()
|
||||
return _json_error(403, "access_denied", "Access denied")
|
||||
if not db_user.email or not db_user.email_verified_at:
|
||||
await session.rollback()
|
||||
return _json_error(400, "email_not_linked", "Email is not linked")
|
||||
|
||||
verify_result = await email_service.verify_code(
|
||||
session,
|
||||
email=db_user.email,
|
||||
purpose="set_password",
|
||||
code=str(password_payload.code or ""),
|
||||
target_user_id=user_id,
|
||||
)
|
||||
if not verify_result.ok:
|
||||
await session.commit()
|
||||
status = 429 if verify_result.error == "rate_limited" else 400
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": verify_result.error or "invalid_code",
|
||||
"retry_after": verify_result.retry_after,
|
||||
"message": "Invalid code",
|
||||
},
|
||||
status=status,
|
||||
)
|
||||
|
||||
db_user.password_hash = _hash_email_password(str(password_payload.password))
|
||||
db_user.password_set_at = datetime.now(timezone.utc)
|
||||
await session.flush()
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.exception("Email password setup failed")
|
||||
return _json_error(500, "password_setup_failed", "Password setup failed")
|
||||
|
||||
await cache_delete(settings, redis_key(settings, "cache", "webapp", "me", user_id))
|
||||
return web.json_response({"ok": True, "password_auth_enabled": True})
|
||||
|
||||
|
||||
async def account_telegram_link_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
|
||||
@@ -132,6 +132,58 @@ def _urlsafe_sha256(value: str) -> str:
|
||||
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
PASSWORD_HASH_ALGORITHM = "pbkdf2_sha256"
|
||||
PASSWORD_HASH_ITERATIONS = 260_000
|
||||
|
||||
|
||||
def _password_hash_b64(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _password_hash_unb64(value: str) -> bytes:
|
||||
padding = "=" * (-len(value) % 4)
|
||||
return base64.urlsafe_b64decode((value + padding).encode("ascii"))
|
||||
|
||||
|
||||
def _hash_email_password(password: str) -> str:
|
||||
salt = secrets.token_bytes(18)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
password.encode("utf-8"),
|
||||
salt,
|
||||
PASSWORD_HASH_ITERATIONS,
|
||||
)
|
||||
return "$".join(
|
||||
[
|
||||
PASSWORD_HASH_ALGORITHM,
|
||||
str(PASSWORD_HASH_ITERATIONS),
|
||||
_password_hash_b64(salt),
|
||||
_password_hash_b64(digest),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _verify_email_password(password: str, stored_hash: Optional[str]) -> bool:
|
||||
if not stored_hash:
|
||||
return False
|
||||
try:
|
||||
algorithm, iterations_raw, salt_raw, digest_raw = stored_hash.split("$", 3)
|
||||
if algorithm != PASSWORD_HASH_ALGORITHM:
|
||||
return False
|
||||
iterations = int(iterations_raw)
|
||||
salt = _password_hash_unb64(salt_raw)
|
||||
expected_digest = _password_hash_unb64(digest_raw)
|
||||
actual_digest = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
password.encode("utf-8"),
|
||||
salt,
|
||||
iterations,
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
return hmac.compare_digest(actual_digest, expected_digest)
|
||||
|
||||
|
||||
async def _exchange_telegram_oauth_code(
|
||||
request: web.Request,
|
||||
*,
|
||||
@@ -438,6 +490,114 @@ async def logout_route(request: web.Request) -> web.Response:
|
||||
return response
|
||||
|
||||
|
||||
def _password_login_failure_response(
|
||||
*,
|
||||
status: int = 401,
|
||||
retry_after: Optional[int] = None,
|
||||
) -> web.Response:
|
||||
payload: Dict[str, Any] = {
|
||||
"ok": False,
|
||||
"error": "password_login_failed",
|
||||
"fallback": "email_code",
|
||||
"message": "Password login failed",
|
||||
}
|
||||
if retry_after is not None:
|
||||
payload["retry_after"] = retry_after
|
||||
return web.json_response(payload, status=status)
|
||||
|
||||
|
||||
async def email_password_auth_route(request: web.Request) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
if not settings.email_auth_configured:
|
||||
return _json_error(503, "email_auth_not_configured", "Email auth is not configured")
|
||||
|
||||
payload = await _read_json(request)
|
||||
password_payload, validation_error = _validate_model_payload(
|
||||
WebAppEmailPasswordPayload,
|
||||
payload,
|
||||
)
|
||||
if validation_error:
|
||||
return validation_error
|
||||
|
||||
email = password_payload.email
|
||||
password = str(password_payload.password or "")
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
authenticated_user_id: Optional[int] = None
|
||||
authenticated_telegram_id: Optional[int] = None
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
throttle = await security_dal.check_throttle(
|
||||
session,
|
||||
scope=security_dal.EMAIL_PASSWORD_LOGIN_SCOPE,
|
||||
identifier=email,
|
||||
now=now,
|
||||
)
|
||||
if throttle.locked:
|
||||
await session.commit()
|
||||
return _json_error(
|
||||
429,
|
||||
"rate_limited",
|
||||
"Too many password attempts",
|
||||
)
|
||||
|
||||
db_user = await user_dal.get_user_by_email(session, email)
|
||||
password_ok = bool(
|
||||
db_user
|
||||
and db_user.email_verified_at
|
||||
and db_user.password_hash
|
||||
and _verify_email_password(password, db_user.password_hash)
|
||||
)
|
||||
|
||||
if not password_ok:
|
||||
throttle_result = await security_dal.record_throttle_failure(
|
||||
session,
|
||||
scope=security_dal.EMAIL_PASSWORD_LOGIN_SCOPE,
|
||||
identifier=email,
|
||||
max_failures=settings.BRUTE_FORCE_MAX_FAILURES,
|
||||
window_seconds=settings.BRUTE_FORCE_WINDOW_SECONDS,
|
||||
lock_seconds=settings.BRUTE_FORCE_LOCK_SECONDS,
|
||||
now=now,
|
||||
)
|
||||
await session.commit()
|
||||
if throttle_result.locked:
|
||||
return _json_error(
|
||||
429,
|
||||
"rate_limited",
|
||||
"Too many password attempts",
|
||||
)
|
||||
return _password_login_failure_response()
|
||||
|
||||
if db_user.is_banned:
|
||||
await session.rollback()
|
||||
return _json_error(403, "banned", "Access denied")
|
||||
|
||||
await security_dal.clear_throttle_state(
|
||||
session,
|
||||
scope=security_dal.EMAIL_PASSWORD_LOGIN_SCOPE,
|
||||
identifier=email,
|
||||
)
|
||||
authenticated_user_id = int(db_user.user_id)
|
||||
authenticated_telegram_id = _telegram_id_for_user(db_user)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.exception("Email password auth failed")
|
||||
return _json_error(500, "auth_failed", "Auth failed")
|
||||
|
||||
token = create_webapp_session_token(settings, int(authenticated_user_id))
|
||||
return _build_webapp_auth_response(
|
||||
settings,
|
||||
{
|
||||
"ok": True,
|
||||
"user_id": int(authenticated_user_id),
|
||||
"telegram_id": authenticated_telegram_id,
|
||||
},
|
||||
token=token,
|
||||
)
|
||||
|
||||
|
||||
async def email_auth_request_route(request: web.Request) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
|
||||
@@ -37,6 +37,13 @@ def _validation_error_response(exc: ValidationError) -> web.Response:
|
||||
if field in {"description", "comment", "note"} and error_type == "string_too_long":
|
||||
return _json_error(400, f"{field}_too_long", f"{field.capitalize()} is too long")
|
||||
|
||||
if field in {"password", "password_confirm"}:
|
||||
if error_type == "string_too_short":
|
||||
return _json_error(400, "password_too_short", "Password is too short")
|
||||
if error_type == "string_too_long":
|
||||
return _json_error(400, "password_too_long", "Password is too long")
|
||||
return _json_error(400, "invalid_password", "Invalid password")
|
||||
|
||||
if error_type == "string_too_long":
|
||||
return _json_error(400, "text_too_long", "Text is too long")
|
||||
|
||||
|
||||
@@ -20,6 +20,18 @@ class WebAppEmailCodePayload(WebAppEmailPayload):
|
||||
code: str = ""
|
||||
|
||||
|
||||
class WebAppEmailPasswordPayload(WebAppEmailPayload):
|
||||
password: constr(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class WebAppSetPasswordPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
password: constr(min_length=8, max_length=128)
|
||||
password_confirm: constr(min_length=8, max_length=128)
|
||||
code: constr(min_length=1, max_length=32)
|
||||
|
||||
|
||||
class WebAppEmailMagicPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from ._runtime import * # noqa: F403,F405
|
||||
|
||||
def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/", index_route)
|
||||
app.router.add_get("/login/password", index_route)
|
||||
app.router.add_get("/home", index_route)
|
||||
app.router.add_get("/invite", index_route)
|
||||
app.router.add_get("/devices", index_route)
|
||||
@@ -43,6 +44,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_post("/api/auth/email/request", email_auth_request_route)
|
||||
app.router.add_post("/api/auth/email/verify", email_auth_verify_route)
|
||||
app.router.add_post("/api/auth/email/magic", email_auth_magic_route)
|
||||
app.router.add_post("/api/auth/email/password", email_password_auth_route)
|
||||
app.router.add_post("/api/auth/logout", logout_route)
|
||||
app.router.add_get("/api/bootstrap", bootstrap_route)
|
||||
app.router.add_get("/api/me", me_route)
|
||||
@@ -50,6 +52,8 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_post("/api/account/language", account_language_route)
|
||||
app.router.add_post("/api/account/email/request", account_email_request_route)
|
||||
app.router.add_post("/api/account/email/verify", account_email_verify_route)
|
||||
app.router.add_post("/api/account/password/request", account_password_request_route)
|
||||
app.router.add_post("/api/account/password/confirm", account_password_confirm_route)
|
||||
app.router.add_post("/api/account/telegram/link", account_telegram_link_route)
|
||||
app.router.add_post("/api/promo/apply", apply_promo_route)
|
||||
app.router.add_post("/api/trial/activate", activate_trial_route)
|
||||
|
||||
@@ -67,6 +67,9 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
"username": db_user.username,
|
||||
"email": db_user.email,
|
||||
"email_verified": bool(db_user.email_verified_at),
|
||||
"password_auth_enabled": bool(
|
||||
db_user.email and db_user.email_verified_at and db_user.password_hash
|
||||
),
|
||||
"telegram_id": db_user.telegram_id,
|
||||
"telegram_linked": bool(_telegram_id_for_user(db_user)),
|
||||
"telegram_photo_url": _telegram_avatar_url(avatar),
|
||||
|
||||
Reference in New Issue
Block a user