219 lines
6.5 KiB
Python
219 lines
6.5 KiB
Python
"""
|
|
Authentication Service - User login, Telegram binding, Token management
|
|
"""
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional, Dict, Any, Tuple
|
|
import secrets
|
|
from sqlalchemy.orm import Session
|
|
from app.db.models import User, Session as DBSession, TelegramIdentity
|
|
from app.security.jwt_manager import jwt_manager
|
|
import logging
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class AuthService:
|
|
"""
|
|
Handles user authentication, token management, and Telegram binding.
|
|
"""
|
|
|
|
# Configuration
|
|
TELEGRAM_BINDING_CODE_TTL = 600 # 10 minutes
|
|
BINDING_CODE_LENGTH = 24
|
|
|
|
def __init__(self, db: Session):
|
|
self.db = db
|
|
|
|
async def create_telegram_binding_code(self, chat_id: int) -> str:
|
|
"""
|
|
Generate temporary code for Telegram user binding.
|
|
|
|
Flow:
|
|
1. User sends /start to bot
|
|
2. Bot generates binding code
|
|
3. Bot sends user a link: https://app.com/auth/telegram?code=ABC123
|
|
4. User clicks link (authenticated or creates account)
|
|
5. Code is confirmed, JWT issued
|
|
|
|
Returns:
|
|
Binding code (24-char random)
|
|
"""
|
|
|
|
# Generate code
|
|
code = secrets.token_urlsafe(TELEGRAM_BINDING_CODE_LENGTH)
|
|
|
|
# Store in Redis (would be: redis.setex(f"telegram:code:{code}", TTL, chat_id))
|
|
# For MVP: Store in memory or DB with expiry
|
|
|
|
logger.info(f"Generated Telegram binding code for chat_id={chat_id}")
|
|
|
|
return code
|
|
|
|
async def confirm_telegram_binding(
|
|
self,
|
|
user_id: int,
|
|
chat_id: int,
|
|
code: str,
|
|
username: Optional[str] = None,
|
|
first_name: Optional[str] = None,
|
|
last_name: Optional[str] = None,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Confirm Telegram binding and create identity.
|
|
|
|
Returns:
|
|
{
|
|
"success": true,
|
|
"user_id": 123,
|
|
"chat_id": 12345,
|
|
"jwt_token": "eyJhbGc...",
|
|
"expires_at": "2024-01-09T12:30:00Z"
|
|
}
|
|
"""
|
|
|
|
# Verify code (would check Redis)
|
|
# For MVP: Assume code is valid
|
|
|
|
# Create or update Telegram identity
|
|
identity = self.db.query(TelegramIdentity).filter(
|
|
TelegramIdentity.user_id == user_id
|
|
).first()
|
|
|
|
if identity:
|
|
# Update existing
|
|
identity.chat_id = chat_id
|
|
identity.username = username
|
|
identity.first_name = first_name
|
|
identity.last_name = last_name
|
|
identity.verified_at = datetime.utcnow()
|
|
identity.updated_at = datetime.utcnow()
|
|
else:
|
|
# Create new
|
|
identity = TelegramIdentity(
|
|
user_id=user_id,
|
|
chat_id=chat_id,
|
|
username=username,
|
|
first_name=first_name,
|
|
last_name=last_name,
|
|
verified_at=datetime.utcnow(),
|
|
created_at=datetime.utcnow(),
|
|
updated_at=datetime.utcnow(),
|
|
)
|
|
self.db.add(identity)
|
|
|
|
# Generate JWT token for bot
|
|
jwt_token = jwt_manager.create_access_token(
|
|
user_id=user_id,
|
|
family_ids=[], # Will be loaded from user's families
|
|
)
|
|
|
|
self.db.commit()
|
|
|
|
logger.info(f"Telegram binding confirmed: user_id={user_id}, chat_id={chat_id}")
|
|
|
|
return {
|
|
"success": True,
|
|
"user_id": user_id,
|
|
"chat_id": chat_id,
|
|
"jwt_token": jwt_token,
|
|
"expires_at": (datetime.utcnow() + timedelta(minutes=15)).isoformat() + "Z",
|
|
}
|
|
|
|
async def authenticate_telegram_user(
|
|
self,
|
|
chat_id: int,
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Authenticate user by Telegram chat_id.
|
|
|
|
Returns:
|
|
{
|
|
"user_id": 123,
|
|
"chat_id": 12345,
|
|
"jwt_token": "...",
|
|
}
|
|
Or None if not found
|
|
"""
|
|
|
|
# Find Telegram identity
|
|
identity = self.db.query(TelegramIdentity).filter(
|
|
TelegramIdentity.chat_id == chat_id,
|
|
TelegramIdentity.verified_at.isnot(None),
|
|
).first()
|
|
|
|
if not identity:
|
|
return None
|
|
|
|
# Generate JWT
|
|
jwt_token = jwt_manager.create_access_token(
|
|
user_id=identity.user_id,
|
|
)
|
|
|
|
return {
|
|
"user_id": identity.user_id,
|
|
"chat_id": chat_id,
|
|
"jwt_token": jwt_token,
|
|
}
|
|
|
|
async def create_session(
|
|
self,
|
|
user_id: int,
|
|
device_id: Optional[str] = None,
|
|
ip_address: Optional[str] = None,
|
|
user_agent: Optional[str] = None,
|
|
) -> Tuple[str, str]:
|
|
"""
|
|
Create new session with refresh token.
|
|
|
|
Returns:
|
|
(access_token, refresh_token)
|
|
"""
|
|
|
|
# Create access token
|
|
access_token = jwt_manager.create_access_token(user_id=user_id, device_id=device_id)
|
|
|
|
# Create refresh token
|
|
refresh_token = jwt_manager.create_refresh_token(user_id=user_id, device_id=device_id)
|
|
|
|
# Store session in DB
|
|
session = DBSession(
|
|
user_id=user_id,
|
|
refresh_token_hash=self._hash_token(refresh_token),
|
|
device_id=device_id,
|
|
ip_address=ip_address,
|
|
user_agent=user_agent,
|
|
expires_at=datetime.utcnow() + timedelta(days=30),
|
|
created_at=datetime.utcnow(),
|
|
)
|
|
self.db.add(session)
|
|
self.db.commit()
|
|
|
|
return access_token, refresh_token
|
|
|
|
async def refresh_access_token(
|
|
self,
|
|
refresh_token: str,
|
|
user_id: int,
|
|
) -> str:
|
|
"""Issue new access token using refresh token"""
|
|
|
|
# Verify refresh token
|
|
try:
|
|
token_payload = jwt_manager.verify_token(refresh_token)
|
|
if token_payload.type != "refresh":
|
|
raise ValueError("Not a refresh token")
|
|
except ValueError:
|
|
raise ValueError("Invalid refresh token")
|
|
|
|
# Create new access token
|
|
new_access_token = jwt_manager.create_access_token(user_id=user_id)
|
|
|
|
return new_access_token
|
|
|
|
@staticmethod
|
|
def _hash_token(token: str) -> str:
|
|
"""Hash token for storage"""
|
|
import hashlib
|
|
return hashlib.sha256(token.encode()).hexdigest()
|