Spring Authorization Server — OAuth2 인가 서버를 직접 구축하는 방법
마이크로서비스가 10개, 20개로 늘어날 때 — 로그인은 어디서 한 번만 처리하고, 나머지 서비스는 토큰만 검증하면 되지 않을까요?
OAuth2에서 "토큰을 발급하는 쪽"이 바로 Authorization Server 입니다. 구글이나 카카오 같은 외부 서비스에 의존하지 않고, 우리 조직만의 인가 서버를 직접 만들 수 있다면 마이크로서비스 아키텍처에서 인증을 중앙화할 수 있습니다.
왜 직접 인가 서버를 구축할까
- **사내 시스템 통합 **: 여러 마이크로서비스가 하나의 인증 체계를 공유해야 할 때
- ** 커스텀 클레임 **: 조직 고유의 권한 체계(부서, 직급 등)를 토큰에 담아야 할 때
- ** 보안 정책 통제 **: 토큰 만료 시간, 리프레시 정책, 클라이언트 관리를 직접 제어해야 할 때
Spring Authorization Server는 더 이상 유지보수되지 않는 Spring Security OAuth 프로젝트의 후속입니다. 신규 프로젝트에서는 반드시 이쪽을 사용해야 합니다.
전체 아키텍처
┌──────────────┐ ┌────────────────────┐ ┌──────────────┐
│ Client │──①──▶│ Authorization │ │ Resource │
│ (SPA, App) │◀──②──│ Server (토큰 발급) │ │ Server │
│ │──────────────③────────────────────▶│ (API 보호) │
└──────────────┘ └────────────────────┘ └──────────────┘
│ │
④ JWK Set ◀───────────────────────┘
(공개키 제공)
- 클라이언트가 인가 코드를 요청
- 인가 서버가 액세스 토큰을 발급
- 클라이언트가 토큰으로 리소스 서버에 API 호출
- 리소스 서버가 JWK Set으로 토큰 서명을 검증
Spring Boot에서 최소 설정
// build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-oauth2-authorization-server'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
}
Spring Boot 3.1 이상부터 스타터가 제공됩니다. 다음은 핵심 설정입니다.
@Configuration
public class AuthorizationServerConfig {
@Bean
@Order(1)
public SecurityFilterChain authServerFilterChain(HttpSecurity http)
throws Exception {
// 인가 서버 기본 설정 적용
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.oidc(Customizer.withDefaults()); // OIDC 활성화
http.exceptionHandling(exceptions ->
exceptions.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
);
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain defaultFilterChain(HttpSecurity http)
throws Exception {
// 일반 보안 설정 — 폼 로그인 활성화
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults());
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
// 개발용 인메모리 사용자 — 실무에서는 DB 연동
var user = User.withDefaultPasswordEncoder()
.username("user").password("password").roles("USER").build();
return new InMemoryUserDetailsManager(user);
}
}
@Order로 필터 체인 순서를 지정하는 것이 중요합니다. /oauth2/authorize, /oauth2/token 같은 인가 서버 엔드포인트는 @Order(1)이 먼저 처리하고, 나머지 요청은 @Order(2)에서 처리합니다.
클라이언트 등록 — RegisteredClientRepository
OAuth2에서 "클라이언트"는 우리 API를 사용하는 애플리케이션입니다. 고유한 client_id와 client_secret을 발급하고, 허용 범위(scope)와 리다이렉트 URI를 등록합니다.
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient webClient = RegisteredClient
.withId(UUID.randomUUID().toString())
.clientId("web-client")
.clientSecret("{noop}web-secret") // 실무에서는 반드시 암호화
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("http://localhost:3000/callback")
.scope(OidcScopes.OPENID) // OIDC 필수 스코프
.scope(OidcScopes.PROFILE)
.scope("read")
.scope("write")
.clientSettings(ClientSettings.builder()
.requireAuthorizationConsent(true) // 동의 화면 표시
.requireProofKey(true) // PKCE 강제
.build())
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofMinutes(30))
.refreshTokenTimeToLive(Duration.ofDays(7))
.build())
.build();
// 인메모리 저장소 — 실무에서는 JdbcRegisteredClientRepository 사용
return new InMemoryRegisteredClientRepository(webClient);
}
운영 환경에서는
JdbcRegisteredClientRepository를 써서 DB에 클라이언트 정보를 저장합니다. 서버가 재시작되어도 클라이언트 정보가 유지되어야 하기 때문입니다.
Authorization Code + PKCE 흐름
PKCE(Proof Key for Code Exchange)는 SPA나 모바일처럼 client_secret을 안전하게 보관할 수 없는 환경에서 필수적인 보안 메커니즘입니다.
1. 클라이언트: code_verifier (랜덤 문자열) 생성
2. 클라이언트: code_challenge = SHA256(code_verifier)
3. 클라이언트 → 인가서버: /oauth2/authorize?code_challenge=xxx
4. 사용자: 로그인 + 동의
5. 인가서버 → 클라이언트: redirect_uri?code=AUTH_CODE
6. 클라이언트 → 인가서버: /oauth2/token (code + code_verifier)
7. 인가서버: SHA256(code_verifier) == 저장된 code_challenge 검증
8. 인가서버 → 클라이언트: access_token + refresh_token + id_token
공격자가 중간에 AUTH_CODE를 탈취하더라도, code_verifier가 없으면 토큰을 교환할 수 없습니다.
OIDC 설정과 ID Token
OAuth2만으로는 "누가 로그인했는지"를 표준적으로 알 수 없습니다. OIDC(OpenID Connect)는 OAuth2 위에 ID Token 을 추가하여 사용자 인증 정보를 제공합니다.
앞서 .oidc(Customizer.withDefaults())를 추가했으므로 다음 엔드포인트가 자동 활성화됩니다.
| 엔드포인트 | 용도 |
|---|---|
/.well-known/openid-configuration | OIDC 디스커버리 문서 |
/oauth2/jwks | JWK Set (공개키) |
/userinfo | 사용자 정보 |
토큰 커스터마이징
발급되는 토큰에 조직 고유 정보를 추가하려면 OAuth2TokenCustomizer를 사용합니다.
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer() {
return context -> {
if (context.getTokenType().getValue().equals("access_token")) {
Authentication principal = context.getPrincipal();
// 사용자 권한 정보를 클레임에 추가
Set<String> authorities = principal.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toSet());
context.getClaims().claims(claims -> {
claims.put("authorities", authorities);
claims.put("org", "my-company");
});
}
};
}
토큰에 너무 많은 정보를 담으면 크기가 커져 매 요청마다 네트워크 비용이 증가합니다. 꼭 필요한 클레임만 포함하고, 상세 정보는
/userinfo엔드포인트를 활용하는 것이 좋습니다.
JWK Set 엔드포인트와 키 관리
리소스 서버가 토큰 서명을 검증하려면 인가 서버의 공개키가 필요합니다. /oauth2/jwks 엔드포인트가 이 역할을 합니다.
@Bean
public JWKSource<SecurityContext> jwkSource() {
KeyPair keyPair = generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString()) // 키 식별자
.build();
return new ImmutableJWKSet<>(new JWKSet(rsaKey));
}
private static KeyPair generateRsaKey() {
try {
KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
gen.initialize(2048); // 최소 2048비트 권장
return gen.generateKeyPair();
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
실무에서는 키를 코드에서 생성하지 않고 KeyStore 파일이나 Vault에서 로드합니다. 서버 재시작마다 키가 바뀌면, 기존에 발급된 토큰이 전부 무효화됩니다.
동의 화면 커스터마이징
기본 동의 화면은 투박합니다. 커스텀 컨트롤러와 템플릿으로 자체 동의 페이지를 만들 수 있습니다.
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.authorizationEndpoint(endpoint ->
endpoint.consentPage("/oauth2/consent") // 커스텀 동의 화면 경로
)
.oidc(Customizer.withDefaults());
해당 경로의 컨트롤러에서 client_id, scope, state 파라미터를 받아 동의 화면을 렌더링하면 됩니다.
리소스 서버와의 연동
인가 서버가 토큰을 발급했으면, 리소스 서버(별도 Spring Boot 앱)가 그 토큰을 검증합니다.
# 리소스 서버 application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: http://localhost:9000/oauth2/jwks
@Configuration
@EnableWebSecurity
public class ResourceServerConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter()))
);
return http.build();
}
// JWT 클레임에서 권한 정보 추출
private JwtAuthenticationConverter jwtAuthConverter() {
var authoritiesConverter = new JwtGrantedAuthoritiesConverter();
authoritiesConverter.setAuthorityPrefix(""); // 접두사 제거
authoritiesConverter.setAuthoritiesClaimName("authorities"); // 커스텀 클레임
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
return converter;
}
}
jwk-set-uri를 설정하면 리소스 서버가 공개키를 캐시해서 토큰 서명을 자체 검증합니다. 인가 서버에 매번 요청하지 않아도 됩니다.
핵심 엔드포인트 정리
| 엔드포인트 | 역할 |
|---|---|
/oauth2/authorize | 인가 코드 요청 |
/oauth2/token | 토큰 발급/갱신 |
/oauth2/revoke | 토큰 폐기 |
/oauth2/introspect | 토큰 유효성 확인 |
/oauth2/jwks | JWK Set (공개키) |
/.well-known/openid-configuration | OIDC 디스커버리 |
실무에서 주의할 점
1. 키 관리를 소홀히 하면 안 된다
JWK 키는 토큰 신뢰의 근간입니다. 키가 유출되면 누구나 유효한 토큰을 만들 수 있습니다. 키 로테이션 전략을 수립하고, 여러 키를 동시에 지원(kid 활용)하는 것이 좋습니다.
2. 인메모리는 개발 전용이다
InMemoryRegisteredClientRepository, 인메모리 토큰 저장소 모두 운영에서는 JDBC로 교체해야 합니다.
@Bean
public OAuth2AuthorizationService authorizationService(
JdbcTemplate jdbcTemplate,
RegisteredClientRepository clientRepository) {
return new JdbcOAuth2AuthorizationService(jdbcTemplate, clientRepository);
}
3. 클라이언트 시크릿 암호화
{noop}은 개발 전용입니다. 운영에서는 BCryptPasswordEncoder로 암호화하고, 평문은 발급 시 한 번만 보여줍니다.
정리
- RegisteredClientRepository 가 클라이언트 관리, OAuth2AuthorizationService 가 토큰 상태 관리를 담당한다
- PKCE 는 SPA/모바일에서 필수 —
requireProofKey(true)로 강제한다 - OIDC 를 활성화하면 ID Token과 디스커버리 엔드포인트가 자동 제공된다
- JWK Set 으로 리소스 서버가 토큰 서명을 자체 검증한다
- 토큰 커스터마이징 은
OAuth2TokenCustomizer로 — 클레임은 필요한 것만 넣는다 - 운영 환경에서는 인메모리 대신 JDBC 기반 저장소 를 반드시 사용한다