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
- Un entorno de KeenAuth. Su emisor tiene un aspecto como
https://your-environment.eu.keenauth.app. - Una aplicación web en el entorno (Crear una aplicación) con un secreto de cliente (
client_secret_basic). En un entornodevelopment, registre el URI de redirecciónhttp://localhost/login/oauth2/code/keenauthy el URI de redirección tras cerrar sesiónhttp://localhost/; un URI de bucle invertido se registra sin puerto y coincide con cualquier puerto, como8080. - Una API registrada en el entorno (Crear una API) con el ámbito
messages:read. Suidentifier, por ejemplohttps://api.example.com, es la audiencia de sus tokens de acceso. El inicio rápido de Express crea una API que acepta estos tokens. - JDK 21 y Maven 3.9.
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
- El inicio de sesión comienza con un
GETa/oauth2/authorization/keenauth, como hace Spring Security por defecto. La comprobación destatey PKCE protegen la devolución de llamada. - Spring Security no comprueba el parámetro
issde la respuesta de autorización (RFC 9207). - Spring Security acepta ID tokens RS256 por defecto. Si su entorno firma con otro algoritmo, configure un bean
OidcIdTokenDecoderFactorycon ese algoritmo. - No hay token de actualización ni inicio de sesión silencioso.
Próximos pasos
- Proteger una API de Express: la API que llama esta aplicación.
- Verificar tokens: el verificador que usa el middleware de su API.