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(customers.bp)
|
||||||
app.register_blueprint(scheduled_reports.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 ───────────────────────
|
# ── Error handler: 413 Request Entity Too Large ───────────────────────
|
||||||
# Nginx can return 413 before Flask sees the request; this handler covers
|
# Nginx can return 413 before Flask sees the request; this handler covers
|
||||||
# the Flask-side rejection and gives users a clear, actionable message
|
# 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)
|
Inspection, InspectionResult)
|
||||||
from app.models.issue import Issue
|
from app.models.issue import Issue
|
||||||
from app.models.project import Project, CustomerAssignment
|
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')
|
||||||
+2
-1
@@ -12,4 +12,5 @@ WTForms
|
|||||||
email-validator
|
email-validator
|
||||||
gunicorn
|
gunicorn
|
||||||
reportlab
|
reportlab
|
||||||
pytz
|
pytz
|
||||||
|
pyJWT
|
||||||
Reference in New Issue
Block a user