
# Spring Boot 앱에 로그인 추가하기

이 빠른 시작 가이드에서는 Spring Security의 OAuth 2.0 클라이언트를 사용해 Spring Boot 4.1 앱에 KeenAuth 로그인을 추가합니다. Spring 생태계의 표준 OpenID Connect 클라이언트입니다. KeenAuth에는 아직 Java SDK가 없으며, 필요하지도 않습니다. KeenAuth는 표준 OpenID Connect 제공자이기 때문입니다. 앱은 OAuth 클라이언트이며 모든 토큰을 서버 측 세션에 보관합니다. 브라우저가 받는 것은 세션 ID 쿠키뿐입니다. 로그인을 설정하고, 페이지를 보호하고, 사용자의 액세스 토큰으로 보호된 API를 호출하고, 로그아웃한 뒤 오프라인에서 앱을 테스트합니다.

앱에는 다음 경로가 있습니다.

| 경로 | 역할 |
|---|---|
| `GET /` | 공개 홈 페이지: 로그인 링크 또는 사용자 이름과 로그아웃 버튼 |
| `GET /dashboard` | 세션이 필요합니다. 사용자와 API가 반환하는 메시지를 보여 줍니다 |
| `GET /oauth2/authorization/keenauth` | Spring Security가 제공합니다: 로그인을 시작합니다 |
| `GET /login/oauth2/code/keenauth` | Spring Security가 제공합니다: 콜백 |
| `POST /logout` | Spring Security가 제공합니다: 여기와 KeenAuth에서 세션을 종료합니다. CSRF 토큰이 필요합니다 |

## 시작하기 전에

- KeenAuth 환경. 발급자는 `https://your-environment.eu.keenauth.app`과 같은 형태입니다.
- 환경 안의 웹 애플리케이션([애플리케이션 만들기](/docs/api/management/applications/create)). 클라이언트 비밀 값(`client_secret_basic`)이 있어야 합니다. `development` 환경에서는 리디렉션 URI `http://localhost/login/oauth2/code/keenauth`와 로그아웃 후 리디렉션 URI `http://localhost/`를 등록합니다. 루프백 URI는 포트 없이 등록되며, `8080`과 같은 모든 포트와 일치합니다.
- 환경에 등록된 API([API 만들기](/docs/api/management/apis/create)). `messages:read` 스코프가 있어야 합니다. 예를 들어 `https://api.example.com`인 `identifier`가 이 API 액세스 토큰의 대상(audience)입니다. [Express 빠른 시작](/docs/quickstarts/express)에서 이런 토큰을 받는 API를 만들 수 있습니다.
- JDK 21과 Maven 3.9.

## 1. 의존성 추가하기

Spring Boot 부모 POM(`spring-boot-starter-parent` 4.1.2)이 모든 버전을 고정합니다. 웹, OAuth 2.0 클라이언트, Thymeleaf 스타터를 추가합니다.

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

이 예제는 라이선스가 예제 허용 목록 밖에 있는 의존성 두 개를 대체합니다. 하나는 Logback(EPL-1.0 또는 LGPL-2.1)을 Log4j 2로, 다른 하나는 앱이 사용하지 않는 `jakarta.annotation-api`(EPL-2.0 또는 Classpath Exception이 붙은 GPL-2.0)입니다. 같은 이유로 테스트는 JUnit(EPL-2.0) 대신 TestNG를 사용합니다. 여러분의 앱에서는 이렇게 할 필요가 없습니다.

## 2. 설정하기

앱은 설정을 환경 변수에서만 읽습니다. 이 값들을 절대 커밋하지 마십시오.

| 변수 | 필수 | 의미 |
|---|---|---|
| `KEENAUTH_ISSUER` | 예 | 환경의 발급자, `https` |
| `KEENAUTH_CLIENT_ID` | 예 | 웹 애플리케이션의 `client_id` |
| `KEENAUTH_CLIENT_SECRET` | 예 | 해당 클라이언트 비밀 값 |
| `APP_BASE_URL` | 예 | 앱의 공개 URL. 리디렉션 URI는 이 값으로 만들어집니다. `localhost`, `127.0.0.1` 또는 `[::1]`에서만 `http`를 사용할 수 있습니다 |
| `API_URL` | 예 | 앱이 호출하는 API. 예: `http://localhost:3001` |
| `KEENAUTH_API_AUDIENCE` | 예 | KeenAuth에 등록된 해당 API의 식별자 |
| `SESSION_SECURE_COOKIE` | 아니요 | 일반 `http://localhost`에서만 `false` |

`KeenAuthSettings.from`은 시작할 때 이 값들을 읽고 검사합니다. 누락되었거나 사용할 수 없는 값이 있으면 앱이 중지되며, 어떤 오류 메시지에도 값이 포함되지 않습니다.

