26

Nov

Publicado por: Alex Magaz | app development | desarrollo | development

Autentica una aplicación REST contra un ADFS a través de SAML2

Se ha convertido en tradición que para que el usuario pueda acceder a los servicios que ofrece cualquier aplicación, debe registrarse y autenticar sus datos como paso inicial. Pero, ¿cuál es el proceso que hay detrás?

En este artículo explicaré cómo implementar la autenticación en una aplicación REST contra un ADFS (Active Directory Federation Services) a través del protocolo SAML2. Para este proceso usaré pac4j, un framework para Java que soporta múltiples métodos de autenticación y que funciona como motor de Seguridad Java para autenticar, autorizar y obtener profile en cualquier aplicación web.

En nuestro caso, la aplicación será Java con el framework Play de la parte del backend y Typescript con Angular para la parte del frontend.

También te podría interesar: Cómo crear un login desde Facebook en una App hecha en Ionic

¿Cómo funciona SAML2?

SAML2 proporciona un protocolo y una gramática independiente del proveedor estándar con el objetivo de transferir información sobre un usuario de un servidor web a otro. Esto significa que permite autenticar un sujeto a petición de un Proveedor de Servicios (SP –Service Provider) contra un Proveedor de Identidades (IdP –Identity provider). El IdP responderá con una serie de aserciones sobre el sujeto que el SP usará, según sea necesario.

El sujeto es el usuario de nuestra aplicación. Este, al acceder a recurso protegido de la aplicación (SP), provocará que se inicie el proceso de autenticación contra el servidor de autenticación (IdP).

Las aserciones no son más que información sobre el usuario. Por ejemplo, nombre y apellidos, email, los grupos a los que pertenece o el tipo de autenticación utilizada (doble factor, reconocimiento facial, etc.).

La comunicación se realiza usando XML. Para intercambiar las peticiones XML SAML primero define varios métodos o bindings. Por ejemplo, a través de un formulario que se autoenvíe desde el navegador con la petición en los campos (POST) o a través de una redirección con la información en la URL (Redirect).

En esta demostración, utilizaré el primer método mencionado:

Proceso de autenticación

Para la aplicación REST que ya tengas establecida, el proceso de autenticación será el siguiente:

  1. El usuario accederá a una URL protegida por autenticación.
  2. El frontend redireccionará el navegador hacia una URL correspondiente a un punto de conexión del backend que iniciará la autenticación SAML.
  3. El backend construirá una petición SAML y la devolverá en forma de página HTML con un formulario que se autoenvía al IdP. El formulario debe contener en sus campos la petición SAML codificada en base 64.
  4. El IdP responderá con un formulario de autenticación típico donde el usuario tendrá que introducir el usuario y la contraseña.
  5. Si el usuario y la contraseña son correctos, el IdP generará una respuesta SAML que enviará a través de un formulario (igual que en el paso 3) a la URL callback configurada en el backend.
  6. El backend extraerá la información del usuario de la petición, que generará un token JWT y responderá con una redirección que enviará al navegador a la página /login-successful del frontend.
  7. El frontend enviará una petición al backend para obtener el token que ha generado. Este token se usará para autenticar el resto de peticiones que se hagan al backend.
También te podría interesar: Cómo crear Login y autenticación con Angular

Instalación y configuración del servidor :

Para el desarrollo usaré un Windows Server 2012 R2 en Azure. Al crear una cuenta en Azure tendré un período gratuito que será suficiente para hacer la pruebas necesarias.

Acá te dejo este manual en el que se explica en detalle el proceso, por si necesitas otra guía adicional en este paso.
Lo siguiente será configurar nuestra aplicación en el ADFS. Para eso seguiremos los pasos de este otro artículo introduciendo los siguientes datos:

Paso 1.5: https://adfsapp.foo.com:9000/callback
Paso 1.6: https://adfsapp.foo.com (aquí realmente se puede poner cualquier identificador único, no tiene que ser una URL ni es necesario que resuelva).

Implementación en el backend

Para configurar la autenticación por SAML, añadiré un módulo que quedará así:

public class SecurityModule extends AbstractModule {
    @Override
    protected void configure() {
        bind(PlaySessionStore.class).to(PlayCacheSessionStore.class);

        // Login callback
        CallbackController callbackController = new CallbackController();
        callbackController.setDefaultUrl("http://adfsapp.foo.com/login-successful");
        bind(CallbackController.class).toInstance(callbackController);
    }

    @Provides
    protected SAML2Client provideSaml2Client() {
        SAML2ClientConfiguration cfg = new SAML2ClientConfiguration(
            "conf/keystore-devel.jks",
            "1234",
            "5678",
            "https://gmd.westeurope.cloudapp.azure.com/FederationMetadata/2007-06/FederationMetadata.xml");
        cfg.setServiceProviderEntityId("https://adfsapp.foo.com");
        cfg.setServiceProviderMetadataPath(new File("conf/sp-metadata.xml").getAbsolutePath());
        cfg.setMaximumAuthenticationLifetime(8*3600);
        cfg.setUseNameQualifier(false);
        return new SAML2Client(cfg);
    }

    @Provides
    protected Config provideConfig(SAML2Client saml2Client) {
        Clients clients = new Clients("https://adfsapp.foo.com:9000/callback", saml2Client);

        Config config = new Config(clients);
        config.setHttpActionAdapter(new DefaultHttpActionAdapter());
        return config;
    }
}

