Añadir inicio de sesión a una aplicación Spring Boot

Este inicio rápido añade el inicio de sesión de KeenAuth a una aplicación Spring Boot 4.1 con el cliente OAuth 2.0 de Spring Security, el cliente estándar de OpenID Connect del ecosistema Spring. KeenAuth todavía no tiene un SDK para Java, y no hace falta: KeenAuth es un proveedor estándar de OpenID Connect. Su aplicación es el cliente OAuth y guarda todos los tokens en la sesión del lado del servidor; el navegador solo recibe una cookie con el identificador de sesión. Usted configura el inicio de sesión, protege una página, llama a una API protegida con el token de acceso del usuario, cierra la sesión y prueba la aplicación sin conexión.

La aplicación tiene estas rutas:

Ruta Qué hace
GET / Página de inicio pública: un enlace para iniciar sesión, o el nombre del usuario y un botón para cerrar sesión
GET /dashboard Necesita una sesión. Muestra el usuario y los mensajes que devuelve su API
GET /oauth2/authorization/keenauth Servido por Spring Security: inicia el inicio de sesión
GET /login/oauth2/code/keenauth Servido por Spring Security: la devolución de llamada
POST /logout Servido por Spring Security: termina la sesión aquí y en KeenAuth. Necesita el token CSRF

Antes de empezar

1. Añadir las dependencias

El padre de Spring Boot (spring-boot-starter-parent 4.1.2) fija todas las versiones. Añada los starters de web, cliente OAuth 2.0 y Thymeleaf:

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-webmvc</artifactId>
      <exclusions>
        <!-- Logback is EPL-1.0 OR LGPL-2.1, outside the licence allow list: Log4j 2 (Apache-2.0) logs instead. -->
        <exclusion>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-logging</artifactId>
        </exclusion>
        <!-- EPL-2.0 OR GPL-2.0 with Classpath Exception, outside the allow list; this app uses none of its annotations. -->
        <exclusion>
          <groupId>jakarta.annotation</groupId>
          <artifactId>jakarta.annotation-api</artifactId>
        </exclusion>
      </exclusions>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-log4j2</artifactId>
    </dependency>
    <!-- Spring Security's OAuth 2.0 client: OpenID Connect sign-in with discovery, PKCE and ID token checks. -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-oauth2-client</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>

El ejemplo reemplaza dos dependencias cuyas licencias están fuera de la lista de licencias permitidas de nuestros ejemplos: Logback (EPL-1.0 o LGPL-2.1) por Log4j 2, y jakarta.annotation-api (EPL-2.0 o GPL-2.0 con Classpath Exception), que la aplicación no usa. Por el mismo motivo, sus pruebas usan TestNG en lugar de JUnit (EPL-2.0). Su propia aplicación no necesita hacer esto.

2. Configurar

La aplicación lee su configuración solo desde variables de entorno. No guarde nunca sus valores en el control de versiones.

Variable Obligatoria Significado
KEENAUTH_ISSUER sí El emisor de su entorno, https
KEENAUTH_CLIENT_ID sí El client_id de la aplicación web
KEENAUTH_CLIENT_SECRET sí Su secreto de cliente
APP_BASE_URL sí La URL pública de la aplicación. El URI de redirección se construye a partir de ella. http solo para localhost, 127.0.0.1 o [::1]
API_URL sí La API que llama la aplicación, por ejemplo http://localhost:3001
KEENAUTH_API_AUDIENCE sí El identificador de esa API en KeenAuth
SESSION_SECURE_COOKIE no false solo para http://localhost sin cifrar