```java
	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. 클라이언트 등록하기

클라이언트 등록 정보는 디스커버리(`<issuer>/.well-known/openid-configuration`)에서 가져오며, Spring Security가 시작할 때 이를 조회합니다. 예제는 클라이언트 자격 증명, 리디렉션 URI, 스코프를 설정하고, 이 기밀 클라이언트에 대해 PKCE를 켭니다.

```java
	/** 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. 로그인, API 대상 및 로그아웃 설정하기

인가 요청 리졸버는 API 식별자를 `resource`(RFC 8707)로 추가합니다. 이 값이 없으면 액세스 토큰이 여러분의 API를 대상으로 하지 않으므로 API가 토큰을 거부합니다. 로그아웃은 CSRF 토큰과 함께 호출하는 Spring Security의 `POST /logout`이며, 이후 `id_token_hint`와 `post_logout_redirect_uri`를 사용하는 OpenID Connect RP-Initiated Logout이 이어집니다.

```java
		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는 콜백에서 `state`를 확인하고, PKCE 검증자로 코드를 교환하며, ID 토큰을 검사합니다(환경의 키로 서명 검증, RS256, `iss`, `aud`, `exp`, `iat`, `nonce`). 로그인 후에는 사용자에게 새 세션 ID를 부여합니다. 세션은 서버에 저장됩니다. 서버가 여러 대라면 공유 저장소와 함께 Spring Session을 사용하십시오.

홈 페이지는 로그인 링크를 제공하고 로그아웃으로 POST합니다. `th:action`이 CSRF 토큰을 추가합니다.

```html
      <!-- 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. API 호출하기

대시보드에는 세션이 필요합니다. Spring Security가 세션에 보관하는 인가된 클라이언트에서 액세스 토큰을 가져와 이 토큰으로 API를 호출합니다. 토큰은 서버에 남고, 브라우저는 HTML만 받습니다.

```java
	/** 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";
	}
```

```java
	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);
	}
```

이 예제에는 리프레시 토큰이 없습니다. 액세스 토큰이 만료되면 사용자는 다시 로그인해야 합니다.

## 6. 앱 실행하기

Express 빠른 시작의 API(또는 이 토큰을 받는 다른 API)를 시작한 뒤, 환경 변수를 내보낸 상태에서 앱을 실행합니다.

```sh
mvn spring-boot:run
```

`http://localhost:8080`을 열고 **로그인**을 선택하고, KeenAuth에서 로그인한 뒤 대시보드를 여십시오.

## 7. 네트워크 없이 테스트하기

테스트에는 네트워크나 KeenAuth 계정이 필요하지 않습니다. 루프백 포트의 작은 HTTP 서버가 여러분의 환경(디스커버리, 테스트 실행용으로 만든 키 세트, 토큰 엔드포인트, userinfo)과 API를 대신합니다. 테스트는 같은 `registration` 메서드를 통해, 위의 발급자 대신 이 가짜 서버에서 검색한 클라이언트 등록으로 교체합니다.

```java
	/** 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));
		}

	}
```

```java
	@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());
```

```sh
mvn dependency:list -DoutputFile=target/dependencies.txt
python3 ../scripts/maven_deps.py target/dependencies.txt    # SHA-256 lock and licences
mvn test
```

테스트는 다음을 확인합니다. 설정이 시작 시 검증되는지(그리고 어떤 오류에도 비밀 값이 없는지). 로그인이 PKCE S256, `state`, `nonce`, `resource`를 보내는지. 코드가 일치하는 검증자와 클라이언트 인증으로 교환되는지. 액세스 토큰이 `Authorization` 헤더로 API에 전달되며 쿠키에는 절대 들어가지 않는지. 알 수 없는 state, 다른 클라이언트용 ID 토큰, 다른 nonce를 가진 ID 토큰이 세션 없이 거부되는지. 세션이 없는 페이지가 로그인을 시작하는지. 로그아웃에 CSRF 토큰이 필요하고 `id_token_hint`와 함께 로그아웃 엔드포인트로 이동하는지.

실제 브라우저로 하는 전체 로그인에는 KeenAuth 환경이 필요합니다. 이는 오프라인 테스트에 포함되지 않습니다.

## 프록시 또는 로드 밸런서 뒤에서 사용하기

`APP_BASE_URL`을 앱의 공개 URL로 설정하십시오. 리디렉션 URI는 이 값으로 만들어집니다. 프록시가 TLS를 종료하는 경우에만, 앱에 도달하는 유일한 경로가 프록시일 때 `server.forward-headers-strategy=framework`를 설정하십시오.

## 현재 제한 사항

- 로그인은 Spring Security의 기본 동작처럼 `/oauth2/authorization/keenauth`에 대한 `GET`으로 시작합니다. 콜백은 `state` 검사와 PKCE로 보호됩니다.
- Spring Security는 인가 응답의 `iss` 매개변수(RFC 9207)를 검사하지 않습니다.
- Spring Security는 기본적으로 RS256 ID 토큰을 허용합니다. 환경이 다른 알고리즘으로 서명한다면, 해당 알고리즘을 사용하는 `OidcIdTokenDecoderFactory` 빈을 설정하십시오.
- 리프레시 토큰과 무음 로그인은 지원하지 않습니다.

## 다음 단계

- [Express API 보호하기](/docs/quickstarts/express): 이 앱이 호출하는 API.
- [토큰 검증](/docs/sdk/token-verification): 여러분의 API 미들웨어 아래에서 동작하는 검증기.
