Files
TimeTracker/app/models/api_token.py
Dries Peeters 90dde470da style: standardize code formatting and normalize line endings
- Normalize line endings from CRLF to LF across all files to match .editorconfig
- Standardize quote style from single quotes to double quotes
- Normalize whitespace and formatting throughout codebase
- Apply consistent code style across 372 files including:
  * Application code (models, routes, services, utils)
  * Test files
  * Configuration files
  * CI/CD workflows

This ensures consistency with the project's .editorconfig settings and
improves code maintainability.
2025-11-28 20:05:37 +01:00

155 lines
5.1 KiB
Python

"""API Token model for REST API authentication"""
import secrets
from datetime import datetime, timedelta
from app import db
from sqlalchemy.orm import relationship
class ApiToken(db.Model):
"""API Token for authenticating REST API requests"""
__tablename__ = "api_tokens"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text)
token_hash = db.Column(db.String(128), unique=True, nullable=False, index=True)
token_prefix = db.Column(db.String(10), nullable=False) # First 8 chars for identification
# Ownership and permissions
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
user = relationship("User", backref="api_tokens")
# Scopes for fine-grained permissions (comma-separated)
# Examples: read:projects, write:time_entries, admin:all
scopes = db.Column(db.Text, default="")
# Token lifecycle
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
expires_at = db.Column(db.DateTime)
last_used_at = db.Column(db.DateTime)
is_active = db.Column(db.Boolean, default=True, nullable=False)
# IP restrictions (comma-separated list of allowed IPs/CIDR blocks)
ip_whitelist = db.Column(db.Text)
# Usage tracking
usage_count = db.Column(db.Integer, default=0, nullable=False)
def __repr__(self):
return f"<ApiToken {self.name} ({self.token_prefix}...)>"
@staticmethod
def generate_token():
"""Generate a new secure random token"""
# Format: tt_<32 random chars>
random_part = secrets.token_urlsafe(32)[:32]
return f"tt_{random_part}"
@staticmethod
def hash_token(token):
"""Hash a token for storage"""
import hashlib
return hashlib.sha256(token.encode()).hexdigest()
@classmethod
def create_token(cls, user_id, name, description="", scopes="", expires_days=None):
"""Create a new API token
Args:
user_id: User ID who owns this token
name: Human-readable name for the token
description: Optional description
scopes: Comma-separated list of scopes
expires_days: Number of days until expiration (None = never expires)
Returns:
tuple: (ApiToken instance, plain_token)
"""
plain_token = cls.generate_token()
token_hash = cls.hash_token(plain_token)
token_prefix = plain_token[:8]
expires_at = None
if expires_days:
expires_at = datetime.utcnow() + timedelta(days=expires_days)
api_token = cls(
name=name,
description=description,
token_hash=token_hash,
token_prefix=token_prefix,
user_id=user_id,
scopes=scopes,
expires_at=expires_at,
)
return api_token, plain_token
def verify_token(self, plain_token):
"""Verify if the provided token matches this record"""
return self.token_hash == self.hash_token(plain_token)
def is_valid(self):
"""Check if token is valid (active and not expired)"""
if not self.is_active:
return False
if self.expires_at and self.expires_at < datetime.utcnow():
return False
return True
def has_scope(self, required_scope):
"""Check if token has a specific scope
Args:
required_scope: The scope to check (e.g., 'read:projects')
Returns:
bool: True if token has the scope
"""
if not self.scopes:
return False
token_scopes = [s.strip() for s in self.scopes.split(",")]
# Check for wildcard admin scope
if "admin:all" in token_scopes or "*" in token_scopes:
return True
# Check for exact match
if required_scope in token_scopes:
return True
# Check for wildcard resource scope (e.g., read:* matches read:projects)
resource_type = required_scope.split(":")[0] if ":" in required_scope else None
if resource_type and f"{resource_type}:*" in token_scopes:
return True
return False
def record_usage(self, ip_address=None):
"""Record token usage"""
self.last_used_at = datetime.utcnow()
self.usage_count += 1
db.session.commit()
def to_dict(self, include_token=False):
"""Convert to dictionary for API responses"""
data = {
"id": self.id,
"name": self.name,
"description": self.description,
"token_prefix": self.token_prefix,
"scopes": self.scopes.split(",") if self.scopes else [],
"created_at": self.created_at.isoformat() if self.created_at else None,
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
"last_used_at": self.last_used_at.isoformat() if self.last_used_at else None,
"is_active": self.is_active,
"usage_count": self.usage_count,
"user_id": self.user_id,
}
return data