March 17 2026: Mobile app phase 1
This commit is contained in:
@@ -131,6 +131,10 @@ def create_app(config_name='default'):
|
||||
app.register_blueprint(customers.bp)
|
||||
app.register_blueprint(scheduled_reports.bp)
|
||||
|
||||
# ── Mobile API ─────────────────────────────────────────────
|
||||
from app.api import register_api
|
||||
register_api(app)
|
||||
|
||||
# ── Error handler: 413 Request Entity Too Large ───────────────────────
|
||||
# Nginx can return 413 before Flask sees the request; this handler covers
|
||||
# the Flask-side rejection and gives users a clear, actionable message
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
app/api/__init__.py
|
||||
-------------------
|
||||
Registers the /api/v1 blueprint group.
|
||||
|
||||
All mobile API routes live under the prefix /api/v1/.
|
||||
This module is imported once from app/__init__.py — see the integration
|
||||
instructions at the bottom of this file.
|
||||
|
||||
Blueprint layout
|
||||
----------------
|
||||
/api/v1/auth/login → api_auth.login
|
||||
/api/v1/auth/refresh → api_auth.refresh
|
||||
/api/v1/auth/logout → api_auth.logout
|
||||
/api/v1/auth/me → api_auth.me
|
||||
/api/v1/devices/register → api_auth.register_device
|
||||
|
||||
Future phases will add:
|
||||
/api/v1/facilities → api_facilities.*
|
||||
/api/v1/inspections → api_inspections.*
|
||||
/api/v1/issues → api_issues.*
|
||||
/api/v1/notifications → api_notifications.*
|
||||
/api/v1/photos/upload → api_photos.*
|
||||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
from app.api.errors import register_error_handlers
|
||||
|
||||
# Parent blueprint — all sub-blueprints registered under this prefix
|
||||
api_bp = Blueprint('api', __name__, url_prefix='/api/v1')
|
||||
|
||||
# Register JSON error handlers so Flask exceptions within /api/v1/
|
||||
# return JSON instead of HTML error pages.
|
||||
register_error_handlers(api_bp)
|
||||
|
||||
|
||||
def register_api(app):
|
||||
"""
|
||||
Import and register all API sub-blueprints onto api_bp, then
|
||||
register api_bp on the Flask app.
|
||||
|
||||
Called once from create_app() in app/__init__.py.
|
||||
"""
|
||||
# ── Phase 1: Auth ────────────────────────────────────────────────────
|
||||
from app.api.auth import bp as auth_bp
|
||||
api_bp.register_blueprint(auth_bp)
|
||||
|
||||
# ── Phase 2+: Additional blueprints registered here as phases complete
|
||||
# from app.api.facilities import bp as facilities_bp
|
||||
# from app.api.inspections import bp as inspections_bp
|
||||
# from app.api.issues import bp as issues_bp
|
||||
# from app.api.notifications import bp as notifications_bp
|
||||
# from app.api.photos import bp as photos_bp
|
||||
# api_bp.register_blueprint(facilities_bp)
|
||||
# api_bp.register_blueprint(inspections_bp)
|
||||
# api_bp.register_blueprint(issues_bp)
|
||||
# api_bp.register_blueprint(notifications_bp)
|
||||
# api_bp.register_blueprint(photos_bp)
|
||||
|
||||
app.register_blueprint(api_bp)
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
app/api/auth.py
|
||||
---------------
|
||||
Authentication endpoints for the JQC mobile app.
|
||||
|
||||
POST /api/v1/auth/login
|
||||
Accepts username + password.
|
||||
Returns a short-lived access token (JWT) and a long-lived refresh token
|
||||
(opaque, stored in DB). The app stores both in the iOS Keychain.
|
||||
|
||||
POST /api/v1/auth/refresh
|
||||
Accepts a refresh token.
|
||||
Returns a new access token. The refresh token is rotated — the old one
|
||||
is revoked and a new one is issued, preventing replay attacks.
|
||||
|
||||
POST /api/v1/auth/logout
|
||||
Accepts a refresh token.
|
||||
Revokes it so it can no longer be used to issue new access tokens.
|
||||
The app should discard both tokens from the Keychain after this call.
|
||||
|
||||
POST /api/v1/devices/register
|
||||
Registers or updates the APNs device token for push notifications.
|
||||
Called on every app launch after the user has already authenticated.
|
||||
Requires a valid access token (JWT).
|
||||
|
||||
GET /api/v1/auth/me
|
||||
Returns the current user's profile from the access token.
|
||||
Useful for the app to verify the token is still valid on launch.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, request, g
|
||||
from flask_wtf.csrf import csrf_exempt
|
||||
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
from app.models.api_token import RefreshToken, DeviceToken
|
||||
from app.api.errors import api_ok, api_error
|
||||
from app.api.jwt_utils import generate_access_token
|
||||
from app.api.decorators import jwt_required
|
||||
from app.utils.audit import log_action, ACTION_LOGIN, ACTION_LOGOUT
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('api_auth', __name__)
|
||||
|
||||
|
||||
def _user_payload(user: User) -> dict:
|
||||
"""Serialize a User to the dict returned in auth responses."""
|
||||
return {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'email': user.email,
|
||||
'role': user.role,
|
||||
'created_at': user.created_at.isoformat() if user.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
# ── Login ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/auth/login', methods=['POST'])
|
||||
@csrf_exempt
|
||||
def login():
|
||||
"""
|
||||
Authenticate with username + password.
|
||||
|
||||
Request JSON
|
||||
------------
|
||||
{
|
||||
"username": "john",
|
||||
"password": "secret",
|
||||
"device_id": "A1B2C3D4...", // UIDevice.identifierForVendor (optional)
|
||||
"device_name": "John's iPhone" // (optional)
|
||||
}
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"access_token": "<jwt>",
|
||||
"refresh_token": "<opaque_hex>",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"user": { id, username, email, role, created_at }
|
||||
}
|
||||
}
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
username = (data.get('username') or '').strip()
|
||||
password = data.get('password') or ''
|
||||
|
||||
if not username or not password:
|
||||
return api_error('username and password are required', 400)
|
||||
|
||||
user = User.query.filter_by(username=username).first()
|
||||
|
||||
# Generic message — never reveal whether the username exists
|
||||
if user is None or not user.check_password(password):
|
||||
logger.warning('API login failed | username=%s | ip=%s',
|
||||
username, request.remote_addr)
|
||||
return api_error('Invalid credentials', 401)
|
||||
|
||||
if not user.active:
|
||||
return api_error('Account is disabled. Please contact an administrator.', 401)
|
||||
|
||||
device_id = (data.get('device_id') or '')[:64] or None
|
||||
device_name = (data.get('device_name') or '')[:100] or None
|
||||
|
||||
# Issue tokens
|
||||
access_token = generate_access_token(user)
|
||||
raw_refresh, rt_row = RefreshToken.create_for(
|
||||
user,
|
||||
device_id=device_id,
|
||||
device_name=device_name,
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
log_action(ACTION_LOGIN, 'User', user.id, user.username,
|
||||
f'source=mobile_api; device_id={device_id}')
|
||||
|
||||
logger.info('API LOGIN | user=%s | role=%s | device_id=%s',
|
||||
user.username, user.role, device_id)
|
||||
|
||||
return api_ok({
|
||||
'access_token': access_token,
|
||||
'refresh_token': raw_refresh,
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': 3600, # seconds — matches ACCESS_TOKEN_LIFETIME_MINUTES * 60
|
||||
'user': _user_payload(user),
|
||||
})
|
||||
|
||||
|
||||
# ── Refresh ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/auth/refresh', methods=['POST'])
|
||||
@csrf_exempt
|
||||
def refresh():
|
||||
"""
|
||||
Exchange a valid refresh token for a new access token.
|
||||
|
||||
The refresh token is rotated on every call — the submitted token is
|
||||
revoked and a brand new one is issued. This limits the damage window
|
||||
if a token is ever stolen.
|
||||
|
||||
Request JSON
|
||||
------------
|
||||
{ "refresh_token": "<opaque_hex>" }
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"access_token": "<new_jwt>",
|
||||
"refresh_token": "<new_opaque_hex>",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600
|
||||
}
|
||||
}
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
raw_token = (data.get('refresh_token') or '').strip()
|
||||
|
||||
if not raw_token:
|
||||
return api_error('refresh_token is required', 400)
|
||||
|
||||
rt_row = RefreshToken.verify(raw_token)
|
||||
if rt_row is None:
|
||||
logger.warning('API refresh rejected | invalid/expired token | ip=%s',
|
||||
request.remote_addr)
|
||||
return api_error('Refresh token is invalid or expired', 401)
|
||||
|
||||
user = User.query.get(rt_row.user_id)
|
||||
if user is None or not user.active:
|
||||
rt_row.revoke()
|
||||
db.session.commit()
|
||||
return api_error('Account not available', 401)
|
||||
|
||||
# Rotate: revoke old token, issue new pair
|
||||
device_id = rt_row.device_id
|
||||
device_name = rt_row.device_name
|
||||
rt_row.revoke()
|
||||
|
||||
new_access = generate_access_token(user)
|
||||
new_raw_refresh, new_rt = RefreshToken.create_for(
|
||||
user,
|
||||
device_id=device_id,
|
||||
device_name=device_name,
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
logger.info('API TOKEN REFRESH | user=%s | device_id=%s',
|
||||
user.username, device_id)
|
||||
|
||||
return api_ok({
|
||||
'access_token': new_access,
|
||||
'refresh_token': new_raw_refresh,
|
||||
'token_type': 'Bearer',
|
||||
'expires_in': 3600,
|
||||
})
|
||||
|
||||
|
||||
# ── Logout ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/auth/logout', methods=['POST'])
|
||||
@csrf_exempt
|
||||
@jwt_required
|
||||
def logout():
|
||||
"""
|
||||
Revoke the current session's refresh token.
|
||||
|
||||
The app should call this when the user taps "Log out" and then discard
|
||||
both the access token and refresh token from the Keychain.
|
||||
|
||||
Request JSON
|
||||
------------
|
||||
{ "refresh_token": "<opaque_hex>" }
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{ "ok": true, "data": { "message": "Logged out" } }
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
raw_token = (data.get('refresh_token') or '').strip()
|
||||
|
||||
if raw_token:
|
||||
rt_row = RefreshToken.verify(raw_token)
|
||||
if rt_row and rt_row.user_id == g.api_user.id:
|
||||
rt_row.revoke()
|
||||
db.session.commit()
|
||||
|
||||
log_action(ACTION_LOGOUT, 'User', g.api_user.id, g.api_user.username,
|
||||
'source=mobile_api')
|
||||
logger.info('API LOGOUT | user=%s', g.api_user.username)
|
||||
|
||||
return api_ok({'message': 'Logged out successfully'})
|
||||
|
||||
|
||||
# ── Current user ──────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/auth/me', methods=['GET'])
|
||||
@jwt_required
|
||||
def me():
|
||||
"""
|
||||
Return the authenticated user's profile.
|
||||
|
||||
Called by the app on launch to verify the stored access token is still
|
||||
valid and to refresh the local user record.
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{ "ok": true, "data": { "user": { id, username, email, role, ... } } }
|
||||
"""
|
||||
return api_ok({'user': _user_payload(g.api_user)})
|
||||
|
||||
|
||||
# ── Device token registration ─────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/devices/register', methods=['POST'])
|
||||
@csrf_exempt
|
||||
@jwt_required
|
||||
def register_device():
|
||||
"""
|
||||
Register or update the APNs device token for the authenticated user.
|
||||
|
||||
Called on every app launch after authentication so the server always
|
||||
has the current token (APNs rotates tokens periodically).
|
||||
|
||||
Request JSON
|
||||
------------
|
||||
{
|
||||
"device_id": "<UIDevice.identifierForVendor>",
|
||||
"apns_token": "<hex_string_from_didRegisterForRemoteNotifications>",
|
||||
"device_name": "John's iPhone", // optional
|
||||
"app_version": "1.0.3" // optional
|
||||
}
|
||||
|
||||
Response 200
|
||||
------------
|
||||
{ "ok": true, "data": { "registered": true } }
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
device_id = (data.get('device_id') or '').strip()[:64]
|
||||
apns_token = (data.get('apns_token') or '').strip()[:200]
|
||||
device_name = (data.get('device_name') or '').strip()[:100] or None
|
||||
app_version = (data.get('app_version') or '').strip()[:20] or None
|
||||
|
||||
if not device_id or not apns_token:
|
||||
return api_error('device_id and apns_token are required', 400)
|
||||
|
||||
# Upsert: update existing row or insert new one
|
||||
existing = DeviceToken.query.filter_by(
|
||||
user_id=g.api_user.id,
|
||||
device_id=device_id,
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
existing.apns_token = apns_token
|
||||
existing.device_name = device_name
|
||||
existing.app_version = app_version
|
||||
existing.registered_at = now_eastern()
|
||||
else:
|
||||
db.session.add(DeviceToken(
|
||||
user_id = g.api_user.id,
|
||||
device_id = device_id,
|
||||
apns_token = apns_token,
|
||||
device_name = device_name,
|
||||
app_version = app_version,
|
||||
))
|
||||
|
||||
db.session.commit()
|
||||
logger.info('API DEVICE REGISTERED | user=%s | device_id=%s | apns_token=...%s',
|
||||
g.api_user.username, device_id, apns_token[-6:])
|
||||
|
||||
return api_ok({'registered': True})
|
||||
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
app/api/decorators.py
|
||||
---------------------
|
||||
Request-level guards for all /api/v1/ endpoints.
|
||||
|
||||
@jwt_required
|
||||
Validates the Bearer token in the Authorization header.
|
||||
On success, sets flask.g.api_user to the authenticated User instance
|
||||
so any route can access it without a second DB query.
|
||||
|
||||
@api_role_required(*roles)
|
||||
Must be applied AFTER @jwt_required.
|
||||
Rejects callers whose role is not in the allowed set.
|
||||
|
||||
Usage
|
||||
-----
|
||||
@bp.route('/inspections')
|
||||
@jwt_required
|
||||
def list_inspections():
|
||||
user = g.api_user
|
||||
...
|
||||
|
||||
@bp.route('/admin/users')
|
||||
@jwt_required
|
||||
@api_role_required('admin')
|
||||
def admin_only():
|
||||
...
|
||||
"""
|
||||
|
||||
import logging
|
||||
from functools import wraps
|
||||
|
||||
from flask import request, g, abort
|
||||
|
||||
from app.api.jwt_utils import decode_access_token
|
||||
from app.api.errors import api_error
|
||||
from app.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def jwt_required(f):
|
||||
"""
|
||||
Validate the JWT Bearer token and load the user into flask.g.api_user.
|
||||
|
||||
Returns 401 if:
|
||||
- Authorization header is missing or malformed
|
||||
- Token is expired or invalid
|
||||
- User referenced by the token no longer exists
|
||||
- User account has been disabled (active=False)
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
if not auth_header.startswith('Bearer '):
|
||||
return api_error('Missing or malformed Authorization header', 401)
|
||||
|
||||
raw_token = auth_header[len('Bearer '):]
|
||||
payload = decode_access_token(raw_token)
|
||||
|
||||
if payload is None:
|
||||
return api_error('Access token is invalid or expired', 401)
|
||||
|
||||
user_id = int(payload.get('sub', 0))
|
||||
user = User.query.get(user_id)
|
||||
|
||||
if user is None:
|
||||
return api_error('User not found', 401)
|
||||
|
||||
if not user.active:
|
||||
return api_error('Account is disabled', 401)
|
||||
|
||||
# Make the user available to the route without re-querying
|
||||
g.api_user = user
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
def api_role_required(*roles):
|
||||
"""
|
||||
Restrict an endpoint to users whose role is in the provided list.
|
||||
|
||||
Must be stacked BELOW @jwt_required so that g.api_user is already set.
|
||||
|
||||
Example
|
||||
-------
|
||||
@jwt_required
|
||||
@api_role_required('admin', 'supervisor')
|
||||
def supervisor_only_route():
|
||||
...
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
user = getattr(g, 'api_user', None)
|
||||
if user is None:
|
||||
# Defensive: jwt_required should always run first
|
||||
return api_error('Authentication required', 401)
|
||||
if user.role not in roles:
|
||||
logger.warning(
|
||||
'API role denied | user=%s role=%s required=%s endpoint=%s',
|
||||
user.username, user.role, roles, request.endpoint,
|
||||
)
|
||||
return api_error('Insufficient permissions', 403)
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
return decorator
|
||||
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
app/api/errors.py
|
||||
-----------------
|
||||
Consistent JSON error responses for every API endpoint.
|
||||
|
||||
Every response — success or failure — uses the same envelope:
|
||||
|
||||
{
|
||||
"ok": true | false,
|
||||
"data": { ... } | null,
|
||||
"error": null | "Human-readable message"
|
||||
}
|
||||
|
||||
Usage
|
||||
-----
|
||||
from app.api.errors import api_error, api_ok
|
||||
|
||||
return api_ok({'inspection': {...}})
|
||||
return api_error('Inspection not found', 404)
|
||||
"""
|
||||
|
||||
from flask import jsonify
|
||||
|
||||
|
||||
def api_ok(data=None, status=200):
|
||||
"""Return a successful JSON response."""
|
||||
return jsonify({
|
||||
'ok': True,
|
||||
'data': data,
|
||||
'error': None,
|
||||
}), status
|
||||
|
||||
|
||||
def api_error(message: str, status: int = 400):
|
||||
"""Return an error JSON response."""
|
||||
return jsonify({
|
||||
'ok': False,
|
||||
'data': None,
|
||||
'error': message,
|
||||
}), status
|
||||
|
||||
|
||||
# ── Registered error handlers (attached to the api blueprint) ─────────────────
|
||||
|
||||
def register_error_handlers(bp):
|
||||
"""
|
||||
Attach JSON error handlers to the given blueprint so that Flask
|
||||
exceptions (404, 405, 500, etc.) return JSON instead of HTML within
|
||||
the /api/v1/ prefix.
|
||||
"""
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
@bp.errorhandler(400)
|
||||
def bad_request(e):
|
||||
return api_error(str(e.description) if hasattr(e, 'description') else 'Bad request', 400)
|
||||
|
||||
@bp.errorhandler(401)
|
||||
def unauthorized(e):
|
||||
return api_error('Authentication required', 401)
|
||||
|
||||
@bp.errorhandler(403)
|
||||
def forbidden(e):
|
||||
return api_error('Access denied', 403)
|
||||
|
||||
@bp.errorhandler(404)
|
||||
def not_found(e):
|
||||
return api_error('Resource not found', 404)
|
||||
|
||||
@bp.errorhandler(405)
|
||||
def method_not_allowed(e):
|
||||
return api_error('Method not allowed', 405)
|
||||
|
||||
@bp.errorhandler(413)
|
||||
def payload_too_large(e):
|
||||
return api_error('Uploaded file is too large', 413)
|
||||
|
||||
@bp.errorhandler(422)
|
||||
def unprocessable(e):
|
||||
return api_error('Unprocessable request', 422)
|
||||
|
||||
@bp.errorhandler(500)
|
||||
def internal_error(e):
|
||||
return api_error('Internal server error', 500)
|
||||
|
||||
@bp.errorhandler(HTTPException)
|
||||
def generic_http(e):
|
||||
return api_error(e.description or e.name, e.code)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
app/api/jwt_utils.py
|
||||
--------------------
|
||||
Thin wrappers around PyJWT for signing and verifying access tokens.
|
||||
|
||||
Access tokens are short-lived JWTs (default 60 minutes) signed with
|
||||
HMAC-SHA256 using the app's SECRET_KEY. They carry only the minimum
|
||||
claims needed to identify the caller:
|
||||
|
||||
{
|
||||
"sub": "42", # user.id as string
|
||||
"role": "inspector", # user.role
|
||||
"iat": 1710000000, # issued-at (UTC epoch)
|
||||
"exp": 1710003600, # expiry (UTC epoch, 60 min later)
|
||||
}
|
||||
|
||||
Refresh tokens are opaque random strings stored in the DB
|
||||
(see app/models/api_token.py). This module only handles JWTs.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
import jwt
|
||||
from flask import current_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ACCESS_TOKEN_LIFETIME_MINUTES = 60
|
||||
|
||||
|
||||
def _secret():
|
||||
return current_app.config['SECRET_KEY']
|
||||
|
||||
|
||||
def generate_access_token(user, lifetime_minutes: int = ACCESS_TOKEN_LIFETIME_MINUTES) -> str:
|
||||
"""
|
||||
Create and sign a new access token for the given user.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
user : User ORM instance
|
||||
lifetime_minutes : Token validity window (default 60 min)
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Signed JWT string ready to include in Authorization header.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
'sub': str(user.id),
|
||||
'role': user.role,
|
||||
'iat': now,
|
||||
'exp': now + timedelta(minutes=lifetime_minutes),
|
||||
}
|
||||
return jwt.encode(payload, _secret(), algorithm='HS256')
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict | None:
|
||||
"""
|
||||
Decode and verify a JWT access token.
|
||||
|
||||
Returns the payload dict on success, or None if the token is invalid,
|
||||
expired, or tampered with. Logs the failure reason at DEBUG level.
|
||||
"""
|
||||
try:
|
||||
return jwt.decode(token, _secret(), algorithms=['HS256'])
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.debug('JWT decode failed: token expired')
|
||||
return None
|
||||
except jwt.InvalidTokenError as exc:
|
||||
logger.debug('JWT decode failed: %s', exc)
|
||||
return None
|
||||
@@ -4,3 +4,4 @@ from app.models.inspection import (InspectionTemplate, ChecklistItem,
|
||||
Inspection, InspectionResult)
|
||||
from app.models.issue import Issue
|
||||
from app.models.project import Project, CustomerAssignment
|
||||
from app.models.api_token import RefreshToken, DeviceToken
|
||||
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
app/models/api_token.py
|
||||
-----------------------
|
||||
Persistent storage for JWT refresh tokens and APNs device tokens.
|
||||
|
||||
RefreshToken
|
||||
One row per active mobile session. When the access token expires the
|
||||
app presents its refresh token here; a new access token is issued and
|
||||
the refresh token is rotated (old one deleted, new one inserted).
|
||||
Revocation is instant: delete the row.
|
||||
|
||||
DeviceToken
|
||||
One row per (user, device) pair. Stores the APNs token so the server
|
||||
can push notifications to the device. Updated on every app launch
|
||||
because APNs tokens can rotate.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from app import db
|
||||
from app.utils.time_utils import now_eastern
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
class RefreshToken(db.Model):
|
||||
"""
|
||||
Opaque refresh token stored server-side.
|
||||
|
||||
The token value itself is a 64-character hex string generated with
|
||||
secrets.token_hex(32). Only the SHA-256 hash is stored so that a DB
|
||||
breach does not expose live tokens.
|
||||
"""
|
||||
__tablename__ = 'api_refresh_tokens'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
# SHA-256 hex digest of the raw token — never store the raw value
|
||||
token_hash = db.Column(db.String(64), nullable=False, unique=True, index=True)
|
||||
# Device identifier supplied by the app (UIDevice.identifierForVendor)
|
||||
device_id = db.Column(db.String(64), nullable=True)
|
||||
device_name = db.Column(db.String(100), nullable=True) # e.g. "John's iPhone"
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
|
||||
expires_at = db.Column(db.DateTime, nullable=False)
|
||||
revoked = db.Column(db.Boolean, nullable=False, default=False)
|
||||
|
||||
user = db.relationship('User', foreign_keys=[user_id],
|
||||
backref=db.backref('refresh_tokens', lazy='dynamic',
|
||||
cascade='all, delete-orphan'))
|
||||
|
||||
@classmethod
|
||||
def create_for(cls, user, device_id=None, device_name=None,
|
||||
lifetime_days=30):
|
||||
"""
|
||||
Generate a new refresh token, persist it, and return the raw token
|
||||
string (only time it is ever available in plaintext).
|
||||
"""
|
||||
import hashlib
|
||||
raw = secrets.token_hex(32) # 64-char hex, 256 bits entropy
|
||||
hashed = hashlib.sha256(raw.encode()).hexdigest()
|
||||
token = cls(
|
||||
user_id = user.id,
|
||||
token_hash = hashed,
|
||||
device_id = device_id,
|
||||
device_name = device_name,
|
||||
expires_at = now_eastern() + timedelta(days=lifetime_days),
|
||||
)
|
||||
db.session.add(token)
|
||||
return raw, token # caller must db.session.commit()
|
||||
|
||||
@classmethod
|
||||
def verify(cls, raw_token):
|
||||
"""
|
||||
Look up a refresh token by its raw value.
|
||||
Returns the RefreshToken row if valid and unexpired, else None.
|
||||
"""
|
||||
import hashlib
|
||||
hashed = hashlib.sha256(raw_token.encode()).hexdigest()
|
||||
row = cls.query.filter_by(token_hash=hashed, revoked=False).first()
|
||||
if row is None:
|
||||
return None
|
||||
if row.expires_at < now_eastern():
|
||||
return None
|
||||
return row
|
||||
|
||||
def revoke(self):
|
||||
self.revoked = True
|
||||
|
||||
def __repr__(self):
|
||||
return f'<RefreshToken user={self.user_id} device={self.device_id}>'
|
||||
|
||||
|
||||
class DeviceToken(db.Model):
|
||||
"""
|
||||
APNs device token for push notification delivery.
|
||||
|
||||
One row per (user, device_id) pair — upserted on every app launch.
|
||||
The apns_token is the hex string returned by the iOS SDK.
|
||||
"""
|
||||
__tablename__ = 'api_device_tokens'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
device_id = db.Column(db.String(64), nullable=False) # UIDevice.identifierForVendor
|
||||
apns_token = db.Column(db.String(200), nullable=False)
|
||||
device_name = db.Column(db.String(100), nullable=True)
|
||||
app_version = db.Column(db.String(20), nullable=True)
|
||||
registered_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('user_id', 'device_id', name='uq_device_token_user_device'),
|
||||
)
|
||||
|
||||
user = db.relationship('User', foreign_keys=[user_id],
|
||||
backref=db.backref('device_tokens', lazy='dynamic',
|
||||
cascade='all, delete-orphan'))
|
||||
|
||||
def __repr__(self):
|
||||
return f'<DeviceToken user={self.user_id} device={self.device_id}>'
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
migrations/versions/phase7_mobile_api.py
|
||||
-----------------------------------------
|
||||
Phase 7 — Mobile API: JWT refresh tokens and APNs device tokens.
|
||||
|
||||
Revision ID : phase7_mobile_api
|
||||
Revises : phase6_features
|
||||
Create Date : 2026-03-17
|
||||
|
||||
New tables
|
||||
----------
|
||||
api_refresh_tokens
|
||||
Stores server-side refresh token hashes for mobile sessions.
|
||||
Enables instant revocation by deleting the row.
|
||||
|
||||
api_device_tokens
|
||||
Stores APNs device tokens for push notification delivery.
|
||||
One row per (user_id, device_id) — upserted on every app launch.
|
||||
|
||||
All columns include safe IF NOT EXISTS / IF EXISTS guards so the
|
||||
migration is idempotent and safe to re-run.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = 'phase7_mobile_api'
|
||||
down_revision = 'phase6_features'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
# ── 1. api_refresh_tokens ─────────────────────────────────────────────
|
||||
if 'api_refresh_tokens' not in tables:
|
||||
op.create_table(
|
||||
'api_refresh_tokens',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('user_id', sa.Integer(),
|
||||
sa.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False),
|
||||
sa.Column('token_hash', sa.String(64), nullable=False, unique=True),
|
||||
sa.Column('device_id', sa.String(64), nullable=True),
|
||||
sa.Column('device_name', sa.String(100), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('revoked', sa.Boolean(), nullable=False,
|
||||
server_default='0'),
|
||||
)
|
||||
op.create_index('ix_api_refresh_tokens_user_id',
|
||||
'api_refresh_tokens', ['user_id'])
|
||||
op.create_index('ix_api_refresh_tokens_token_hash',
|
||||
'api_refresh_tokens', ['token_hash'], unique=True)
|
||||
|
||||
# ── 2. api_device_tokens ──────────────────────────────────────────────
|
||||
if 'api_device_tokens' not in tables:
|
||||
op.create_table(
|
||||
'api_device_tokens',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('user_id', sa.Integer(),
|
||||
sa.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False),
|
||||
sa.Column('device_id', sa.String(64), nullable=False),
|
||||
sa.Column('apns_token', sa.String(200), nullable=False),
|
||||
sa.Column('device_name', sa.String(100), nullable=True),
|
||||
sa.Column('app_version', sa.String(20), nullable=True),
|
||||
sa.Column('registered_at', sa.DateTime(), nullable=False),
|
||||
)
|
||||
op.create_index('ix_api_device_tokens_user_id',
|
||||
'api_device_tokens', ['user_id'])
|
||||
op.create_unique_constraint(
|
||||
'uq_device_token_user_device',
|
||||
'api_device_tokens',
|
||||
['user_id', 'device_id'],
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
if 'api_device_tokens' in tables:
|
||||
op.drop_table('api_device_tokens')
|
||||
|
||||
if 'api_refresh_tokens' in tables:
|
||||
op.drop_table('api_refresh_tokens')
|
||||
@@ -13,3 +13,4 @@ email-validator
|
||||
gunicorn
|
||||
reportlab
|
||||
pytz
|
||||
pyJWT
|
||||
Reference in New Issue
Block a user