March 17 2026: Mobile app phase 1

This commit is contained in:
2026-03-17 17:24:55 -04:00
parent 28196b4a03
commit 75a7b26695
10 changed files with 873 additions and 1 deletions
+4
View File
@@ -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
+60
View File
@@ -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
View File
@@ -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})
+108
View File
@@ -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
+87
View File
@@ -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)
+74
View File
@@ -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
+1
View File
@@ -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
+127
View File
@@ -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}>'
+91
View File
@@ -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
View File
@@ -12,4 +12,5 @@ WTForms
email-validator
gunicorn
reportlab
pytz
pytz
pyJWT