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
+3 -2
View File
@@ -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",
}
+79
View File
@@ -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"]
+160
View File
@@ -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)
+7
View File
@@ -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")
+12
View File
@@ -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
View File
@@ -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),
+10 -1
View File
@@ -196,7 +196,11 @@ class EmailAuthService:
code = f"{secrets.randbelow(1_000_000):06d}"
magic_token = secrets.token_urlsafe(32)
magic_link = self._build_magic_link(token=magic_token, purpose=purpose)
magic_link = (
self._build_magic_link(token=magic_token, purpose=purpose)
if purpose == "login"
else None
)
code_model = EmailVerificationCode(
email=normalized_email,
code_hash=self._hash_code(normalized_email, purpose, code),
@@ -214,6 +218,7 @@ class EmailAuthService:
code=code,
language_code=language_code,
magic_link=magic_link,
purpose=purpose,
)
return EmailCodeRequestResult(ok=True)
@@ -405,6 +410,7 @@ class EmailAuthService:
code: str,
language_code: str,
magic_link: Optional[str] = None,
purpose: str = "login",
) -> None:
await asyncio.to_thread(
self._send_code_email_sync,
@@ -412,6 +418,7 @@ class EmailAuthService:
code=code,
language_code=language_code,
magic_link=magic_link,
purpose=purpose,
)
async def send_custom_email(
@@ -450,12 +457,14 @@ class EmailAuthService:
code: str,
language_code: str,
magic_link: Optional[str] = None,
purpose: str = "login",
) -> None:
content = render_login_code(
self.settings,
code=code,
language_code=language_code,
magic_link=magic_link,
purpose=purpose,
)
message = EmailMessage()
+10 -8
View File
@@ -209,6 +209,7 @@ def render_login_code(
code: str,
language_code: Optional[str],
magic_link: Optional[str] = None,
purpose: str = "login",
i18n: Optional[JsonI18n] = None,
) -> EmailContent:
i18n = _resolve_i18n(i18n)
@@ -216,16 +217,17 @@ def render_login_code(
minutes = _format_minutes(settings.EMAIL_CODE_TTL_SECONDS)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
brand = _brand_title(settings)
safe_magic_link = (magic_link or "").strip()
template_prefix = "email_set_password_code" if purpose == "set_password" else "email_login_code"
safe_magic_link = (magic_link or "").strip() if template_prefix == "email_login_code" else ""
subject = _t_text(i18n, lang, "email_login_code_subject", code=code)
preheader = _t_text(i18n, lang, "email_login_code_preheader", minutes=minutes)
heading = _t_text(i18n, lang, "email_login_code_heading")
intro = _t_text(i18n, lang, "email_login_code_intro")
expiry_html = _t_html(i18n, lang, "email_login_code_expiry_html", minutes=minutes)
security = _t_text(i18n, lang, "email_login_code_security")
subject = _t_text(i18n, lang, f"{template_prefix}_subject", code=code)
preheader = _t_text(i18n, lang, f"{template_prefix}_preheader", minutes=minutes)
heading = _t_text(i18n, lang, f"{template_prefix}_heading")
intro = _t_text(i18n, lang, f"{template_prefix}_intro")
expiry_html = _t_html(i18n, lang, f"{template_prefix}_expiry_html", minutes=minutes)
security = _t_text(i18n, lang, f"{template_prefix}_security")
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
text_lines = [_t_text(i18n, lang, "email_login_code_text", code=code, minutes=minutes)]
text_lines = [_t_text(i18n, lang, f"{template_prefix}_text", code=code, minutes=minutes)]
if safe_magic_link:
text_lines.append(_t_text(i18n, lang, "email_login_code_text_magic", url=safe_magic_link))
+1
View File
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from ..models import SecurityThrottle
EMAIL_CODE_VERIFY_SCOPE = "email_code_verify"
EMAIL_PASSWORD_LOGIN_SCOPE = "email_password_login"
PROMO_CODE_APPLY_SCOPE = "promo_code_apply"
+15
View File
@@ -744,6 +744,16 @@ def _migration_0022_add_indexes_for_admin_reports(connection: Connection) -> Non
)
def _migration_0023_add_email_password_auth_fields(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
if "password_hash" not in columns:
connection.execute(text("ALTER TABLE users ADD COLUMN password_hash VARCHAR"))
if "password_set_at" not in columns:
connection.execute(text("ALTER TABLE users ADD COLUMN password_set_at TIMESTAMPTZ"))
MIGRATIONS: List[Migration] = [
Migration(
id="0001_add_channel_subscription_fields",
@@ -866,6 +876,11 @@ MIGRATIONS: List[Migration] = [
description="Indexes to speed up financial stats and admin log queries",
upgrade=_migration_0022_add_indexes_for_admin_reports,
),
Migration(
id="0023_add_email_password_auth_fields",
description="Store hashed passwords for optional email password login",
upgrade=_migration_0023_add_email_password_auth_fields,
),
]
+2
View File
@@ -29,6 +29,8 @@ class User(Base):
username = Column(String, nullable=True, index=True)
email = Column(String, nullable=True, unique=True, index=True)
email_verified_at = Column(DateTime(timezone=True), nullable=True)
password_hash = Column(String, nullable=True)
password_set_at = Column(DateTime(timezone=True), nullable=True)
telegram_id = Column(BigInteger, nullable=True, unique=True, index=True)
telegram_photo_url = Column(Text, nullable=True)
first_name = Column(String, nullable=True)