400 lines
15 KiB
Python
400 lines
15 KiB
Python
"""
|
|
Authentication Routes (admin-edu-space)
|
|
Implements Hybrid Authentication:
|
|
- Local Login with admin fallback
|
|
- Google OAuth 2.0 (inspired by AlumnosLS domain & email validation)
|
|
- Moodle Delegated Authentication with dynamic role mapping
|
|
- Token Refresh & Session Management (Redis / JWT)
|
|
"""
|
|
from flask import Blueprint, request, jsonify, g, make_response
|
|
from pydantic import ValidationError
|
|
import jwt
|
|
import requests
|
|
import logging
|
|
|
|
from app import db
|
|
from app.models.user import User
|
|
from app.models.role import Role
|
|
from app.models.setting import SystemSetting
|
|
from app.services.user_service import UserService
|
|
from app.services.jwt_service import JWTService
|
|
from app.services.cache_service import cache_service
|
|
from app.services.moodle_client import moodle_client
|
|
from app.schemas.auth_dto import LoginDTO, RefreshTokenDTO
|
|
from app.utils.jwt_decorators import jwt_required
|
|
|
|
logger = logging.getLogger(__name__)
|
|
api_auth_bp = Blueprint('api_auth', __name__, url_prefix='/api/v1/auth')
|
|
user_service = UserService()
|
|
|
|
|
|
@api_auth_bp.route('/providers', methods=['GET'])
|
|
def get_auth_providers():
|
|
"""
|
|
Public endpoint returning active authentication methods and Google client ID for the UI.
|
|
"""
|
|
local_val = SystemSetting.get_value('auth_local_enabled', 'true').lower() in ('true', '1')
|
|
google_val = SystemSetting.get_value('auth_google_enabled', 'false').lower() in ('true', '1')
|
|
moodle_val = SystemSetting.get_value('auth_moodle_enabled', 'false').lower() in ('true', '1')
|
|
google_client_id = SystemSetting.get_value('google_client_id', '')
|
|
|
|
return jsonify({
|
|
'status': 'success',
|
|
'providers': {
|
|
'local': local_val,
|
|
'google': google_val,
|
|
'moodle': moodle_val
|
|
},
|
|
'google_client_id': google_client_id
|
|
}), 200
|
|
|
|
|
|
@api_auth_bp.route('/login', methods=['POST'])
|
|
def login():
|
|
"""
|
|
Native local authentication endpoint (Access + Refresh tokens).
|
|
Respects global auth_local_enabled setting, allowing emergency ADMIN access if disabled.
|
|
"""
|
|
data = request.get_json(silent=True)
|
|
if not isinstance(data, dict):
|
|
return jsonify({'error': 'BadRequest', 'message': 'El cuerpo de la solicitud debe ser un objeto JSON.'}), 400
|
|
try:
|
|
dto = LoginDTO(**data)
|
|
except ValidationError as e:
|
|
return jsonify({'error': 'ValidationError', 'details': e.errors()}), 400
|
|
|
|
local_enabled = SystemSetting.get_value('auth_local_enabled', 'true').lower() in ('true', '1')
|
|
|
|
user = user_service.authenticate(dto.email, dto.password)
|
|
if not user:
|
|
return jsonify({
|
|
'error': 'Unauthorized',
|
|
'message': 'Credenciales de acceso incorrectas o cuenta inactiva.'
|
|
}), 401
|
|
|
|
is_admin = user.is_admin() or getattr(user, 'role', '').upper() == 'ADMIN'
|
|
if not local_enabled and not is_admin:
|
|
return jsonify({
|
|
'error': 'Forbidden',
|
|
'message': 'El acceso local con contraseña está deshabilitado por el administrador. Inicie sesión mediante Google OAuth o Moodle.'
|
|
}), 403
|
|
|
|
tokens = JWTService.generate_tokens(user)
|
|
profile = user_service.get_profile_data(user)
|
|
|
|
response_data = {
|
|
'access_token': tokens['access_token'],
|
|
'refresh_token': tokens['refresh_token'],
|
|
'token_type': tokens['token_type'],
|
|
'expires_in': tokens['expires_in'],
|
|
'user': profile
|
|
}
|
|
|
|
resp = make_response(jsonify(response_data), 200)
|
|
resp.set_cookie(
|
|
'refresh_token',
|
|
tokens['refresh_token'],
|
|
httponly=True,
|
|
samesite='Lax',
|
|
max_age=7 * 24 * 3600,
|
|
path='/api/v1/auth/refresh'
|
|
)
|
|
return resp
|
|
|
|
|
|
@api_auth_bp.route('/google', methods=['POST'])
|
|
def google_auth():
|
|
"""
|
|
Google OAuth 2.0 authentication endpoint.
|
|
Receives Google profile / token data, verifies domain whitelist (AlumnosLS architecture),
|
|
creates/updates local user and issues JWT.
|
|
"""
|
|
google_enabled = SystemSetting.get_value('auth_google_enabled', 'false').lower() in ('true', '1')
|
|
if not google_enabled:
|
|
return jsonify({'error': 'Forbidden', 'message': 'El inicio de sesión con Google no está habilitado.'}), 403
|
|
|
|
data = request.get_json(silent=True) or {}
|
|
email = data.get('email', '').strip().lower()
|
|
name = data.get('name', '').strip()
|
|
domain = data.get('domain') or (email.split('@')[1] if '@' in email else '')
|
|
photo = data.get('photo', '')
|
|
|
|
if not email:
|
|
return jsonify({'error': 'BadRequest', 'message': 'El email de Google es obligatorio.'}), 400
|
|
|
|
# Domain restriction check
|
|
allowed_domains_str = SystemSetting.get_value('google_allowed_domains', 'unicaba.edu.ar')
|
|
allowed_domains = [d.strip().lower() for d in allowed_domains_str.split(',') if d.strip()]
|
|
|
|
if allowed_domains and domain.lower() not in allowed_domains:
|
|
logger.warning(f"[GoogleAuth] Access denied for {email}: domain {domain} not in {allowed_domains}")
|
|
return jsonify({
|
|
'error': 'Forbidden',
|
|
'message': f'Acceso denegado. El dominio @{domain} no está autorizado en esta institución.'
|
|
}), 403
|
|
|
|
user = User.query.filter(User.email.ilike(email)).first()
|
|
if not user:
|
|
# Create local user on first Google login
|
|
parts = name.split()
|
|
first_name = parts[0] if parts else 'Usuario'
|
|
last_name = ' '.join(parts[1:]) if len(parts) > 1 else 'Google'
|
|
|
|
# Default role: Docente
|
|
docente_role = Role.query.filter(Role.name.ilike('Docente')).first()
|
|
user = User(
|
|
email=email,
|
|
name=name or f"{first_name} {last_name}",
|
|
first_name=first_name,
|
|
last_name=last_name,
|
|
role='Docente' if not docente_role else docente_role.name,
|
|
role_id=docente_role.id if docente_role else None,
|
|
is_active=True
|
|
)
|
|
user.set_password(f"GoogleSSO_{email}")
|
|
db.session.add(user)
|
|
db.session.commit()
|
|
logger.info(f"[GoogleAuth] Created new local user for {email} with role Docente.")
|
|
|
|
if not user.is_active:
|
|
return jsonify({'error': 'Unauthorized', 'message': 'Su cuenta se encuentra inactiva. Contacte a Bedelía.'}), 401
|
|
|
|
tokens = JWTService.generate_tokens(user)
|
|
profile = user_service.get_profile_data(user)
|
|
|
|
response_data = {
|
|
'access_token': tokens['access_token'],
|
|
'refresh_token': tokens['refresh_token'],
|
|
'token_type': tokens['token_type'],
|
|
'expires_in': tokens['expires_in'],
|
|
'user': profile
|
|
}
|
|
|
|
resp = make_response(jsonify(response_data), 200)
|
|
resp.set_cookie(
|
|
'refresh_token',
|
|
tokens['refresh_token'],
|
|
httponly=True,
|
|
samesite='Lax',
|
|
max_age=7 * 24 * 3600,
|
|
path='/api/v1/auth/refresh'
|
|
)
|
|
return resp
|
|
|
|
|
|
@api_auth_bp.route('/moodle', methods=['POST'])
|
|
def moodle_auth():
|
|
"""
|
|
Moodle Delegated Authentication endpoint.
|
|
Validates user credentials against Moodle server (/login/token.php),
|
|
fetches Moodle profile, implements initial role mapping (if new user),
|
|
and caches profile in Redis.
|
|
"""
|
|
moodle_enabled = SystemSetting.get_value('auth_moodle_enabled', 'false').lower() in ('true', '1')
|
|
if not moodle_enabled:
|
|
return jsonify({'error': 'Forbidden', 'message': 'El inicio de sesión con Moodle no está habilitado.'}), 403
|
|
|
|
data = request.get_json(silent=True) or {}
|
|
username = data.get('username', '').strip()
|
|
password = data.get('password', '').strip()
|
|
|
|
if not username or not password:
|
|
return jsonify({'error': 'BadRequest', 'message': 'Usuario y contraseña de Moodle son obligatorios.'}), 400
|
|
|
|
server_url = SystemSetting.get_value('moodle_server_url', 'http://10.0.0.207/moodle').rstrip('/')
|
|
|
|
# Authenticate against Moodle login/token.php
|
|
token_url = f"{server_url}/login/token.php"
|
|
try:
|
|
resp = requests.post(token_url, data={
|
|
'username': username,
|
|
'password': password,
|
|
'service': 'moodle_mobile_app'
|
|
}, timeout=10)
|
|
res_data = resp.json()
|
|
except Exception as e:
|
|
logger.error(f"[MoodleAuth] Connection error to Moodle server: {e}")
|
|
return jsonify({'error': 'ServiceUnavailable', 'message': 'No se pudo conectar con el servidor de Moodle. Intente más tarde.'}), 503
|
|
|
|
if 'error' in res_data or 'token' not in res_data:
|
|
err_msg = res_data.get('error', 'Credenciales inválidas en Moodle.')
|
|
return jsonify({'error': 'Unauthorized', 'message': f'Moodle: {err_msg}'}), 401
|
|
|
|
moodle_user_token = res_data['token']
|
|
|
|
# Retrieve Moodle user profile via Web Services
|
|
moodle_profile = None
|
|
cache_key = f"moodle_user:{username.lower()}"
|
|
cached = cache_service.get(cache_key)
|
|
if cached:
|
|
moodle_profile = cached
|
|
else:
|
|
try:
|
|
m_users = moodle_client.get_users([{'key': 'username', 'value': username.lower()}])
|
|
if m_users and isinstance(m_users, list) and len(m_users) > 0:
|
|
moodle_profile = m_users[0]
|
|
cache_service.set(cache_key, moodle_profile, ttl_seconds=3600)
|
|
except Exception as e:
|
|
logger.warning(f"[MoodleAuth] Could not fetch extended Moodle profile: {e}")
|
|
|
|
email = moodle_profile.get('email') if moodle_profile else f"{username}@unicaba.edu.ar"
|
|
firstname = moodle_profile.get('firstname', username) if moodle_profile else username
|
|
lastname = moodle_profile.get('lastname', 'Moodle') if moodle_profile else 'Moodle'
|
|
|
|
# Local user lookup or creation
|
|
user = User.query.filter((User.email.ilike(email)) | (User.email.ilike(f"{username}@%"))).first()
|
|
if not user:
|
|
# Determine initial role: if username is admin or has manager role -> ADMIN, else Docente
|
|
role_name = 'ADMIN' if username.lower() in ('admin', 'manager') else 'Docente'
|
|
role_obj = Role.query.filter(Role.name.ilike(role_name)).first()
|
|
|
|
user = User(
|
|
email=email,
|
|
name=f"{firstname} {lastname}".strip(),
|
|
first_name=firstname,
|
|
last_name=lastname,
|
|
role=role_obj.name if role_obj else role_name,
|
|
role_id=role_obj.id if role_obj else None,
|
|
is_active=True
|
|
)
|
|
user.set_password(f"MoodleLinked_{username}")
|
|
db.session.add(user)
|
|
db.session.commit()
|
|
logger.info(f"[MoodleAuth] Created new local user for Moodle username '{username}' with role '{role_name}'.")
|
|
|
|
if not user.is_active:
|
|
return jsonify({'error': 'Unauthorized', 'message': 'Su cuenta en Edu-Space está desactivada.'}), 401
|
|
|
|
tokens = JWTService.generate_tokens(user)
|
|
profile = user_service.get_profile_data(user)
|
|
|
|
response_data = {
|
|
'access_token': tokens['access_token'],
|
|
'refresh_token': tokens['refresh_token'],
|
|
'token_type': tokens['token_type'],
|
|
'expires_in': tokens['expires_in'],
|
|
'user': profile
|
|
}
|
|
|
|
resp = make_response(jsonify(response_data), 200)
|
|
resp.set_cookie(
|
|
'refresh_token',
|
|
tokens['refresh_token'],
|
|
httponly=True,
|
|
samesite='Lax',
|
|
max_age=7 * 24 * 3600,
|
|
path='/api/v1/auth/refresh'
|
|
)
|
|
return resp
|
|
|
|
|
|
@api_auth_bp.route('/refresh', methods=['POST'])
|
|
def refresh():
|
|
"""
|
|
Silent Access Token renewal using Refresh Token.
|
|
"""
|
|
data = request.get_json(silent=True)
|
|
if not isinstance(data, dict):
|
|
data = {}
|
|
refresh_token = data.get('refresh_token') or request.cookies.get('refresh_token')
|
|
|
|
if not refresh_token:
|
|
return jsonify({
|
|
'error': 'BadRequest',
|
|
'message': 'Refresh token no suministrado en el cuerpo ni en cookies.'
|
|
}), 400
|
|
|
|
try:
|
|
payload = JWTService.decode_token(refresh_token, expected_type='refresh')
|
|
user_id = int(payload.get('sub'))
|
|
user = user_service.get_by_id(user_id)
|
|
|
|
if not user or not user.is_active:
|
|
return jsonify({
|
|
'error': 'Unauthorized',
|
|
'message': 'Usuario no encontrado o inactivo.'
|
|
}), 401
|
|
|
|
new_access_token = JWTService.create_access_token(user)
|
|
return jsonify({
|
|
'access_token': new_access_token,
|
|
'token_type': 'Bearer',
|
|
'expires_in': int(JWTService.ACCESS_TOKEN_EXPIRES.total_seconds())
|
|
}), 200
|
|
|
|
except jwt.ExpiredSignatureError:
|
|
return jsonify({'error': 'TokenExpired', 'message': 'El refresh token ha expirado. Reingrese credenciales.'}), 401
|
|
except jwt.InvalidTokenError as e:
|
|
return jsonify({'error': 'InvalidToken', 'message': str(e)}), 401
|
|
|
|
|
|
@api_auth_bp.route('/logout', methods=['POST'])
|
|
@jwt_required
|
|
def logout():
|
|
"""
|
|
Session logout: revokes active access token and clears cookies.
|
|
"""
|
|
JWTService.revoke_token(g.jwt_token)
|
|
resp = make_response(jsonify({'message': 'Sesión finalizada y token revocado exitosamente.'}), 200)
|
|
resp.delete_cookie('refresh_token', path='/api/v1/auth/refresh')
|
|
return resp
|
|
|
|
|
|
@api_auth_bp.route('/change_password', methods=['POST'])
|
|
@api_auth_bp.route('/change-password', methods=['POST'])
|
|
@jwt_required
|
|
def change_password():
|
|
"""
|
|
Permite al usuario autenticado cambiar su propia contraseña institucional.
|
|
Valida la contraseña actual y la confirmación de la nueva clave.
|
|
"""
|
|
data = request.get_json(silent=True) or request.form.to_dict() or {}
|
|
current_password = data.get('current_password', '').strip()
|
|
new_password = data.get('new_password', '').strip()
|
|
confirm_password = data.get('confirm_password', '').strip()
|
|
|
|
if not current_password or not new_password:
|
|
return jsonify({
|
|
'error': 'BadRequest',
|
|
'message': 'Debe ingresar la contraseña actual y la nueva contraseña.'
|
|
}), 400
|
|
|
|
if new_password != confirm_password:
|
|
return jsonify({
|
|
'error': 'BadRequest',
|
|
'message': 'La nueva contraseña y su confirmación no coinciden.'
|
|
}), 400
|
|
|
|
if len(new_password) < 6:
|
|
return jsonify({
|
|
'error': 'BadRequest',
|
|
'message': 'La nueva contraseña debe tener al menos 6 caracteres.'
|
|
}), 400
|
|
|
|
user = g.jwt_user
|
|
if not user.check_password(current_password):
|
|
return jsonify({
|
|
'error': 'Unauthorized',
|
|
'message': 'La contraseña actual ingresada es incorrecta.'
|
|
}), 401
|
|
|
|
user.set_password(new_password)
|
|
db.session.commit()
|
|
|
|
logger.info(f"[Auth] Password successfully updated for user {user.email}")
|
|
return jsonify({
|
|
'status': 'success',
|
|
'message': 'Contraseña actualizada correctamente.'
|
|
}), 200
|
|
|
|
|
|
@api_auth_bp.route('/me', methods=['GET'])
|
|
@jwt_required
|
|
def get_current_user():
|
|
"""
|
|
Returns current authenticated profile and permissions.
|
|
"""
|
|
profile = user_service.get_profile_data(g.jwt_user)
|
|
return jsonify(profile), 200
|
|
|