KeenAuthSettings.from lee y comprueba estos valores al arrancar. Si falta un valor o no es utilizable, la aplicación se detiene, y ningún mensaje de error contiene un valor.

	public static KeenAuthSettings from(Function<String, String> env) {
		return new KeenAuthSettings(url(need(env, "KEENAUTH_ISSUER"), "KEENAUTH_ISSUER", true, false),
				need(env, "KEENAUTH_CLIENT_ID"), need(env, "KEENAUTH_CLIENT_SECRET"),
				url(need(env, "APP_BASE_URL"), "APP_BASE_URL", false, false),
				url(need(env, "API_URL"), "API_URL", false, false),
				// RFC 8707: the access token is requested for your API, which then accepts it as its audience.
				url(need(env, "KEENAUTH_API_AUDIENCE"), "KEENAUTH_API_AUDIENCE", false, true));
	}

3. Registrar el cliente

El registro del cliente proviene del descubrimiento (<issuer>/.well-known/openid-configuration), que Spring Security obtiene al arrancar. El ejemplo define las credenciales del cliente, el URI de redirección y los ámbitos, y activa PKCE para este cliente confidencial:

	/** The endpoints and the signing keys come from discovery: <issuer>/.well-known/openid-configuration. */
	@Bean
	ClientRegistrationRepository clientRegistrationRepository(KeenAuthSettings settings) {
		return new InMemoryClientRegistrationRepository(
				registration(ClientRegistrations.fromIssuerLocation(settings.issuer()), settings));
	}

	static ClientRegistration registration(ClientRegistration.Builder discovered, KeenAuthSettings settings) {
		return discovered.registrationId("keenauth")
			.clientId(settings.clientId())
			.clientSecret(settings.clientSecret())
			.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
			.redirectUri(settings.redirectUri())
			// messages:read is a scope of your API; the example API requires it on /api/messages.
			.scope("openid", "profile", "email", "messages:read")
			// PKCE (RFC 7636) with S256 for this confidential client too; state and nonce are always on.
			.clientSettings(ClientRegistration.ClientSettings.builder().requireProofKey(true).build())
			.build();
	}

4. Configurar el inicio de sesión, la audiencia de la API y el cierre de sesión

El resolvedor de la solicitud de autorización añade el identificador de su API como resource (RFC 8707). Sin él, el token de acceso no está destinado a su API y la API lo rechaza. El cierre de sesión es el POST /logout de Spring Security con el token CSRF, seguido de OpenID Connect RP-Initiated Logout con id_token_hint y post_logout_redirect_uri:

		DefaultOAuth2AuthorizationRequestResolver resolver = new DefaultOAuth2AuthorizationRequestResolver(clients,
				"/oauth2/authorization");
		// RFC 8707: an access token for your API.
		resolver.setAuthorizationRequestCustomizer(
				(request) -> request.additionalParameters((params) -> params.put("resource", settings.apiAudience())));

		OidcClientInitiatedLogoutSuccessHandler logout = new OidcClientInitiatedLogoutSuccessHandler(clients);
		logout.setPostLogoutRedirectUri(settings.postLogoutRedirectUri());

		http.authorizeHttpRequests((authorize) -> authorize.requestMatchers("/", "/error").permitAll()
			.anyRequest()
			.authenticated())
			.oauth2Login((login) -> login.authorizationEndpoint((endpoint) -> endpoint.authorizationRequestResolver(resolver)))
			// POST /logout with the CSRF token; then OpenID Connect RP-Initiated Logout with id_token_hint.
			.logout((out) -> out.logoutSuccessHandler(logout));
		return http.build();

Spring Security comprueba state en la devolución de llamada, intercambia el código con el verificador PKCE y comprueba el ID token (firma con las claves de su entorno, RS256, iss, aud, exp, iat y nonce). Tras el inicio de sesión, entrega al usuario un nuevo identificador de sesión. La sesión vive en el servidor; con varios servidores, use Spring Session con un almacén compartido.

La página de inicio enlaza al inicio de sesión y envía un formulario al cierre de sesión; th:action añade el token CSRF:

      <!-- th:action adds the CSRF token. -->
      <form th:action="@{/logout}" method="post"><button type="submit">Sign out</button></form>
    </div>
    <p th:unless="${user}"><a href="/oauth2/authorization/keenauth">Sign in</a></p>

5. Llamar a su API

