diff --git a/docker-platform/settings.py b/docker-platform/settings.py index 297e4f3570..0739f7e929 100644 --- a/docker-platform/settings.py +++ b/docker-platform/settings.py @@ -333,4 +333,16 @@ from wirecloud.glogger import config as LOGGING -# MIDDLEWARE = ('django.middleware.gzip.GZipMiddleware',) + MIDDLEWARE \ No newline at end of file +# MIDDLEWARE = ('django.middleware.gzip.GZipMiddleware',) + MIDDLEWARE + +VC_LOGIN_CONFIG = { + 'enabled': os.environ.get('VC_LOGIN_ENABLED', 'False').lower() in ('true', 'yes', 't'), + 'verifier_host': os.environ.get('VC_VERIFIER_HOST'), + 'verifier_qr_path': os.environ.get('VC_VERIFIER_QR_PATH', '/api/v2/loginQR'), + 'verifier_token_path': os.environ.get('VC_VERIFIER_TOKEN_PATH', '/token'), + 'verifier_jwks_path': os.environ.get('VC_VERIFIER_JWKS_PATH', '/.well-known/jwks'), + 'client_id': os.environ.get('VC_CLIENT_ID'), + 'scope': os.environ.get('VC_SCOPE', 'openid learcredential'), + 'role_target': os.environ.get('VC_ROLE_TARGET'), + 'credential_type': os.environ.get('VC_CREDENTIAL_TYPE', 'LegalPersonCredential'), +} \ No newline at end of file diff --git a/src/requirements.txt b/src/requirements.txt index 60a244ccd6..c3ff5ea653 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,4 +1,5 @@ Django>=2.0,<2.3 +django-nose lxml>=2.3 django_compressor>=2.0,<2.3 rdflib>=3.2.0 @@ -15,3 +16,4 @@ pyScss>=1.3.4,<2.0 Pygments pillow jsonpatch +python-jose>=3.3.0 \ No newline at end of file diff --git a/src/settings.py b/src/settings.py index 1ece504c45..6a4716408e 100644 --- a/src/settings.py +++ b/src/settings.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Django settings used as base for developing wirecloud. -from os import path +from os import path, environ from wirecloud.commons.utils.conf import load_default_wirecloud_conf from django.urls import reverse_lazy @@ -116,7 +116,7 @@ # Authentication AUTHENTICATION_BACKENDS = ( - 'django.contrib.auth.backends.ModelBackend', + 'django.contrib.auth.backends.ModelBackend' ) # WGT deployment dirs @@ -169,3 +169,19 @@ # 'CLASS': 'selenium.webdriver.Safari', # }, # } + + +VC_LOGIN_CONFIG = { + 'enabled': environ.get('VC_LOGIN_ENABLED', 'False').lower() in ('true', 'yes', 't'), + 'verifier_host': environ.get('VC_VERIFIER_HOST'), + 'verifier_qr_path': environ.get('VC_VERIFIER_QR_PATH', '/api/v2/loginQR'), + 'verifier_token_path': environ.get('VC_VERIFIER_TOKEN_PATH', '/token'), + 'verifier_jwks_path': environ.get('VC_VERIFIER_JWKS_PATH', '/.well-known/jwks'), + 'client_id': environ.get('VC_CLIENT_ID'), + 'scope': environ.get('VC_SCOPE', 'openid learcredential'), + 'role_target': environ.get('VC_ROLE_TARGET'), + 'credential_type': environ.get('VC_CREDENTIAL_TYPE', 'LegalPersonCredential'), +} + +if VC_LOGIN_CONFIG['enabled']: + AUTHENTICATION_BACKENDS += ('wirecloud.backends.vc_backend.VCBackend') \ No newline at end of file diff --git a/src/urls.py b/src/urls.py index 5196e6bebb..64ffe09498 100644 --- a/src/urls.py +++ b/src/urls.py @@ -5,10 +5,16 @@ from django.contrib import admin from django.contrib.auth import views as django_auth from django.contrib.staticfiles.urls import staticfiles_urlpatterns +from django.conf import settings from wirecloud.commons import authentication as wc_auth import wirecloud.platform.urls + +vc_login_enabled = settings.VC_LOGIN_CONFIG['enabled'] +if vc_login_enabled: + from wirecloud.vc_login import views as vc_login_views + admin.autodiscover() urlpatterns = ( @@ -23,6 +29,11 @@ url(r'^login/?$', django_auth.LoginView.as_view(), name="login"), url(r'^logout/?$', wc_auth.logout, name="logout"), url(r'^admin/logout/?$', wc_auth.logout), + # VC login when enabled + *([ + url(r'^vc/login/?$', vc_login_views.vc_sso_login, name='vc_sso_login'), + url(r'^vc/callback/?$', vc_login_views.vc_sso_callback, name='vc_sso_callback'), + ] if vc_login_enabled else []), # Admin interface url(r'^admin/', admin.site.urls), diff --git a/src/wirecloud/backends/vc_backend.py b/src/wirecloud/backends/vc_backend.py new file mode 100644 index 0000000000..0d4a689930 --- /dev/null +++ b/src/wirecloud/backends/vc_backend.py @@ -0,0 +1,99 @@ +from django.contrib.auth.backends import ModelBackend +from django.contrib.auth import get_user_model +from django.db import IntegrityError +import logging +from wirecloud.vc_login.vc_payload import VCPayload +from django.contrib.auth.models import Group, AbstractUser +from django.conf import settings + +# Get the active User model (handles custom user models) +User = get_user_model() +logger = logging.getLogger(__name__) + +class VCBackend(ModelBackend): + """ + Custom authentication backend that provisions (creates) a new user or + logs in and updates an existing user based on a validated + Verifiable Credential (VC) payload. + """ + + def authenticate(self, request, vc_payload: VCPayload = None, **kwargs): + """ + Retrieves user data from the VC payload and handles the login/creation process. + """ + if vc_payload is None: + return None + + email = vc_payload.email + first_name = vc_payload.first_name + last_name = vc_payload.last_name + user = None + roles = self._get_roles(vc_payload) + if not email: + logger.error("VC Payload missing required 'email' field for authentication.") + return None + + try: + user = User.objects.get(email=email) + + is_updated = False + + if user.first_name != first_name: + user.first_name = first_name + is_updated = True + if user.last_name != last_name: + user.last_name = last_name + is_updated = True + if is_updated: + user.save() + + logger.info(f"Existing user logged in successfully: {email}") + + except User.DoesNotExist: + try: + logger.info(f"User not found. Creating new user: {email}") + user = User.objects.create_user( + username=email, + email=email, + first_name=first_name, + last_name=last_name + ) + logger.info(f"New user provisioned: {user.username}") + + except IntegrityError: + logger.warning(f"Integrity conflict during user creation for {email}.") + return None + except Exception as e: + logger.error(f"Unexpected error during user creation: {e}") + return None + logger.info("User logged. Assign roles") + self._assign_roles(roles, user) + return user + + def get_user(self, user_id): + """Required method for Django session management.""" + try: + return User.objects.get(pk=user_id) + except User.DoesNotExist: + return None + + def get_groups(self, groups): + return Group.objects.filter(name__in=groups) + + def _assign_roles(self, user_roles, user: AbstractUser): + logger.info(f"User roles: {user_roles}") + allowed_groups = self.get_groups(user_roles) + if not allowed_groups: + logger.info("No allowed groups") + return + groups_to_add = allowed_groups.exclude(id__in=user.groups.values_list('id', flat=True)) + if groups_to_add.exists(): + groups = ", ".join([g.name for g in groups_to_add]) + logger.info(f"Add user '{user.username}' to groups: {groups}") + user.groups.add(*groups_to_add) + + def _get_roles(self, vc_payload: VCPayload): + client_id = settings.VC_LOGIN_CONFIG['role_target'] + if not vc_payload.roles: + return [] + return next((role['names'] for role in vc_payload.roles if role['target'] == client_id), []) \ No newline at end of file diff --git a/src/wirecloud/commons/utils/conf.py b/src/wirecloud/commons/utils/conf.py index 53d3bec5c1..82a3a325af 100644 --- a/src/wirecloud/commons/utils/conf.py +++ b/src/wirecloud/commons/utils/conf.py @@ -106,6 +106,7 @@ def load_default_wirecloud_conf(settings, instance_type='platform'): 'django.contrib.messages.context_processors.messages', 'wirecloud.platform.context_processors.plugins', 'wirecloud.platform.context_processors.active_theme', + 'wirecloud.platform.context_processors.vc_login_context', ), 'debug': settings['DEBUG'], 'loaders': ( diff --git a/src/wirecloud/defaulttheme/locale/es/LC_MESSAGES/django.po b/src/wirecloud/defaulttheme/locale/es/LC_MESSAGES/django.po index 601805e01b..1c7a0bbf8c 100644 --- a/src/wirecloud/defaulttheme/locale/es/LC_MESSAGES/django.po +++ b/src/wirecloud/defaulttheme/locale/es/LC_MESSAGES/django.po @@ -119,6 +119,22 @@ msgstr "Contraseña" msgid "Sign in" msgstr "Iniciar sessión" +#: templates/registration/login.html:61 +msgid "Sign In with Verifiable Credentials" +msgstr "Iniciar sesión con Credenciales Verificables" + +#: templates/registration/login.html:67 +msgid "Invalid Verifiable Credential" +msgstr "Credenciales Verificables invalidas" + +#: templates/registration/login.html:69 +msgid "Unable to login using Verifiable Credentials" +msgstr "No se pudo loguear usando Credenciales Verificables" + +#: templates/registration/login.html:71 +msgid "An unexpected error has occurred. Please try again later" +msgstr "Error inexperado. Por favor, intentelo más tarde" + #: templates/wirecloud/catalogue/main_resource_details.html:3 msgid "Description" msgstr "Descripción" @@ -389,3 +405,11 @@ msgstr "" "
  • Instalar otra versión del widget y usar la opción " "Actualizar/Desactualizar
  • \n" " " + +#: templates/registration/login.html:54 +msgid "Use your digital wallet to authenticate with SSO" +msgstr "Usa tu wallet digital para autenticarte con SSO" + +#: templates/registration/login.html:55 +msgid "Sign In with Verificable Credentials" +msgstr "Iniciar sesión con Credenciales verificables" \ No newline at end of file diff --git a/src/wirecloud/defaulttheme/static/css/wirecloud_core.scss b/src/wirecloud/defaulttheme/static/css/wirecloud_core.scss index cb4e40f420..cc9816c5dc 100644 --- a/src/wirecloud/defaulttheme/static/css/wirecloud_core.scss +++ b/src/wirecloud/defaulttheme/static/css/wirecloud_core.scss @@ -124,4 +124,4 @@ body { height: 20px; background: url("../images/logos/wc1-mini.png"); background-size: 33px 20px; -} +} \ No newline at end of file diff --git a/src/wirecloud/defaulttheme/templates/registration/login.html b/src/wirecloud/defaulttheme/templates/registration/login.html index ed1b8b110a..d4bf29a267 100644 --- a/src/wirecloud/defaulttheme/templates/registration/login.html +++ b/src/wirecloud/defaulttheme/templates/registration/login.html @@ -1,66 +1,94 @@ {% load compress i18n wirecloudtags %}{% load static from staticfiles %} - + {% trans "WireCloud Platform - Sign in" %} - + {% compress css %} {% platform_css "index" %} {% endcompress %} - +
    - {% with wc_logo_id="123456"|make_list|random %}{% with wc_logo="images/logos/wc"|add:wc_logo_id|add:".png" %} {% endwith %}{% endwith %}
    - + -
    -
    + +
    -
    +
    - {% if form.errors %} -
    {% blocktrans %}

    Your username and password didn't match.

    Please try again.

    {% endblocktrans %}
    - {% endif %} + {% if form.errors %} +
    {% blocktrans %}

    Your username and password didn't match. +

    +

    Please try again.

    {% endblocktrans %} +
    + {% endif %} - {% csrf_token %} - - -
    -
    -
    + {% csrf_token %} + + +
    +
    +
    +
    +
    + + -
    - - -
    - -
    - + - + + \ No newline at end of file diff --git a/src/wirecloud/platform/context_processors.py b/src/wirecloud/platform/context_processors.py index d1a39b2ad1..bba8e73ad7 100644 --- a/src/wirecloud/platform/context_processors.py +++ b/src/wirecloud/platform/context_processors.py @@ -19,7 +19,8 @@ from wirecloud.platform.plugins import get_plugins from wirecloud.platform.themes import get_active_theme_name - +from django.conf import settings +from django.urls import reverse def active_theme(request): return {'THEME_ACTIVE': get_active_theme_name()} @@ -33,3 +34,16 @@ def plugins(request): context.update(plugin.get_django_template_context_processors()) return context + +def vc_login_context(request): + + vc_enabled = settings.VC_LOGIN_CONFIG['enabled'] + vc_login_path = '' + if vc_enabled: + vc_login_path = request.build_absolute_uri(reverse('vc_sso_login')) + + return { + 'VC_LOGIN_ENABLED': vc_enabled, + 'VC_LOGIN_PATH': vc_login_path + + } \ No newline at end of file diff --git a/src/wirecloud/vc_login/exceptions.py b/src/wirecloud/vc_login/exceptions.py new file mode 100644 index 0000000000..867304a864 --- /dev/null +++ b/src/wirecloud/vc_login/exceptions.py @@ -0,0 +1,25 @@ +class AccessTokenError(Exception): + """ + Exception raised when the access token retrieval or exchange fails + (e.g., connection error, HTTP 4xx/5xx error from the verifier server). + """ + def __init__(self, message, status_code=500): + super().__init__(message) + self.status_code = status_code + self.message = message + + def __str__(self): + return f"AccessTokenError [{self.status_code}]: {self.message}" + + +class TokenVerificationError(Exception): + """ + Exception raised when the token (JWT) is invalid or cannot be verified + (e.g., invalid signature, token expiration, incorrect format). + """ + def __init__(self, message): + super().__init__(message) + self.message = message + + def __str__(self): + return f"TokenVerificationError: {self.message}" \ No newline at end of file diff --git a/src/wirecloud/vc_login/vc_login.py b/src/wirecloud/vc_login/vc_login.py new file mode 100644 index 0000000000..802c07a27b --- /dev/null +++ b/src/wirecloud/vc_login/vc_login.py @@ -0,0 +1,173 @@ +import logging +import requests +from django.conf import settings +from jose import jwt, exceptions +from .exceptions import AccessTokenError, TokenVerificationError +from .vc_payload import VCPayload + +class VCLogin: + """ + Utility class for the Verifiable Credential (VC) login flow. + Reads configuration directly from django.conf.settings. + """ + + # --- Internal Utility Function to get Configuration --- + @staticmethod + def _get_config(): + """Reads and returns the VC login settings dictionary from Django settings.""" + return settings.VC_LOGIN_CONFIG + + # --- Auxiliary Static Methods --- + + @staticmethod + def _verify_jwk(token, jwks_data): + """ + Verifies the signature and validity of a JWT token using a JWKS dictionary. + Raises TokenVerificationError on failure. + """ + try: + payload = jwt.decode( + token, + key=jwks_data, + options={ + "verify_aud": False + } + ) + return payload + + except exceptions.ExpiredSignatureError: + logging.error("Token expired") + raise TokenVerificationError("Token expired") + except exceptions.JWTError as e: + logging.error(f"Invalid token: {e}") + raise TokenVerificationError("Invalid token") + + @staticmethod + def _make_token_request(params): + """Performs the POST request to exchange the authorization code for tokens.""" + logging.info('Making token request to verifier') + config = VCLogin._get_config() + + try: + # Construct the token endpoint URL + url = f"{config['verifier_host']}{config['verifier_token_path']}" + logging.info(f"Getting token from {url}") + response = requests.post(url, data=params) + + # Raises an exception for 4xx or 5xx status codes + response.raise_for_status() + + logging.info(f"Token response: {response.status_code}") + data = response.json() + + access_token = data.get('access_token') + # Fallback to access_token if refresh_token is missing + refresh_token = data.get('refresh_token') or access_token + + logging.info('Token info successfully parsed') + return { + "access_token": access_token, + "refresh_token": refresh_token + } + + except requests.exceptions.HTTPError as err: + # Catch HTTP errors (4xx, 5xx) + status_code = err.response.status_code + reason = err.response.json() + print(f"Error getting access token from the verifier (Status: {status_code}\n Message: {reason})") + raise AccessTokenError('Failed to obtain access token', status_code) + + except requests.exceptions.RequestException as err: + # Catch connection errors (DNS, timeouts, etc.) + print(err) + raise AccessTokenError('Failed to obtain access token (Connection Error)', 500) + + + # --- Public Static Methods --- + + @staticmethod + def _verify_token(access_token): + """Verifies the JWT signature and returns the payload if valid.""" + config = VCLogin._get_config() + + logging.info(f"Verify access token: {access_token[:10]}...") + try: + # 1. Get JWKS endpoint URL and fetch keys + url = f"{config['verifier_host']}{config['verifier_jwks_path']}" + logging.debug(f"Requesting JWKS from '{url}'") + response = requests.get(url) + response.raise_for_status() + + jwks = response.json() + + try: + # 2. Verify token using JWKS + payload = VCLogin._verify_jwk(access_token, jwks) + + logging.info('Token verified') + return payload + + except TokenVerificationError as err: + # Catch specific JOSE errors handled by _verify_jwk + logging.error(f'Token verification failed: {err}') + return None + + except requests.exceptions.HTTPError as err: + status_code = err.response.status_code + print(f'Error Accessing JWSK endpoint (Status: {status_code})') + return None + + except requests.exceptions.RequestException as err: + print('Exception Accessing JWSK endpoint') + print(err) + return None + + except Exception as err: + print('General exception during verification') + print(err) + return None + + + @staticmethod + + def login(code, redirect_uri) -> VCPayload: + logging.info("Requesting a new token using provided code") + params = { + 'code': code, + 'grant_type': 'authorization_code', + 'redirect_uri': redirect_uri + } + token_info = VCLogin._make_token_request(params) + + return VCLogin._load_user_profile(token_info['access_token'], token_info['refresh_token']) + + @staticmethod + def _load_user_profile(access_token, refresh_token) -> VCPayload: + """Loads the user profile data from the verified Verifiable Credential payload.""" + + credential_Type = settings.VC_LOGIN_CONFIG['credential_type'] + # Call static method to verify and get payload + payload = VCLogin._verify_token(access_token) + + if payload is None: + logging.error("Payload is empty or verification failed") + return VCPayload(None, refresh_token=refresh_token) + + vc = payload.get('verifiableCredential') + if not vc: + logging.warning("Payload is not a verifiableCredential type") + return VCPayload(None, refresh_token=refresh_token) + + # Check for specific credential type and extract fields + if vc['type'] == credential_Type: + logging.info(f"VC is type {credential_Type}. Extracting subject claims.") + subject = vc['credentialSubject'] + return VCPayload( + subject.get('email'), + first_name=subject.get('firstName', ''), + last_name=subject.get('lastName', ''), + type=vc['type'], + roles=subject.get('roles')) + else: + logging.warning(f"Not supported VC type: {vc['type']}") + return VCPayload(None, refresh_token=refresh_token) \ No newline at end of file diff --git a/src/wirecloud/vc_login/vc_payload.py b/src/wirecloud/vc_login/vc_payload.py new file mode 100644 index 0000000000..004f0722e4 --- /dev/null +++ b/src/wirecloud/vc_login/vc_payload.py @@ -0,0 +1,16 @@ +class VCPayload: + + def __init__( + self, email, + first_name='', + last_name='', + refresh_token='', + roles=[], + type=''): + + self.email = email + self.first_name = first_name + self.last_name = last_name + self.refresh_token=refresh_token + self.roles = roles + self.type = type diff --git a/src/wirecloud/vc_login/views.py b/src/wirecloud/vc_login/views.py new file mode 100644 index 0000000000..61fa1278a3 --- /dev/null +++ b/src/wirecloud/vc_login/views.py @@ -0,0 +1,78 @@ +import uuid +from urllib.parse import quote + +from django.shortcuts import redirect +from django.contrib.auth import authenticate, login +from django.http import HttpResponse +from django.views.decorators.http import require_http_methods +from django.urls import reverse +from django.conf import settings + +from .vc_login import VCLogin +from .exceptions import AccessTokenError, TokenVerificationError + +@require_http_methods(["GET"]) +def vc_sso_login(request): + + try: + redirect_uri = request.build_absolute_uri(reverse('vc_sso_callback')) + state = str(uuid.uuid4()) + nonce = str(uuid.uuid4()) + scope = quote(settings.VC_LOGIN_CONFIG['scope']) + url = f"{settings.VC_LOGIN_CONFIG['verifier_host']}{settings.VC_LOGIN_CONFIG['verifier_qr_path']}" + sso_url = ( + f"{url}?" + f"client_id={settings.VC_LOGIN_CONFIG['client_id']}&" + f"redirect_uri={redirect_uri}&" + f"scope={scope}&" + f"state={state}&" + f"nonce={nonce}&" + "response_type=code" + ) + + return redirect(sso_url) + + except Exception as e: + return HttpResponse(f"Error al iniciar el flujo de login: {e}", status=500) + +@require_http_methods(["GET"]) +def vc_sso_callback(request): + """ + Step 2: Receives the request from the VC provider, validates the code/token, + and starts the Django session. + """ + code = request.GET.get('code') + + if not code: + error = request.GET.get('error', 'Code not received.') + return HttpResponse(f"SSO Error: {error}", status=400) + + try: + redirect_uri = request.build_absolute_uri(reverse('vc_sso_callback')) + vc_payload = VCLogin.login(code, redirect_uri) + + if not vc_payload: + return redirect("/login?error=403001") + + # Authenticate using the Custom Django Backend + # The backend (VerifiableCredentialBackend) uses 'vc_payload' to find, + # update, or create the User object. + user = authenticate(request, vc_payload=vc_payload) + + if user is not None: + login(request, user) + return redirect('/') + else: + return redirect("/login?error=403002") + + except AccessTokenError as e: + # Catch errors related to network failures or server-side token rejection + return HttpResponse(f"Token exchange failed: {e.message}", status=e.status_code) + + except TokenVerificationError as e: + # Catch errors related to signature failure, token expiration, etc. + return HttpResponse(f"Token verification failed: {e.message}", status=403) + + except Exception as e: + # Catch unexpected errors during the process + return HttpResponse(f"Error in the callback process: {e}", status=500) \ No newline at end of file