Single Sign-On: un enfoque práctico ( y muy básico)

Este post, es hasta la fecha, el que más trabajo me ha dado. Básicamente porque en el desarrollo del mismo, entraban muchos elementos en juego y diversas tecnologías.

Hacía tiempo que me apetecía poner en marcha un proyecto un poco más complejo que los que hemos visto hasta ahora en el blog y solo hizo falta un pequeño empujón, para que me pusiera las pilas.

¿De que se trata? Fundamentalmente se trata de la puesta en marcha de un sistema de Single Sign-On, que nos permite conectar con un usuario registrado de una aplicación origen a otra destino.

¿Que es SSO?

Vamos a ir aclarando conceptos.

Single Sign-On es un método de autenticación que permite a los usuarios acceder a múltiples aplicaciones y servicios con un solo sesión de inicio de sesión. En lugar de tener que recordar y gestionar múltiples nombres de usuario y contraseñas para diferentes aplicaciones, los usuarios solo necesitan iniciar sesión una vez con una única credencial para acceder a todos sus recursos autorizados. Esto simplifica la experiencia del usuario y mejora la seguridad y eficiencia en la gestión de accesos.

En el caso que nos ocupa, el escenario es el siguiente:

– Tenemos una aplicación origen, creada con Codeigniter 4.5, muy sencilla, que permite a los usuarios registrarse, loguearse y ver una página donde aparece información sobre su conexión, si ha sido OK y un botón para conectar a una aplicación destino.

Login a la aplicación de origen
Dashboard de la aplicación de origen

– Una aplicación destino, que corre un backend con Django RestFramework para crear diversas API’s y un frontend con Vue.js que sirve crear una aplicación web que almacena servicios y contraseñas del usuario. Se trata de la misma aplicación que se creó en un post anterior.

Login a la aplicacion de destino
Dashboard de la aplicación de destino

¿Como funciona?

La aplicación origen, corriendo Codeigniter, usa la librería Shield para generar los métodos, modelos y rutas que gestionan a los usuarios (login, register, etc). Para el ejemplo que nos ocupa, se ha hecho una instalación por defecto de esta librería, sin tocar prácticamente nada.

composer require codeigniter4/shield

