"""Authentication endpoints. Split-token design: * ``access_token`` — short-lived (1h), stateless, verified via signature only. Sent on every request as ``Authorization: Bearer ``. * ``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': '', }