mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-25 22:19:53 -06:00
- 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.
155 lines
5.1 KiB
Python
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
|