324 lines
12 KiB
Python
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': '',
|
|
}
|