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>
109 lines
4.1 KiB
Python
109 lines
4.1 KiB
Python
"""Personal API token (PAT) authentication shim.
|
|
|
|
A request may send `Authorization: Bearer shopdb_pat_...`. This is recognized
|
|
BEFORE any JWT decode: a before_request hook validates the PAT (hash lookup,
|
|
active, not expired, active owner) and, on success, mints a short internal
|
|
request-scoped JWT for the token's user and swaps it into the request's
|
|
Authorization header.
|
|
|
|
Why mint a JWT instead of only stashing the user on g: every write route in
|
|
this app stacks a mandatory @jwt_required() ABOVE @require_permission /
|
|
@require_role. That mandatory decorator decodes the Authorization header
|
|
itself, so the ONLY way a PAT reaches the whole existing auth+authz stack
|
|
(jwt_required, require_permission, require_role, import_mode, current_user,
|
|
get_jwt_identity) unchanged is to present a genuine JWT downstream. The minted
|
|
token lives only in this request's environ and is never returned to the client.
|
|
|
|
Result: a PAT authenticates any route a login JWT would, acting as its owner,
|
|
with zero changes to the authz decorators or import-mode helpers.
|
|
"""
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from flask import g, request
|
|
from flask_jwt_extended import create_access_token
|
|
|
|
from shopdb.extensions import db
|
|
from shopdb.core.models.apitoken import ApiToken, TOKEN_SECRET_PREFIX
|
|
from shopdb.utils.responses import error_response, ErrorCodes
|
|
|
|
|
|
# Only rewrite lastusedat when it is older than this, to avoid a DB write on
|
|
# every single request a busy integration makes.
|
|
_LASTUSED_THROTTLE_SECONDS = 60
|
|
|
|
|
|
def _utcnow():
|
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
|
|
|
|
|
def _extract_pat_secret():
|
|
"""Return the PAT secret from the Authorization header, or None."""
|
|
header = request.headers.get('Authorization', '')
|
|
parts = header.split()
|
|
if len(parts) == 2 and parts[0] == 'Bearer' \
|
|
and parts[1].startswith(TOKEN_SECRET_PREFIX):
|
|
return parts[1]
|
|
return None
|
|
|
|
|
|
def _resolve_pat(secret):
|
|
"""Validate a PAT secret. Return (token, user) or None."""
|
|
from shopdb.core.models import User
|
|
|
|
token = ApiToken.query.filter_by(
|
|
tokenhash=ApiToken.hash_secret(secret), isactive=True).first()
|
|
if token is None or token.is_expired:
|
|
return None
|
|
user = db.session.get(User, token.userid)
|
|
if user is None or not user.isactive:
|
|
return None
|
|
return token, user
|
|
|
|
|
|
def _touch_lastused(token):
|
|
"""Throttled lastusedat write. Independent commit; nothing else is pending
|
|
this early in the request, so it cannot clobber route work."""
|
|
now = _utcnow()
|
|
if token.lastusedat is None \
|
|
or (now - token.lastusedat).total_seconds() > _LASTUSED_THROTTLE_SECONDS:
|
|
token.lastusedat = now
|
|
db.session.commit()
|
|
|
|
|
|
def install_apitoken_auth(app):
|
|
"""Register the before_request PAT shim on the app."""
|
|
|
|
@app.before_request
|
|
def _apitoken_before_request():
|
|
secret = _extract_pat_secret()
|
|
if secret is None:
|
|
return
|
|
resolved = _resolve_pat(secret)
|
|
if resolved is None:
|
|
# The caller clearly meant to use a PAT (shopdb_pat_ prefix) but it
|
|
# is unknown, revoked, or expired. Reject with a clear 401 instead
|
|
# of letting the JWT decoder emit a confusing 422 on the non-JWT.
|
|
return error_response(
|
|
ErrorCodes.UNAUTHORIZED,
|
|
'Invalid, revoked, or expired API token',
|
|
http_code=401)
|
|
token, user = resolved
|
|
|
|
# Read claim inputs before the (possible) commit expires the instance.
|
|
claims = {
|
|
'username': user.username,
|
|
'roles': [role.rolename for role in user.roles],
|
|
}
|
|
# Expose the token/user for audit and introspection if a handler wants it.
|
|
g.apitokenid = token.tokenid
|
|
g.apitokenuser = user
|
|
|
|
_touch_lastused(token)
|
|
|
|
# Mint a request-scoped JWT for the owner and swap it into the header
|
|
# so the whole downstream auth stack authenticates as that user.
|
|
access_token = create_access_token(
|
|
identity=str(user.userid), additional_claims=claims)
|
|
request.environ['HTTP_AUTHORIZATION'] = f'Bearer {access_token}'
|