auth email/password

This commit is contained in:
JSC
2025-06-28 18:30:30 +02:00
parent 8e2dbd8723
commit ceafed9108
25 changed files with 1694 additions and 314 deletions

View File

@@ -1,21 +1,29 @@
"""Authentication service for Google OAuth."""
"""Authentication service for multiple OAuth providers."""
import os
from typing import Any
from authlib.integrations.flask_client import OAuth
from flask import Flask, make_response, request
from flask import Flask, jsonify
from flask_jwt_extended import (
get_jwt_identity,
jwt_required,
set_access_cookies,
set_refresh_cookies,
unset_jwt_cookies,
)
from app.models.user import User
from app.services.oauth_providers.registry import OAuthProviderRegistry
from app.services.token_service import TokenService
class AuthService:
"""Service for handling Google OAuth authentication."""
"""Service for handling multiple OAuth providers authentication."""
def __init__(self, app: Flask | None = None) -> None:
"""Initialize the authentication service."""
self.oauth = OAuth()
self.google = None
self.provider_registry = None
self.token_service = TokenService()
if app:
self.init_app(app)
@@ -24,120 +32,219 @@ class AuthService:
"""Initialize the service with Flask app."""
self.oauth.init_app(app)
# Configure Google OAuth
self.google = self.oauth.register(
name="google",
client_id=os.getenv("GOOGLE_CLIENT_ID"),
client_secret=os.getenv("GOOGLE_CLIENT_SECRET"),
server_metadata_url="https://accounts.google.com/.well-known/openid_configuration",
client_kwargs={"scope": "openid email profile"},
)
# Initialize provider registry
self.provider_registry = OAuthProviderRegistry(self.oauth)
def get_login_url(self, redirect_uri: str) -> str:
"""Generate Google OAuth login URL."""
if not self.google:
msg = "Google OAuth not configured"
def redirect_to_login(self, provider_name: str, redirect_uri: str):
"""Redirect to OAuth provider login."""
provider = self.provider_registry.get_provider(provider_name)
if not provider:
msg = f"OAuth provider '{provider_name}' not configured"
raise RuntimeError(msg)
return self.google.authorize_redirect(redirect_uri).location
def handle_callback(self) -> tuple[dict[str, Any], Any]:
client = provider.get_client()
return client.authorize_redirect(redirect_uri)
def get_login_url(self, provider_name: str, redirect_uri: str) -> str:
"""Generate OAuth provider login URL (for testing or manual use)."""
provider = self.provider_registry.get_provider(provider_name)
if not provider:
msg = f"OAuth provider '{provider_name}' not configured"
raise RuntimeError(msg)
return provider.get_authorization_url(redirect_uri)
def handle_callback(self, provider_name: str) -> Any:
"""Handle OAuth callback and exchange code for token."""
if not self.google:
msg = "Google OAuth not configured"
provider = self.provider_registry.get_provider(provider_name)
if not provider:
msg = f"OAuth provider '{provider_name}' not configured"
raise RuntimeError(msg)
token = self.google.authorize_access_token()
user_info = token.get("userinfo")
token = provider.exchange_code_for_token(None, None)
raw_user_info = provider.get_user_info(token)
user_data = provider.normalize_user_data(raw_user_info)
if user_info:
user_data = {
"id": user_info["sub"],
"email": user_info["email"],
"name": user_info["name"],
"picture": user_info.get("picture"),
if user_data and user_data.get("id"):
# Find or create user in database
user, oauth_provider = User.find_or_create_from_oauth(
provider=provider_name,
provider_id=user_data["id"],
email=user_data["email"],
name=user_data["name"],
picture=user_data.get("picture"),
)
# Check if user account is active
if not user.is_active:
response = jsonify({"error": "Account is disabled"})
response.status_code = 401
return response
# Prepare user data for JWT token
jwt_user_data = {
"id": str(user.id),
"email": user.email,
"name": user.name,
"picture": user.picture,
"role": user.role,
"is_active": user.is_active,
"provider": oauth_provider.provider,
"providers": [p.provider for p in user.oauth_providers],
}
# Generate JWT tokens
access_token = self.token_service.generate_access_token(user_data)
refresh_token = self.token_service.generate_refresh_token(user_data)
access_token = self.token_service.generate_access_token(
jwt_user_data
)
refresh_token = self.token_service.generate_refresh_token(
jwt_user_data
)
# Create response and set HTTP-only cookies
response = make_response({
"message": "Login successful",
"user": user_data,
})
response.set_cookie(
"access_token",
access_token,
httponly=True,
secure=True,
samesite="Lax",
max_age=15 * 60, # 15 minutes
response = jsonify(
{
"message": "Login successful",
"user": jwt_user_data,
}
)
response.set_cookie(
"refresh_token",
refresh_token,
httponly=True,
secure=True,
samesite="Lax",
max_age=7 * 24 * 60 * 60, # 7 days
)
# Set JWT cookies
set_access_cookies(response, access_token)
set_refresh_cookies(response, refresh_token)
return user_data, response
return response
msg = "Failed to get user information from Google"
msg = f"Failed to get user information from {provider.display_name}"
raise ValueError(msg)
def get_current_user(self) -> dict[str, Any] | None:
"""Get current user from access token."""
access_token = request.cookies.get("access_token")
if not access_token:
return None
def get_available_providers(self) -> dict[str, Any]:
"""Get list of available OAuth providers."""
if not self.provider_registry:
return {}
return self.token_service.get_user_from_access_token(access_token)
def refresh_tokens(self) -> Any:
"""Refresh access token using refresh token."""
refresh_token = request.cookies.get("refresh_token")
if not refresh_token:
return None
payload = self.token_service.verify_token(refresh_token)
if not payload or not self.token_service.is_refresh_token(payload):
return None
# For refresh, we need to get user data (in a real app, from database)
# For now, we'll extract what we can from the refresh token
user_data = {
"id": payload["user_id"],
"email": "", # Would need to fetch from database
"name": "", # Would need to fetch from database
providers = self.provider_registry.get_available_providers()
return {
name: {"name": provider.name, "display_name": provider.display_name}
for name, provider in providers.items()
}
# Generate new access token
new_access_token = self.token_service.generate_access_token(user_data)
@jwt_required()
def get_current_user(self) -> dict[str, Any] | None:
"""Get current user from JWT token."""
from flask_jwt_extended import get_jwt
response = make_response({"message": "Token refreshed"})
response.set_cookie(
"access_token",
new_access_token,
httponly=True,
secure=True,
samesite="Lax",
max_age=15 * 60, # 15 minutes
current_user_id = get_jwt_identity()
claims = get_jwt()
if current_user_id:
return {
"id": current_user_id,
"email": claims.get("email", ""),
"name": claims.get("name", ""),
"picture": claims.get("picture"),
"role": claims.get("role", "user"),
"is_active": claims.get("is_active", True),
"provider": claims.get("provider", "unknown"),
"providers": claims.get("providers", []),
}
return None
def register_with_password(
self, email: str, password: str, name: str
) -> Any:
"""Register new user with email and password."""
try:
# Create user with password
user = User.create_with_password(email, password, name)
# Prepare user data for JWT token
jwt_user_data = {
"id": str(user.id),
"email": user.email,
"name": user.name,
"picture": user.picture,
"role": user.role,
"is_active": user.is_active,
"provider": "password",
"providers": ["password"],
}
# Generate JWT tokens
access_token = self.token_service.generate_access_token(
jwt_user_data
)
refresh_token = self.token_service.generate_refresh_token(
jwt_user_data
)
# Create response and set HTTP-only cookies
response = jsonify(
{
"message": "Registration successful",
"user": jwt_user_data,
}
)
# Set JWT cookies
set_access_cookies(response, access_token)
set_refresh_cookies(response, refresh_token)
return response
except ValueError as e:
response = jsonify({"error": str(e)})
response.status_code = 400
return response
def login_with_password(self, email: str, password: str) -> Any:
"""Login user with email and password."""
# Authenticate user
user = User.authenticate_with_password(email, password)
if not user:
response = jsonify(
{"error": "Invalid email, password or disabled account"}
)
response.status_code = 401
return response
# Prepare user data for JWT token
oauth_providers = [p.provider for p in user.oauth_providers]
if user.has_password():
oauth_providers.append("password")
jwt_user_data = {
"id": str(user.id),
"email": user.email,
"name": user.name,
"picture": user.picture,
"role": user.role,
"is_active": user.is_active,
"provider": "password",
"providers": oauth_providers,
}
# Generate JWT tokens
access_token = self.token_service.generate_access_token(jwt_user_data)
refresh_token = self.token_service.generate_refresh_token(jwt_user_data)
# Create response and set HTTP-only cookies
response = jsonify(
{
"message": "Login successful",
"user": jwt_user_data,
}
)
# Set JWT cookies
set_access_cookies(response, access_token)
set_refresh_cookies(response, refresh_token)
return response
def logout(self) -> Any:
"""Clear authentication cookies."""
response = make_response({"message": "Logged out successfully"})
response.set_cookie("access_token", "", expires=0)
response.set_cookie("refresh_token", "", expires=0)
response = jsonify({"message": "Logged out successfully"})
unset_jwt_cookies(response)
return response
def is_authenticated(self) -> bool:
"""Check if user is authenticated."""
return self.get_current_user() is not None

View File

@@ -1,37 +1,148 @@
"""Authentication decorators and middleware."""
from functools import wraps
from typing import Any, Callable
from typing import Any
from flask import jsonify, request
from app.services.token_service import TokenService
from flask_jwt_extended import get_jwt, get_jwt_identity, jwt_required
def require_auth(f: Callable[..., Any]) -> Callable[..., Any]:
def require_auth(f):
"""Decorator to require authentication for routes."""
@wraps(f)
def decorated_function(*args: Any, **kwargs: Any) -> Any:
token_service = TokenService()
access_token = request.cookies.get("access_token")
if not access_token:
return jsonify({"error": "Authentication required"}), 401
user_data = token_service.get_user_from_access_token(access_token)
if not user_data:
return jsonify({"error": "Invalid or expired token"}), 401
return f(*args, **kwargs)
return decorated_function
return jwt_required()(f)
def get_current_user() -> dict[str, Any] | None:
"""Helper function to get current user from access token."""
token_service = TokenService()
access_token = request.cookies.get("access_token")
if not access_token:
"""Helper function to get current user from JWT token."""
try:
current_user_id = get_jwt_identity()
if not current_user_id:
return None
claims = get_jwt()
is_active = claims.get("is_active", True)
# Check if user is active
if not is_active:
return None
return {
"id": current_user_id,
"email": claims.get("email", ""),
"name": claims.get("name", ""),
"picture": claims.get("picture"),
"role": claims.get("role", "user"),
"is_active": is_active,
"provider": claims.get("provider", "unknown"),
"providers": claims.get("providers", []),
}
except Exception:
return None
return token_service.get_user_from_access_token(access_token)
def require_role(required_role: str):
"""Decorator to require specific role for routes."""
def decorator(f):
@wraps(f)
@jwt_required()
def wrapper(*args, **kwargs):
user = get_current_user()
if not user:
return jsonify({"error": "Authentication required"}), 401
if user.get("role") != required_role:
return jsonify({"error": f"Access denied. {required_role.title()} role required"}), 403
return f(*args, **kwargs)
return wrapper
return decorator
def require_admin(f):
"""Decorator to require admin role for routes."""
return require_role("admin")(f)
def require_user_or_admin(f):
"""Decorator to require user or admin role for routes."""
@wraps(f)
@jwt_required()
def wrapper(*args, **kwargs):
user = get_current_user()
if not user:
return jsonify({"error": "Authentication required"}), 401
if user.get("role") not in ["user", "admin"]:
return jsonify({"error": "Access denied"}), 403
return f(*args, **kwargs)
return wrapper
def get_user_from_api_token() -> dict[str, Any] | None:
"""Get user from API token in request headers."""
try:
# Check for API token in Authorization header
auth_header = request.headers.get("Authorization")
if not auth_header:
return None
# Expected format: "Bearer <token>" or "Token <token>"
parts = auth_header.split()
if len(parts) != 2 or parts[0].lower() not in ["bearer", "token"]:
return None
api_token = parts[1]
# Import here to avoid circular imports
from app.models.user import User
user = User.find_by_api_token(api_token)
if user and user.is_active:
return {
"id": str(user.id),
"email": user.email,
"name": user.name,
"picture": user.picture,
"role": user.role,
"is_active": user.is_active,
"provider": "api_token",
"providers": [p.provider for p in user.oauth_providers] + ["api_token"],
}
return None
except Exception:
return None
def require_api_token(f):
"""Decorator to require API token authentication for routes."""
@wraps(f)
def wrapper(*args, **kwargs):
user = get_user_from_api_token()
if not user:
return jsonify({"error": "Valid API token required"}), 401
return f(*args, **kwargs)
return wrapper
def require_auth_or_api_token(f):
"""Decorator to accept either JWT or API token authentication."""
@wraps(f)
def wrapper(*args, **kwargs):
# Try JWT authentication first
try:
user = get_current_user()
if user:
return f(*args, **kwargs)
except Exception:
pass
# Try API token authentication
user = get_user_from_api_token()
if user:
return f(*args, **kwargs)
return jsonify({"error": "Authentication required (JWT or API token)"}), 401
return wrapper

View File

View File

@@ -0,0 +1,68 @@
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional
from authlib.integrations.flask_client import OAuth
class OAuthProvider(ABC):
"""Abstract base class for OAuth providers."""
def __init__(self, oauth: OAuth, client_id: str, client_secret: str):
self.oauth = oauth
self.client_id = client_id
self.client_secret = client_secret
self._client = None
@property
@abstractmethod
def name(self) -> str:
"""Provider name (e.g., 'google', 'github')."""
pass
@property
@abstractmethod
def display_name(self) -> str:
"""Human-readable provider name (e.g., 'Google', 'GitHub')."""
pass
@abstractmethod
def get_client_config(self) -> Dict[str, Any]:
"""Return OAuth client configuration."""
pass
@abstractmethod
def get_user_info(self, token: Dict[str, Any]) -> Dict[str, Any]:
"""Extract user information from OAuth token response."""
pass
def get_client(self):
"""Get or create OAuth client."""
if self._client is None:
config = self.get_client_config()
self._client = self.oauth.register(
name=self.name,
client_id=self.client_id,
client_secret=self.client_secret,
**config
)
return self._client
def get_authorization_url(self, redirect_uri: str) -> str:
"""Generate authorization URL for OAuth flow."""
client = self.get_client()
return client.authorize_redirect(redirect_uri).location
def exchange_code_for_token(self, code: str = None, redirect_uri: str = None) -> Dict[str, Any]:
"""Exchange authorization code for access token."""
client = self.get_client()
token = client.authorize_access_token()
return token
def normalize_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]:
"""Normalize user data to common format."""
return {
'id': user_info.get('id'),
'email': user_info.get('email'),
'name': user_info.get('name'),
'picture': user_info.get('picture'),
'provider': self.name
}

View File

@@ -0,0 +1,52 @@
from typing import Dict, Any
from .base import OAuthProvider
class GitHubOAuthProvider(OAuthProvider):
"""GitHub OAuth provider implementation."""
@property
def name(self) -> str:
return 'github'
@property
def display_name(self) -> str:
return 'GitHub'
def get_client_config(self) -> Dict[str, Any]:
"""Return GitHub OAuth client configuration."""
return {
'access_token_url': 'https://github.com/login/oauth/access_token',
'authorize_url': 'https://github.com/login/oauth/authorize',
'api_base_url': 'https://api.github.com/',
'client_kwargs': {
'scope': 'user:email'
}
}
def get_user_info(self, token: Dict[str, Any]) -> Dict[str, Any]:
"""Extract user information from GitHub OAuth token response."""
client = self.get_client()
# Get user profile
user_resp = client.get('user', token=token)
user_data = user_resp.json()
# Get user email (may be private)
email = user_data.get('email')
if not email:
# If email is private, get from emails endpoint
emails_resp = client.get('user/emails', token=token)
emails = emails_resp.json()
# Find primary email
for email_obj in emails:
if email_obj.get('primary', False):
email = email_obj.get('email')
break
return {
'id': str(user_data.get('id')),
'email': email,
'name': user_data.get('name') or user_data.get('login'),
'picture': user_data.get('avatar_url')
}

View File

@@ -0,0 +1,34 @@
from typing import Any, Dict
from .base import OAuthProvider
class GoogleOAuthProvider(OAuthProvider):
"""Google OAuth provider implementation."""
@property
def name(self) -> str:
return "google"
@property
def display_name(self) -> str:
return "Google"
def get_client_config(self) -> Dict[str, Any]:
"""Return Google OAuth client configuration."""
return {
"server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration",
"client_kwargs": {"scope": "openid email profile"},
}
def get_user_info(self, token: Dict[str, Any]) -> Dict[str, Any]:
"""Extract user information from Google OAuth token response."""
client = self.get_client()
user_info = client.userinfo(token=token)
return {
"id": user_info.get("sub"),
"email": user_info.get("email"),
"name": user_info.get("name"),
"picture": user_info.get("picture"),
}

View File

@@ -0,0 +1,45 @@
import os
from typing import Dict, Optional
from authlib.integrations.flask_client import OAuth
from .base import OAuthProvider
from .google import GoogleOAuthProvider
from .github import GitHubOAuthProvider
class OAuthProviderRegistry:
"""Registry for OAuth providers."""
def __init__(self, oauth: OAuth):
self.oauth = oauth
self._providers: Dict[str, OAuthProvider] = {}
self._initialize_providers()
def _initialize_providers(self):
"""Initialize available providers based on environment variables."""
# Google OAuth
google_client_id = os.getenv('GOOGLE_CLIENT_ID')
google_client_secret = os.getenv('GOOGLE_CLIENT_SECRET')
if google_client_id and google_client_secret:
self._providers['google'] = GoogleOAuthProvider(
self.oauth, google_client_id, google_client_secret
)
# GitHub OAuth
github_client_id = os.getenv('GITHUB_CLIENT_ID')
github_client_secret = os.getenv('GITHUB_CLIENT_SECRET')
if github_client_id and github_client_secret:
self._providers['github'] = GitHubOAuthProvider(
self.oauth, github_client_id, github_client_secret
)
def get_provider(self, name: str) -> Optional[OAuthProvider]:
"""Get OAuth provider by name."""
return self._providers.get(name)
def get_available_providers(self) -> Dict[str, OAuthProvider]:
"""Get all available providers."""
return self._providers.copy()
def is_provider_available(self, name: str) -> bool:
"""Check if provider is available."""
return name in self._providers

View File

@@ -1,75 +1,24 @@
"""JWT token service for handling access and refresh tokens."""
"""JWT token service using Flask-JWT-Extended."""
import os
from datetime import datetime, timedelta, timezone
from typing import Any
import jwt
from flask_jwt_extended import create_access_token, create_refresh_token
class TokenService:
"""Service for handling JWT tokens."""
def __init__(self) -> None:
"""Initialize the token service."""
self.secret_key = os.environ.get("JWT_SECRET_KEY", "jwt-secret-key")
self.algorithm = "HS256"
self.access_token_expire_minutes = 15
self.refresh_token_expire_days = 7
"""Service for handling JWT tokens using Flask-JWT-Extended."""
def generate_access_token(self, user_data: dict[str, Any]) -> str:
"""Generate an access token for the user."""
payload = {
"user_id": user_data["id"],
"email": user_data["email"],
"name": user_data["name"],
"type": "access",
"exp": datetime.now(timezone.utc) + timedelta(
minutes=self.access_token_expire_minutes
),
"iat": datetime.now(timezone.utc),
}
return jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
return create_access_token(
identity=user_data["id"],
additional_claims={
"email": user_data["email"],
"name": user_data["name"],
"picture": user_data.get("picture"),
},
)
def generate_refresh_token(self, user_data: dict[str, Any]) -> str:
"""Generate a refresh token for the user."""
payload = {
"user_id": user_data["id"],
"type": "refresh",
"exp": datetime.now(timezone.utc) + timedelta(
days=self.refresh_token_expire_days
),
"iat": datetime.now(timezone.utc),
}
return jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
def verify_token(self, token: str) -> dict[str, Any] | None:
"""Verify and decode a JWT token."""
try:
payload = jwt.decode(
token, self.secret_key, algorithms=[self.algorithm]
)
return payload
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
def is_access_token(self, payload: dict[str, Any]) -> bool:
"""Check if the token payload is for an access token."""
return payload.get("type") == "access"
def is_refresh_token(self, payload: dict[str, Any]) -> bool:
"""Check if the token payload is for a refresh token."""
return payload.get("type") == "refresh"
def get_user_from_access_token(self, token: str) -> dict[str, Any] | None:
"""Extract user data from access token."""
payload = self.verify_token(token)
if payload and self.is_access_token(payload):
return {
"id": payload["user_id"],
"email": payload["email"],
"name": payload["name"],
}
return None
return create_refresh_token(identity=user_data["id"])