401 lines
14 KiB
Python
401 lines
14 KiB
Python
"""
|
|
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 app import db, limiter
|
|
from app.tenancy.gates import feature_required
|
|
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,
|
|
'full_name': user.full_name or '',
|
|
'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'])
|
|
@limiter.limit('10 per minute; 3 per second')
|
|
# The plan gate belongs HERE, not only on the write endpoints. It used to sit
|
|
# on POST /inspections and POST /issues alone, so a tenant without mobile API
|
|
# access could sign in, sync reference data and let an inspector complete a
|
|
# whole inspection on site — and only then get a 403, with the work already
|
|
# done and no way to submit it. Refusing at the door is the honest answer.
|
|
# Inert in single-tenant mode and for any plan that allows the mobile API.
|
|
@feature_required('mobile_api')
|
|
def login():
|
|
"""
|
|
Authenticate with username + password.
|
|
|
|
Request JSON
|
|
------------
|
|
{
|
|
"username": "john",
|
|
"password": "secret",
|
|
"mfa_code": "123456", // required IF the account has 2FA on
|
|
"device_id": "A1B2C3D4...", // UIDevice.identifierForVendor (optional)
|
|
"device_name": "John's iPhone" // (optional)
|
|
}
|
|
|
|
Response 401 — second factor needed
|
|
-----------------------------------
|
|
{
|
|
"ok": false,
|
|
"error": "A verification code is required for this account.",
|
|
"mfa_required": true
|
|
}
|
|
|
|
The password was correct; the client should prompt for the 6-digit code
|
|
(or a recovery code) and POST again with `mfa_code`.
|
|
|
|
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)
|
|
|
|
# ── Two-factor (phase35 parity) ───────────────────────────────────────
|
|
# The web login defers identity to /auth/mfa when an account has TOTP
|
|
# enabled. This endpoint did not, so anyone who turned MFA on could skip it
|
|
# entirely by signing in through the app — the factor was decorative for
|
|
# exactly the accounts that chose to enable it.
|
|
#
|
|
# Accepts either a TOTP code or a single-use recovery code, the same two
|
|
# the web challenge accepts. A missing code is answered with
|
|
# `mfa_required: true` so a client can prompt for it rather than treating
|
|
# this as a wrong password.
|
|
if user.mfa_enabled and user.mfa_secret:
|
|
from app.utils.mfa import verify_totp, check_and_consume_recovery
|
|
|
|
code = (data.get('mfa_code') or '').strip()
|
|
if not code:
|
|
logger.info('API login | mfa_required | username=%s', user.username)
|
|
return api_error('A verification code is required for this account.',
|
|
401, extra={'mfa_required': True})
|
|
|
|
if not verify_totp(user.mfa_secret, code):
|
|
matched, remaining = check_and_consume_recovery(
|
|
user.mfa_recovery_codes, code)
|
|
if not matched:
|
|
logger.warning('API login | mfa_failed | username=%s | ip=%s',
|
|
user.username, request.remote_addr)
|
|
return api_error('That verification code is not valid.',
|
|
401, extra={'mfa_required': True})
|
|
# Recovery codes are single-use — persist the shortened list before
|
|
# any token is issued, so a crash cannot hand out a login while
|
|
# leaving the code usable again.
|
|
user.mfa_recovery_codes = remaining
|
|
db.session.commit()
|
|
logger.warning('API login | recovery_code_used | username=%s | '
|
|
'remaining=%d', user.username, len(remaining))
|
|
|
|
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()
|
|
|
|
# Passive cleanup — delete expired/revoked tokens for this user only
|
|
# so the table never accumulates dead rows without a cron dependency.
|
|
try:
|
|
from app.utils.time_utils import now_eastern
|
|
now = now_eastern()
|
|
RefreshToken.query.filter(
|
|
RefreshToken.user_id == user.id,
|
|
db.or_(
|
|
RefreshToken.expires_at < now,
|
|
RefreshToken.revoked == True, # noqa: E712
|
|
),
|
|
).delete(synchronize_session=False)
|
|
db.session.commit()
|
|
except Exception as _cleanup_exc:
|
|
logger.warning('API LOGIN passive token cleanup failed: %s', _cleanup_exc)
|
|
db.session.rollback()
|
|
|
|
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'])
|
|
@limiter.limit('30 per minute; 5 per second')
|
|
# Gated too: without it a device that signed in before the plan changed would
|
|
# keep rotating tokens forever and never notice it had lost access.
|
|
# logout stays open on purpose — a blocked device must still be able to
|
|
# surrender its refresh token and clean up.
|
|
@feature_required('mobile_api')
|
|
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 = db.session.get(User, 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'])
|
|
@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'])
|
|
@jwt_required
|
|
def register_device():
|
|
"""
|
|
Register or update device info for the authenticated user.
|
|
|
|
Called on every app launch so the server always has the current
|
|
app version and iOS version for the admin Devices page.
|
|
apns_token is optional (empty string when APNs push is not configured).
|
|
|
|
Request JSON
|
|
------------
|
|
{
|
|
"device_id": "<stable UUID from Keychain>",
|
|
"device_name": "Nguyen\'s iPad",
|
|
"app_version": "1.0.3",
|
|
"ios_version": "18.3.1",
|
|
"apns_token": ""
|
|
}
|
|
|
|
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
|
|
ios_version = (data.get('ios_version') or '').strip()[:20] or None
|
|
|
|
if not device_id:
|
|
return api_error('device_id is required', 400)
|
|
|
|
now = now_eastern()
|
|
|
|
existing = DeviceToken.query.filter_by(
|
|
user_id=g.api_user.id,
|
|
device_id=device_id,
|
|
).first()
|
|
|
|
if existing:
|
|
existing.apns_token = apns_token or existing.apns_token
|
|
existing.device_name = device_name or existing.device_name
|
|
existing.app_version = app_version or existing.app_version
|
|
existing.ios_version = ios_version or existing.ios_version
|
|
existing.last_seen_at = now
|
|
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,
|
|
ios_version = ios_version,
|
|
last_seen_at = now,
|
|
))
|
|
|
|
db.session.commit()
|
|
logger.info('API DEVICE REGISTERED | user=%s | device_id=%s | app=%s | ios=%s',
|
|
g.api_user.username, device_id[:8], app_version, ios_version)
|
|
|
|
return api_ok({'registered': True})
|