La aplicación origen, también cuenta con la librería JWT, que permite crear tokens JWT (necesarios para la validación en la aplicación destino. Cuando el usuario se registra o se loguea, se genera un token JWT, que se almacena en una tabla que he creado a proposito llamada token_sso. Cada vez que el usuario se loguea en la aplicación, el registro del token se actualiza.

composer require firebase/php-jwt:^6.4
Tabla token_sso

El token está generado con este formato:

{ 
"iat": 1716047516, // "Issued At" - Fecha y hora en que se generó este token
"nbf": 1716047516, // "Not Before" - Fecha y hora antes de la cual el token no es válido
"exp": 1716051116, // "Expiration" - Fecha y hora en que expira el token
"sub": 2, // "Subject" - Identificador del usuario o entidad a la que se refiere el token. 
"email": "test@test.es", // Correo electrónico del usuario. 
"name": "test" // Nombre del usuario. 
}

Podemos verificar el payload de este o de cualquier token jwt accediendo a https://jwt.io/

Se han tenido que modificar tanto el controller de registro, como el de login, para incluir la creación y actualización de este token.

// RegisterController.php
...
  public function registerAction(): RedirectResponse
    {
        if (auth()->loggedIn()) {
            return redirect()->to(config('Auth')->registerRedirect());
        }

        // Check if registration is allowed
        if (! setting('Auth.allowRegistration')) {
            return redirect()->back()->withInput()
                ->with('error', lang('Auth.registerDisabled'));
        }

        $users = $this->getUserProvider();

        // Validate here first, since some things,
        // like the password, can only be validated properly here.
        $rules = $this->getValidationRules();

        if (! $this->validateData($this->request->getPost(), $rules, [], config('Auth')->DBGroup)) {
            return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
        }

        // Save the user
        $allowedPostFields = array_keys($rules);
        $user              = $this->getUserEntity();
        $user->fill($this->request->getPost($allowedPostFields));

        // Workaround for email only registration/login
        if ($user->username === null) {
            $user->username = null;
        }

        try {
            $users->save($user);
        } catch (ValidationException $e) {
            return redirect()->back()->withInput()->with('errors', $users->errors());
        }

        // To get the complete user object with ID, we need to get from the database
        $user = $users->findById($users->getInsertID());

        // Add to default group
        $users->addToDefaultGroup($user);

        Events::trigger('register', $user);

        /** @var Session $authenticator */
        $authenticator = auth('session')->getAuthenticator();

        $authenticator->startLogin($user);

        // If an action has been defined for register, start it up.
        $hasAction = $authenticator->startUpAction('register', $user);
        if ($hasAction) {
            return redirect()->route('auth-action-show');
        }

        // Set the user active
        $user->activate();

        $authenticator->completeLogin($user);

        // Generate JWT token
        $key = 'YOUR_SECRET_KEY'; // Replace with your secret key
        $payload = [
            'iat' => time(), // Issued at
            'nbf' => time(), // Not before
            'exp' => time() + 3600, // Expiration time (e.g., 1 hour)
            'sub' => $user->id, // Subject
            'email' => $user->email, // Additional user data
            'name' => $user->username // Additional user data
        ];

        $jwt = JWT::encode($payload, $key, 'HS256');

        // Save or update the token in the database
        $tokenModel = new TokenSsoModel();
        $tokenModel->saveToken($user->id, $jwt);

        // Success!
        return redirect()->to(config('Auth')->registerRedirect())
            ->with('message', lang('Auth.registerSuccess'));
    }
...
// LoginController.php
...
 public function loginAction(): RedirectResponse
    {
        // Validate here first, since some things,
        // like the password, can only be validated properly here.
        $rules = $this->getValidationRules();

        if (! $this->validateData($this->request->getPost(), $rules, [], config('Auth')->DBGroup)) {
            return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
        }

        /** @var array $credentials */
        $credentials             = $this->request->getPost(setting('Auth.validFields')) ?? [];
        $credentials             = array_filter($credentials);
        $credentials['password'] = $this->request->getPost('password');
        $remember                = (bool) $this->request->getPost('remember');

        /** @var Session $authenticator */
        $authenticator = auth('session')->getAuthenticator();

        // Check if the user is already logged in, if so, log them out
        if (auth()->loggedIn()) {
            auth()->logout();
        }

        // Attempt to login
        $result = $authenticator->remember($remember)->attempt($credentials);
        if (! $result->isOK()) {
            return redirect()->route('login')->withInput()->with('error', $result->reason());
        }

        // Get user data
        $user = auth()->user();

        // Generate JWT token
        $key = 'YOUR_SECRET_KEY'; // Replace with your secret key
        $payload = [
            // 'iss' => "your_issuer", // Issuer
            // 'aud' => "your_audience", // Audience
            'iat' => time(), // Issued at
            'nbf' => time(), // Not before
            'exp' => time() + 3600, // Expiration time (e.g., 1 hour)
            'sub' => $user->id, // Subject
            'email' => $user->email, // Additional user data
            'name' => $user->username // Additional user data
        ];

        $jwt = JWT::encode($payload, $key, 'HS256');

        // Save or update the token in the database
        $tokenModel = new TokenSsoModel();
        $tokenModel->saveToken($user->id, $jwt);

        // If an action has been defined for login, start it up.
        if ($authenticator->hasAction()) {
            return redirect()->route('demo')->withCookies();
        }

        // Redirect to the intended page or home
        return redirect()->to(config('Auth')->loginRedirect())->with('message', lang('Auth.successLogin'));
    }

En este punto, hay que indicar que es importante que $key = ‘YOUR_SECRET_KEY’; o la clave que va servir para codificar o descodificar los token, sea la misma en las aplicaciones de origen que en la de destino.

Una vez tenemos al usuario logueado en nuestra aplicación origen, se puede ver como un botón nos permite acceder a la aplicación destino.

Conexión a la aplicación destino

Se trata de un simple botón que lanza una función JS que nos lleva a una url concreta.

Función de conexión

La url dentro del frontend, se encarga de conectar con la API de usuarios y el método correspondiente al login por SSO.

Esto se consigue creando una vista que gestione la petición de la url que viene hacia el SSO. En este caso SSOLogin.vue

// views/SSOLogin.vue

<template>
  <div>
    <p>Iniciando sesión...</p>
  </div>
</template>

<script>
import auth from "@/logic/auth"; // Asegúrate de importar tu módulo de autenticación

export default {
  name: "SSOLogin",
  async created() {
    const urlParams = new URLSearchParams(window.location.search);
    const token = urlParams.get('token');
    if (token) {
      try {
        console.log("Token encontrado:", token);
        const response = await auth.ssoLogin(token);
        console.log("Respuesta de la autenticación SSO:", response);
        if (response.status === 200) {
          console.log("Autenticación exitosa, redirigiendo...");
          this.$router.push({ name: 'Dashboard' }); // Redirige a la página principal u otra página
        } else {
          console.error("Error en la autenticación SSO, status no 200:", response.status);
        }
      } catch (error) {
        console.error("Error en la solicitud de autenticación SSO:", error);
      }
    } else {
      console.error("Token no proporcionado");
    }
  }
};
</script>

Este metodo, conecta con logic/auth.js y nos permite hacer la llamada a la API de usuarios con normalidad:

// logic/auth.js
....
// Autenticación SSO
  async ssoLogin(token) {
    try {
      console.log("Enviando solicitud SSO con token:", token);
      const response = await axios.post(API_PATH + "users/sso-login/", { token });
      const data = response.data;
      console.log(response.data);

      // Guarda los tokens y la información del usuario en el almacenamiento local
      sessionStorage.setItem("access_token", data.access);
      sessionStorage.setItem("refresh_token", data.refresh);
      sessionStorage.setItem("user_id", data.user_id);
      sessionStorage.setItem("username", data.username);
      sessionStorage.setItem("email", data.email);

      console.log("Datos de autenticación guardados en sessionStorage");
      return response; // Devuelve la respuesta
    } catch (error) {
      console.error("Error en ssoLogin:", error);
      throw error;
    }
  },

Si está todo OK, el método reconoce el token y crea al usuario en la aplicación destino y si ya existe, actualiza sus datos.

// views.py
....
class SSOLoginView(APIView):
    def post(self, request):
        print(request.data)  # Imprime los datos recibidos en la solicitud
        token = request.data.get("token")  # Obtiene el token de los datos de la solicitud
        print(token)  # Imprime el token recibido

        if not token:
            return Response({"error": "Token no proporcionado"}, status=status.HTTP_400_BAD_REQUEST)  # Retorna un error si no se proporciona el token

        try:
            # Decodifica el token usando la misma clave secreta utilizada para firmar el token
            decoded_token = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])

            user_id = decoded_token.get("sub")  # Obtiene el ID del usuario del token decodificado
            email = decoded_token.get("email")  # Obtiene el email del token decodificado
            username = decoded_token.get("name")  # Obtiene el nombre de usuario del token decodificado

            if not user_id or not email or not username:
                return Response({"error": "Token inválido: falta información necesaria"}, status=status.HTTP_400_BAD_REQUEST)  # Retorna un error si falta información necesaria en el token

            # Valida si el correo electrónico ya existe
            try:
                user = User.objects.get(email=email)  # Busca al usuario por correo electrónico
                # Actualiza la información del usuario
                user.username = username
                user.id = user_id
                user.save()
            except User.DoesNotExist:
                # Crea un nuevo usuario si no existe
                user = User(id=user_id, username=username, email=email)
                user.save()

            # Crea nuevos tokens para el usuario
            refresh = RefreshToken.for_user(user)  # Crea un token de refresco para el usuario
            access_token = refresh.access_token  # Crea un token de acceso para el usuario

            return Response({
                "refresh": str(refresh),
                "access": str(access_token),
                "user_id": user.id,
                "username": user.username,
                "email": user.email,
            }, status=status.HTTP_200_OK)  # Retorna los tokens y la información del usuario

        except jwt.ExpiredSignatureError:
            return Response({"error": "El token ha expirado"}, status=status.HTTP_400_BAD_REQUEST)  # Retorna un error si el token ha expirado
        except jwt.InvalidTokenError as invalid_token_error:
            return Response({"error": f"Token inválido: {invalid_token_error}"}, status=status.HTTP_400_BAD_REQUEST)  # Retorna un error si el token es inválido
        except Exception as e:
            return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)  # Retorna un error genérico si ocurre otra excepción

Esta última vista, como decíamos, se encarga de verificar que el token sea correo, validar si el usuario existe o no y si esta todo OK, conectarlo a la aplicación.

El esquema fundamental del proceso es el siguiente:

En este breve video, podeís ver como funciona el proceso (es interesante que os fijeís en como van cambiando las url que aparecen en la barra de direcciones del navegador, para que podaís ver que se trata de aplicaciones complemetamente diferentes):

Obviamente queda mucho trabajo por hacer en temas de seguridad y para casos concretos, la estructura del token tendrá que ser diferentes, pero como reza el título del post es una primera aproximación al SSO y como tal lo trato.

Os dejo el codigo completo de todo el proyecto en mi perfil de Github, para que podáis verlo y hacer vuestras mejoras.

Por Jose Manuel Sanz Prieto

Desarrollador web. En este blog hablo de fotografía, programación con Django, Python, PHP y privacidad.

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.