feat(backend): Phase 2/3 hardening release
Roadmap P0 — platform safety & ops
- Merge duplicate encoach.student.attempt/answer models into encoach_scoring
and drop the stale encoach_exam_template copies.
- Remove duplicate /api/exam/* routes; canonicalize on one controller tree.
- Gate raw-SQL seeds in seed_demo_data.py behind an explicit env flag.
- Add /api/health and /api/health/ready (DB + LLM reachability) endpoints.
- Fix docker-compose + ship odoo-docker.conf for container-local runs.
- Enforce OpenAI request_timeout=30s and @jwt_required on all AI/coach routes.
- Promote canonical cefr_mapper to encoach_ai.services.cefr_mapper.
- JWT cache TTL=30s + invalidation hook on user mutation.
Roadmap P1 — exam correctness & data provenance
- Wire QualityChecker + IeltsValidator into exam submit with a
pending_review gate (encoach_ai.services.question_validator).
- Populate RAG metadata (course_id, subject_id, entity_id, taxonomy) on
encoach_vector embeddings and add a chunking pipeline (>2000 chars).
- Add provenance fields on encoach.question (model, prompt_hash, log_id)
and validate LLM output with schema before DB insert.
- Unify response envelope to {items,total,page,size}.
- Approval reject rollback with savepoint atomicity.
- Ticket notifications on status/assignee change.
Roadmap P2 — performance & observability
- Reports: replace Python loops with SQL read_group aggregations.
- X-Request-ID middleware + structured JSON logs.
- In-process/Prometheus counters and openapi.py controller exporting a
spec by scanning @http.route decorators.
- Paymob real checkout + HMAC-SHA512 webhook verification, backed by a
new encoach.paymob.order model and ir.config_parameter credentials.
- JWT refresh tokens + revocation table.
- Composite DB indexes on hot report/ticket/attempt paths.
Roadmap P3 — human-in-the-loop & compliance
- Human-in-the-loop exam review workflow (pending_review → publish) with
new review controller and status transitions.
- encoach.ai.prompt model + versioning + admin editor endpoints (one
active version per key, render-preview dry run).
- Student feedback loop → encoach.ai.feedback (upsert per user/subject,
admin triage + resolve endpoints).
- GDPR export (/api/gdpr/export) and right-to-erasure (/api/gdpr/delete)
with anonymization, tombstone record, and admin-self-erasure guard.
- HttpCase smoke tests for /api/health and /api/health/ready.
Made-with: Cursor
This commit is contained in:
@@ -1,5 +1,25 @@
|
||||
"""Authentication endpoints.
|
||||
|
||||
Split-token design:
|
||||
|
||||
* ``access_token`` — short-lived (1h), stateless, verified via signature only.
|
||||
Sent on every request as ``Authorization: Bearer <access_token>``.
|
||||
* ``refresh_token`` — long-lived (7d), stateful, backed by
|
||||
``encoach.jwt.token``. Never sent on regular API calls; only to
|
||||
``/api/auth/refresh``. Rotated on every refresh so a leaked token is
|
||||
usable at most once.
|
||||
|
||||
The frontend should store ``refresh_token`` in a secure, non-extractable
|
||||
location (ideally ``httpOnly`` cookie if the deployment proxy allows it,
|
||||
otherwise ``localStorage`` behind an explicit trust boundary) and call
|
||||
``/api/auth/refresh`` in the background whenever the access token is within
|
||||
2 minutes of expiry.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import jwt as pyjwt
|
||||
|
||||
@@ -13,6 +33,40 @@ from .base import (
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
ACCESS_TTL_SECONDS = 3600 # 1 hour
|
||||
REFRESH_TTL_SECONDS = 7 * 86400 # 7 days
|
||||
|
||||
|
||||
def _issue_tokens(user, *, user_agent=None, remote_ip=None):
|
||||
"""Mint a fresh ``(access_token, refresh_token, refresh_jti)`` triple.
|
||||
|
||||
Persisting the refresh token is the caller's job — they get the ``jti``
|
||||
back so they can insert exactly one row.
|
||||
"""
|
||||
secret = _get_jwt_secret()
|
||||
now = int(time.time())
|
||||
access_token = pyjwt.encode(
|
||||
{
|
||||
"user_id": user.id,
|
||||
"type": "access",
|
||||
"iat": now,
|
||||
"exp": now + ACCESS_TTL_SECONDS,
|
||||
},
|
||||
secret, algorithm="HS256",
|
||||
)
|
||||
refresh_jti = uuid.uuid4().hex
|
||||
refresh_token = pyjwt.encode(
|
||||
{
|
||||
"user_id": user.id,
|
||||
"type": "refresh",
|
||||
"jti": refresh_jti,
|
||||
"iat": now,
|
||||
"exp": now + REFRESH_TTL_SECONDS,
|
||||
},
|
||||
secret, algorithm="HS256",
|
||||
)
|
||||
return access_token, refresh_token, refresh_jti
|
||||
|
||||
|
||||
class EncoachAuthController(http.Controller):
|
||||
|
||||
@@ -30,7 +84,6 @@ class EncoachAuthController(http.Controller):
|
||||
if not login or not password:
|
||||
return _error_response('login and password are required', 400)
|
||||
|
||||
# Odoo 19: session.authenticate(env, credential_dict)
|
||||
credential = {
|
||||
'type': 'password',
|
||||
'login': login,
|
||||
@@ -49,26 +102,41 @@ class EncoachAuthController(http.Controller):
|
||||
|
||||
user = request.env['res.users'].sudo().browse(uid)
|
||||
|
||||
# Generate JWT token
|
||||
secret = _get_jwt_secret()
|
||||
if not secret:
|
||||
return _error_response('JWT not configured on server', 500)
|
||||
|
||||
token = pyjwt.encode(
|
||||
{'user_id': user.id, 'exp': int(time.time()) + 86400},
|
||||
secret, algorithm='HS256',
|
||||
)
|
||||
# User-Agent / IP are best-effort — some proxies strip them; we
|
||||
# record whatever we get but never refuse login because they're
|
||||
# missing.
|
||||
ua = request.httprequest.headers.get('User-Agent') if request.httprequest else ''
|
||||
ip = request.httprequest.remote_addr if request.httprequest else ''
|
||||
|
||||
access_token, refresh_token, refresh_jti = _issue_tokens(
|
||||
user, user_agent=ua, remote_ip=ip,
|
||||
)
|
||||
request.env['encoach.jwt.token'].sudo().create({
|
||||
'jti': refresh_jti,
|
||||
'user_id': user.id,
|
||||
'issued_at': fields.Datetime.now(),
|
||||
'expires_at': datetime.utcnow() + timedelta(seconds=REFRESH_TTL_SECONDS),
|
||||
'user_agent': (ua or '')[:255],
|
||||
'remote_ip': (ip or '')[:64],
|
||||
})
|
||||
|
||||
# Get permissions
|
||||
permissions = []
|
||||
if hasattr(user, 'get_all_permissions'):
|
||||
permissions = user.get_all_permissions().mapped('code')
|
||||
|
||||
# Update last login
|
||||
user.write({'last_login': fields.Datetime.now()})
|
||||
|
||||
return _json_response({
|
||||
'token': token,
|
||||
'token': access_token, # legacy name (frontend fallback)
|
||||
'access_token': access_token,
|
||||
'refresh_token': refresh_token,
|
||||
'expires_in': ACCESS_TTL_SECONDS,
|
||||
'refresh_expires_in': REFRESH_TTL_SECONDS,
|
||||
'token_type': 'Bearer',
|
||||
'user': self._user_to_dict(user),
|
||||
'permissions': permissions,
|
||||
})
|
||||
@@ -77,6 +145,90 @@ class EncoachAuthController(http.Controller):
|
||||
_logger.exception('login failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/auth/refresh
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/auth/refresh', type='http', auth='public',
|
||||
methods=['POST'], csrf=False)
|
||||
def refresh(self, **kw):
|
||||
"""Rotate a refresh token into a fresh access+refresh pair.
|
||||
|
||||
Consumes (revokes) the presented refresh token and issues new ones.
|
||||
This is the *only* place a refresh token is ever accepted, so replay
|
||||
attempts against other endpoints fail at the access-token layer.
|
||||
"""
|
||||
try:
|
||||
body = _get_json_body()
|
||||
token = body.get('refresh_token') or ''
|
||||
if not token:
|
||||
return _error_response('refresh_token is required', 400)
|
||||
|
||||
secret = _get_jwt_secret()
|
||||
if not secret:
|
||||
return _error_response('JWT not configured on server', 500)
|
||||
|
||||
try:
|
||||
claims = pyjwt.decode(token, secret, algorithms=['HS256'])
|
||||
except pyjwt.ExpiredSignatureError:
|
||||
return _error_response('refresh_token expired', 401)
|
||||
except pyjwt.InvalidTokenError:
|
||||
return _error_response('refresh_token invalid', 401)
|
||||
|
||||
if claims.get('type') != 'refresh':
|
||||
return _error_response('wrong token type', 401)
|
||||
|
||||
jti = claims.get('jti')
|
||||
user_id = claims.get('user_id')
|
||||
if not jti or not user_id:
|
||||
return _error_response('refresh_token malformed', 401)
|
||||
|
||||
Token = request.env['encoach.jwt.token'].sudo()
|
||||
row = Token.search([
|
||||
('jti', '=', jti),
|
||||
('user_id', '=', user_id),
|
||||
('revoked', '=', False),
|
||||
], limit=1)
|
||||
if not row:
|
||||
# Either already rotated (possible replay) or never existed.
|
||||
# Revoke every active token for that user as a defensive
|
||||
# measure — stolen refresh tokens shouldn't buy multiple
|
||||
# rotations.
|
||||
Token.search([
|
||||
('user_id', '=', user_id),
|
||||
('revoked', '=', False),
|
||||
]).write({'revoked': True})
|
||||
return _error_response('refresh_token revoked', 401)
|
||||
|
||||
user = request.env['res.users'].sudo().browse(user_id)
|
||||
if not user.exists() or not user.active:
|
||||
return _error_response('user disabled', 401)
|
||||
|
||||
ua = request.httprequest.headers.get('User-Agent') if request.httprequest else ''
|
||||
ip = request.httprequest.remote_addr if request.httprequest else ''
|
||||
access_token, refresh_token, refresh_jti = _issue_tokens(
|
||||
user, user_agent=ua, remote_ip=ip,
|
||||
)
|
||||
row.write({'revoked': True, 'last_used_at': fields.Datetime.now()})
|
||||
Token.create({
|
||||
'jti': refresh_jti,
|
||||
'user_id': user.id,
|
||||
'issued_at': fields.Datetime.now(),
|
||||
'expires_at': datetime.utcnow() + timedelta(seconds=REFRESH_TTL_SECONDS),
|
||||
'user_agent': (ua or '')[:255],
|
||||
'remote_ip': (ip or '')[:64],
|
||||
})
|
||||
return _json_response({
|
||||
'access_token': access_token,
|
||||
'token': access_token,
|
||||
'refresh_token': refresh_token,
|
||||
'expires_in': ACCESS_TTL_SECONDS,
|
||||
'refresh_expires_in': REFRESH_TTL_SECONDS,
|
||||
'token_type': 'Bearer',
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('refresh failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/user (returns current authenticated user)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -107,7 +259,32 @@ class EncoachAuthController(http.Controller):
|
||||
@http.route('/api/logout', type='http', auth='public',
|
||||
methods=['POST'], csrf=False)
|
||||
def logout(self, **kw):
|
||||
# JWT is stateless — client clears token. Server just returns OK.
|
||||
"""Revoke the presented refresh token (if any) and return OK.
|
||||
|
||||
Access tokens remain stateless; they expire naturally within an hour.
|
||||
Clients that care about immediate lockout should also delete the
|
||||
access token from local storage when they call this endpoint.
|
||||
"""
|
||||
try:
|
||||
body = _get_json_body() or {}
|
||||
token = body.get('refresh_token') or ''
|
||||
if token:
|
||||
secret = _get_jwt_secret()
|
||||
if secret:
|
||||
try:
|
||||
claims = pyjwt.decode(
|
||||
token, secret, algorithms=['HS256'],
|
||||
options={'verify_exp': False},
|
||||
)
|
||||
jti = claims.get('jti')
|
||||
if jti:
|
||||
request.env['encoach.jwt.token'].sudo().revoke_by_jti(jti)
|
||||
except pyjwt.InvalidTokenError:
|
||||
# Invalid tokens can't be revoked but also can't be
|
||||
# reused, so this is effectively a no-op.
|
||||
pass
|
||||
except Exception:
|
||||
_logger.exception('logout cleanup failed')
|
||||
return _json_response({'ok': True})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user