이전 블로그 포스팅에서 눈치 채셨겠지만, 저는 다음을 좋아합니다. Spring + Java 그리고 Spring + Kotlin. 따라서 OAuth 2.0 인증을 구현해야 할 때마다 스프링-security-oauth2 라이브러리는 자연스럽게 선택됩니다.
그러나 Spring Security와 OAuth2를 함께 사용하는 방법을 보여주는 방법은 거의 없습니다. 스프링 보안 인증 2 를 inMemory 및 JDBC 이외의 다른 데이터 소스와 함께 사용할 수 있습니다. 많은 것을 구성해야 하므로 이 튜토리얼은 사용자 인증 방법의 세 부분으로 나누어 설명하겠습니다, 토큰 저장소를 구성하는 방법 동적 클라이언트를 구성하는 방법에 대해 설명합니다. 그럼 시작해 보겠습니다!
먼저, 최신 버전 중 하나를 사용하고 있다고 가정합니다. 스프링 보안 인증 2:
|
1 2 3 4 5 |
<dependency> <groupId>org.springframework.security.oauth</groupId> <artifactId>spring-security-oauth2</artifactId> <version>2.3.3.RELEASE</version> </dependency> |
둘째, 저는 스프링 데이터와 함께 카우치베이스를 사용하고 있습니다. 다른 데이터 소스를 사용 중이더라도 이 블로그 시리즈에서 많은 코드를 재사용할 수 있습니다.
|
1 2 3 4 5 |
<dependency> <groupId>org.springframework.data</groupId> <artifactId>spring-data-couchbase</artifactId> <version>3.0.5.RELEASE</version> </dependency> |
또한 다음과 같은 기능을 추가했습니다. 롬복 를 종속성으로 사용하여 Java의 상용구를 줄입니다:
|
1 2 3 4 5 |
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> |
다음과 관련된 Spring 보안 문서에 따라 리소스 서버를 구성해 보겠습니다. 스프링 보안 인증 2: "리소스 서버(권한 서버와 동일하거나 별도의 애플리케이션일 수 있음)는 OAuth2 토큰으로 보호되는 리소스를 제공합니다. Spring OAuth는 이 보호를 구현하는 Spring 보안 인증 필터를 제공합니다. 이를 켜려면 @EnableResourceServer 에 @Configuration 클래스를 생성하고, 필요에 따라 리소스 서버 컨피규레이터를 사용하여 구성합니다."
|
1 2 3 4 5 6 7 8 9 10 11 |
@Configuration @EnableResourceServer public class ResourceServerConfig extends ResourceServerConfigurerAdapter { private static final String RESOURCE_ID = "resource_id"; @Override public void configure(ResourceServerSecurityConfigurer resources) { resources.resourceId(RESOURCE_ID).stateless(false); } } |
이제 다음과 같은 인터페이스를 구현해 보겠습니다. UserDetailsService. 이 인터페이스는 데이터 소스와 Spring Security OAuth 사이의 다리 역할을 담당합니다:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
import com.bc.quicktask.standalone.model.CustomUserDetail; import com.bc.quicktask.standalone.model.SecurityGroup; import com.bc.quicktask.standalone.model.User; import com.bc.quicktask.standalone.repository.UserRepository; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; 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; import java.util.List; import java.util.stream.Collectors; @Slf4j @Service public class CustomUserDetailsService implements UserDetailsService { @Autowired private UserRepository userRepository; @Autowired private SecurityGroupService securityGroupService; @Override public UserDetails loadUserByUsername(String name) throws UsernameNotFoundException { List<User> users = userRepository.findByUsername(name); if(users.isEmpty()) { throw new UsernameNotFoundException("Could not find the user "+name); } User user = users.get(0); List<SecurityGroup> securityGroups = securityGroupService.listUserGroups(user.getCompanyId(), user.getId()); return new CustomUserDetail(user, securityGroups.stream() .map(e->e.getId()) .collect(Collectors.toList()) ); } } |
위 코드에서는 다음과 같은 유형의 클래스를 반환하고 있습니다. 사용자 세부 정보도 Spring에서 가져온 것입니다. 다음은 그 구현입니다:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
Data public class CustomUserDetail implements UserDetails { private User user; private List<String> groups; public CustomUserDetail(User user, List<String> groups) { this.user = user; this.groups = groups; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return null; } @Override public String getPassword() { return user.getPassword(); } @Override public String getUsername() { return user.getUsername(); } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return user.getIsEnabled(); } } |
User 클래스가 UserDetails를 직접 구현하도록 할 수도 있었습니다. 하지만 제 사용 사례에는 사용자가 속한 그룹 목록도 필요하기 때문에 위에 구현을 추가했습니다.
사용자, 보안 그룹 및 각 리포지토리의 모습은 다음과 같습니다:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
@Data public class User extends BasicEntity implements Serializable { @Id @NotNull private String id; @Field @NotNull private String username; @Field @NotNull private String companyId; @Field @NotNull private String password; @NotNull private Boolean isEnabled; @Field private Boolean isVisible; } |
|
1 2 3 4 5 6 7 |
@N1qlPrimaryIndexed @ViewIndexed(designDoc = "user") public interface UserRepository extends CouchbasePagingAndSortingRepository<User, String> { List<User> findByUsername(String username); } |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
@Document @Data @NoArgsConstructor @Builder public class SecurityGroup extends BasicEntity implements Serializable { @Id private String id; @NotNull @Field private String name; @Field private String description; @NotNull @Field private String companyId; @Field private List<String> users = new ArrayList<>(); @Field private boolean removed = false; } |
|
1 2 3 4 5 6 7 8 9 10 |
@N1qlPrimaryIndexed @ViewIndexed(designDoc = "securityGroup") public interface SecurityGroupRepository extends CouchbasePagingAndSortingRepository<SecurityGroup, String> { @Query("#{#n1ql.selectEntity} where #{#n1ql.filter} and companyId = $1 and removed = false " + " AND ARRAY_CONTAINS(users, $2) ") List<SecurityGroup> listUserGroups(String companyId, String userId); } |
그리고 기본 엔티티 클래스는 스프링 데이터와 카우치베이스에서 더 잘 작동하기 위한 작은 해킹이기도 합니다:
|
1 2 3 4 5 6 7 |
public class BasicEntity { @Getter(PROTECTED) @Setter(PROTECTED) @Ignore protected String _class; } |
마지막으로 SecurityConfig 클래스의 구현은 다음과 같습니다:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
@Configuration @EnableWebMvc public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private CustomUserDetailsService customUserDetailsService; @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Autowired public void globalUserDetails(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(customUserDetailsService) .passwordEncoder(encoder()); } @Override public void configure( WebSecurity web ) throws Exception { web.ignoring().antMatchers( HttpMethod.OPTIONS, "/**" ); } @Override protected void configure(HttpSecurity http) throws Exception { http .csrf().disable() .authorizeRequests() .antMatchers("/oauth/token").permitAll() .antMatchers("/api-docs/**").permitAll() .anyRequest().authenticated() .and().anonymous().disable(); } @Bean public TokenStore tokenStore() { return new InMemoryTokenStore(); } @Bean public PasswordEncoder encoder(){ return NoOpPasswordEncoder.getInstance(); } @Bean public FilterRegistrationBean corsFilter() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); CorsConfiguration config = new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOrigin("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); source.registerCorsConfiguration("/**", config); FilterRegistrationBean bean = new FilterRegistrationBean(new CorsFilter(source)); bean.setOrder(0); return bean; } } |
Spring-Boot 2.0에서는 더 이상 AuthenticationManager 빈을 직접 주입할 수 없지만 Spring Security 프레임워크에서는 여전히 필요합니다. 따라서 이 객체에 대한 액세스 권한을 얻으려면 작은 해킹을 구현해야 합니다:
|
1 2 3 4 5 |
@Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } |
이 수업을 잘게 나누어 무슨 일이 일어나고 있는지 이해해 보겠습니다:
|
1 2 3 4 |
@Bean public PasswordEncoder encoder(){ return NoOpPasswordEncoder.getInstance(); } |
사용자의 비밀번호는 일반 텍스트로 되어 있으므로 새 NoOpPasswordEncoder 인스턴스를 반환하면 됩니다. 일반적인 표준은 BCryptPasswordEncoder 클래스의 인스턴스를 반환하는 것입니다.
|
1 2 3 4 |
@Bean public TokenStore tokenStore() { return new InMemoryTokenStore(); } |
지금은 인메모리 토큰 저장소를 사용하겠지만, 2부에서는 카우치베이스를 토큰 저장소로 사용하는 방법도 살펴보겠습니다.
|
1 2 3 4 5 |
@Autowired public void globalUserDetails(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(customUserDetailsService) .passwordEncoder(encoder()); } |
여기서 OAuth2와 Spring Boot의 마법이 일어납니다. 구체적으로, 우리는 Spring에 우리의 사용자 세부 정보 서비스 를 사용하여 사용자를 검색할 수 있습니다. 이 코드 블록은 지금까지 수행한 작업의 핵심 부분입니다.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
@Bean public FilterRegistrationBean corsFilter() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); CorsConfiguration config = new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOrigin("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); source.registerCorsConfiguration("/**", config); FilterRegistrationBean bean = new FilterRegistrationBean(new CorsFilter(source)); bean.setOrder(0); return bean; } |
이 블록을 사용하면 CORS(교차 출처 리소스 공유)를 사용하여 요청할 수 있습니다.
|
1 2 3 4 |
@Override public void configure( WebSecurity web ) throws Exception { web.ignoring().antMatchers( HttpMethod.OPTIONS, "/**" ); } |
마지막으로 JQuery를 통해 API를 호출해야 하는 경우 위의 코드도 추가해야 합니다. 그렇지 않으면 "사전 비행에 대한 응답에 HTTP 확인 상태가 없습니다.." 오류입니다.
이제 남은 것은 인증 서버를 추가하는 것뿐입니다:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; 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.TokenStore; @Configuration @EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { static final String CLIENT_ID = "android-client"; static final String CLIENT_SECRET = "android-secret"; static final String GRANT_TYPE_PASSWORD = "password"; static final String AUTHORIZATION_CODE = "authorization_code"; static final String REFRESH_TOKEN = "refresh_token"; static final String IMPLICIT = "implicit"; static final String SCOPE_READ = "read"; static final String SCOPE_WRITE = "write"; static final String TRUST = "trust"; static final int ACCESS_TOKEN_VALIDITY_SECONDS = 1*60*60; static final int REFRESH_TOKEN_VALIDITY_SECONDS = 6*60*60; @Autowired private TokenStore tokenStore; @Autowired private AuthenticationManager authenticationManager; @Override public void configure(ClientDetailsServiceConfigurer configurer) throws Exception { configurer .inMemory() .withClient(CLIENT_ID) .secret(CLIENT_SECRET) .authorizedGrantTypes(GRANT_TYPE_PASSWORD, AUTHORIZATION_CODE, REFRESH_TOKEN, IMPLICIT ) .scopes(SCOPE_READ, SCOPE_WRITE, TRUST) .accessTokenValiditySeconds(ACCESS_TOKEN_VALIDITY_SECONDS). refreshTokenValiditySeconds(REFRESH_TOKEN_VALIDITY_SECONDS); } @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints.tokenStore(tokenStore) .authenticationManager(authenticationManager); } @Override public void configure(AuthorizationServerSecurityConfigurer oauthServer) throws Exception { oauthServer.tokenKeyAccess("permitAll()") .checkTokenAccess("isAuthenticated()"); } } |
이제 앱을 시작하고 Postman 또는 Jquery를 통해 호출할 수 있습니다:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
var data = { "grant_type": "password", "username": "myuser", "password":"mypassword", "client_id":"android-client", "client_secret":"android-secret" } $.ajax({ 'url': "https://localhost:8080/oauth/token", 'type': 'POST', "crossDomain": true, "headers": { 'Authorization': 'Basic YW5kcm9pZC1jbGllbnQ6YW5kcm9pZC1zZWNyZXQ=', //android-client:android-secret in Base64 'Content-Type':'application/x-www-form-urlencoded'}, "data":data, 'success': function (result) { console.log( "My Access token = "+ result.access_token); console.log( "My refresh token = "+ result.refresh_token); console.log("expires in = "+result.expires_in) succesCallback() }, 'error': function (XMLHttpRequest, textStatus, errorThrown) { errorCallback(XMLHttpRequest, textStatus, errorThrown) } }); |
성능 향상
Couchbase를 사용하는 경우 사용자 이름을 문서의 키로 사용하는 것이 좋습니다. 이렇게 하면 키-값 저장소 를 사용하여 N1QL 쿼리를 실행하는 대신 로그인 성능을 크게 향상시킬 수 있습니다.
Couchbase, OAuth 보안 또는 Spring 및 OAuth2 인증 최적화에 대해 궁금한 점이 있으면 다음 주소로 트윗해 주세요. @deniswsrosa
아주 좋은 글입니다. 이 게시물의 코드를 공유해 주시겠어요?