Para que Play lo cargue, añadiré la siguiente línea en el application.conf:

play.modules.enabled += "modules.SecurityModule"

En configure tendré que decirle al controlador del callback del inicio de sesión que redireccione a la página /login-successful, que es la que se encargará de pedir al backend el token y así, poder finalizar el proceso de autenticación. Este paso lo desgloso más adelante, donde explico el desarrollo del frontend.

Para esto definiré las rutas en el conf/routes:

GET  /callback controllers.AuthenticationController.samlCallback
POST /callback controllers.AuthenticationController.samlCallback

Y en el controlador:

public CompletionStage<Result> samlCallback() {
    return callbackController.callback().thenComposeAsync(result -> {
        PlayWebContext context = new PlayWebContext(getHttpContext(),
                                                    playSessionStore);
        ProfileManager<CommonProfile> profileManager = new ProfileManager<>(context);

        return authenticationService.finishSamlUserAuthentication(profileManager)
            .thenApplyAsync(sessionDTO -> {
                ObjectMapper objectMapper = new ObjectMapper();
                String sessionDTOJson = null;
                try {
                    sessionDTOJson = objectMapper.writeValueAsString(sessionDTO);
                } catch (JsonProcessingException e) {
                    returnError(e);
                }
                session().put("sessionDTO", sessionDTOJson);
                return result;
            }, httpExecutionContext.current());
    },
    httpExecutionContext.current())
    .exceptionally(this::returnError);
}

El controlador llama a authenticationService.finishSamlUserAuthentication que se encarga de extraer la información del perfil del usuario y generar un token JWT:

final String SAML_PROFILE_GIVEN_NAME = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname";
final String SAML_PROFILE_SURNAME = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname";

public CompletionStage<SessionDTO> finishSamlUserAuthentication(
                            ProfileManager<CommonProfile> profileManager) {
    CommonProfile profile = profileManager.get(true)
        .orElseThrow(() ->
            new CompletionException(new Exception("No profile received")));

    User user = new User();
    user.setUsername(profile.getId());

    String givenName;
    String surname;
    try {
        givenName = ((List<String>) profile.getAttribute(SAML_PROFILE_GIVEN_NAME)).get(0);
        surname = ((List<String>) profile.getAttribute(SAML_PROFILE_SURNAME)).get(0);
    } catch(Exception e) {
        throw new CompletionException(new Exception("Missing profile attributes"));
    }
    user.setName(givenName + " " + surname);

    String token = tokenService.generateToken(new TokenData(user));
    String refreshToken = tokenService.generateRefreshToken(new TokenData(user));
    return completedFuture(new SessionDTO(user, token, refreshToken));
}

El resto del código del controlador configura la autenticación por SAML tal y como explica en la documentación de pac4j sobre SAML.
Por último, queda darle forma al punto de conexión para iniciar la autenticación por SAML.

En el conf/routes:

GET /api/authenticate/saml/login controllers.AuthenticationController.samlLogin()
En AuthenticationController:
@Secure(clients = "SAML2Client")
public Result samlLogin() {
    return ok();
}

Implementación en el frontend

En el frontend lo primero será fijar un guard, que será el que usaré en las rutas protegidas:

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate() {
    if (this.isAuthenticated()) {
      return true;
    }

    // not logged in, so initiate SAML authentication
    window.location.replace('https://adfsapp.foo.com:9000/api/authenticate/saml/login');
    return false;
  }

  isAuthenticated() {
    const session = JSOG.parse(localStorage.getItem('session'));
    return session.token !== null;
  }
}

Esto servirá para comprobar si el usuario está autenticado, y si no lo está, lo redireccionará a la URL del backend que iniciará la autenticación por SAML.

El backend ya lo he configurado para que nos lleve a la URL /login-successful al finalizar la autenticación. El componente que se encarga de terminar la autenticación en el frontend, que recogerá el token y llevará al usuario de vuelta a una página funcional, es el siguiente:

@Component({
    selector: 'app-login-successful',
    template: '',
})
export class LoginSuccessfulComponent implements OnInit {
    constructor(private http: HttpClient,
                private router: Router) {
    }

    ngOnInit() {
        this.http.get('http://adfsapp.foo.com:9000/authenticate/saml/session')
            .subscribe(
                session => {
                    localStorage.setItem('session', JSOG.stringify(session));
                    this.router.navigate(['home']);
                }
            );
    }
}

Para finalizar….

La autenticación a través de SAML en una aplicación REST que puede ser un poco complicado, especialmente cuando los errores que nos devuelve el ADFS no son demasiado explicativos.

Como alternativa para hacer las pruebas, también tenemos la opción de usar SimpleSAML. El comportamiento no es exactamente el mismo, pero la comodidad de poder ejecutarlo en nuestra máquina con una simple imagen de Docker, puede compensarnos en algunos momentos.

¡Mucha suerte!

Si te ha gustado o te ha parecido interesante este artículo, compártelo con algún compañero que lo necesite o a través de tus redes sociales.

 

Barcelona
Passeig Gaiolà 13
+34 933 801 144
Lleida
Carrer Agustins 7
+34 973 988 222
Andorra
(Escaldes-Engordany)
Parc de la Mola 10, AD700
Bogota
Carrera 9A #99-07 Piso 9. Despacho 02
Torre la Equidad