Anmeldung in eine Spring-Boot-App hinzufügen

Dieser Schnellstart fügt einer Spring-Boot-4.1-App die KeenAuth-Anmeldung mit dem OAuth-2.0-Client von Spring Security hinzu, dem Standard-OpenID-Connect-Client des Spring-Ökosystems. KeenAuth hat noch kein Java-SDK, und ein solches ist auch nicht nötig: KeenAuth ist ein Standard-OpenID-Connect-Provider. Ihre App ist der OAuth-Client und bewahrt jedes Token in der serverseitigen Sitzung auf; der Browser erhält nur ein Sitzungs-ID-Cookie. Sie konfigurieren die Anmeldung, schützen eine Seite, rufen eine geschützte API mit dem Access Token des Benutzers auf, melden sich ab und testen die App offline.

Die App hat diese Routen:

Route Was sie tut
GET / Öffentliche Startseite: ein Anmeldelink oder der Name des Benutzers und eine Schaltfläche zum Abmelden
GET /dashboard Benötigt eine Sitzung. Zeigt den Benutzer und die Nachrichten, die Ihre API zurückgibt
GET /oauth2/authorization/keenauth Von Spring Security bereitgestellt: startet die Anmeldung
GET /login/oauth2/code/keenauth Von Spring Security bereitgestellt: der Callback
POST /logout Von Spring Security bereitgestellt: beendet die Sitzung hier und bei KeenAuth. Benötigt das CSRF-Token

Bevor Sie beginnen

1. Abhängigkeiten hinzufügen

Das Spring-Boot-Parent (spring-boot-starter-parent 4.1.2) legt jede Version fest. Fügen Sie die Starter für Web, OAuth-2.0-Client und Thymeleaf hinzu:

    <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>

Das Beispiel ersetzt zwei Abhängigkeiten, deren Lizenzen nicht auf der Lizenz-Allowlist unserer Beispiele stehen: Logback (EPL-1.0 oder LGPL-2.1) durch Log4j 2 und jakarta.annotation-api (EPL-2.0 oder GPL-2.0 mit Classpath Exception), die die App nicht verwendet. Aus demselben Grund verwenden die Tests TestNG statt JUnit (EPL-2.0). Ihre eigene App muss das nicht tun.

2. Konfigurieren

Die App liest ihre Konfiguration ausschließlich aus Umgebungsvariablen. Committen Sie deren Werte niemals.

Variable Erforderlich Bedeutung
KEENAUTH_ISSUER ja Der Issuer Ihrer Umgebung, https
KEENAUTH_CLIENT_ID ja Die client_id der Webanwendung
KEENAUTH_CLIENT_SECRET ja Ihr Client Secret
APP_BASE_URL ja Die öffentliche URL der App. Der Redirect-URI wird daraus gebildet. http nur für localhost, 127.0.0.1 oder [::1]
API_URL ja Die API, die die App aufruft, zum Beispiel http://localhost:3001
KEENAUTH_API_AUDIENCE ja Der Identifier dieser API in KeenAuth
SESSION_SECURE_COOKIE nein false nur für einfaches http://localhost

KeenAuthSettings.from liest diese Werte beim Start ein und prüft sie. Fehlt ein Wert oder ist er unbrauchbar, stoppt die App, und keine Fehlermeldung enthält einen Wert.

	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. Client registrieren

Die Client-Registrierung stammt aus Discovery (<issuer>/.well-known/openid-configuration), das Spring Security beim Start abruft. Das Beispiel setzt die Client-Zugangsdaten, den Redirect-URI und die Scopes und aktiviert PKCE für diesen vertraulichen Client:

	/** 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. Anmeldung, API-Audience und Abmeldung konfigurieren

Der Resolver für die Autorisierungsanfrage fügt den Identifier Ihrer API als resource hinzu (RFC 8707). Ohne ihn ist das Access Token nicht für Ihre API bestimmt, und die API weist es zurück. Die Abmeldung ist das POST /logout von Spring Security mit dem CSRF-Token, gefolgt von OpenID Connect RP-Initiated Logout mit id_token_hint und 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 prüft state beim Callback, tauscht den Code mit dem PKCE-Verifier ein und prüft das ID Token (Signatur mit den Schlüsseln Ihrer Umgebung, RS256, iss, aud, exp, iat und nonce). Nach der Anmeldung vergibt es dem Benutzer eine neue Sitzungs-ID. Die Sitzung lebt auf dem Server; bei mehreren Servern verwenden Sie Spring Session mit einem gemeinsamen Speicher.

Die Startseite verlinkt zur Anmeldung und sendet ein Formular an die Abmeldung; th:action fügt das CSRF-Token hinzu:

      <!-- 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. Ihre API aufrufen

Das Dashboard benötigt eine Sitzung. Es nimmt das Access Token aus dem autorisierten Client, den Spring Security in der Sitzung hält, und ruft damit Ihre API auf. Das Token bleibt auf dem Server; der Browser erhält nur das 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);
	}

Das Beispiel hat kein Refresh Token: Läuft das Access Token ab, meldet sich der Benutzer erneut an.

6. Die App ausführen

Starten Sie die API aus dem Express-Schnellstart (oder eine andere API, die die Tokens akzeptiert) und führen Sie die App mit exportierten Variablen aus:

mvn spring-boot:run

Öffnen Sie http://localhost:8080, wählen Sie Anmelden, melden Sie sich bei KeenAuth an und öffnen Sie das Dashboard.

7. Ohne Netzwerk testen

Die Tests benötigen weder Netzwerk noch ein KeenAuth-Konto. Ein kleiner HTTP-Server auf einem Loopback-Port übernimmt die Rolle Ihrer Umgebung (Discovery, ein für den Testlauf erzeugter Schlüsselsatz, der Token-Endpunkt und Userinfo) sowie die der API. Der Test ersetzt die Client-Registrierung durch eine, die von diesem Server aus Discovery bezieht, und zwar über dieselbe Methode 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

Die Tests prüfen, dass die Konfiguration beim Start validiert wird (und dass keine Fehlermeldung ein Geheimnis enthält); dass die Anmeldung PKCE S256, state, nonce und resource sendet; dass der Code mit dem passenden Verifier und mit Client-Authentifizierung eingetauscht wird; dass das Access Token im Header Authorization an die API geht und nie in ein Cookie gelangt; dass ein unbekannter State, ein ID Token für einen anderen Client und ein ID Token mit einem anderen Nonce ohne Sitzung abgelehnt werden; dass eine Seite ohne Sitzung die Anmeldung startet; und dass die Abmeldung das CSRF-Token benötigt und mit id_token_hint zum Logout-Endpunkt führt.

Die vollständige Anmeldung mit einem echten Browser benötigt eine KeenAuth-Umgebung. Sie ist nicht Teil der Offline-Tests.

Hinter einem Proxy oder Load Balancer

Setzen Sie APP_BASE_URL auf die öffentliche URL der App; der Redirect-URI wird daraus gebildet. Wenn ein Proxy TLS beendet, setzen Sie server.forward-headers-strategy=framework nur, wenn der Proxy der einzige Weg zur App ist.

Aktuelle Grenzen

Nächste Schritte