OAuth2 및 JWT REST 보호에 Spring Boot 사용
게시 됨: 2022-03-11이 문서는 Spring Boot 및 Maven을 사용하여 JSON 웹 토큰(JWT) - OAuth2 인증 프레임워크의 서버 측 구현을 설정하는 방법에 대한 가이드입니다.
OAuth2에 대한 초기 이해가 권장되며 위에 링크된 초안을 읽거나 이와 같은 웹에서 유용한 정보를 검색하여 얻을 수 있습니다.
OAuth2는 2006년에 생성된 첫 번째 버전 OAuth를 대체하는 인증 프레임워크입니다. 보호된 리소스에 액세스하기 위해 클라이언트와 하나 이상의 HTTP 서비스 간의 인증 흐름을 정의합니다.
OAuth2는 다음과 같은 서버 측 역할을 정의합니다.
- 리소스 소유자: 리소스 액세스 제어를 담당하는 서비스
- 리소스 서버: 리소스 를 실제로 공급하는 서비스
- Authorization Server: 클라이언트와 리소스 소유자 사이의 중개자 역할을 하는 인증 프로세스를 처리하는 서비스
JSON 웹 토큰(JWT)은 두 당사자 간에 전송되는 클레임 표현에 대한 사양입니다. 클레임은 암호화된 구조의 페이로드로 사용되는 JSON 개체로 인코딩되어 클레임을 디지털 서명하거나 암호화할 수 있습니다.
포함하는 구조는 JSON 웹 서명(JWS) 또는 JSON 웹 암호화(JWE)일 수 있습니다.
JWT는 OAuth2 프로토콜 내에서 사용되는 액세스 및 새로 고침 토큰의 형식으로 선택할 수 있습니다.
OAuth2 및 JWT는 다음 기능으로 인해 지난 몇 년 동안 큰 인기를 얻었습니다.
- Stateless REST 프로토콜을 위한 Stateless 인증 시스템 제공
- 여러 리소스 서버가 단일 인증 서버를 공유할 수 있는 마이크로 서비스 아키텍처에 적합
- JSON 형식으로 인해 클라이언트 측에서 관리하기 쉬운 토큰 콘텐츠
그러나 프로젝트에 다음 고려 사항이 중요한 경우 OAuth2 및 JWT가 항상 최선의 선택은 아닙니다.
- 상태 비저장 프로토콜은 서버 측에서 액세스 취소를 허용하지 않습니다.
- 토큰의 고정 수명은 보안을 손상시키지 않고 장기 실행 세션을 관리하기 위해 복잡성을 추가합니다(예: 토큰 새로 고침).
- 클라이언트 측 토큰의 보안 저장소에 대한 요구 사항
예상 프로토콜 흐름
OAuth2의 주요 기능 중 하나는 권한 부여 프로세스를 리소스 소유자와 분리하기 위해 권한 부여 계층을 도입한 것이지만 단순성을 위해 기사의 결과는 모든 리소스 소유자 , 권한 부여 서버 및 리소스 서버 역할. 이 때문에 통신은 서버와 클라이언트의 두 엔터티 사이에서만 흐릅니다.
이 단순화는 기사의 목적, 즉 Spring Boot 환경에서 그러한 시스템의 설정에 초점을 맞추는 데 도움이 될 것입니다.
단순화된 흐름은 아래에 설명되어 있습니다.
- 권한 부여 요청은 암호 권한 부여를 사용하여 클라이언트에서 서버(리소스 소유자 역할)로 전송됩니다.
- 액세스 토큰은 새로 고침 토큰과 함께 클라이언트에 반환됩니다.
- 그런 다음 보호된 리소스 액세스를 요청할 때마다 액세스 토큰이 클라이언트에서 서버(리소스 서버 역할)로 전송됩니다.
- 서버는 필수 보호 리소스로 응답합니다.
스프링 시큐리티와 스프링 부트
우선, 이 프로젝트를 위해 선택된 기술 스택에 대한 간략한 소개입니다.
선택한 프로젝트 관리 도구는 Maven이지만 프로젝트의 단순성으로 인해 Gradle과 같은 다른 도구로 전환하는 것이 어렵지 않을 것입니다.
이 기사의 연속에서는 Spring Security 측면에만 초점을 맞추지만 모든 코드 발췌는 REST 리소스를 사용하는 클라이언트와 함께 공개 리포지토리에서 소스 코드를 사용할 수 있는 완전히 작동하는 서버 측 애플리케이션에서 가져온 것입니다.
Spring Security는 Spring 기반 애플리케이션을 위한 거의 선언적 보안 서비스를 제공하는 프레임워크입니다. 그것의 뿌리는 Spring의 첫 번째 시작부터이고 다루어지는 많은 다른 보안 기술로 인해 모듈 세트로 구성됩니다.
Spring Security 아키텍처에 대해 간략히 살펴보겠습니다(더 자세한 가이드는 여기에서 찾을 수 있습니다).
보안은 대부분 인증 (예: 신원 확인)과 권한 부여(리소스에 대한 액세스 권한 부여)에 관한 것입니다.
Spring 보안은 타사에서 제공하거나 기본적으로 구현된 광범위한 인증 모델을 지원합니다. 목록은 여기에서 찾을 수 있습니다.
승인과 관련하여 세 가지 주요 영역이 식별됩니다.
- 웹 요청 승인
- 메소드 레벨 인증
- 도메인 개체 인스턴스에 대한 액세스 권한 부여
입증
기본 인터페이스는 인증 방법 제공을 담당하는 AuthenticationManager
입니다. UserDetailsService
는 사용자 정보 수집과 관련된 인터페이스로, 직접 구현하거나 표준 JDBC나 LDAP 방식의 경우 내부적으로 사용할 수 있다.
권한 부여
기본 인터페이스는 AccessDecisionManager
입니다. 위에 나열된 세 영역 모두에 대한 구현은 AccessDecisionVoter
체인에 위임됩니다. 후자의 인터페이스의 각 인스턴스는 Authentication
(사용자 ID, 보안 주체라는 이름), 리소스 및 리소스 소유자가 리소스 자체에 대한 액세스를 허용한 방법을 설명하는 규칙 집합인 ConfigAttribute
컬렉션 간의 연결을 나타냅니다. 사용자 역할 사용.
웹 애플리케이션에 대한 보안은 위에서 설명한 기본 요소를 사용하여 서블릿 필터 체인에서 구현되며 WebSecurityConfigurerAdapter
클래스는 리소스의 액세스 규칙을 표현하는 선언적 방법으로 노출됩니다.
메서드 보안은 먼저 @EnableGlobalMethodSecurity(securedEnabled = true)
주석의 존재에 의해 활성화된 다음 @Secured
, @PreAuthorize 및 @PostAuthorize
와 같이 보호할 각 메서드에 적용할 특수 주석 집합을 사용하여 @PreAuthorize
됩니다.
Spring Boot는 고품질 표준을 유지하면서 개발을 용이하게 하기 위해 이 모든 것에 독단적인 애플리케이션 구성 및 타사 라이브러리 컬렉션을 추가합니다.
스프링 부트가 있는 JWT OAuth2
이제 Spring Boot를 사용하여 OAuth2 및 JWT를 구현하는 애플리케이션을 설정하는 원래 문제로 이동해 보겠습니다.
Java 세계에 여러 서버 측 OAuth2 라이브러리가 존재하지만(목록은 여기에서 찾을 수 있음) Spring 기반 구현은 Spring Security 아키텍처에 잘 통합되어 많은 것을 처리할 필요가 없기 때문에 자연스러운 선택입니다. 사용에 대한 낮은 수준의 세부 정보입니다.
모든 보안 관련 라이브러리 종속성은 Maven의 구성 파일 pom.xml 내에서 명시적 버전을 요구하는 유일한 구성요소인 Spring Boot의 도움으로 Maven에 의해 처리됩니다(즉, 라이브러리 버전은 Maven이 가장 최신 버전을 선택하여 자동으로 유추합니다. 삽입된 Spring Boot 버전과 호환되는 버전).
Spring Boot 보안과 관련된 종속성을 포함하는 maven의 구성 파일 pom.xml 에서 발췌한 내용을 아래에서 찾으십시오.
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.security.oauth.boot</groupId> <artifactId>spring-security-oauth2-autoconfigure</artifactId> <version>2.1.0.RELEASE</version> </dependency>
앱은 OAuth2 인증 서버/리소스 소유자 및 리소스 서버 역할을 합니다.
보호된 리소스(리소스 서버)는 /api/ 경로 아래에 게시되고 인증 경로(리소스 소유자/권한 부여 서버)는 제안된 기본값에 따라 /oauth/token 에 매핑됩니다.
앱의 구조:
-
security
구성이 포함된 보안 패키지 -
errors
처리를 포함하는 오류 패키지 -
users
, 모델, 리포지토리 및 컨트롤러를 포함한 REST 리소스용glee
패키지
다음 단락에서는 위에서 언급한 세 가지 OAuth2 역할 각각에 대한 구성을 다룹니다. 관련 클래스는 security
패키지 내부에 있습니다.
-
OAuthConfiguration
,AuthorizationServerConfigurerAdapter
확장 -
ResourceServerConfiguration
,ResourceServerConfigurerAdapter
확장 -
ServerSecurityConfig
,WebSecurityConfigurerAdapter
확장 -
UserService
,UserDetailsService
구현
리소스 소유자 및 권한 부여 서버 설정
@EnableAuthorizationServer
주석이 있으면 권한 부여 서버 동작이 활성화됩니다. 해당 구성은 리소스 소유자 동작과 관련된 구성과 병합되며 둘 다 AuthorizationServerConfigurerAdapter
클래스에 포함됩니다.
여기에 적용된 구성은 다음과 관련이 있습니다.
- 클라이언트 액세스(
ClientDetailsServiceConfigurer
사용)-
inMemory
또는jdbc
메소드를 사용하여 클라이언트 세부사항에 대해 인메모리 또는 JDBC 기반 스토리지 사용 선택 -
clientId
및clientSecret
(선택한PasswordEncoder
빈으로 인코딩됨) 속성을 사용한 클라이언트의 기본 인증 -
accessTokenValiditySeconds
및refreshTokenValiditySeconds
속성을 사용한 액세스 및 새로 고침 토큰의 유효 시간 -
authorizedGrantTypes
속성을 사용하여 허용되는 부여 유형 -
scopes
방법으로 액세스 범위를 정의합니다. - 클라이언트의 액세스 가능한 리소스 식별
-
- 권한 부여 서버 끝점(
AuthorizationServerEndpointsConfigurer
사용)-
accessTokenConverter
로 JWT 토큰 사용 정의 -
UserDetailsService
및AuthenticationManager
인터페이스의 사용을 정의하여 인증 수행(리소스 소유자로서)
-
package net.reliqs.gleeometer.security; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer; import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer; import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer; import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter; @Configuration @EnableAuthorizationServer public class OAuthConfiguration extends AuthorizationServerConfigurerAdapter { private final AuthenticationManager authenticationManager; private final PasswordEncoder passwordEncoder; private final UserDetailsService userService; @Value("${jwt.clientId:glee-o-meter}") private String clientId; @Value("${jwt.client-secret:secret}") private String clientSecret; @Value("${jwt.signing-key:123}") private String jwtSigningKey; @Value("${jwt.accessTokenValidititySeconds:43200}") // 12 hours private int accessTokenValiditySeconds; @Value("${jwt.authorizedGrantTypes:password,authorization_code,refresh_token}") private String[] authorizedGrantTypes; @Value("${jwt.refreshTokenValiditySeconds:2592000}") // 30 days private int refreshTokenValiditySeconds; public OAuthConfiguration(AuthenticationManager authenticationManager, PasswordEncoder passwordEncoder, UserDetailsService userService) { this.authenticationManager = authenticationManager; this.passwordEncoder = passwordEncoder; this.userService = userService; } @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient(clientId) .secret(passwordEncoder.encode(clientSecret)) .accessTokenValiditySeconds(accessTokenValiditySeconds) .refreshTokenValiditySeconds(refreshTokenValiditySeconds) .authorizedGrantTypes(authorizedGrantTypes) .scopes("read", "write") .resourceIds("api"); } @Override public void configure(final AuthorizationServerEndpointsConfigurer endpoints) { endpoints .accessTokenConverter(accessTokenConverter()) .userDetailsService(userService) .authenticationManager(authenticationManager); } @Bean JwtAccessTokenConverter accessTokenConverter() { JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); return converter; } }
다음 섹션에서는 리소스 서버에 적용할 구성에 대해 설명합니다.
리소스 서버 설정
리소스 서버 동작은 @EnableResourceServer
주석을 사용하여 활성화되며 해당 구성은 ResourceServerConfiguration
클래스에 포함됩니다.
여기서 필요한 유일한 구성은 이전 클래스에서 정의된 클라이언트의 액세스와 일치시키기 위한 리소스 식별의 정의입니다.
package net.reliqs.gleeometer.security; import org.springframework.context.annotation.Configuration; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer; import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter; import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer; @Configuration @EnableResourceServer public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter { @Override public void configure(ResourceServerSecurityConfigurer resources) { resources.resourceId("api"); } }
마지막 구성 요소는 웹 애플리케이션 보안의 정의에 대한 것입니다.

웹 보안 설정
Spring 웹 보안 설정은 @EnableWebSecurity
어노테이션을 사용하여 활성화된 ServerSecurityConfig
클래스에 포함된다. @EnableGlobalMethodSecurity
는 메서드 수준에서 보안을 지정할 수 있도록 합니다. 컨트롤러는 일반적으로 인터페이스를 구현하지 않는 클래스이기 때문에 RestController
의 메서드에 대해 이것이 작동하도록 하기 위해 속성 proxyTargetClass
가 설정됩니다.
다음을 정의합니다.
- 사용할 인증 제공자, bean
authenticationProvider
정의 - 사용할 비밀번호 인코더, 빈
passwordEncoder
정의 - 인증 관리자 빈
-
HttpSecurity
를 사용하여 게시된 경로에 대한 보안 구성 - 표준 Spring REST 오류 핸들러
ResponseEntityExceptionHandler
외부의 오류 메시지를 처리하기 위해 사용자 정의AuthenticationEntryPoint
사용
package net.reliqs.gleeometer.security; import net.reliqs.gleeometer.errors.CustomAccessDeniedHandler; import net.reliqs.gleeometer.errors.CustomAuthenticationEntryPoint; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true, proxyTargetClass = true) public class ServerSecurityConfig extends WebSecurityConfigurerAdapter { private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; private final UserDetailsService userDetailsService; public ServerSecurityConfig(CustomAuthenticationEntryPoint customAuthenticationEntryPoint, @Qualifier("userService") UserDetailsService userDetailsService) { this.customAuthenticationEntryPoint = customAuthenticationEntryPoint; this.userDetailsService = userDetailsService; } @Bean public DaoAuthenticationProvider authenticationProvider() { DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); provider.setPasswordEncoder(passwordEncoder()); provider.setUserDetailsService(userDetailsService); return provider; } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Override protected void configure(HttpSecurity http) throws Exception { http .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/api/signin/**").permitAll() .antMatchers("/api/glee/**").hasAnyAuthority("ADMIN", "USER") .antMatchers("/api/users/**").hasAuthority("ADMIN") .antMatchers("/api/**").authenticated() .anyRequest().authenticated() .and().exceptionHandling().authenticationEntryPoint(customAuthenticationEntryPoint).accessDeniedHandler(new CustomAccessDeniedHandler()); } }
아래 코드 추출은 리소스 소유자의 인증을 제공하기 위한 UserDetailsService
인터페이스의 구현에 대한 것입니다.
package net.reliqs.gleeometer.security; import net.reliqs.gleeometer.users.User; import net.reliqs.gleeometer.users.UserRepository; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; @Service public class UserService implements UserDetailsService { private final UserRepository repository; public UserService(UserRepository repository) { this.repository = repository; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = repository.findByEmail(username).orElseThrow(() -> new RuntimeException("User not found: " + username)); GrantedAuthority authority = new SimpleGrantedAuthority(user.getRole().name()); return new org.springframework.security.core.userdetails.User(user.getEmail(), user.getPassword(), Arrays.asList(authority)); } }
다음 섹션에서는 보안 제약 조건이 매핑되는 방식을 확인하기 위해 REST 컨트롤러 구현에 대한 설명입니다.
REST 컨트롤러
REST 컨트롤러 내에서 각 리소스 메서드에 대한 액세스 제어를 적용하는 두 가지 방법을 찾을 수 있습니다.
- Spring에서 매개변수로 전달한
OAuth2Authentication
인스턴스 사용 -
@PreAuthorize
또는@PostAuthorize
주석 사용
package net.reliqs.gleeometer.users; import lombok.extern.slf4j.Slf4j; import net.reliqs.gleeometer.errors.EntityNotFoundException; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.security.access.prepost.PostAuthorize; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.provider.OAuth2Authentication; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import javax.validation.ConstraintViolationException; import javax.validation.Valid; import javax.validation.constraints.Size; import java.util.HashSet; @RestController @RequestMapping("/api/users") @Slf4j @Validated class UserController { private final UserRepository repository; private final PasswordEncoder passwordEncoder; UserController(UserRepository repository, PasswordEncoder passwordEncoder) { this.repository = repository; this.passwordEncoder = passwordEncoder; } @GetMapping Page<User> all(@PageableDefault(size = Integer.MAX_VALUE) Pageable pageable, OAuth2Authentication authentication) { String auth = (String) authentication.getUserAuthentication().getPrincipal(); String role = authentication.getAuthorities().iterator().next().getAuthority(); if (role.equals(User.Role.USER.name())) { return repository.findAllByEmail(auth, pageable); } return repository.findAll(pageable); } @GetMapping("/search") Page<User> search(@RequestParam String email, Pageable pageable, OAuth2Authentication authentication) { String auth = (String) authentication.getUserAuthentication().getPrincipal(); String role = authentication.getAuthorities().iterator().next().getAuthority(); if (role.equals(User.Role.USER.name())) { return repository.findAllByEmailContainsAndEmail(email, auth, pageable); } return repository.findByEmailContains(email, pageable); } @GetMapping("/findByEmail") @PreAuthorize("!hasAuthority('USER') || (authentication.principal == #email)") User findByEmail(@RequestParam String email, OAuth2Authentication authentication) { return repository.findByEmail(email).orElseThrow(() -> new EntityNotFoundException(User.class, "email", email)); } @GetMapping("/{id}") @PostAuthorize("!hasAuthority('USER') || (returnObject != null && returnObject.email == authentication.principal)") User one(@PathVariable Long id) { return repository.findById(id).orElseThrow(() -> new EntityNotFoundException(User.class, "id", id.toString())); } @PutMapping("/{id}") @PreAuthorize("!hasAuthority('USER') || (authentication.principal == @userRepository.findById(#id).orElse(new net.reliqs.gleeometer.users.User()).email)") void update(@PathVariable Long id, @Valid @RequestBody User res) { User u = repository.findById(id).orElseThrow(() -> new EntityNotFoundException(User.class, "id", id.toString())); res.setPassword(u.getPassword()); res.setGlee(u.getGlee()); repository.save(res); } @PostMapping @PreAuthorize("!hasAuthority('USER')") User create(@Valid @RequestBody User res) { return repository.save(res); } @DeleteMapping("/{id}") @PreAuthorize("!hasAuthority('USER')") void delete(@PathVariable Long id) { if (repository.existsById(id)) { repository.deleteById(id); } else { throw new EntityNotFoundException(User.class, "id", id.toString()); } } @PutMapping("/{id}/changePassword") @PreAuthorize("!hasAuthority('USER') || (#oldPassword != null && !#oldPassword.isEmpty() && authentication.principal == @userRepository.findById(#id).orElse(new net.reliqs.gleeometer.users.User()).email)") void changePassword(@PathVariable Long id, @RequestParam(required = false) String oldPassword, @Valid @Size(min = 3) @RequestParam String newPassword) { User user = repository.findById(id).orElseThrow(() -> new EntityNotFoundException(User.class, "id", id.toString())); if (oldPassword == null || oldPassword.isEmpty() || passwordEncoder.matches(oldPassword, user.getPassword())) { user.setPassword(passwordEncoder.encode(newPassword)); repository.save(user); } else { throw new ConstraintViolationException("old password doesn't match", new HashSet<>()); } } }
결론
Spring Security 및 Spring Boot를 사용하면 거의 선언적인 방식으로 완전한 OAuth2 권한 부여/인증 서버를 빠르게 설정할 수 있습니다. 이 튜토리얼에서 설명한 대로 application.properties/yml
파일에서 직접 OAuth2 클라이언트의 속성을 구성하여 설정을 더욱 단축할 수 있습니다.
모든 소스 코드는 이 GitHub 리포지토리: spring-glee-o-meter에서 사용할 수 있습니다. 게시된 리소스를 사용하는 Angular 클라이언트는 이 GitHub 리포지토리: glee-o-meter에서 찾을 수 있습니다.