Skip to content

Commit beb4fe9

Browse files
committed
Fix isort failure in mod_api/__init__.py
1 parent ae5de4f commit beb4fe9

12 files changed

Lines changed: 229 additions & 121 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Add api_token table for scoped API token auth.
2+
3+
Revision ID: d4f8e2a1b3c7
4+
Revises: c8f3a2b1d4e5
5+
Create Date: 2026-06-11 03:00:00.000000
6+
7+
"""
8+
import sqlalchemy as sa
9+
from alembic import op
10+
11+
# revision identifiers, used by Alembic.
12+
revision = 'd4f8e2a1b3c7'
13+
down_revision = 'c8f3a2b1d4e5'
14+
branch_labels = None
15+
depends_on = None
16+
17+
18+
def upgrade():
19+
"""Apply the migration."""
20+
op.add_column('user', sa.Column('github_login', sa.String(length=255), nullable=True))
21+
op.create_table(
22+
'api_token',
23+
sa.Column('id', sa.Integer(), nullable=False, autoincrement=True),
24+
sa.Column('user_id', sa.Integer(), nullable=False),
25+
sa.Column('token_name', sa.String(length=50), nullable=False),
26+
sa.Column('token_hash', sa.String(length=255), nullable=False),
27+
sa.Column('token_prefix', sa.String(length=16), nullable=False),
28+
sa.Column('scopes_json', sa.Text(), nullable=False),
29+
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
30+
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
31+
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
32+
sa.PrimaryKeyConstraint('id'),
33+
sa.ForeignKeyConstraint(['user_id'], ['user.id'], onupdate='CASCADE', ondelete='CASCADE'),
34+
sa.UniqueConstraint('user_id', 'token_name', name='uq_user_token_name'),
35+
mysql_engine='InnoDB'
36+
)
37+
op.create_index('ix_api_token_token_prefix', 'api_token', ['token_prefix'])
38+
39+
40+
def downgrade():
41+
"""Revert the migration."""
42+
op.drop_index('ix_api_token_token_prefix', table_name='api_token')
43+
op.drop_table('api_token')
44+
op.drop_column('user', 'github_login')

mod_api/__init__.py

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,28 @@
99

1010
mod_api = Blueprint('api', __name__)
1111

12-
# Middleware (registers before_request hooks and error handlers)
13-
# WARNING: auth must be imported before rate_limit. The auth middleware
14-
# manually calls check_rate_limit() for unauthenticated paths. If
15-
# rate_limit is imported first, its before_request hook fires first and
16-
# the auth middleware's manual call would double-count requests.
17-
from mod_api.middleware import auth # noqa: E402, F401
18-
from mod_api.middleware import error_handler # noqa: E402, F401
19-
from mod_api.middleware import rate_limit # noqa: E402, F401
20-
from mod_api.middleware import security # noqa: E402, F401
12+
# Middleware imports
13+
from mod_api.middleware import auth # noqa: E402
14+
from mod_api.middleware import error_handler # noqa: E402
15+
from mod_api.middleware import rate_limit # noqa: E402
16+
from mod_api.middleware import security # noqa: E402
17+
18+
# Explicitly register before_request hooks in the exact order they should run
19+
mod_api.before_request(auth.authenticate_request)
20+
mod_api.before_request(rate_limit.check_rate_limit)
21+
mod_api.before_request(auth.enforce_auth_error)
22+
23+
# Explicitly register after_request hooks.
24+
# NOTE: Flask executes after_request hooks in REVERSE registration order.
25+
# Registration: security → rate_limit → (convert is app-level, see below)
26+
# Execution: rate_limit → security
27+
# This means rate-limit headers are added first, then security headers layer
28+
# on top — both on the same response object.
29+
mod_api.after_request(security.add_security_headers)
30+
mod_api.after_request(rate_limit.add_rate_limit_headers)
31+
32+
# Registered as after_app_request so it fires for ALL requests (including
33+
# routing-level 404s/405s that never enter the blueprint).
34+
mod_api.after_app_request(error_handler.convert_api_errors_to_json)
35+
2136
# Route modules will be imported in subsequent PRs.

mod_api/middleware/auth.py

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515

1616
from flask import g, request
1717

18-
from mod_api import mod_api
1918
from mod_api.middleware.error_handler import make_error_response
2019
from mod_api.models.api_token import ApiToken
2120

@@ -30,41 +29,47 @@
3029

3130
def _unauthorized():
3231
"""Shorthand for a 401 response with the standard auth failure message."""
33-
from mod_api.middleware.rate_limit import check_rate_limit
34-
rate_limit_resp = check_rate_limit()
35-
if rate_limit_resp:
36-
return rate_limit_resp
37-
3832
return make_error_response(
3933
'unauthorized', _AUTH_FAILED_MSG, http_status=401)
4034

4135

42-
@mod_api.before_request
4336
def authenticate_request():
44-
"""Validate Bearer token and attach user context to the request."""
37+
"""Validate Bearer token and attach user context to the request.
38+
39+
If auth fails, sets g.auth_error instead of returning immediately,
40+
so that subsequent hooks (like rate limiting) still run.
41+
"""
4542
if request.endpoint in _PUBLIC_ENDPOINTS:
4643
g.api_user = None
4744
g.api_token = None
4845
return
4946

5047
auth_header = request.headers.get('Authorization', '')
5148
if not auth_header:
52-
return _unauthorized()
49+
g.auth_error = _unauthorized()
50+
return
5351

5452
parts = auth_header.split(' ', 1)
5553
if len(parts) != 2 or parts[0] != 'Bearer':
56-
return _unauthorized()
54+
g.auth_error = _unauthorized()
55+
return
5756

5857
token_value = parts[1].strip()
5958
if not token_value or not token_value.startswith('spci_'):
60-
return _unauthorized()
59+
g.auth_error = _unauthorized()
60+
return
6161

6262
# Look up by prefix, then verify the full hash against each candidate.
6363
prefix = ApiToken.extract_prefix(token_value)
6464
candidates = ApiToken.query.filter_by(token_prefix=prefix).all()
6565

6666
if not candidates:
67-
return _unauthorized()
67+
# Dummy verification to prevent timing attacks on non-existent tokens
68+
ApiToken.verify_token(
69+
'dummy',
70+
'$argon2id$v=19$m=65536,t=3,p=4$ZHVtbXlfc2FsdF9mb3JfdGltaW5n$A1H8jT2lJ1t5fX9gK0rX4M')
71+
g.auth_error = _unauthorized()
72+
return
6873

6974
matched_token = None
7075
for candidate in candidates:
@@ -73,15 +78,23 @@ def authenticate_request():
7378
break
7479

7580
if matched_token is None:
76-
return _unauthorized()
81+
g.auth_error = _unauthorized()
82+
return
7783

7884
if not matched_token.is_valid:
79-
return _unauthorized()
85+
g.auth_error = _unauthorized()
86+
return
8087

8188
g.api_token = matched_token
8289
g.api_user = matched_token.user
8390

8491

92+
def enforce_auth_error():
93+
"""Return any stored auth errors after rate limiting."""
94+
if hasattr(g, 'auth_error') and g.auth_error is not None:
95+
return g.auth_error
96+
97+
8598
def require_scope(*scopes: str):
8699
"""Reject the request if the token lacks any of the ``scopes``."""
87100
def decorator(f):

mod_api/middleware/error_handler.py

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Structured JSON error responses for API routes."""
22

