[Spring Authorization Server Sample 따라 구현하기] Authorization Server
2024. 9. 25. 02:07ㆍSpring Security/Spring Authorization Server
반응형
작년 이맘때 쯤 지금은 운영하지 않는 velog 블로그에 게시글을 옮겨온 글
개요
샘플의 마지막 구현인 인증 서버를 살펴본다.
방법과 구현
구조
- 내가 생각했던 레이어와는 다르지만, 우선 기본적인 골격을 갖추고 있다.
- config 패키지는 인증 서버의 보안 설정을 다룬다.
- federation 패키지는 인증 이후의 보안 컨텍스트 (SecurityContext) 의 설정을 다룬다.
- jose 패키지는 인증 서버가 다른 서버와 통신할 때 사용할 키를 생성한다.
의존성 관리 - build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-authorization-server'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.webjars:webjars-locator-core'
implementation 'org.webjars:bootstrap:5.2.3'
implementation 'org.webjars:jquery:3.6.4'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
- spring-boot-starter-data-jdbc : DB 와 자바 프로젝트의 통신에 사용하는 스프링 부트 하위 패키지
- spring-boot-starter-oauth2-authorization-server : 인증 서버 구축에 필요한 스프링 시큐리티 하위 패키지
- com.h2database:h2 : 간단하고 빠른 구축에 필요한 in-memory 데이터베이스. runtimeOnly 이므로 서버를 종료하면 초기화된다.
어플리케이션 설정 - application.yml
server:
port: 9000
spring:
security:
oauth2:
client:
registration:
google-idp:
provider: google
client-id: ${GOOGLE_CLIENT_ID:google-client-id}
client-secret: ${GOOGLE_CLIENT_SECRET:google-client-secret}
scope: openid, https://www.googleapis.com/auth/userinfo.profile, https://www.googleapis.com/auth/userinfo.email
client-name: Sign in with Google
github-idp:
provider: github
client-id: ${GITHUB_CLIENT_ID:github-client-id}
client-secret: ${GITHUB_CLIENT_SECRET:github-client-secret}
scope: user:email, read:user
client-name: Sign in with GitHub
provider:
google:
user-name-attribute: email
github:
user-name-attribute: login
- server.port : 9000 → 인증 서버의 포트를 9000번으로 설정
- spring.security.oauth2.client.registration:..
- 이 인증 서버를 클라이언트로 하여, 외부 인증 서버인 google과 github에 oauth2 인증을 구현한다.
- 인증 서버가 또다른 인증 서버에게 인증을 위임하는 형태이다.
보안 설정 - DefaultSecurityConfig.java
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
public class DefaultSecurityConfig {
@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize ->
authorize
.requestMatchers(
new AntPathRequestMatcher("/assets/**"),
new AntPathRequestMatcher("/webjars/**"),
new AntPathRequestMatcher("/login"))
.permitAll()
.anyRequest().authenticated()
)
.formLogin(formLogin ->
formLogin
.loginPage("/login")
)
.oauth2Login(oauth2Login ->
oauth2Login
.loginPage("/login")
.successHandler(authenticationSuccessHandler())
);
return http.build();
}
private AuthenticationSuccessHandler authenticationSuccessHandler() {
return new FederatedIdentityAuthenticationSuccessHandler();
}
@Bean
public UserDetailsService users() {
UserDetails user = User.withDefaultPasswordEncoder()
.username("user1")
.password("password")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user);
}
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}
}
- defaultSecurityFilterChain()
- .authorizeHttpRequests() : html 처리에 필요한 경로와 로그인 이전 경로에 대한 요청은 전부 허용하고 나머지 경로는 전부 인증을 요구한다.
- .formLogin() : 로그인 화면 호출에 /login 경로를 사용함을 명시
- .oauth2Login() : oauth2 로그인 인증에 사용할 로그인 페이지를 명시하고, 로그인 성공시 동작을 제어할 successHandler 를 부착한다.
- users() : 회원 가입 기능이 구현되어 있지 않은 상태이므로 DB 에 유저 정보가 하나도 없다. 테스트 유저를 하나 생성한다.
- sessionRegistry(), httpSessionEventPublisher() : 스프링 시큐리티가 제공하는 세션 관리 모듈을 bean으로 등록한다.
인증 서버 설정 - AuthrorizationServerConfig.java
@Configuration(proxyBeanMethods = false)
public class AuthorizationServerConfig {
private static final String CUSTOM_CONSENT_PAGE_URI = "/oauth2/consent";
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authorizationServerSecurityFilterChain(
HttpSecurity http, RegisteredClientRepository registeredClientRepository,
AuthorizationServerSettings authorizationServerSettings) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
DeviceClientAuthenticationProvider deviceClientAuthenticationProvider =
new DeviceClientAuthenticationProvider(registeredClientRepository);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.deviceAuthorizationEndpoint(deviceAuthorizationEndpoint ->
deviceAuthorizationEndpoint.verificationUri("/activate")
)
.deviceVerificationEndpoint(deviceVerificationEndpoint ->
deviceVerificationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI)
)
.clientAuthentication(clientAuthentication ->
clientAuthentication
.authenticationProvider(deviceClientAuthenticationProvider)
)
.authorizationEndpoint(authorizationEndpoint ->
authorizationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI))
.oidc(Customizer.withDefaults());
http
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
)
.oauth2ResourceServer(oauth2ResourceServer ->
oauth2ResourceServer.jwt(Customizer.withDefaults()));
return http.build();
}
@Bean
public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("messaging-client")
.clientSecret("{noop}secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc")
.redirectUri("http://127.0.0.1:8080/authorized")
.postLogoutRedirectUri("http://127.0.0.1:8080/logged-out")
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
.scope("message.read")
.scope("message.write")
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
.build();
RegisteredClient deviceClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("device-messaging-client")
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.scope("message.read")
.scope("message.write")
.build();
JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate);
registeredClientRepository.save(registeredClient);
registeredClientRepository.save(deviceClient);
return registeredClientRepository;
}
@Bean
public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate,
RegisteredClientRepository registeredClientRepository) {
return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
}
@Bean
public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate,
RegisteredClientRepository registeredClientRepository) {
return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);
}
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> idTokenCustomizer() {
return new FederatedIdentityIdTokenCustomizer();
}
@Bean
public JWKSource<SecurityContext> jwkSource() {
RSAKey rsaKey = Jwks.generateRsa();
JWKSet jwkSet = new JWKSet(rsaKey);
return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
}
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder().build();
}
@Bean
public EmbeddedDatabase embeddedDatabase() {
return new EmbeddedDatabaseBuilder()
.generateUniqueName(true)
.setType(EmbeddedDatabaseType.H2)
.setScriptEncoding("UTF-8")
.addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql")
.addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-consent-schema.sql")
.addScript("org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql")
.build();
}
}
Annotation
- @Order : 현재 인증 서버에는 두 개의 SecurityFilterChain 객체가 Bean 으로 등록되어 있는 상태이다. 이 경우 실행시 순서를 지정해 주지 않으면 충돌이 일어나므로 우선순위를 지정해 준다.
Method
- applyDefaultSecuirty() : 인증 서버에 스프링 인증 서버 프레임워크가 제공하는 기본 설정 셋을 적용한다.
- authorizationEndPoint() : 인증에 성공한 경우 동작슬 설정한다. 샘플에서는 conentPage() 메서드를 사용하여 필요한 권한 동의를 받는 페이지로 이동하도록 되어 있다.
- exceptionHasndling() : 로그인 페이지에서 발생한 예외들을 어떻게 처리할 것인지 설정한다.
- oauth2ResourceServer() : 리소스 서버에 정보를 요청하는 방식을 설정한다. 스프링이 제공하는 기본 설정을 사용하는 것으로 되어 있다.
Bean
- RegisterdClientRepository : 인증 서버를 이용할 클라이언트를 등록하는 부분이다. 여러가지 정보를 등록해둔다
- Client의 접근을 위한 Id와 Secret
- 클라이언트의 인증 방식
- 인증에 성공할 경우 콜백 경로 (redirectUri)
- 로그아웃 할 경우 리디렉션 경로
- 클라이언트가 요청 가능한 범위 (scope)
- OAuth2AuthorizationService : 등록한 클라이언트의 유저를 인증시키는 구현. 스프링 기본 구현체가 사용되었다.
- OAuth2AuthorizationConsentService : 인증 유저의 권한 동의 구현. 스프링 기본 구현체가 사용되었다.
- EmbeddedDatabase : 서버가 실행될 때 사용할 DB 정보를 외부에서 가져오기 위한 구현
- in memory DB 인 H2 가 사용되었다.
- 스프링 공식 repo에 있는 스키마 스크립트를 불러와 테이블을 생성한다.
직렬화 - jose
public final class Jwks {
private Jwks() {
}
public static RSAKey generateRsa() {
KeyPair keyPair = KeyGeneratorUtils.generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
return new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
}
}
final class KeyGeneratorUtils {
private KeyGeneratorUtils() {
}
static KeyPair generateRsaKey() {
KeyPair keyPair;
try {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
keyPair = keyPairGenerator.generateKeyPair();
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
return keyPair;
}
}
- JOSE 는 Java Object Serialization 의 약자로, 자바 시스템 내에서 사용되는 객체를 외부에서 사용할 때 데이터를 변환하여 호환시키는 기술이다.
- 데이터를 직렬화 (Serialize) 또는 역직렬화 (Serialize) 하는 것은 암호화 복호화와 같이 대칭 키를 필요로 한다.
- jwk (json web key) 와 RSA 를 사용한 기본 구현 형태를 제공하고 있다.
인증 객체와 필터 설정 - federation
- federation 은 ‘연합’ 의 의미로, 인증 전후의 정보들을 통합 관리하겠다는 의미이다.
- 인증 과정에서 거치는 각 필터의 구성요소들을 구현 또는 대체하고 있다
public class FederatedIdentityAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
}
}
- 스프링 시큐리티의 기본 구현체인 AuthenticationSuccessHandler 의 구현체이다.
- onAuthenticationSuccess() 메서드를 오버라이딩하여야 하며, 인증에 성공한 객체를 어떻게 처리할 것인지 다룬다.
- 기본 구현은 특별한 동작을 구현하고 있지는 않다.
public final class FederatedIdentityIdTokenCustomizer implements OAuth2TokenCustomizer<JwtEncodingContext> {
private static final Set<String> ID_TOKEN_CLAIMS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
IdTokenClaimNames.ISS,
IdTokenClaimNames.SUB,
IdTokenClaimNames.AUD,
IdTokenClaimNames.EXP,
IdTokenClaimNames.IAT,
IdTokenClaimNames.AUTH_TIME,
IdTokenClaimNames.NONCE,
IdTokenClaimNames.ACR,
IdTokenClaimNames.AMR,
IdTokenClaimNames.AZP,
IdTokenClaimNames.AT_HASH,
IdTokenClaimNames.C_HASH
)));
@Override
public void customize(JwtEncodingContext context) {
if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
Map<String, Object> thirdPartyClaims = extractClaims(context.getPrincipal());
context.getClaims().claims(existingClaims -> {
existingClaims.keySet().forEach(thirdPartyClaims::remove);
ID_TOKEN_CLAIMS.forEach(thirdPartyClaims::remove);
existingClaims.putAll(thirdPartyClaims);
});
}
}
private Map<String, Object> extractClaims(Authentication principal) {
Map<String, Object> claims;
if (principal.getPrincipal() instanceof OidcUser) {
OidcUser oidcUser = (OidcUser) principal.getPrincipal();
OidcIdToken idToken = oidcUser.getIdToken();
claims = idToken.getClaims();
} else if (principal.getPrincipal() instanceof OAuth2User) {
OAuth2User oauth2User = (OAuth2User) principal.getPrincipal();
claims = oauth2User.getAttributes();
} else {
claims = Collections.emptyMap();
}
return new HashMap<>(claims);
}
}
- 인증 결과 생성되는 Auth Token, Id Token 에 포함될 정보를 커스터마이징한다.
- ID_TOKEN_CLAIMS : ID 토큰에 저장될 정보를 정의한다.
- iss : issuer. 발급자
- sub : subject. 토큰 제목
- aud : audience. 토큰의 대상
- exp : expiration. 토큰 만료 시간
- iat : issued at. 토큰 발급 시간
- nonce : 암호화 전용 임시 토큰
실행
Run
Brouser (http://localhost:9000)
결론
인증 서버 구현이 끝났다. 이제 OAuth2 구조대로 인증이 이루어지는지 확인할 것이다.
Repository
https://github.com/gigyesik/spring-authorization-server-sample/tree/clone
반응형
'Spring Security > Spring Authorization Server' 카테고리의 다른 글
[Spring Authorization Server Sample 따라 구현하기] H2 DB 설정 (0) | 2024.10.01 |
---|---|
[Spring Authorization Server Sample 따라 구현하기] 서버 실행 (0) | 2024.09.30 |
[Spring Authorization Server Sample 따라 구현하기] Client Server (0) | 2024.09.24 |
[Spring Authorization Server Sample 따라 구현하기] Resource Server (0) | 2024.09.23 |
[Spring Authorization Server Sample 따라 구현하기] 시작 (2) | 2024.09.22 |