Files

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})