API tokens: any user mints named, optionally-expiring tokens (shopdb_pat_..., sha256-stored, secret shown once) at Settings > API Tokens; a before-request shim swaps a valid PAT for a request-scoped JWT of its owner, so the entire existing auth/authz/import-mode stack works unchanged and revoked/expired tokens 401 cleanly. Built for long-running scripts - the legacy import no longer dies when a login JWT expires. Migration 7d21_apitokens; create/revoke audit-logged. Audited integration gaps fixed: Asset.to_dict serializes measuring tools (typedata + pluginid - relationship links to tools resolve); map subtype filter/colors and MapEditor include them; dashboard totals count them; warranty links use a new by-asset route; the measuringtools ADR-010 hooks are real (corrected presentation token, implemented map-overlay endpoint); the login avatar resolves through the employee-photo helper. 737 tests pass; naming green; frontend builds; both features verified live end-to-end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
90 lines
3.7 KiB
Python
90 lines
3.7 KiB
Python
"""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 <secret>".
|
|
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
|