diff --git a/.envs/.local/.django b/.envs/.local/.django index 8d7bde4..d0dc63a 100644 --- a/.envs/.local/.django +++ b/.envs/.local/.django @@ -4,11 +4,12 @@ USE_DOCKER=yes IPYTHONDIR=/app/.ipython DJANGO_DEBUG=True +DJANGO_SECRET_KEY=hIIbCQezMLys5Ya2Flyx2NDdrs4ZG6DA2pLDu8kKPgtjP7xx6D1m8yccSD6zX6Br DATABASE_URL=postgres://debug:debug@postgres:5432/sky_viewer # UWSGI -DJANGO_UWSGI_WORKER_PROCESSES=4 -DJANGO_UWSGI_WORKER_THREADS=2 +DJANGO_UWSGI_WORKER_PROCESSES=1 +DJANGO_UWSGI_WORKER_THREADS=1 # Redis # ------------------------------------------------------------------------------ @@ -28,5 +29,6 @@ CELERY_FLOWER_PASSWORD=debug ENVIRONMENT_NAME=development BASE_HOST=http://localhost -# Url de login utilizada pelo frontend -LINEA_LOGIN_URL=/admin/login/?next=/ \ No newline at end of file +# Urls de login SAML/CILogon +LINEA_LOGIN_URL=$BASE_HOST/saml2/login/?idp=https://satosa.linea.org.br/linea/proxy/aHR0cHM6Ly9jaWxvZ29uLm9yZw== +RUBIN_LOGIN_URL=$BASE_HOST/saml2/login/?idp=https://satosa-dev.linea.org.br/linea_saml_mirror/proxy/aHR0cHM6Ly9kYXRhLmxzc3QuY2xvdWQ=&next=/ diff --git a/backend/config/settings/base.py b/backend/config/settings/base.py index b8ffa70..9e57cf1 100644 --- a/backend/config/settings/base.py +++ b/backend/config/settings/base.py @@ -19,6 +19,8 @@ # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#debug DEBUG = env.bool("DJANGO_DEBUG", False) +LOG_LEVEL = env.bool("DJANGO_LOG_LEVEL", "INFO") +LOG_DIR = "/logs" # Local time zone. Choices are # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name # though not all of them may be available with every OS. @@ -191,6 +193,7 @@ "django.template.context_processors.tz", "django.contrib.messages.context_processors.messages", "sky_viewer.users.context_processors.allauth_settings", + "django_settings_export.settings_export", ], }, }, @@ -357,5 +360,29 @@ # LINEA Settings # ------------------------------------------------------------------------------ ENVIRONMENT_NAME = env("ENVIRONMENT_NAME", default="development").lower() +# Complete URL of the production server with protocol and port +BASE_HOST = env("BASE_HOST", default="http://localhost") +# URL de login utilizada pelo frontend. +# Em dev: /admin/login/?next=/ +# Em produção: /api/login/ +LOGIN_URL = "/admin/login/?next=/" +# LOGIN_URL = "/api/login/" +LOGOUT_URL = "/api/logout/" +# Urls for login with SAML2/CILogon +# URL_CILOGON example: https://skyviewer.linea.org.br/saml2/login/?idp=https://satosa.linea.org.br/linea/proxy/aHR0cHM6Ly9jaWxvZ29uLm9yZw== LINEA_LOGIN_URL = env("LINEA_LOGIN_URL", default="/admin/login/?next=/") +RUBIN_LOGIN_URL = env("RUBIN_LOGIN_URL", default="/admin/login/?next=/") + +# Url de registro para os diferentes idps. +LINEA_REGISTER_URL="https://register-dev.linea.org.br/Shibboleth.sso/Login?SAMLDS=1&target=https://register-dev.linea.org.br/registry/co_petitions/start/coef:155&entityID=https://satosa.linea.org.br/linea/proxy/aHR0cHM6Ly9jaWxvZ29uLm9yZw==" +RUBIN_REGISTER_URL="https://register-dev.linea.org.br/Shibboleth.sso/Login?SAMLDS=1&target=https://register-dev.linea.org.br/registry/co_petitions/start/coef:231&entityID=https://satosa-dev.linea.org.br/linea_saml_mirror/proxy/aHR0cHM6Ly9kYXRhLmxzc3QuY2xvdWQ=" + +SETTINGS_EXPORT = [ + "BASE_HOST", + "LOGOUT_URL", + "LINEA_LOGIN_URL", + "LINEA_REGISTER_URL", + "RUBIN_LOGIN_URL", + "RUBIN_REGISTER_URL" +] \ No newline at end of file diff --git a/backend/config/settings/production.py b/backend/config/settings/production.py index 05c8d39..61343dd 100644 --- a/backend/config/settings/production.py +++ b/backend/config/settings/production.py @@ -150,18 +150,37 @@ "disable_existing_loggers": True, "formatters": { "verbose": { - "format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s", + "format": "%(asctime)s [%(levelname)s] %(message)s", }, }, "handlers": { "console": { - "level": "DEBUG", + "level": LOG_LEVEL, "class": "logging.StreamHandler", "formatter": "verbose", }, + "default": { + "level": LOG_LEVEL, + "class": "logging.handlers.RotatingFileHandler", + "filename": os.path.join(LOG_DIR, "django.log"), + "formatter": "verbose", + }, + "djangosaml2": { + "level": LOG_LEVEL, + "class": "logging.handlers.RotatingFileHandler", + "maxBytes": 1024 * 1024 * 5, # 5 MB + "backupCount": 5, + "filename": os.path.join(LOG_DIR, "djangosaml2.log"), + "formatter": "verbose", + }, }, "root": {"level": "INFO", "handlers": ["console"]}, "loggers": { + "django": { + "level": LOG_LEVEL, + "handlers": ["default", "console"], + "propagate": True + }, "django.db.backends": { "level": "ERROR", "handlers": ["console"], @@ -174,6 +193,11 @@ "handlers": ["console"], "propagate": False, }, + "djangosaml2": { + "level": LOG_LEVEL, + "handlers": ["djangosaml2"], + "propagate": True + }, }, } @@ -210,14 +234,21 @@ # Your stuff... # ------------------------------------------------------------------------------ -# COmanage Autorization -# ------------------------------------------------------------------------------ -COMANAGE_SERVER_URL = os.environ.get( - "COMANAGE_SERVER_URL", "https://register.linea.org.br" -) -COMANAGE_USER = os.environ.get("COMANAGE_USER", "co_2.linea.apps") -COMANAGE_PASSWORD = os.environ.get("COMANAGE_PASSWORD") -COMANAGE_COID = os.environ.get("COMANAGE_COID") +# Qualquer view que requer um usuário autenticado deve redirecionar o navegador para esta url +LOGIN_URL = "/api/login" +# Urls for login with SAML2/CILogon +# URL_CILOGON example: https://skyviewer.linea.org.br/saml2/login/?idp=https://satosa.linea.org.br/linea/proxy/aHR0cHM6Ly9jaWxvZ29uLm9yZw== +LINEA_LOGIN_URL = env("LINEA_LOGIN_URL") +RUBIN_LOGIN_URL = env("RUBIN_LOGIN_URL") + +# # COmanage Autorization +# # ------------------------------------------------------------------------------ +# COMANAGE_SERVER_URL = os.environ.get( +# "COMANAGE_SERVER_URL", "https://register.linea.org.br" +# ) +# COMANAGE_USER = os.environ.get("COMANAGE_USER", "co_2.linea.apps") +# COMANAGE_PASSWORD = os.environ.get("COMANAGE_PASSWORD") +# COMANAGE_COID = os.environ.get("COMANAGE_COID") # Django SAML2 # ------------------------------------------------------------------------------ @@ -238,19 +269,15 @@ AUTHENTICATION_BACKENDS += ("common.saml2.LineaSaml2Backend",) # Including SAML2 Middleware MIDDLEWARE += ("djangosaml2.middleware.SamlSessionMiddleware",) - +# SAML2 Custom error handler +# https://djangosaml2.readthedocs.io/contents/developer.html#custom-error-handler +SAML_ACS_FAILURE_RESPONSE_FUNCTION = 'common.views.saml2_template_failure' # configurações relativas ao session cookie SAML_SESSION_COOKIE_NAME = "saml_session" SESSION_COOKIE_SECURE = True -# Qualquer view que requer um usuário autenticado deve redirecionar o navegador para esta url -# LOGIN_URL = "/saml2/login/" -LOGIN_URL = "/api/api-auth/login" -# URL_CILOGON example: https://skyviewer.linea.org.br/saml2/login/?idp=https://satosa.linea.org.br/linea/proxy/aHR0cHM6Ly9jaWxvZ29uLm9yZw== -AUTH_SAML2_LOGIN_URL_CILOGON = env("AUTH_SAML2_LOGIN_URL_CILOGON") - # Encerra a sessão quando o usuário fecha o navegador -SESSION_EXPIRE_AT_BROWSER_CLOSE = True +SESSION_EXPIRE_AT_BROWSER_CLOSE = False # Tipo de binding utilizado SAML_DEFAULT_BINDING = saml2.BINDING_HTTP_POST @@ -270,6 +297,7 @@ "givenName": ("first_name",), "sn": ("last_name",), "email": ("email",), + "isMemberOf": ("name",), } SAML_CONFIG = { @@ -278,13 +306,13 @@ "entityid": FQDN + "/saml2/metadata/", # Diretório contendo os esquemas de mapeamento de atributo "attribute_map_dir": str(ATTR_DIR), - "description": "SP Target Viewer", + "description": "SP Sky Viewer", "service": { "sp": { - "name": "SP Target Viewer", + "name": "SP Sky Viewer", "ui_info": { - "display_name": {"text": "SP Target Viewer", "lang": "en"}, - "description": {"text": "SP Target Viewer", "lang": "en"}, + "display_name": {"text": "SP Sky Viewer", "lang": "en"}, + "description": {"text": "SP Sky Viewer", "lang": "en"}, "information_url": {"text": FQDN, "lang": "en"}, "privacy_statement_url": {"text": FQDN, "lang": "en"}, }, @@ -324,6 +352,14 @@ "url": "https://www.linea.org.br/static/metadata/satosa-prod-frontend-cilogon.xml", "cert": None, }, + { + "url": "https://www.linea.org.br/static/metadata/satosa-dev-frontend-cilogon.xml", + "cert": None, + }, + { + "url": "https://www.linea.org.br/static/metadata/satosa-dev-frontend-rubin.xml", + "cert": None, + }, ], }, # Configurado como 1 para fornecer informações de debug diff --git a/backend/config/urls.py b/backend/config/urls.py index 84feac7..31c99cc 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -18,14 +18,22 @@ # TemplateView.as_view(template_name="pages/about.html"), # name="about", # ), - # Django Admin, use {% url 'admin:index' %} - path(settings.ADMIN_URL, admin.site.urls), # User management # path("/users/", include("sky_viewer.users.urls", namespace="users")), # path("/accounts/", include("allauth.urls")), + + # Django Admin, use {% url 'admin:index' %} + path(settings.ADMIN_URL, admin.site.urls), + # Your stuff: custom urls includes go here # Auth SAML2 path("saml2/", include("djangosaml2.urls")), + path( + "api/login/", + TemplateView.as_view(template_name="pages/linea_login.html"), + name="login", + ), + # Media files *static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT), ] @@ -34,7 +42,7 @@ urlpatterns += [ # API base url path("api/", include("config.api_router")), - path("api/logout/", CommonViews.teste, name="logout_user"), + path("api/logout/", CommonViews.logout_user, name="logout_user"), path( "api/environment_settings/", CommonViews.environment_settings, diff --git a/backend/requirements/base.txt b/backend/requirements/base.txt index e6776a3..e3dba0f 100644 --- a/backend/requirements/base.txt +++ b/backend/requirements/base.txt @@ -22,6 +22,7 @@ django-crispy-forms==2.3 # https://github.com/django-crispy-forms/django-crispy crispy-bootstrap5==2024.10 # https://github.com/django-crispy-forms/crispy-bootstrap5 django-compressor==4.5.1 # https://github.com/django-compressor/django-compressor django-redis==5.4.0 # https://github.com/jazzband/django-redis +django-settings-export==1.2.1 # https://github.com/jkbrzt/django-settings-export, # Django REST Framework djangorestframework==3.15.2 # https://github.com/encode/django-rest-framework django-cors-headers==4.6.0 # https://github.com/adamchainz/django-cors-headers diff --git a/backend/sky_viewer/common/api/views.py b/backend/sky_viewer/common/api/views.py index da33127..7fb32c3 100644 --- a/backend/sky_viewer/common/api/views.py +++ b/backend/sky_viewer/common/api/views.py @@ -30,7 +30,7 @@ def environment_settings(request): if enviroment in dev_environments: is_dev = True - login_url = settings.LINEA_LOGIN_URL + login_url = settings.LOGIN_URL env_settings = { "environment": enviroment, diff --git a/backend/sky_viewer/common/saml2.py b/backend/sky_viewer/common/saml2.py index ea9fb00..50dda82 100644 --- a/backend/sky_viewer/common/saml2.py +++ b/backend/sky_viewer/common/saml2.py @@ -17,15 +17,105 @@ class LineaSaml2Backend(Saml2Backend): - def __init__(self): - # self.log = logging.getLogger("djangosaml2") - - self.comanage = Comanage( - service_url=settings.COMANAGE_SERVER_URL, - user=settings.COMANAGE_USER, - password=settings.COMANAGE_PASSWORD, - coid=settings.COMANAGE_COID, + + def authenticate(self, request, session_info=None, attribute_mapping=None, create_unknown_user=True, assertion_info=None, **kwargs): + logger.info("=====================================================") + logger.info("authenticate()") + logger.info(f"request: {request}") + logger.info(f"session_info: {session_info}") + logger.info(f"attribute_mapping: {attribute_mapping}") + logger.info(f"create_unknown_user: {create_unknown_user}") + logger.info(f"assertion_info: {assertion_info}") + # logger.info(f"kwargs: {kwargs}") + + if session_info is None or attribute_mapping is None: + logger.info("Session info or attribute mapping are None") + return None + + if "ava" not in session_info: + logger.error('"ava" key not found in session_info') + return None + + idp_entityid = session_info["issuer"] + attributes = self.clean_attributes(session_info["ava"], idp_entityid) + + idp_name = attributes.get('schacProjectMembership', None) + logger.info(f"idp_name: {idp_name}") + request.session['idp_name'] = idp_name + + if self.get_user_identifier(attributes) is None: + logger.error("No user identifier found in attributes. Redirect to registration page.") + # Define flag para redirecionamento para página de registro + request.session['needs_registration'] = True + + return None + + + user = super().authenticate(request, session_info, attribute_mapping, create_unknown_user, assertion_info, **kwargs) + + if user is not None: + # Tratamento dos grupos que o usuario pertence + self.setup_groups(user, attributes) + + # Get User Status + user_status = attributes.get('schacUserStatus', None) + request.session['user_status'] = user_status + logger.info(f"User Status: {user_status}") + if user_status is None: + logger.error("User status not found in attributes.") + return None + + if user_status in ['PendingApproval', 'Pending']: + logger.info(f"User status is {user_status}. Redirecting to login error page.") + return None + + return user + else: + logger.error("Authentication failed. User not found or not created.") + return None + + def is_authorized( + self, + attributes: dict, + attribute_mapping: dict, + idp_entityid: str, + assertion_info: dict, + **kwargs, + ) -> bool: + """Hook to allow custom authorization policies based on SAML attributes.""" + logger.info("-----------------------------------------") + logger.info("Checks if the user is authorized") + logger.info("is_authorized()") + + logger.info(f"attributes: {attributes}") + logger.info(f"attribute_mapping: {attribute_mapping}") + logger.info(f"idp_entityid: {idp_entityid}") + logger.info(f"assertion_info: {assertion_info}") + # logger.info(f"kwargs: {kwargs}") + logger.info("-----------------------------------------") + + # Estamos considerando que todos os usuarios terão cadastro no LIneA mesmo os de outras instituições. + # O SATOSA do linea sempre vai retornar o uid do usuario um status e o member que são os grupos que o usuario pertence + # Se o usuario não tiver uid será redirecionado para a tela de cadastro + # O status será utilizado para dar o feedback correto, cadastro necessário ou em andamento. + + user_lookup_value = self.get_user_identifier(attributes) + + logger.info( + f"User Lookup Value: {user_lookup_value} Type: {type(user_lookup_value)}" ) + if (user_lookup_value in [None, "None", ""]): + logger.error("No user uid identifier.") + + # Verificar se o usuario é do linea ou do Rubin + # Verificar o status do cadastro do usuario + + # Redirecionar para a tela de cadastro + return False + + # Usuario com uid pode prosseguir com a autenticação + return True + def _extract_user_identifier_params( self, session_info: dict, attributes: dict, attribute_mapping: dict @@ -36,132 +126,67 @@ def _extract_user_identifier_params( logger.info("-----------------------------------------") logger.info("Extract user identifier.") logger.info("_extract_user_identifier_params()") + logger.info(f"session_info: {session_info}") + logger.info(f"attributes: {attributes}") + logger.info(f"attribute_mapping: {attribute_mapping}") logger.info("-----------------------------------------") - # Lookup key - user_lookup_key = self._user_lookup_attribute + user_lookup_key = 'username' logger.info(f"User Lookup Key: {user_lookup_key}") # Lookup value - user_lookup_value = None try: - if getattr(settings, "SAML_USE_NAME_ID_AS_USERNAME", False): - if session_info.get("name_id"): - logger.info(f"name_id: {session_info['name_id']}") - user_lookup_value = session_info["name_id"].text - else: - logger.error( - "The nameid is not available. Cannot find user without a nameid." - ) - else: - # Obtain the value of the custom attribute to use - user_lookup_value = self._get_attribute_value( - user_lookup_key, attributes, attribute_mapping - ) - except Exception as e: - logger.error("Failed to extract user identifier.") - logger.error(e) - return user_lookup_key, None + user_lookup_value = self.get_user_identifier(attributes) - logger.info( - f"User Lookup Value: {user_lookup_value} Type: {type(user_lookup_value)}" - ) - if ( - user_lookup_value is None - or user_lookup_value == "None" - or user_lookup_value == "" - ): - logger.error("No identifier to search in COmanager.") - return user_lookup_key, None - - # Utiliza o identificador de usuario do SAML (eppn) - # para fazer uma consulta ao COmanage do LIneA - # e descobrir UID do LDAP para este usuario. - try: - eppn = user_lookup_value - self.eppn = eppn - - logger.info("Retriving ldap uid from COmanage.") - ldap_uid = self.comanage.get_ldap_uid(identifier=eppn) - logger.info(f"LDAP UID: {ldap_uid}") - - user_lookup_value = self.clean_user_main_attribute(ldap_uid) - logger.info(f"User Lookup Value: {user_lookup_value}") + logger.info( + f"User Lookup Value: {user_lookup_value} Type: {type(user_lookup_value)}" + ) + if (user_lookup_value in [None, "None", ""]): + logger.error("No identifier to search in COmanager.") + return user_lookup_key, None return user_lookup_key, user_lookup_value except Exception as e: + logger.error("Failed to extract user identifier.") logger.error(e) return user_lookup_key, None - def clean_user_main_attribute(self, main_attribute: Any) -> Any: - """Hook to clean the extracted user-identifying value. No-op by default.""" - main_attribute = main_attribute.replace(".", "_") - return main_attribute - def is_authorized( - self, - attributes: dict, - attribute_mapping: dict, - idp_entityid: str, - assertion_info: dict, - **kwargs, - ) -> bool: - """Hook to allow custom authorization policies based on SAML attributes. True by default.""" - logger.info("-----------------------------------------") - logger.info("Checks if the user is authorized") - logger.info("is_authorized()") + def get_user_identifier(self, attributes: dict) -> Any: + """Returns the user identifier to use for authentication.""" logger.info("-----------------------------------------") + logger.info("Get user identifier.") + logger.info("get_user_identifier()") # Lookup key - user_lookup_key = self._user_lookup_attribute + user_lookup_key = "uid" logger.info(f"User Lookup Key: {user_lookup_key}") - # Lookup value - user_lookup_value = None try: - # Obtain the value of the custom attribute to use - user_lookup_value = self._get_attribute_value( - user_lookup_key, attributes, attribute_mapping - ) + # Lookup value + user_lookup_value = attributes.get(user_lookup_key, None) + if isinstance(user_lookup_value, list) and len(user_lookup_value) > 0: + user_lookup_value = user_lookup_value[0] + logger.info(f"User Lookup Value: {user_lookup_value} Type: {type(user_lookup_value)}") + except Exception as e: - logger.error("Failed to extract user identifier.") + logger.error("Failed to extract user identifier uid.") logger.error(e) - return user_lookup_key, None - - logger.info( - f"User Lookup Value: {user_lookup_value} Type: {type(user_lookup_value)}" - ) - if ( - user_lookup_value is None - or user_lookup_value == "None" - or user_lookup_value == "" - ): - logger.error("No identifier to search in COmanager.") - return False + return None - # Faz uma consulta no COmanage com as credenciais do usuario - # Retornadas pelo idp_entityid - # Caso exista registro no comange retorna True e o usuario está autorizado a prosseguir com o login - # Caso NÃO Exista registro no comanage retorna False e o login é interrompido. - # No caso do LIneA estamos considerando que todos os usuarios terão registro no COmanage - # Mesmo os de outras instituições. - try: - # Utiliza o identificador de usuario do SAML (eppn) - # para fazer uma consulta ao COmanage do LIneA - # e descobrir UID do LDAP para este usuario. - eppn = user_lookup_value + if (user_lookup_value in [None, "None", ""]): + logger.error("No identifier uid to search user.") + return None - logger.info("Retriving ldap uid from COmanage.") - ldap_uid = self.comanage.get_ldap_uid(identifier=eppn) - logger.info(f"LDAP UID: {ldap_uid}") + logger.info(f"LDAP UID: {user_lookup_value}") + return self.clean_user_main_attribute(user_lookup_value) - return True + def clean_user_main_attribute(self, main_attribute: Any) -> Any: + """Hook to clean the extracted user-identifying value. No-op by default.""" + main_attribute = main_attribute.replace(".", "_") + return main_attribute - except Exception as e: - logger.error(e) - logger.error("Credentials not found in COmanage.") - return False def user_can_authenticate(self, user) -> bool: """ @@ -172,12 +197,18 @@ def user_can_authenticate(self, user) -> bool: return is_active or is_active is None def save_user(self, user, *args, **kwargs): + logger.info("-----------------------------------------") + logger.info("save_user()") + logger.info(f"user: {user}") + logger.info(f"args: {args}") + # logger.info(f"kwargs: {kwargs}") + user = super().save_user(user, *args, **kwargs) - # Tratamento dos grupos que o usuario pertence - self.setup_groups(user) + # # Tratamento dos grupos que o usuario pertence + # self.setup_groups(user) return user - def setup_groups(self, user): + def setup_groups(self, user, attributes: dict): logger.info("Setup User Groups") # Add a custom group saml for mark this user make login using djangosaml2. @@ -185,15 +216,12 @@ def setup_groups(self, user): # Recupera os grupos do usuario try: - logger.info("Retriving User Groups from COmanage.") - personid = self.comanage.get_co_person_id(identifier=self.eppn) - cogroups = self.comanage.get_groups(personid) - - for group in cogroups: - groups.append(group["Name"]) + logger.info("Retriving User Groups.") + for group in attributes.get("member", []): + groups.append(group) except Exception as e: - msg = f"Failed on retrive groups from COmanage. Error: {e}" + msg = f"Failed on retrive groups. Error: {e}" logger.error(msg) # Remove the user from all groups that are not specified @@ -211,3 +239,4 @@ def setup_groups(self, user): logger.info(f"Groups: {groups}") return user + diff --git a/backend/sky_viewer/common/saml2_comanage.py b/backend/sky_viewer/common/saml2_comanage.py new file mode 100644 index 0000000..7a23d9a --- /dev/null +++ b/backend/sky_viewer/common/saml2_comanage.py @@ -0,0 +1,237 @@ +import logging +from typing import Any +from typing import Optional +from typing import Tuple + +from common.comanage import Comanage +from django.apps import apps +from django.conf import settings +from django.contrib import auth +from django.contrib.auth.backends import ModelBackend +from django.contrib.auth.models import Group +from django.core.exceptions import ImproperlyConfigured +from django.core.exceptions import MultipleObjectsReturned +from djangosaml2.backends import Saml2Backend + +logger = logging.getLogger("djangosaml2") + + +class LineaSaml2Backend(Saml2Backend): + def __init__(self): + # self.log = logging.getLogger("djangosaml2") + + self.comanage = Comanage( + service_url=settings.COMANAGE_SERVER_URL, + user=settings.COMANAGE_USER, + password=settings.COMANAGE_PASSWORD, + coid=settings.COMANAGE_COID, + ) + + def _extract_user_identifier_params( + self, session_info: dict, attributes: dict, attribute_mapping: dict + ) -> Tuple[str, Optional[Any]]: + """Returns the attribute to perform a user lookup on, and the value to use for it. + The value could be the name_id, or any other saml attribute from the request. + """ + logger.info("-----------------------------------------") + logger.info("Extract user identifier.") + logger.info("_extract_user_identifier_params()") + logger.info("-----------------------------------------") + + logger.info("-----------------------------------------") + logger.info("session_info:") + logger.info(session_info) + logger.info("-----------------------------------------") + logger.info("attributes:") + logger.info(attributes) + logger.info("-----------------------------------------") + logger.info("attribute_mapping:") + logger.info(attribute_mapping) + logger.info("-----------------------------------------") + + # Lookup key + user_lookup_key = self._user_lookup_attribute + logger.info(f"User Lookup Key: {user_lookup_key}") + + # Lookup value + user_lookup_value = None + try: + if getattr(settings, "SAML_USE_NAME_ID_AS_USERNAME", False): + if session_info.get("name_id"): + logger.info(f"name_id: {session_info['name_id']}") + user_lookup_value = session_info["name_id"].text + else: + logger.error( + "The nameid is not available. Cannot find user without a nameid." + ) + else: + # Obtain the value of the custom attribute to use + user_lookup_value = self._get_attribute_value( + user_lookup_key, attributes, attribute_mapping + ) + except Exception as e: + logger.error("Failed to extract user identifier.") + logger.error(e) + return user_lookup_key, None + + logger.info( + f"User Lookup Value: {user_lookup_value} Type: {type(user_lookup_value)}" + ) + if ( + user_lookup_value is None + or user_lookup_value == "None" + or user_lookup_value == "" + ): + logger.error("No identifier to search in COmanager.") + return user_lookup_key, None + + # Utiliza o identificador de usuario do SAML (eppn) + # para fazer uma consulta ao COmanage do LIneA + # e descobrir UID do LDAP para este usuario. + try: + eppn = user_lookup_value + self.eppn = eppn + + logger.info("Retriving ldap uid from COmanage.") + ldap_uid = self.comanage.get_ldap_uid(identifier=eppn) + logger.info(f"LDAP UID: {ldap_uid}") + + user_lookup_value = self.clean_user_main_attribute(ldap_uid) + logger.info(f"User Lookup Value: {user_lookup_value}") + + return user_lookup_key, user_lookup_value + + except Exception as e: + logger.error(e) + return user_lookup_key, None + + def clean_user_main_attribute(self, main_attribute: Any) -> Any: + """Hook to clean the extracted user-identifying value. No-op by default.""" + main_attribute = main_attribute.replace(".", "_") + return main_attribute + + def is_authorized( + self, + attributes: dict, + attribute_mapping: dict, + idp_entityid: str, + assertion_info: dict, + **kwargs, + ) -> bool: + """Hook to allow custom authorization policies based on SAML attributes. True by default.""" + logger.info("=========================================") + logger.info("Checks if the user is authorized") + logger.info("is_authorized()") + logger.info("-----------------------------------------") + + logger.info("-----------------------------------------") + logger.info("attributes:") + logger.info(attributes) + logger.info("-----------------------------------------") + logger.info("attribute_mapping:") + logger.info(attribute_mapping) + logger.info("-----------------------------------------") + logger.info("idp_entityid:") + logger.info(idp_entityid) + logger.info("-----------------------------------------") + logger.info("assertion_info:") + logger.info(assertion_info) + logger.info("-----------------------------------------") + # Lookup key + user_lookup_key = self._user_lookup_attribute + logger.info(f"User Lookup Key: {user_lookup_key}") + + # Lookup value + user_lookup_value = None + try: + # Obtain the value of the custom attribute to use + user_lookup_value = self._get_attribute_value( + user_lookup_key, attributes, attribute_mapping + ) + except Exception as e: + logger.error("Failed to extract user identifier.") + logger.error(e) + return user_lookup_key, None + + logger.info( + f"User Lookup Value: {user_lookup_value} Type: {type(user_lookup_value)}" + ) + if ( + user_lookup_value is None + or user_lookup_value == "None" + or user_lookup_value == "" + ): + logger.error("No identifier to search in COmanager.") + return False + + # Faz uma consulta no COmanage com as credenciais do usuario + # Retornadas pelo idp_entityid + # Caso exista registro no comange retorna True e o usuario está autorizado a prosseguir com o login + # Caso NÃO Exista registro no comanage retorna False e o login é interrompido. + # No caso do LIneA estamos considerando que todos os usuarios terão registro no COmanage + # Mesmo os de outras instituições. + try: + # Utiliza o identificador de usuario do SAML (eppn) + # para fazer uma consulta ao COmanage do LIneA + # e descobrir UID do LDAP para este usuario. + eppn = user_lookup_value + + logger.info("Retriving ldap uid from COmanage.") + ldap_uid = self.comanage.get_ldap_uid(identifier=eppn) + logger.info(f"LDAP UID: {ldap_uid}") + + return True + + except Exception as e: + logger.error(e) + logger.error("Credentials not found in COmanage.") + return False + + def user_can_authenticate(self, user) -> bool: + """ + Reject users with is_active=False. Custom user models that don't have + that attribute are allowed. + """ + is_active = getattr(user, "is_active", None) + return is_active or is_active is None + + def save_user(self, user, *args, **kwargs): + user = super().save_user(user, *args, **kwargs) + # Tratamento dos grupos que o usuario pertence + self.setup_groups(user) + return user + + def setup_groups(self, user): + logger.info("Setup User Groups") + + # Add a custom group saml for mark this user make login using djangosaml2. + groups = ["saml2"] + + # Recupera os grupos do usuario + try: + logger.info("Retriving User Groups from COmanage.") + personid = self.comanage.get_co_person_id(identifier=self.eppn) + cogroups = self.comanage.get_groups(personid) + + for group in cogroups: + groups.append(group["Name"]) + + except Exception as e: + msg = f"Failed on retrive groups from COmanage. Error: {e}" + logger.error(msg) + + # Remove the user from all groups that are not specified + for group in user.groups.all(): + if group.name not in groups: + group.user_set.remove(user) + logger.info(f"User has been removed from the group {group.name}") + + # Add the user to all groups in the shibboleth metadata + for g in groups: + group, created = Group.objects.get_or_create(name=g) + user.groups.add(group) + + logger.info("User has been added to the following groups") + logger.info(f"Groups: {groups}") + + return user diff --git a/backend/sky_viewer/common/views.py b/backend/sky_viewer/common/views.py new file mode 100644 index 0000000..3ea54b1 --- /dev/null +++ b/backend/sky_viewer/common/views.py @@ -0,0 +1,37 @@ +from django.shortcuts import render +import logging + +def saml2_template_failure(request, exception=None, status=403, **kwargs): + """ Renders a simple template with an error message. """ + logger = logging.getLogger("djangosaml2") + logger.info("------------------------------------------") + logger.info("saml2_template_failure()") + logger.info(f"request: {request}") + logger.info(f"exception: {exception}") + logger.info(f"status: {status}") + logger.info(f"kwargs: {kwargs}") + + idp_name = request.session['idp_name'] + needs_registration = request.session.get('needs_registration', False) + user_status = request.session.get('user_status') + + logger.info(f"idp_name: {idp_name}") + logger.info(f"needs_registration: {needs_registration}") + logger.info(f"user_status: {user_status}") + + # Se o usuario não possui o uid do linea, redireciona para a pagina de registro + if needs_registration: + + if idp_name == 'rubin_oidc': + logger.info("Redirecting to Rubin registration page.") + return render(request, 'djangosaml2/rubin_need_registration.html', {'exception': exception}, status=status) + + logger.info("Redirecting to LIneA registration page.") + return render(request, 'djangosaml2/linea_need_registration.html', {'exception': exception}, status=status) + + # Se o usuario ainda não tiver com o status de aprovado, redireciona para a pagina de aguardando aprovação + if user_status in ['PendingApproval', 'Pending']: + logger.info(f"User status is {user_status}. Redirecting to waiting aproval error page.") + return render(request, 'djangosaml2/waiting_approval.html', {'exception': exception}, status=status) + + return render(request, 'djangosaml2/login_error.html', {'exception': exception}, status=status) diff --git a/backend/sky_viewer/static/images/background.jpg b/backend/sky_viewer/static/images/background.jpg new file mode 100644 index 0000000..e6fed07 Binary files /dev/null and b/backend/sky_viewer/static/images/background.jpg differ diff --git a/backend/sky_viewer/static/images/cilogon_logo.png b/backend/sky_viewer/static/images/cilogon_logo.png new file mode 100644 index 0000000..763e204 Binary files /dev/null and b/backend/sky_viewer/static/images/cilogon_logo.png differ diff --git a/backend/sky_viewer/static/images/favicons/favicon.ico b/backend/sky_viewer/static/images/favicons/favicon.ico index e1c1dd1..0a2cd87 100644 Binary files a/backend/sky_viewer/static/images/favicons/favicon.ico and b/backend/sky_viewer/static/images/favicons/favicon.ico differ diff --git a/backend/sky_viewer/static/images/linea-logo.png b/backend/sky_viewer/static/images/linea-logo.png new file mode 100644 index 0000000..df92cd9 Binary files /dev/null and b/backend/sky_viewer/static/images/linea-logo.png differ diff --git a/backend/sky_viewer/static/images/linea-symbol.svg b/backend/sky_viewer/static/images/linea-symbol.svg new file mode 100644 index 0000000..8a8cb12 --- /dev/null +++ b/backend/sky_viewer/static/images/linea-symbol.svg @@ -0,0 +1,16 @@ + diff --git a/backend/sky_viewer/templates/base.html b/backend/sky_viewer/templates/base.html index 0f3eb33..87d3791 100644 --- a/backend/sky_viewer/templates/base.html +++ b/backend/sky_viewer/templates/base.html @@ -3,129 +3,71 @@ {% get_current_language as LANGUAGE_CODE %} -
- - -Use this document as a way to quick start any new project.
- {% endblock content %} - {% endblock main %} -Use this document as a way to quick start any new project.
+ {% endblock content %} + {% endblock main %} +