El panel necesita una sesión. Toma el token de acceso del cliente autorizado que Spring Security guarda en la sesión y llama a su API con él. El token permanece en el servidor; el navegador recibe solo el HTML.

	/** Needs a session (SecurityConfig). The access token stays on the server; the browser receives only the HTML. */
	@GetMapping("/dashboard")
	public String dashboard(@AuthenticationPrincipal OidcUser user,
			@RegisteredOAuth2AuthorizedClient("keenauth") OAuth2AuthorizedClient client, Model model) {
		ResponseEntity<String> response = this.messages.fetch(client.getAccessToken().getTokenValue());
		model.addAttribute("user", user);
		model.addAttribute("status", response.getStatusCode().value());
		model.addAttribute("messages", response.getStatusCode().is2xxSuccessful() ? response.getBody() : null);
		return "dashboard";
	}
	public ResponseEntity<String> fetch(String accessToken) {
		return this.rest.get()
			.uri("/api/messages")
			.headers((headers) -> headers.setBearerAuth(accessToken))
			.retrieve()
			.onStatus((status) -> true, (request, response) -> {
			})
			.toEntity(String.class);
	}

El ejemplo no tiene token de actualización: cuando caduca el token de acceso, el usuario vuelve a iniciar sesión.

6. Ejecutar la aplicación

Inicie la API del inicio rápido de Express (o cualquier API que acepte los tokens) y ejecute la aplicación con las variables exportadas:

mvn spring-boot:run

Abra http://localhost:8080, elija Sign in, inicie sesión en KeenAuth y abra el panel.

7. Probar sin red

Las pruebas no necesitan red ni una cuenta de KeenAuth. Un servidor HTTP pequeño en un puerto de bucle invertido simula su entorno (descubrimiento, un conjunto de claves creado para la ejecución de las pruebas, el endpoint de token y userinfo) y la API. La prueba sustituye el registro del cliente por uno descubierto a partir de ese servidor, mediante el mismo método registration:

	/** The registration of SecurityConfig, discovered from the fake environment instead of the issuer above. */
	@TestConfiguration
	static class FakeDiscovery {

		@Bean
		ClientRegistrationRepository clientRegistrationRepository(KeenAuthSettings settings) {
			return new InMemoryClientRegistrationRepository(
					SecurityConfig.registration(ClientRegistrations.fromIssuerLocation(KEENAUTH.issuer()), settings));
		}

	}
	@Test
	public void callbackWithAnUnknownStateIsRefusedBeforeAnyTokenRequest() throws Exception {
		MockHttpSession session = new MockHttpSession();
		startSignIn(session);
		MvcResult result = returnFromKeenAuth(session, "not-the-state");
		assertEquals(result.getResponse().getRedirectedUrl(), "/login?error");
		assertTrue(KEENAUTH.requestsTo("/oauth2/token").isEmpty());
mvn dependency:list -DoutputFile=target/dependencies.txt
python3 ../scripts/maven_deps.py target/dependencies.txt    # SHA-256 lock and licences
mvn test

Las pruebas comprueban que la configuración se valida al arrancar (y que ningún error contiene un secreto); que el inicio de sesión envía PKCE S256, state, nonce y resource; que el código se intercambia con el verificador correspondiente y la autenticación del cliente; que el token de acceso va a la API en la cabecera Authorization y nunca en una cookie; que un state desconocido, un ID token de otro cliente y un ID token con otro nonce se rechazan sin sesión; que una página sin sesión inicia el inicio de sesión; y que el cierre de sesión necesita el token CSRF y va al endpoint de logout con id_token_hint.

El inicio de sesión completo con un navegador real necesita un entorno de KeenAuth. No forma parte de las pruebas sin conexión.

Detrás de un proxy o balanceador de carga

Defina APP_BASE_URL con la URL pública de la aplicación; el URI de redirección se construye a partir de ella. Si un proxy termina TLS, defina server.forward-headers-strategy=framework solo cuando el proxy sea la única vía para llegar a la aplicación.

Límites actuales

Próximos pasos