Aug 27 - Update free-plan gate, MFA is no longer bypassable via the app
This commit is contained in:
@@ -32,6 +32,7 @@ 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
|
||||
@@ -61,6 +62,13 @@ def _user_payload(user: User) -> dict:
|
||||
|
||||
@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.
|
||||
@@ -70,10 +78,22 @@ def login():
|
||||
{
|
||||
"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
|
||||
------------
|
||||
{
|
||||
@@ -106,6 +126,41 @@ def login():
|
||||
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
|
||||
|
||||
@@ -154,6 +209,11 @@ def login():
|
||||
|
||||
@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.
|
||||
|
||||
+15
-4
@@ -31,13 +31,24 @@ def api_ok(data=None, status=200):
|
||||
}), status
|
||||
|
||||
|
||||
def api_error(message: str, status: int = 400):
|
||||
"""Return an error JSON response."""
|
||||
return jsonify({
|
||||
def api_error(message: str, status: int = 400, extra: dict | None = None):
|
||||
"""Return an error JSON response.
|
||||
|
||||
`extra` merges additional top-level keys into the envelope — for flags a
|
||||
client must branch on rather than parse out of the message, e.g.
|
||||
`mfa_required` on a login that needs a second factor. Reserved keys
|
||||
(ok/data/error) always win, so a caller cannot accidentally rewrite the
|
||||
envelope's shape.
|
||||
"""
|
||||
payload = {
|
||||
'ok': False,
|
||||
'data': None,
|
||||
'error': message,
|
||||
}), status
|
||||
}
|
||||
if extra:
|
||||
for k, v in extra.items():
|
||||
payload.setdefault(k, v)
|
||||
return jsonify(payload), status
|
||||
|
||||
|
||||
# ── Registered error handlers (attached to the api blueprint) ─────────────────
|
||||
|
||||
Reference in New Issue
Block a user