auth email/password
This commit is contained in:
19
.env.example
Normal file
19
.env.example
Normal file
@@ -0,0 +1,19 @@
|
||||
# Flask Configuration
|
||||
SECRET_KEY=your_flask_secret_key_here
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET_KEY=your_jwt_secret_key_here
|
||||
|
||||
# Database Configuration
|
||||
DATABASE_URL=sqlite:///soundboard.db
|
||||
|
||||
# OAuth Providers Configuration
|
||||
# Configure the providers you want to support by setting their client credentials
|
||||
|
||||
# Google OAuth
|
||||
GOOGLE_CLIENT_ID=your_google_client_id_here
|
||||
GOOGLE_CLIENT_SECRET=your_google_client_secret_here
|
||||
|
||||
# GitHub OAuth
|
||||
GITHUB_CLIENT_ID=your_github_client_id_here
|
||||
GITHUB_CLIENT_SECRET=your_github_client_secret_here
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -5,6 +5,11 @@ build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
.pytest_cache/
|
||||
instance/
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
.venv/
|
||||
|
||||
# Env
|
||||
.env
|
||||
@@ -1,19 +1,46 @@
|
||||
import os
|
||||
from datetime import timedelta
|
||||
|
||||
from flask import Flask
|
||||
from flask_jwt_extended import JWTManager
|
||||
|
||||
from app.services.auth_service import AuthService
|
||||
from app.database import init_db
|
||||
|
||||
# Global auth service instance
|
||||
auth_service = AuthService()
|
||||
|
||||
|
||||
def create_app():
|
||||
"""Create and configure the Flask application."""
|
||||
app = Flask(__name__)
|
||||
|
||||
# Configure session
|
||||
# Configure Flask secret key (required for sessions used by OAuth)
|
||||
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-key")
|
||||
|
||||
# Initialize authentication service
|
||||
auth_service = AuthService(app)
|
||||
# Configure SQLAlchemy database
|
||||
database_url = os.environ.get("DATABASE_URL", "sqlite:///soundboard.db")
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = database_url
|
||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
|
||||
# Configure Flask-JWT-Extended
|
||||
app.config["JWT_SECRET_KEY"] = os.environ.get("JWT_SECRET_KEY", "jwt-secret-key")
|
||||
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(minutes=15)
|
||||
app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=7)
|
||||
app.config["JWT_TOKEN_LOCATION"] = ["cookies"]
|
||||
app.config["JWT_COOKIE_SECURE"] = False # Set to True in production
|
||||
app.config["JWT_COOKIE_CSRF_PROTECT"] = False
|
||||
app.config["JWT_ACCESS_COOKIE_PATH"] = "/api/"
|
||||
app.config["JWT_REFRESH_COOKIE_PATH"] = "/api/auth/refresh"
|
||||
|
||||
# Initialize JWT manager
|
||||
jwt = JWTManager(app)
|
||||
|
||||
# Initialize database
|
||||
init_db(app)
|
||||
|
||||
# Initialize authentication service with app
|
||||
auth_service.init_app(app)
|
||||
|
||||
# Register blueprints
|
||||
from app.routes import main, auth
|
||||
|
||||
18
app/database.py
Normal file
18
app/database.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Database configuration and initialization."""
|
||||
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
|
||||
|
||||
def init_db(app):
|
||||
"""Initialize database with Flask app."""
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
|
||||
# Import models here to ensure they are registered with SQLAlchemy
|
||||
from app.models import user, user_oauth # noqa: F401
|
||||
|
||||
return db
|
||||
6
app/models/__init__.py
Normal file
6
app/models/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Database models."""
|
||||
|
||||
from .user import User
|
||||
from .user_oauth import UserOAuth
|
||||
|
||||
__all__ = ["User", "UserOAuth"]
|
||||
240
app/models/user.py
Normal file
240
app/models/user.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""User model for authentication."""
|
||||
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
from sqlalchemy import String, DateTime
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import db
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user_oauth import UserOAuth
|
||||
|
||||
|
||||
class User(db.Model):
|
||||
"""User model for storing user information."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
# Primary user information (can be updated from any connected provider)
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
picture: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Password authentication (optional - users can use OAuth instead)
|
||||
password_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# Role-based access control
|
||||
role: Mapped[str] = mapped_column(String(50), nullable=False, default="user")
|
||||
|
||||
# User status
|
||||
is_active: Mapped[bool] = mapped_column(nullable=False, default=True)
|
||||
|
||||
# API token for programmatic access
|
||||
api_token: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
api_token_expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||
)
|
||||
|
||||
# Relationships
|
||||
oauth_providers: Mapped[list["UserOAuth"]] = relationship(
|
||||
"UserOAuth", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of User."""
|
||||
provider_count = len(self.oauth_providers)
|
||||
return f"<User {self.email} ({provider_count} providers)>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert user to dictionary."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"email": self.email,
|
||||
"name": self.name,
|
||||
"picture": self.picture,
|
||||
"role": self.role,
|
||||
"is_active": self.is_active,
|
||||
"api_token": self.api_token,
|
||||
"api_token_expires_at": self.api_token_expires_at.isoformat() if self.api_token_expires_at else None,
|
||||
"providers": [provider.provider for provider in self.oauth_providers],
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
def get_provider(self, provider_name: str) -> Optional["UserOAuth"]:
|
||||
"""Get specific OAuth provider for this user."""
|
||||
for provider in self.oauth_providers:
|
||||
if provider.provider == provider_name:
|
||||
return provider
|
||||
return None
|
||||
|
||||
def has_provider(self, provider_name: str) -> bool:
|
||||
"""Check if user has specific OAuth provider connected."""
|
||||
return self.get_provider(provider_name) is not None
|
||||
|
||||
def update_from_provider(self, provider_data: dict) -> None:
|
||||
"""Update user info from provider data (email, name, picture)."""
|
||||
self.email = provider_data.get("email", self.email)
|
||||
self.name = provider_data.get("name", self.name)
|
||||
self.picture = provider_data.get("picture", self.picture)
|
||||
self.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
def set_password(self, password: str) -> None:
|
||||
"""Hash and set user password."""
|
||||
self.password_hash = generate_password_hash(password)
|
||||
self.updated_at = datetime.utcnow()
|
||||
|
||||
def check_password(self, password: str) -> bool:
|
||||
"""Check if provided password matches user's password."""
|
||||
if not self.password_hash:
|
||||
return False
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
def has_password(self) -> bool:
|
||||
"""Check if user has a password set."""
|
||||
return self.password_hash is not None
|
||||
|
||||
def generate_api_token(self) -> str:
|
||||
"""Generate a new API token for the user."""
|
||||
self.api_token = secrets.token_urlsafe(32)
|
||||
self.api_token_expires_at = None # No expiration by default
|
||||
self.updated_at = datetime.utcnow()
|
||||
return self.api_token
|
||||
|
||||
def is_api_token_valid(self) -> bool:
|
||||
"""Check if the user's API token is valid (exists and not expired)."""
|
||||
if not self.api_token:
|
||||
return False
|
||||
|
||||
if self.api_token_expires_at is None:
|
||||
return True # No expiration
|
||||
|
||||
return datetime.utcnow() < self.api_token_expires_at
|
||||
|
||||
def revoke_api_token(self) -> None:
|
||||
"""Revoke the user's API token."""
|
||||
self.api_token = None
|
||||
self.api_token_expires_at = None
|
||||
self.updated_at = datetime.utcnow()
|
||||
|
||||
def activate(self) -> None:
|
||||
"""Activate the user account."""
|
||||
self.is_active = True
|
||||
self.updated_at = datetime.utcnow()
|
||||
|
||||
def deactivate(self) -> None:
|
||||
"""Deactivate the user account."""
|
||||
self.is_active = False
|
||||
self.updated_at = datetime.utcnow()
|
||||
|
||||
@classmethod
|
||||
def find_by_email(cls, email: str) -> Optional["User"]:
|
||||
"""Find user by email address."""
|
||||
return cls.query.filter_by(email=email).first()
|
||||
|
||||
@classmethod
|
||||
def find_by_api_token(cls, api_token: str) -> Optional["User"]:
|
||||
"""Find user by API token if token is valid."""
|
||||
user = cls.query.filter_by(api_token=api_token).first()
|
||||
if user and user.is_api_token_valid():
|
||||
return user
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def find_or_create_from_oauth(
|
||||
cls, provider: str, provider_id: str, email: str, name: str, picture: Optional[str] = None
|
||||
) -> tuple["User", "UserOAuth"]:
|
||||
"""Find existing user or create new one from OAuth data."""
|
||||
from app.models.user_oauth import UserOAuth
|
||||
|
||||
# First, try to find existing OAuth provider
|
||||
oauth_provider = UserOAuth.find_by_provider_and_id(provider, provider_id)
|
||||
|
||||
if oauth_provider:
|
||||
# Update existing provider and user info
|
||||
user = oauth_provider.user
|
||||
oauth_provider.email = email
|
||||
oauth_provider.name = name
|
||||
oauth_provider.picture = picture
|
||||
oauth_provider.updated_at = datetime.utcnow()
|
||||
|
||||
# Update user info with latest data
|
||||
user.update_from_provider({"email": email, "name": name, "picture": picture})
|
||||
else:
|
||||
# Try to find user by email to link the new provider
|
||||
user = cls.find_by_email(email)
|
||||
|
||||
if not user:
|
||||
# Check if this is the first user (admin)
|
||||
user_count = cls.query.count()
|
||||
role = "admin" if user_count == 0 else "user"
|
||||
|
||||
# Create new user
|
||||
user = cls(
|
||||
email=email,
|
||||
name=name,
|
||||
picture=picture,
|
||||
role=role,
|
||||
)
|
||||
user.generate_api_token() # Generate API token on creation
|
||||
db.session.add(user)
|
||||
db.session.flush() # Flush to get user.id
|
||||
|
||||
# Create new OAuth provider
|
||||
oauth_provider = UserOAuth.create_or_update(
|
||||
user_id=user.id,
|
||||
provider=provider,
|
||||
provider_id=provider_id,
|
||||
email=email,
|
||||
name=name,
|
||||
picture=picture,
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
return user, oauth_provider
|
||||
|
||||
@classmethod
|
||||
def create_with_password(cls, email: str, password: str, name: str) -> "User":
|
||||
"""Create new user with email and password."""
|
||||
# Check if user already exists
|
||||
existing_user = cls.find_by_email(email)
|
||||
if existing_user:
|
||||
raise ValueError("User with this email already exists")
|
||||
|
||||
# Check if this is the first user (admin)
|
||||
user_count = cls.query.count()
|
||||
role = "admin" if user_count == 0 else "user"
|
||||
|
||||
# Create new user
|
||||
user = cls(
|
||||
email=email,
|
||||
name=name,
|
||||
role=role,
|
||||
)
|
||||
user.set_password(password)
|
||||
user.generate_api_token() # Generate API token on creation
|
||||
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user
|
||||
|
||||
@classmethod
|
||||
def authenticate_with_password(cls, email: str, password: str) -> Optional["User"]:
|
||||
"""Authenticate user with email and password."""
|
||||
user = cls.find_by_email(email)
|
||||
if user and user.check_password(password) and user.is_active:
|
||||
return user
|
||||
return None
|
||||
105
app/models/user_oauth.py
Normal file
105
app/models/user_oauth.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""User OAuth model for storing user's connected providers."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String, DateTime, Text, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import db
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class UserOAuth(db.Model):
|
||||
"""Model for storing user's connected OAuth providers."""
|
||||
|
||||
__tablename__ = "user_oauth"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
# User relationship
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
|
||||
# OAuth provider information
|
||||
provider: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
provider_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
|
||||
# Provider-specific user information
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
picture: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||
)
|
||||
|
||||
# Unique constraint on provider + provider_id combination
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint("provider", "provider_id", name="unique_provider_user"),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship("User", back_populates="oauth_providers")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of UserOAuth."""
|
||||
return f"<UserOAuth {self.email} ({self.provider})>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert oauth provider to dictionary."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"provider": self.provider,
|
||||
"provider_id": self.provider_id,
|
||||
"email": self.email,
|
||||
"name": self.name,
|
||||
"picture": self.picture,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def find_by_provider_and_id(cls, provider: str, provider_id: str) -> Optional["UserOAuth"]:
|
||||
"""Find OAuth provider by provider name and provider ID."""
|
||||
return cls.query.filter_by(provider=provider, provider_id=provider_id).first()
|
||||
|
||||
@classmethod
|
||||
def create_or_update(
|
||||
cls,
|
||||
user_id: int,
|
||||
provider: str,
|
||||
provider_id: str,
|
||||
email: str,
|
||||
name: str,
|
||||
picture: Optional[str] = None
|
||||
) -> "UserOAuth":
|
||||
"""Create new OAuth provider or update existing one."""
|
||||
oauth_provider = cls.find_by_provider_and_id(provider, provider_id)
|
||||
|
||||
if oauth_provider:
|
||||
# Update existing provider
|
||||
oauth_provider.user_id = user_id
|
||||
oauth_provider.email = email
|
||||
oauth_provider.name = name
|
||||
oauth_provider.picture = picture
|
||||
oauth_provider.updated_at = datetime.utcnow()
|
||||
else:
|
||||
# Create new provider
|
||||
oauth_provider = cls(
|
||||
user_id=user_id,
|
||||
provider=provider,
|
||||
provider_id=provider_id,
|
||||
email=email,
|
||||
name=name,
|
||||
picture=picture,
|
||||
)
|
||||
db.session.add(oauth_provider)
|
||||
|
||||
db.session.commit()
|
||||
return oauth_provider
|
||||
@@ -1,31 +1,54 @@
|
||||
"""Authentication routes."""
|
||||
|
||||
from flask import Blueprint, url_for
|
||||
from flask import Blueprint, jsonify, url_for
|
||||
from flask_jwt_extended import create_access_token, get_jwt_identity, jwt_required
|
||||
|
||||
from app.services.auth_service import AuthService
|
||||
from app import auth_service
|
||||
from app.services.decorators import get_current_user
|
||||
|
||||
bp = Blueprint("auth", __name__)
|
||||
auth_service = AuthService()
|
||||
|
||||
|
||||
@bp.route("/login")
|
||||
def login() -> dict[str, str]:
|
||||
"""Initiate Google OAuth login."""
|
||||
redirect_uri = url_for("auth.callback", _external=True)
|
||||
login_url = auth_service.get_login_url(redirect_uri)
|
||||
return {"login_url": login_url}
|
||||
@bp.route("/login/<provider>")
|
||||
def login_oauth(provider):
|
||||
"""Initiate OAuth login for specified provider."""
|
||||
redirect_uri = url_for("auth.callback", provider=provider, _external=True)
|
||||
return auth_service.redirect_to_login(provider, redirect_uri)
|
||||
|
||||
|
||||
@bp.route("/callback")
|
||||
def callback():
|
||||
"""Handle OAuth callback from Google."""
|
||||
@bp.route("/callback/<provider>")
|
||||
def callback(provider):
|
||||
"""Handle OAuth callback from specified provider."""
|
||||
try:
|
||||
user_data, response = auth_service.handle_callback()
|
||||
return response
|
||||
return auth_service.handle_callback(provider)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}, 400
|
||||
|
||||
|
||||
@bp.route("/providers")
|
||||
def providers():
|
||||
"""Get list of available OAuth providers."""
|
||||
return {"providers": auth_service.get_available_providers()}
|
||||
|
||||
|
||||
@bp.route("/login", methods=["POST"])
|
||||
def login():
|
||||
"""Login user with email and password."""
|
||||
from flask import request
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return {"error": "No data provided"}, 400
|
||||
|
||||
email = data.get("email")
|
||||
password = data.get("password")
|
||||
|
||||
if not email or not password:
|
||||
return {"error": "Email and password are required"}, 400
|
||||
|
||||
return auth_service.login_with_password(email, password)
|
||||
|
||||
|
||||
@bp.route("/logout")
|
||||
def logout():
|
||||
"""Logout current user."""
|
||||
@@ -33,20 +56,182 @@ def logout():
|
||||
|
||||
|
||||
@bp.route("/me")
|
||||
def me() -> dict[str, str] | tuple[dict[str, str], int]:
|
||||
@jwt_required()
|
||||
def me():
|
||||
"""Get current user information."""
|
||||
user = auth_service.get_current_user()
|
||||
if not user:
|
||||
return {"error": "Not authenticated"}, 401
|
||||
|
||||
user = get_current_user()
|
||||
return {"user": user}
|
||||
|
||||
|
||||
@bp.route("/refresh")
|
||||
@bp.route("/refresh", methods=["POST"])
|
||||
@jwt_required(refresh=True)
|
||||
def refresh():
|
||||
"""Refresh access token using refresh token."""
|
||||
response = auth_service.refresh_tokens()
|
||||
if not response:
|
||||
return {"error": "Invalid or expired refresh token"}, 401
|
||||
current_user_id = get_jwt_identity()
|
||||
|
||||
# Create new access token
|
||||
new_access_token = create_access_token(identity=current_user_id)
|
||||
|
||||
response = jsonify({"message": "Token refreshed"})
|
||||
|
||||
# Set new access token cookie
|
||||
from flask_jwt_extended import set_access_cookies
|
||||
set_access_cookies(response, new_access_token)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@bp.route("/link/<provider>")
|
||||
@jwt_required()
|
||||
def link_provider(provider):
|
||||
"""Link a new OAuth provider to current user account."""
|
||||
redirect_uri = url_for("auth.link_callback", provider=provider, _external=True)
|
||||
return auth_service.redirect_to_login(provider, redirect_uri)
|
||||
|
||||
|
||||
@bp.route("/link/callback/<provider>")
|
||||
@jwt_required()
|
||||
def link_callback(provider):
|
||||
"""Handle OAuth callback for linking new provider."""
|
||||
try:
|
||||
current_user_id = get_jwt_identity()
|
||||
if not current_user_id:
|
||||
return {"error": "User not authenticated"}, 401
|
||||
|
||||
# Get current user from database
|
||||
from app.models.user import User
|
||||
user = User.query.get(current_user_id)
|
||||
if not user:
|
||||
return {"error": "User not found"}, 404
|
||||
|
||||
# Process OAuth callback but link to existing user
|
||||
from app.services.oauth_providers.registry import OAuthProviderRegistry
|
||||
from authlib.integrations.flask_client import OAuth
|
||||
|
||||
oauth = OAuth()
|
||||
registry = OAuthProviderRegistry(oauth)
|
||||
oauth_provider = registry.get_provider(provider)
|
||||
|
||||
if not oauth_provider:
|
||||
return {"error": f"OAuth provider '{provider}' not configured"}, 400
|
||||
|
||||
token = oauth_provider.exchange_code_for_token(None, None)
|
||||
raw_user_info = oauth_provider.get_user_info(token)
|
||||
provider_data = oauth_provider.normalize_user_data(raw_user_info)
|
||||
|
||||
if not provider_data.get("id"):
|
||||
return {"error": "Failed to get user information from provider"}, 400
|
||||
|
||||
# Check if this provider is already linked to another user
|
||||
from app.models.user_oauth import UserOAuth
|
||||
existing_provider = UserOAuth.find_by_provider_and_id(
|
||||
provider, provider_data["id"]
|
||||
)
|
||||
|
||||
if existing_provider and existing_provider.user_id != user.id:
|
||||
return {"error": "This provider account is already linked to another user"}, 409
|
||||
|
||||
# Link the provider to current user
|
||||
UserOAuth.create_or_update(
|
||||
user_id=user.id,
|
||||
provider=provider,
|
||||
provider_id=provider_data["id"],
|
||||
email=provider_data["email"],
|
||||
name=provider_data["name"],
|
||||
picture=provider_data.get("picture")
|
||||
)
|
||||
|
||||
return {"message": f"{provider.title()} account linked successfully"}
|
||||
|
||||
except Exception as e:
|
||||
return {"error": str(e)}, 400
|
||||
|
||||
|
||||
@bp.route("/unlink/<provider>", methods=["DELETE"])
|
||||
@jwt_required()
|
||||
def unlink_provider(provider):
|
||||
"""Unlink an OAuth provider from current user account."""
|
||||
try:
|
||||
current_user_id = get_jwt_identity()
|
||||
if not current_user_id:
|
||||
return {"error": "User not authenticated"}, 401
|
||||
|
||||
from app.models.user import User
|
||||
from app.models.user_oauth import UserOAuth
|
||||
from app.database import db
|
||||
|
||||
user = User.query.get(current_user_id)
|
||||
if not user:
|
||||
return {"error": "User not found"}, 404
|
||||
|
||||
# Check if user has more than one provider (prevent locking out)
|
||||
if len(user.oauth_providers) <= 1:
|
||||
return {"error": "Cannot unlink last authentication provider"}, 400
|
||||
|
||||
# Find and remove the provider
|
||||
oauth_provider = user.get_provider(provider)
|
||||
if not oauth_provider:
|
||||
return {"error": f"Provider '{provider}' not linked to this account"}, 404
|
||||
|
||||
db.session.delete(oauth_provider)
|
||||
db.session.commit()
|
||||
|
||||
return {"message": f"{provider.title()} account unlinked successfully"}
|
||||
|
||||
except Exception as e:
|
||||
return {"error": str(e)}, 400
|
||||
|
||||
|
||||
@bp.route("/register", methods=["POST"])
|
||||
def register():
|
||||
"""Register new user with email and password."""
|
||||
from flask import request
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return {"error": "No data provided"}, 400
|
||||
|
||||
email = data.get("email")
|
||||
password = data.get("password")
|
||||
name = data.get("name")
|
||||
|
||||
if not email or not password or not name:
|
||||
return {"error": "Email, password, and name are required"}, 400
|
||||
|
||||
# Basic email validation
|
||||
if "@" not in email or "." not in email:
|
||||
return {"error": "Invalid email format"}, 400
|
||||
|
||||
# Basic password validation
|
||||
if len(password) < 6:
|
||||
return {"error": "Password must be at least 6 characters long"}, 400
|
||||
|
||||
return auth_service.register_with_password(email, password, name)
|
||||
|
||||
|
||||
@bp.route("/regenerate-api-token", methods=["POST"])
|
||||
@jwt_required()
|
||||
def regenerate_api_token():
|
||||
"""Regenerate API token for current user."""
|
||||
current_user_id = get_jwt_identity()
|
||||
if not current_user_id:
|
||||
return {"error": "User not authenticated"}, 401
|
||||
|
||||
from app.models.user import User
|
||||
from app.database import db
|
||||
|
||||
user = User.query.get(current_user_id)
|
||||
if not user:
|
||||
return {"error": "User not found"}, 404
|
||||
|
||||
# Generate new API token
|
||||
new_token = user.generate_api_token()
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
"message": "API token regenerated successfully",
|
||||
"api_token": new_token,
|
||||
"expires_at": user.api_token_expires_at.isoformat() if user.api_token_expires_at else None
|
||||
}
|
||||
|
||||
|
||||
return response
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
from app.services.decorators import get_current_user, require_auth
|
||||
from app.services.decorators import get_current_user, require_auth, require_admin, require_auth_or_api_token, get_user_from_api_token
|
||||
from app.services.greeting_service import GreetingService
|
||||
|
||||
bp = Blueprint("main", __name__)
|
||||
@@ -24,7 +24,7 @@ def hello(name: str | None = None) -> dict[str, str]:
|
||||
@bp.route("/protected")
|
||||
@require_auth
|
||||
def protected() -> dict[str, str]:
|
||||
"""Protected endpoint that requires authentication."""
|
||||
"""Protected endpoint that requires JWT authentication."""
|
||||
user = get_current_user()
|
||||
return {
|
||||
"message": f"Hello {user['name']}, this is a protected endpoint!",
|
||||
@@ -32,6 +32,33 @@ def protected() -> dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
@bp.route("/api-protected")
|
||||
@require_auth_or_api_token
|
||||
def api_protected() -> dict[str, str]:
|
||||
"""Protected endpoint that accepts JWT or API token authentication."""
|
||||
# Try to get user from JWT first, then API token
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
user = get_user_from_api_token()
|
||||
|
||||
return {
|
||||
"message": f"Hello {user['name']}, you accessed this via {user['provider']}!",
|
||||
"user": user
|
||||
}
|
||||
|
||||
|
||||
@bp.route("/admin")
|
||||
@require_admin
|
||||
def admin_only() -> dict[str, str]:
|
||||
"""Admin-only endpoint to demonstrate role-based access."""
|
||||
user = get_current_user()
|
||||
return {
|
||||
"message": f"Hello admin {user['name']}, you have admin access!",
|
||||
"user": user,
|
||||
"admin_info": "This endpoint is only accessible to admin users"
|
||||
}
|
||||
|
||||
|
||||
@bp.route("/health")
|
||||
def health() -> dict[str, str]:
|
||||
"""Health check endpoint."""
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
0
app/services/oauth_providers/__init__.py
Normal file
0
app/services/oauth_providers/__init__.py
Normal file
68
app/services/oauth_providers/base.py
Normal file
68
app/services/oauth_providers/base.py
Normal 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
|
||||
}
|
||||
52
app/services/oauth_providers/github.py
Normal file
52
app/services/oauth_providers/github.py
Normal 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')
|
||||
}
|
||||
34
app/services/oauth_providers/google.py
Normal file
34
app/services/oauth_providers/google.py
Normal 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"),
|
||||
}
|
||||
45
app/services/oauth_providers/registry.py
Normal file
45
app/services/oauth_providers/registry.py
Normal 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
|
||||
@@ -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"])
|
||||
5
main.py
5
main.py
@@ -1,5 +1,10 @@
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from app import create_app
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the Flask application."""
|
||||
|
||||
28
migrate_db.py
Normal file
28
migrate_db.py
Normal file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Database migration script for Flask-Migrate."""
|
||||
|
||||
import os
|
||||
from flask.cli import FlaskGroup
|
||||
from app import create_app
|
||||
from app.database import db
|
||||
|
||||
app = create_app()
|
||||
cli = FlaskGroup(app)
|
||||
|
||||
@cli.command()
|
||||
def init_db():
|
||||
"""Initialize the database."""
|
||||
print("Initializing database...")
|
||||
db.create_all()
|
||||
print("Database initialized successfully!")
|
||||
|
||||
@cli.command()
|
||||
def reset_db():
|
||||
"""Reset the database (drop all tables and recreate)."""
|
||||
print("Resetting database...")
|
||||
db.drop_all()
|
||||
db.create_all()
|
||||
print("Database reset successfully!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
@@ -5,7 +5,16 @@ description = "Soundboard V2 - Backend"
|
||||
authors = [{ name = "quaik8", email = "quaik8@gmail.com" }]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["flask==3.1.1", "authlib==1.3.2", "requests==2.32.3", "pyjwt==2.9.0"]
|
||||
dependencies = [
|
||||
"authlib==1.6.0",
|
||||
"flask==3.1.1",
|
||||
"flask-jwt-extended==4.7.1",
|
||||
"flask-migrate==4.1.0",
|
||||
"flask-sqlalchemy==3.1.1",
|
||||
"python-dotenv==1.1.1",
|
||||
"requests==2.32.4",
|
||||
"werkzeug==3.1.3",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["black==25.1.0", "pytest==8.4.1", "ruff==0.12.1"]
|
||||
|
||||
5
reset.sh
Executable file
5
reset.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
rm instance/soundboard.db
|
||||
uv run migrate_db.py init-db
|
||||
uv run main.py
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for authentication routes."""
|
||||
"""Tests for authentication routes with Flask-JWT-Extended."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
@@ -12,12 +12,13 @@ def client():
|
||||
"""Create a test client for the Flask application."""
|
||||
app = create_app()
|
||||
app.config["TESTING"] = True
|
||||
app.config["JWT_COOKIE_SECURE"] = False # Allow cookies in testing
|
||||
with app.test_client() as client:
|
||||
yield client
|
||||
|
||||
|
||||
class TestAuthRoutes:
|
||||
"""Test cases for authentication routes."""
|
||||
class TestAuthRoutesJWTExtended:
|
||||
"""Test cases for authentication routes with Flask-JWT-Extended."""
|
||||
|
||||
@patch("app.routes.auth.auth_service.get_login_url")
|
||||
def test_login_route(self, mock_get_login_url: Mock, client) -> None:
|
||||
@@ -30,33 +31,18 @@ class TestAuthRoutes:
|
||||
assert "login_url" in data
|
||||
assert data["login_url"] == "https://accounts.google.com/oauth/authorize?..."
|
||||
|
||||
def test_callback_route_no_code(self, client) -> None:
|
||||
"""Test callback route without authorization code."""
|
||||
response = client.get("/api/auth/callback")
|
||||
assert response.status_code == 400
|
||||
data = response.get_json()
|
||||
assert data["error"] == "Authorization code not found"
|
||||
|
||||
@patch("app.routes.auth.auth_service.handle_callback")
|
||||
def test_callback_route_success(self, mock_handle_callback: Mock, client) -> None:
|
||||
"""Test successful callback route."""
|
||||
user_data = {
|
||||
"id": "123",
|
||||
"email": "test@example.com",
|
||||
"name": "Test User"
|
||||
}
|
||||
mock_response = Mock()
|
||||
mock_response.get_json.return_value = {
|
||||
"message": "Login successful",
|
||||
"user": user_data
|
||||
"user": {"id": "123", "email": "test@example.com", "name": "Test User"}
|
||||
}
|
||||
mock_handle_callback.return_value = (user_data, mock_response)
|
||||
mock_handle_callback.return_value = mock_response
|
||||
|
||||
with patch("app.routes.auth.client.get") as mock_get:
|
||||
mock_get.return_value = mock_response
|
||||
response = client.get("/api/auth/callback?code=test_code")
|
||||
# Since we're returning the mock response directly, we need to verify differently
|
||||
mock_handle_callback.assert_called_once()
|
||||
response = client.get("/api/auth/callback?code=test_code")
|
||||
mock_handle_callback.assert_called_once()
|
||||
|
||||
@patch("app.routes.auth.auth_service.handle_callback")
|
||||
def test_callback_route_error(self, mock_handle_callback: Mock, client) -> None:
|
||||
@@ -75,32 +61,19 @@ class TestAuthRoutes:
|
||||
mock_response.get_json.return_value = {"message": "Logged out successfully"}
|
||||
mock_logout.return_value = mock_response
|
||||
|
||||
with patch("app.routes.auth.client.get") as mock_get:
|
||||
mock_get.return_value = mock_response
|
||||
response = client.get("/api/auth/logout")
|
||||
mock_logout.assert_called_once()
|
||||
response = client.get("/api/auth/logout")
|
||||
mock_logout.assert_called_once()
|
||||
|
||||
@patch("app.routes.auth.auth_service.get_current_user")
|
||||
def test_me_route_authenticated(self, mock_get_current_user: Mock, client) -> None:
|
||||
"""Test /me route when authenticated."""
|
||||
user_data = {
|
||||
"id": "123",
|
||||
"email": "test@example.com",
|
||||
"name": "Test User"
|
||||
}
|
||||
mock_get_current_user.return_value = user_data
|
||||
|
||||
response = client.get("/api/auth/me")
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data["user"] == user_data
|
||||
|
||||
@patch("app.routes.auth.auth_service.get_current_user")
|
||||
def test_me_route_not_authenticated(self, mock_get_current_user: Mock, client) -> None:
|
||||
def test_me_route_not_authenticated(self, client) -> None:
|
||||
"""Test /me route when not authenticated."""
|
||||
mock_get_current_user.return_value = None
|
||||
|
||||
response = client.get("/api/auth/me")
|
||||
assert response.status_code == 401
|
||||
data = response.get_json()
|
||||
assert data["error"] == "Not authenticated"
|
||||
assert "msg" in data # Flask-JWT-Extended error format
|
||||
|
||||
def test_refresh_route_not_authenticated(self, client) -> None:
|
||||
"""Test /refresh route when not authenticated."""
|
||||
response = client.post("/api/auth/refresh")
|
||||
assert response.status_code == 401
|
||||
data = response.get_json()
|
||||
assert "msg" in data # Flask-JWT-Extended error format
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Tests for AuthService."""
|
||||
"""Tests for AuthService with Flask-JWT-Extended."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from app import create_app
|
||||
from app.services.auth_service import AuthService
|
||||
|
||||
|
||||
class TestAuthService:
|
||||
"""Test cases for AuthService."""
|
||||
class TestAuthServiceJWTExtended:
|
||||
"""Test cases for AuthService with Flask-JWT-Extended."""
|
||||
|
||||
def test_init_without_app(self) -> None:
|
||||
"""Test initializing AuthService without Flask app."""
|
||||
@@ -23,59 +24,25 @@ class TestAuthService:
|
||||
"GOOGLE_CLIENT_SECRET": "test_client_secret"
|
||||
}.get(key)
|
||||
|
||||
mock_app = Mock()
|
||||
app = create_app()
|
||||
auth_service = AuthService()
|
||||
auth_service.init_app(mock_app)
|
||||
auth_service.init_app(app)
|
||||
|
||||
auth_service.oauth.init_app.assert_called_once_with(mock_app)
|
||||
# Verify OAuth was initialized
|
||||
assert auth_service.google is not None
|
||||
|
||||
@patch("app.services.auth_service.request")
|
||||
def test_get_current_user_no_token(self, mock_request: Mock) -> None:
|
||||
"""Test getting current user when no token exists."""
|
||||
mock_request.cookies.get.return_value = None
|
||||
auth_service = AuthService()
|
||||
|
||||
user = auth_service.get_current_user()
|
||||
assert user is None
|
||||
|
||||
@patch("app.services.auth_service.request")
|
||||
def test_get_current_user_with_token(self, mock_request: Mock) -> None:
|
||||
"""Test getting current user when valid token exists."""
|
||||
mock_request.cookies.get.return_value = "valid.access.token"
|
||||
auth_service = AuthService()
|
||||
|
||||
user_data = {"id": "123", "email": "test@example.com", "name": "Test User"}
|
||||
with patch.object(auth_service.token_service, 'get_user_from_access_token', return_value=user_data):
|
||||
user = auth_service.get_current_user()
|
||||
assert user == user_data
|
||||
|
||||
@patch("app.services.auth_service.request")
|
||||
def test_is_authenticated_false(self, mock_request: Mock) -> None:
|
||||
"""Test authentication check when not authenticated."""
|
||||
mock_request.cookies.get.return_value = None
|
||||
auth_service = AuthService()
|
||||
|
||||
assert not auth_service.is_authenticated()
|
||||
|
||||
@patch("app.services.auth_service.request")
|
||||
def test_is_authenticated_true(self, mock_request: Mock) -> None:
|
||||
"""Test authentication check when authenticated."""
|
||||
mock_request.cookies.get.return_value = "valid.access.token"
|
||||
auth_service = AuthService()
|
||||
user_data = {"id": "123", "email": "test@example.com", "name": "Test User"}
|
||||
|
||||
with patch.object(auth_service.token_service, 'get_user_from_access_token', return_value=user_data):
|
||||
assert auth_service.is_authenticated()
|
||||
|
||||
@patch("app.services.auth_service.make_response")
|
||||
def test_logout(self, mock_make_response: Mock) -> None:
|
||||
@patch("app.services.auth_service.unset_jwt_cookies")
|
||||
@patch("app.services.auth_service.jsonify")
|
||||
def test_logout(self, mock_jsonify: Mock, mock_unset: Mock) -> None:
|
||||
"""Test logout functionality."""
|
||||
mock_response = Mock()
|
||||
mock_make_response.return_value = mock_response
|
||||
|
||||
auth_service = AuthService()
|
||||
result = auth_service.logout()
|
||||
|
||||
assert result == mock_response
|
||||
mock_response.set_cookie.assert_any_call("access_token", "", expires=0)
|
||||
mock_response.set_cookie.assert_any_call("refresh_token", "", expires=0)
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
mock_response = Mock()
|
||||
mock_jsonify.return_value = mock_response
|
||||
|
||||
auth_service = AuthService()
|
||||
result = auth_service.logout()
|
||||
|
||||
assert result == mock_response
|
||||
mock_unset.assert_called_once_with(mock_response)
|
||||
mock_jsonify.assert_called_once_with({"message": "Logged out successfully"})
|
||||
57
tests/test_token_service_jwt_extended.py
Normal file
57
tests/test_token_service_jwt_extended.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Tests for TokenService using Flask-JWT-Extended."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from app import create_app
|
||||
from app.services.token_service import TokenService
|
||||
|
||||
|
||||
class TestTokenServiceJWTExtended:
|
||||
"""Test cases for TokenService with Flask-JWT-Extended."""
|
||||
|
||||
def test_generate_access_token(self) -> None:
|
||||
"""Test access token generation."""
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
token_service = TokenService()
|
||||
user_data = {
|
||||
"id": "123",
|
||||
"email": "test@example.com",
|
||||
"name": "Test User",
|
||||
"picture": "https://example.com/pic.jpg"
|
||||
}
|
||||
|
||||
token = token_service.generate_access_token(user_data)
|
||||
assert isinstance(token, str)
|
||||
assert len(token) > 0
|
||||
|
||||
def test_generate_refresh_token(self) -> None:
|
||||
"""Test refresh token generation."""
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
token_service = TokenService()
|
||||
user_data = {
|
||||
"id": "123",
|
||||
"email": "test@example.com",
|
||||
"name": "Test User"
|
||||
}
|
||||
|
||||
token = token_service.generate_refresh_token(user_data)
|
||||
assert isinstance(token, str)
|
||||
assert len(token) > 0
|
||||
|
||||
def test_generate_tokens_different(self) -> None:
|
||||
"""Test that access and refresh tokens are different."""
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
token_service = TokenService()
|
||||
user_data = {
|
||||
"id": "123",
|
||||
"email": "test@example.com",
|
||||
"name": "Test User"
|
||||
}
|
||||
|
||||
access_token = token_service.generate_access_token(user_data)
|
||||
refresh_token = token_service.generate_refresh_token(user_data)
|
||||
|
||||
assert access_token != refresh_token
|
||||
340
uv.lock
generated
340
uv.lock
generated
@@ -2,6 +2,32 @@ version = 1
|
||||
revision = 1
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "alembic"
|
||||
version = "1.16.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mako" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9c/35/116797ff14635e496bbda0c168987f5326a6555b09312e9b817e360d1f56/alembic-1.16.2.tar.gz", hash = "sha256:e53c38ff88dadb92eb22f8b150708367db731d58ad7e9d417c9168ab516cbed8", size = 1963563 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/e2/88e425adac5ad887a087c38d04fe2030010572a3e0e627f8a6e8c33eeda8/alembic-1.16.2-py3-none-any.whl", hash = "sha256:5f42e9bd0afdbd1d5e3ad856c01754530367debdebf21ed6894e34af52b3bb03", size = 242717 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "authlib"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/9d/b1e08d36899c12c8b894a44a5583ee157789f26fc4b176f8e4b6217b56e1/authlib-1.6.0.tar.gz", hash = "sha256:4367d32031b7af175ad3a323d571dc7257b7099d55978087ceae4a0d88cd3210", size = 158371 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/84/29/587c189bbab1ccc8c86a03a5d0e13873df916380ef1be461ebe6acebf48d/authlib-1.6.0-py2.py3-none-any.whl", hash = "sha256:91685589498f79e8655e8a8947431ad6288831d643f11c55c2143ffcc738048d", size = 239981 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "black"
|
||||
version = "25.1.0"
|
||||
@@ -35,6 +61,83 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.6.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/73/f7/f14b46d4bcd21092d7d3ccef689615220d8a08fb25e564b65d20738e672e/certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b", size = 158753 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "1.17.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790 },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924 },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567 },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408 },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537 },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565 },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622 },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435 },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442 },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454 },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174 },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166 },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641 },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.2.1"
|
||||
@@ -56,6 +159,41 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "45.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/c8/a2a376a8711c1e11708b9c9972e0c3223f5fc682552c82d8db844393d6ce/cryptography-45.0.4.tar.gz", hash = "sha256:7405ade85c83c37682c8fe65554759800a4a8c54b2d96e0f8ad114d31b808d57", size = 744890 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/1c/92637793de053832523b410dbe016d3f5c11b41d0cf6eef8787aabb51d41/cryptography-45.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:425a9a6ac2823ee6e46a76a21a4e8342d8fa5c01e08b823c1f19a8b74f096069", size = 7055712 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/14/93b69f2af9ba832ad6618a03f8a034a5851dc9a3314336a3d71c252467e1/cryptography-45.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:680806cf63baa0039b920f4976f5f31b10e772de42f16310a6839d9f21a26b0d", size = 4205335 },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/30/fae1000228634bf0b647fca80403db5ca9e3933b91dd060570689f0bd0f7/cryptography-45.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4ca0f52170e821bc8da6fc0cc565b7bb8ff8d90d36b5e9fdd68e8a86bdf72036", size = 4431487 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/5a/7dffcf8cdf0cb3c2430de7404b327e3db64735747d641fc492539978caeb/cryptography-45.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f3fe7a5ae34d5a414957cc7f457e2b92076e72938423ac64d215722f6cf49a9e", size = 4208922 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/f3/528729726eb6c3060fa3637253430547fbaaea95ab0535ea41baa4a6fbd8/cryptography-45.0.4-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:25eb4d4d3e54595dc8adebc6bbd5623588991d86591a78c2548ffb64797341e2", size = 3900433 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/4a/67ba2e40f619e04d83c32f7e1d484c1538c0800a17c56a22ff07d092ccc1/cryptography-45.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ce1678a2ccbe696cf3af15a75bb72ee008d7ff183c9228592ede9db467e64f1b", size = 4464163 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/9a/b4d5aa83661483ac372464809c4b49b5022dbfe36b12fe9e323ca8512420/cryptography-45.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:49fe9155ab32721b9122975e168a6760d8ce4cffe423bcd7ca269ba41b5dfac1", size = 4208687 },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/b7/a84bdcd19d9c02ec5807f2ec2d1456fd8451592c5ee353816c09250e3561/cryptography-45.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2882338b2a6e0bd337052e8b9007ced85c637da19ef9ecaf437744495c8c2999", size = 4463623 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/84/69707d502d4d905021cac3fb59a316344e9f078b1da7fb43ecde5e10840a/cryptography-45.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:23b9c3ea30c3ed4db59e7b9619272e94891f8a3a5591d0b656a7582631ccf750", size = 4332447 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/ee/d4f2ab688e057e90ded24384e34838086a9b09963389a5ba6854b5876598/cryptography-45.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0a97c927497e3bc36b33987abb99bf17a9a175a19af38a892dc4bbb844d7ee2", size = 4572830 },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/d4/994773a261d7ff98034f72c0e8251fe2755eac45e2265db4c866c1c6829c/cryptography-45.0.4-cp311-abi3-win32.whl", hash = "sha256:e00a6c10a5c53979d6242f123c0a97cff9f3abed7f064fc412c36dc521b5f257", size = 2932769 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/42/c80bd0b67e9b769b364963b5252b17778a397cefdd36fa9aa4a5f34c599a/cryptography-45.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:817ee05c6c9f7a69a16200f0c90ab26d23a87701e2a284bd15156783e46dbcc8", size = 3410441 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/0b/2488c89f3a30bc821c9d96eeacfcab6ff3accc08a9601ba03339c0fd05e5/cryptography-45.0.4-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:964bcc28d867e0f5491a564b7debb3ffdd8717928d315d12e0d7defa9e43b723", size = 7031836 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/51/8c584ed426093aac257462ae62d26ad61ef1cbf5b58d8b67e6e13c39960e/cryptography-45.0.4-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6a5bf57554e80f75a7db3d4b1dacaa2764611ae166ab42ea9a72bcdb5d577637", size = 4195746 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/7d/4b0ca4d7af95a704eef2f8f80a8199ed236aaf185d55385ae1d1610c03c2/cryptography-45.0.4-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:46cf7088bf91bdc9b26f9c55636492c1cce3e7aaf8041bbf0243f5e5325cfb2d", size = 4424456 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/45/5fabacbc6e76ff056f84d9f60eeac18819badf0cefc1b6612ee03d4ab678/cryptography-45.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7bedbe4cc930fa4b100fc845ea1ea5788fcd7ae9562e669989c11618ae8d76ee", size = 4198495 },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/b7/ffc9945b290eb0a5d4dab9b7636706e3b5b92f14ee5d9d4449409d010d54/cryptography-45.0.4-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:eaa3e28ea2235b33220b949c5a0d6cf79baa80eab2eb5607ca8ab7525331b9ff", size = 3885540 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/e3/57b010282346980475e77d414080acdcb3dab9a0be63071efc2041a2c6bd/cryptography-45.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7ef2dde4fa9408475038fc9aadfc1fb2676b174e68356359632e980c661ec8f6", size = 4452052 },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/e6/ddc4ac2558bf2ef517a358df26f45bc774a99bf4653e7ee34b5e749c03e3/cryptography-45.0.4-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6a3511ae33f09094185d111160fd192c67aa0a2a8d19b54d36e4c78f651dc5ad", size = 4198024 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/c0/85fa358ddb063ec588aed4a6ea1df57dc3e3bc1712d87c8fa162d02a65fc/cryptography-45.0.4-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:06509dc70dd71fa56eaa138336244e2fbaf2ac164fc9b5e66828fccfd2b680d6", size = 4451442 },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/67/362d6ec1492596e73da24e669a7fbbaeb1c428d6bf49a29f7a12acffd5dc/cryptography-45.0.4-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5f31e6b0a5a253f6aa49be67279be4a7e5a4ef259a9f33c69f7d1b1191939872", size = 4325038 },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/75/82a14bf047a96a1b13ebb47fb9811c4f73096cfa2e2b17c86879687f9027/cryptography-45.0.4-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:944e9ccf67a9594137f942d5b52c8d238b1b4e46c7a0c2891b7ae6e01e7c80a4", size = 4560964 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/37/1a3cba4c5a468ebf9b95523a5ef5651244693dc712001e276682c278fc00/cryptography-45.0.4-cp37-abi3-win32.whl", hash = "sha256:c22fe01e53dc65edd1945a2e6f0015e887f84ced233acecb64b4daadb32f5c97", size = 2924557 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/4b/3256759723b7e66380397d958ca07c59cfc3fb5c794fb5516758afd05d41/cryptography-45.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:627ba1bc94f6adf0b0a2e35d87020285ead22d9f648c7e75bb64f367375f3b22", size = 3395508 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask"
|
||||
version = "3.1.1"
|
||||
@@ -73,6 +211,89 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/68/9d4508e893976286d2ead7f8f571314af6c2037af34853a30fd769c02e9d/flask-3.1.1-py3-none-any.whl", hash = "sha256:07aae2bb5eaf77993ef57e357491839f5fd9f4dc281593a81a9e4d79a24f295c", size = 103305 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask-jwt-extended"
|
||||
version = "4.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "flask" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "werkzeug" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/51/16/96b101f18cba17ecce3225ab07bc4c8f23e6befd8552dbbed87482e7c7fb/flask_jwt_extended-4.7.1.tar.gz", hash = "sha256:8085d6757505b6f3291a2638c84d207e8f0ad0de662d1f46aa2f77e658a0c976", size = 34411 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/67/34/9a91da47b1565811ab4aa5fb134632c8d1757960bfa7d457f486947c4d75/Flask_JWT_Extended-4.7.1-py2.py3-none-any.whl", hash = "sha256:52f35bf0985354d7fb7b876e2eb0e0b141aaff865a22ff6cc33d9a18aa987978", size = 22588 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask-migrate"
|
||||
version = "4.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
{ name = "flask" },
|
||||
{ name = "flask-sqlalchemy" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/47c7b3c93855ceffc2eabfa271782332942443321a07de193e4198f920cf/flask_migrate-4.1.0.tar.gz", hash = "sha256:1a336b06eb2c3ace005f5f2ded8641d534c18798d64061f6ff11f79e1434126d", size = 21965 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/c4/3f329b23d769fe7628a5fc57ad36956f1fb7132cf8837be6da762b197327/Flask_Migrate-4.1.0-py3-none-any.whl", hash = "sha256:24d8051af161782e0743af1b04a152d007bad9772b2bca67b7ec1e8ceeb3910d", size = 21237 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask-sqlalchemy"
|
||||
version = "3.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "flask" },
|
||||
{ name = "sqlalchemy" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/91/53/b0a9fcc1b1297f51e68b69ed3b7c3c40d8c45be1391d77ae198712914392/flask_sqlalchemy-3.1.1.tar.gz", hash = "sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312", size = 81899 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/6a/89963a5c6ecf166e8be29e0d1bf6806051ee8fe6c82e232842e3aeac9204/flask_sqlalchemy-3.1.1-py3-none-any.whl", hash = "sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0", size = 25125 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/92/bb85bd6e80148a4d2e0c59f7c0c2891029f8fd510183afc7d8d2feeed9b6/greenlet-3.2.3.tar.gz", hash = "sha256:8b0dd8ae4c0d6f5e54ee55ba935eeb3d735a9b58a8a1e5b5cbab64e01a39f365", size = 185752 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/94/ad0d435f7c48debe960c53b8f60fb41c2026b1d0fa4a99a1cb17c3461e09/greenlet-3.2.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:25ad29caed5783d4bd7a85c9251c651696164622494c00802a139c00d639242d", size = 271992 },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/5d/7c27cf4d003d6e77749d299c7c8f5fd50b4f251647b5c2e97e1f20da0ab5/greenlet-3.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88cd97bf37fe24a6710ec6a3a7799f3f81d9cd33317dcf565ff9950c83f55e0b", size = 638820 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/7e/807e1e9be07a125bb4c169144937910bf59b9d2f6d931578e57f0bce0ae2/greenlet-3.2.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:baeedccca94880d2f5666b4fa16fc20ef50ba1ee353ee2d7092b383a243b0b0d", size = 653046 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/ab/158c1a4ea1068bdbc78dba5a3de57e4c7aeb4e7fa034320ea94c688bfb61/greenlet-3.2.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:be52af4b6292baecfa0f397f3edb3c6092ce071b499dd6fe292c9ac9f2c8f264", size = 647701 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/0d/93729068259b550d6a0288da4ff72b86ed05626eaf1eb7c0d3466a2571de/greenlet-3.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0cc73378150b8b78b0c9fe2ce56e166695e67478550769536a6742dca3651688", size = 649747 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/f6/c82ac1851c60851302d8581680573245c8fc300253fc1ff741ae74a6c24d/greenlet-3.2.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:706d016a03e78df129f68c4c9b4c4f963f7d73534e48a24f5f5a7101ed13dbbb", size = 605461 },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/82/d022cf25ca39cf1200650fc58c52af32c90f80479c25d1cbf57980ec3065/greenlet-3.2.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:419e60f80709510c343c57b4bb5a339d8767bf9aef9b8ce43f4f143240f88b7c", size = 1121190 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/e1/25297f70717abe8104c20ecf7af0a5b82d2f5a980eb1ac79f65654799f9f/greenlet-3.2.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:93d48533fade144203816783373f27a97e4193177ebaaf0fc396db19e5d61163", size = 1149055 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/8f/8f9e56c5e82eb2c26e8cde787962e66494312dc8cb261c460e1f3a9c88bc/greenlet-3.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:7454d37c740bb27bdeddfc3f358f26956a07d5220818ceb467a483197d84f849", size = 297817 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/cf/f5c0b23309070ae93de75c90d29300751a5aacefc0a3ed1b1d8edb28f08b/greenlet-3.2.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:500b8689aa9dd1ab26872a34084503aeddefcb438e2e7317b89b11eaea1901ad", size = 270732 },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/ae/91a957ba60482d3fecf9be49bc3948f341d706b52ddb9d83a70d42abd498/greenlet-3.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a07d3472c2a93117af3b0136f246b2833fdc0b542d4a9799ae5f41c28323faef", size = 639033 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/df/20ffa66dd5a7a7beffa6451bdb7400d66251374ab40b99981478c69a67a8/greenlet-3.2.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8704b3768d2f51150626962f4b9a9e4a17d2e37c8a8d9867bbd9fa4eb938d3b3", size = 652999 },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/b4/ebb2c8cb41e521f1d72bf0465f2f9a2fd803f674a88db228887e6847077e/greenlet-3.2.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5035d77a27b7c62db6cf41cf786cfe2242644a7a337a0e155c80960598baab95", size = 647368 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/6a/1e1b5aa10dced4ae876a322155705257748108b7fd2e4fae3f2a091fe81a/greenlet-3.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2d8aa5423cd4a396792f6d4580f88bdc6efcb9205891c9d40d20f6e670992efb", size = 650037 },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/f2/ad51331a157c7015c675702e2d5230c243695c788f8f75feba1af32b3617/greenlet-3.2.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c724620a101f8170065d7dded3f962a2aea7a7dae133a009cada42847e04a7b", size = 608402 },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/bc/862bd2083e6b3aff23300900a956f4ea9a4059de337f5c8734346b9b34fc/greenlet-3.2.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:873abe55f134c48e1f2a6f53f7d1419192a3d1a4e873bace00499a4e45ea6af0", size = 1119577 },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/94/1fc0cc068cfde885170e01de40a619b00eaa8f2916bf3541744730ffb4c3/greenlet-3.2.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:024571bbce5f2c1cfff08bf3fbaa43bbc7444f580ae13b0099e95d0e6e67ed36", size = 1147121 },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/1a/199f9587e8cb08a0658f9c30f3799244307614148ffe8b1e3aa22f324dea/greenlet-3.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:5195fb1e75e592dd04ce79881c8a22becdfa3e6f500e7feb059b1e6fdd54d3e3", size = 297603 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/ca/accd7aa5280eb92b70ed9e8f7fd79dc50a2c21d8c73b9a0856f5b564e222/greenlet-3.2.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:3d04332dddb10b4a211b68111dabaee2e1a073663d117dc10247b5b1642bac86", size = 271479 },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/71/01ed9895d9eb49223280ecc98a557585edfa56b3d0e965b9fa9f7f06b6d9/greenlet-3.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8186162dffde068a465deab08fc72c767196895c39db26ab1c17c0b77a6d8b97", size = 683952 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/61/638c4bdf460c3c678a0a1ef4c200f347dff80719597e53b5edb2fb27ab54/greenlet-3.2.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f4bfbaa6096b1b7a200024784217defedf46a07c2eee1a498e94a1b5f8ec5728", size = 696917 },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/cc/0bd1a7eb759d1f3e3cc2d1bc0f0b487ad3cc9f34d74da4b80f226fde4ec3/greenlet-3.2.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:ed6cfa9200484d234d8394c70f5492f144b20d4533f69262d530a1a082f6ee9a", size = 692443 },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/10/b2a4b63d3f08362662e89c103f7fe28894a51ae0bc890fabf37d1d780e52/greenlet-3.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:02b0df6f63cd15012bed5401b47829cfd2e97052dc89da3cfaf2c779124eb892", size = 692995 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/c6/ad82f148a4e3ce9564056453a71529732baf5448ad53fc323e37efe34f66/greenlet-3.2.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86c2d68e87107c1792e2e8d5399acec2487a4e993ab76c792408e59394d52141", size = 655320 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size = 301236 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.1.0"
|
||||
@@ -103,6 +324,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mako"
|
||||
version = "1.3.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.2"
|
||||
@@ -186,6 +419,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "2.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
@@ -195,6 +437,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.10.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.4.1"
|
||||
@@ -211,6 +462,30 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.12.1"
|
||||
@@ -241,7 +516,14 @@ name = "sdb-backend"
|
||||
version = "2.0.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "authlib" },
|
||||
{ name = "flask" },
|
||||
{ name = "flask-jwt-extended" },
|
||||
{ name = "flask-migrate" },
|
||||
{ name = "flask-sqlalchemy" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "requests" },
|
||||
{ name = "werkzeug" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
@@ -252,7 +534,16 @@ dev = [
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "flask", specifier = "==3.1.1" }]
|
||||
requires-dist = [
|
||||
{ name = "authlib", specifier = "==1.6.0" },
|
||||
{ name = "flask", specifier = "==3.1.1" },
|
||||
{ name = "flask-jwt-extended", specifier = "==4.7.1" },
|
||||
{ name = "flask-migrate", specifier = "==4.1.0" },
|
||||
{ name = "flask-sqlalchemy", specifier = "==3.1.1" },
|
||||
{ name = "python-dotenv", specifier = "==1.1.1" },
|
||||
{ name = "requests", specifier = "==2.32.4" },
|
||||
{ name = "werkzeug", specifier = "==3.1.3" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
@@ -261,6 +552,53 @@ dev = [
|
||||
{ name = "ruff", specifier = "==0.12.1" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.41"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/63/66/45b165c595ec89aa7dcc2c1cd222ab269bc753f1fc7a1e68f8481bd957bf/sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9", size = 9689424 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/2a/f1f4e068b371154740dd10fb81afb5240d5af4aa0087b88d8b308b5429c2/sqlalchemy-2.0.41-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:81f413674d85cfd0dfcd6512e10e0f33c19c21860342a4890c3a2b59479929f9", size = 2119645 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/e8/c664a7e73d36fbfc4730f8cf2bf930444ea87270f2825efbe17bf808b998/sqlalchemy-2.0.41-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:598d9ebc1e796431bbd068e41e4de4dc34312b7aa3292571bb3674a0cb415dd1", size = 2107399 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/78/8a9cf6c5e7135540cb682128d091d6afa1b9e48bd049b0d691bf54114f70/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a104c5694dfd2d864a6f91b0956eb5d5883234119cb40010115fd45a16da5e70", size = 3293269 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/35/f74add3978c20de6323fb11cb5162702670cc7a9420033befb43d8d5b7a4/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6145afea51ff0af7f2564a05fa95eb46f542919e6523729663a5d285ecb3cf5e", size = 3303364 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/d4/c990f37f52c3f7748ebe98883e2a0f7d038108c2c5a82468d1ff3eec50b7/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b46fa6eae1cd1c20e6e6f44e19984d438b6b2d8616d21d783d150df714f44078", size = 3229072 },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/69/cab11fecc7eb64bc561011be2bd03d065b762d87add52a4ca0aca2e12904/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41836fe661cc98abfae476e14ba1906220f92c4e528771a8a3ae6a151242d2ae", size = 3268074 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/ca/0c19ec16858585d37767b167fc9602593f98998a68a798450558239fb04a/sqlalchemy-2.0.41-cp312-cp312-win32.whl", hash = "sha256:a8808d5cf866c781150d36a3c8eb3adccfa41a8105d031bf27e92c251e3969d6", size = 2084514 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/23/4c2833d78ff3010a4e17f984c734f52b531a8c9060a50429c9d4b0211be6/sqlalchemy-2.0.41-cp312-cp312-win_amd64.whl", hash = "sha256:5b14e97886199c1f52c14629c11d90c11fbb09e9334fa7bb5f6d068d9ced0ce0", size = 2111557 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/ad/2e1c6d4f235a97eeef52d0200d8ddda16f6c4dd70ae5ad88c46963440480/sqlalchemy-2.0.41-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4eeb195cdedaf17aab6b247894ff2734dcead6c08f748e617bfe05bd5a218443", size = 2115491 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/8d/be490e5db8400dacc89056f78a52d44b04fbf75e8439569d5b879623a53b/sqlalchemy-2.0.41-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d4ae769b9c1c7757e4ccce94b0641bc203bbdf43ba7a2413ab2523d8d047d8dc", size = 2102827 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/72/c97ad430f0b0e78efaf2791342e13ffeafcbb3c06242f01a3bb8fe44f65d/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a62448526dd9ed3e3beedc93df9bb6b55a436ed1474db31a2af13b313a70a7e1", size = 3225224 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/51/5ba9ea3246ea068630acf35a6ba0d181e99f1af1afd17e159eac7e8bc2b8/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc56c9788617b8964ad02e8fcfeed4001c1f8ba91a9e1f31483c0dffb207002a", size = 3230045 },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/2f/8c14443b2acea700c62f9b4a8bad9e49fc1b65cfb260edead71fd38e9f19/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c153265408d18de4cc5ded1941dcd8315894572cddd3c58df5d5b5705b3fa28d", size = 3159357 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/b2/43eacbf6ccc5276d76cea18cb7c3d73e294d6fb21f9ff8b4eef9b42bbfd5/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f67766965996e63bb46cfbf2ce5355fc32d9dd3b8ad7e536a920ff9ee422e23", size = 3197511 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/2e/677c17c5d6a004c3c45334ab1dbe7b7deb834430b282b8a0f75ae220c8eb/sqlalchemy-2.0.41-cp313-cp313-win32.whl", hash = "sha256:bfc9064f6658a3d1cadeaa0ba07570b83ce6801a1314985bf98ec9b95d74e15f", size = 2082420 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/61/e8c1b9b6307c57157d328dd8b8348ddc4c47ffdf1279365a13b2b98b8049/sqlalchemy-2.0.41-cp313-cp313-win_amd64.whl", hash = "sha256:82ca366a844eb551daff9d2e6e7a9e5e76d2612c8564f58db6c19a726869c1df", size = 2108329 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "werkzeug"
|
||||
version = "3.1.3"
|
||||
|
||||
Reference in New Issue
Block a user