3-
from flask import jsonify, make_response, request
3+
from flask import current_app, jsonify, request
44
from marshmallow import ValidationError as MarshmallowValidationError
55
from sqlalchemy.exc import SQLAlchemyError
66

@@ -101,6 +101,7 @@ def handle_429(error):
101101
@mod_api.errorhandler(500)
102102
def handle_500(error):
103103
"""Handle unexpected server errors for API routes."""
104+
current_app.logger.exception(error)
104105
return make_error_response(
105106
'internal_error',
106107
'An unexpected error occurred.',
@@ -122,27 +123,42 @@ def handle_marshmallow_validation_error(error):
122123
@mod_api.errorhandler(SQLAlchemyError)
123124
def handle_sqlalchemy_error(error):
124125
"""Log database errors."""
125-
from flask import g
126-
log = getattr(g, 'log', None)
127-
if log:
128-
log.error(f'Database error in API: {type(error).__name__}')
126+
current_app.logger.exception(error)
129127
return make_error_response(
130128
'internal_error',
131129
'An unexpected database error occurred.',
132130
http_status=500,
133131
)
134132

135133

136-
@mod_api.after_app_request
134+
@mod_api.errorhandler(ValueError)
135+
def handle_value_error(error):
136+
"""Catch plain ValueErrors raised by model @validates (e.g. scopes_json)."""
137+
return make_error_response(
138+
'invalid_input',
139+
str(error),
140+
http_status=400,
141+
)
142+
143+
137144
def convert_api_errors_to_json(response):
138145
"""Catch routing errors that were handled by global app handlers and convert them to JSON."""
139146
if request.path.startswith(_API_PREFIX):
140147
if response.status_code >= 500:
141-
return make_error_response(
148+
new_resp = make_error_response(
142149
'internal_error', 'An unexpected error occurred.', http_status=response.status_code
143150
)
151+
response.data = new_resp.data
152+
response.mimetype = new_resp.mimetype
153+
return response
144154
if response.status_code == 404:
145-
return make_error_response('not_found', 'Resource not found.', http_status=404)
155+
new_resp = make_error_response('not_found', 'Resource not found.', http_status=404)
156+
response.data = new_resp.data
157+
response.mimetype = new_resp.mimetype
158+
return response
146159
if response.status_code == 405:
147-
return make_error_response('method_not_allowed', 'Method not allowed.', http_status=405)
160+
new_resp = make_error_response('method_not_allowed', 'Method not allowed.', http_status=405)
161+
response.data = new_resp.data
162+
response.mimetype = new_resp.mimetype
163+
return response
148164
return response

mod_api/middleware/rate_limit.py

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717
import threading
1818
import time
1919

20-
from flask import g, request
20+
from flask import current_app, g, request
2121

22-
from mod_api import mod_api
22+
from mod_api.middleware.error_handler import make_error_response
2323

2424
_rate_limit_store = {} # key -> {'count': int, 'window_start': float}
2525
_rate_limit_lock = threading.Lock()
@@ -45,7 +45,7 @@ def _evict_stale_entries():
4545

4646

4747
def _get_client_ip():
48-
"""Extract the real client IP, ignoring X-Forwarded-For to prevent spoofing."""
48+
"""Extract the real client IP (ProxyFix handles X-Forwarded-For securely)."""
4949
return request.remote_addr
5050

5151

@@ -68,10 +68,8 @@ def _get_limits():
6868
return 120, 60
6969

7070

71-
@mod_api.before_request
7271
def check_rate_limit():
73-
"""Reject the request if the client has exceeded their rate limit."""
74-
from flask import current_app
72+
"""Apply rate limits based on client IP or API token."""
7573
if current_app.config.get('TESTING'):
7674
return
7775

@@ -92,8 +90,6 @@ def check_rate_limit():
9290
reset_at = int(entry['window_start'] + window_seconds)
9391
retry_after = max(1, reset_at - int(now))
9492

95-
from mod_api.middleware.error_handler import \
96-
make_error_response
9793
response = make_error_response(
9894
'rate_limited',
9995
f'Rate limit exceeded. Retry after {retry_after} seconds.',
@@ -111,11 +107,9 @@ def check_rate_limit():
111107
return response
112108

113109

114-
@mod_api.after_request
115110
def add_rate_limit_headers(response):
116-
"""Attach X-RateLimit-* headers to every response."""
117-
from flask import current_app
118-
if current_app.config.get('TESTING'):
111+
"""Inject X-RateLimit-* headers based on the current window."""
112+
if current_app.config.get('TESTING') or response.status_code == 429:
119113
return response
120114

121115
key = _get_rate_limit_key()

mod_api/middleware/security.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
from mod_api import mod_api
1+
"""Security headers middleware for API responses."""
22

33

4-
@mod_api.after_request
54
def add_security_headers(response):
65
"""Attach security headers to all API responses."""
76
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'

0 commit comments

Comments
 (0)