"""Personal API token model. A personal API token (PAT) lets a script or integration authenticate as a user without the hourly-expiring login JWT. The secret is shown ONCE at creation; only its sha256 hash is stored. The token acts as its owning user, so the existing role/permission decorators authorize it unchanged. """ import hashlib import secrets from datetime import datetime, timezone from shopdb.extensions import db from .base import BaseModel def _utcnow(): # naive UTC to match the other DB DateTime columns (stored without tzinfo) return datetime.now(timezone.utc).replace(tzinfo=None) # Wire label on the full secret. Scripts send "Authorization: Bearer ". TOKEN_SECRET_PREFIX = 'shopdb_pat_' # Hex chars of randomness after the label (secrets.token_hex(20) => 40 hex). _TOKEN_RANDOM_BYTES = 20 # How many leading random-hex chars we keep in the clear for display/lookup. _TOKEN_PREFIX_LEN = 8 class ApiToken(BaseModel): """Personal API token. Stores only the hash of the secret.""" __tablename__ = 'apitokens' tokenid = db.Column(db.Integer, primary_key=True) # The token acts as this user; NOT NULL so authz always has a principal. userid = db.Column(db.Integer, db.ForeignKey('users.userid'), nullable=False, index=True) # What the token is for (e.g. "legacy import runner"). name = db.Column(db.String(100), nullable=False) # First few random-hex chars, kept clear so a user can tell tokens apart. tokenprefix = db.Column(db.String(16), nullable=True, index=True) # sha256 hex of the full secret. Unique so a hash lookup finds one row. tokenhash = db.Column(db.String(64), unique=True, nullable=False, index=True) # Null expiresat means the token never expires. expiresat = db.Column(db.DateTime, nullable=True) # Last time the token authenticated a request (throttled write). lastusedat = db.Column(db.DateTime, nullable=True) user = db.relationship('User', backref=db.backref('apitokens', lazy='dynamic')) @staticmethod def generate_secret() -> str: """Return a fresh full secret: shopdb_pat_<40 hex>. Never stored.""" return TOKEN_SECRET_PREFIX + secrets.token_hex(_TOKEN_RANDOM_BYTES) @staticmethod def hash_secret(secret: str) -> str: """sha256 hex of the full secret. The token has 160 bits of entropy, so a plain hash lookup (not a slow password hash) is appropriate.""" return hashlib.sha256(secret.encode('utf-8')).hexdigest() @staticmethod def prefix_of(secret: str) -> str: """The clear display prefix (leading random-hex chars) of a secret.""" randompart = secret[len(TOKEN_SECRET_PREFIX):] return randompart[:_TOKEN_PREFIX_LEN] @property def is_expired(self) -> bool: """True when expiresat is set and in the past.""" return self.expiresat is not None and self.expiresat < _utcnow() def to_dict(self, include_owner: bool = False) -> dict: """Serialize for the API. NEVER includes the hash or the secret.""" result = { 'tokenid': self.tokenid, 'userid': self.userid, 'name': self.name, 'tokenprefix': self.tokenprefix, 'displayprefix': f'{TOKEN_SECRET_PREFIX}{self.tokenprefix or ""}', 'expiresat': self.expiresat.isoformat() + 'Z' if self.expiresat else None, 'lastusedat': self.lastusedat.isoformat() + 'Z' if self.lastusedat else None, 'isactive': self.isactive, 'isexpired': self.is_expired, 'createddate': self.createddate.isoformat() + 'Z' if self.createddate else None, } if include_owner: result['username'] = self.user.username if self.user else None return result