Files
encoach_backend_new_v2/custom_addons/encoach_api/controllers/auth.py
Yamen Ahmad 3972023a30 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
2026-04-19 14:16:09 +04:00

324 lines
12 KiB
Python

"""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
from odoo import http, fields
from odoo.http import request
from odoo.exceptions import AccessDenied
from .base import (
_json_response, _error_response, _get_json_body, _get_jwt_secret, validate_token,
)
_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):
# ------------------------------------------------------------------
# POST /api/login
# ------------------------------------------------------------------
@http.route('/api/login', type='http', auth='public',
methods=['POST'], csrf=False)
def login(self, **kw):
try:
body = _get_json_body()
login = (body.get('login') or body.get('email') or '').strip().lower()
password = body.get('password', '')
if not login or not password:
return _error_response('login and password are required', 400)
credential = {
'type': 'password',
'login': login,
'password': password,
}
try:
request.session.authenticate(request.env, credential)
uid = request.session.uid
if not uid:
return _error_response('Invalid email or password', 401)
except AccessDenied:
return _error_response('Invalid email or password', 401)
except Exception as auth_err:
_logger.warning('Auth error for %s: %s', login, auth_err)
return _error_response('Invalid email or password', 401)
user = request.env['res.users'].sudo().browse(uid)
secret = _get_jwt_secret()
if not secret:
return _error_response('JWT not configured on server', 500)
# 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],
})
permissions = []
if hasattr(user, 'get_all_permissions'):
permissions = user.get_all_permissions().mapped('code')
user.write({'last_login': fields.Datetime.now()})
return _json_response({
'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,
})
except Exception as e:
_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)
# ------------------------------------------------------------------
@http.route('/api/user', type='http', auth='public',
methods=['GET'], csrf=False)
def get_current_user(self, **kw):
try:
user = validate_token()
if not user:
return _error_response('Authentication required', 401)
permissions = []
if hasattr(user, 'get_all_permissions'):
permissions = user.get_all_permissions().mapped('code')
return _json_response({
'user': self._user_to_dict(user),
'permissions': permissions,
})
except Exception as e:
_logger.exception('get_current_user failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/logout
# ------------------------------------------------------------------
@http.route('/api/logout', type='http', auth='public',
methods=['POST'], csrf=False)
def logout(self, **kw):
"""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})
# ------------------------------------------------------------------
# helpers
# ------------------------------------------------------------------
def _user_to_dict(self, user):
"""Convert res.users to the dict shape the React frontend expects."""
entities = []
if hasattr(user, 'entity_ids'):
entities = [
{'id': e.id, 'name': e.name, 'role': ''}
for e in user.entity_ids
]
classrooms = []
return {
'id': user.id,
'name': user.name or '',
'email': user.email or '',
'login': user.login or '',
'user_type': getattr(user, '_api_user_type', lambda: user.user_type or 'student')()
if callable(getattr(user, '_api_user_type', None))
else (user.user_type or 'student'),
'avatar': bool(getattr(user, 'encoach_avatar', False)),
'phone': user.phone or '',
'country': '',
'timezone': '',
'bio': '',
'gender': getattr(user, 'gender', '') or '',
'student_id': '',
'is_verified': getattr(user, 'is_verified', False),
'entities': entities,
'classrooms': classrooms,
'expiry_date': '',
}