""" 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