Spring Security JWT OAuth Example

14 분 소요


RECOMMEND POSTS BEFORE THIS

1. 용어 정리

인증 예제를 살펴보기 전에 간단하게 OAuth(OpenID Authentication) 인증과 관련된 용어에 대해 알아보겠습니다.

  • Resource Owner
    • 사용자이며 개인 정보에 주인입니다.
  • Application (혹은 Client)
    • 사용자가 이용하고 싶은 서비스 혹은 어플리케이션입니다.
  • Authorization Server
    • 사용자 정보를 인증하는 서버이며 인증된 사용자에게 토큰을 발급해줍니다.
    • clientId, clientSecret을 전달받아 인증을 수행합니다.
  • Resource Server
    • 사용자 정보를 지닌 서버이며 인증된 사용자에게만 발급된 토큰을 통해 접근 가능합니다.
인증 프로세스 예시

간단하게 StackOverflow 서비스 로그인을 예시로 들어보겠습니다.

  1. 사용자(Resource Owner)StackOverflow(Application) 서비스에 질문을 남기기 위해 로그인을 시도합니다.
  2. StackOverflow 서비스는 사용자 로그인 옵션을 제공합니다.
    • E-Mail, 비밀번호로 로그인
    • 구글 계정으로 로그인
    • GitHub 계정으로 로그인
    • Facebook 계정으로 로그인
  3. 사용자는 GitHub 계정으로 로그인 옵션을 선택합니다.
    • GitHub 계정으로 로그인하기 위한 화면이 연결됩니다.
    • 사용자는 자신의 GitHub 계정 정보를 입력합니다.
  4. 사용자의 GitHub 계정 정보는 GitHub 인증 서버(Authorization Server)로 전달됩니다.
  5. 정상적인 인증이 된다면 GitHub 인증 서버StackOverflow 서비스로 인증 토큰을 발급합니다.
  6. StackOverflow 서비스는 발급받은 인증 토큰으로 GitHub 리소스 서버(Resource Server)에 필요한 사용자 정보를 요청하여 전달받습니다.
  7. 사용자의 화면은 인증 화면에서 StackOverflow 서비스 화면으로 리다이렉트(redirect)됩니다.
  8. 사용자StackOverflow 서비스를 이용할 수 있습니다.

https://docs.pivotal.io/p-identity/1-14/grant-types.html

2. 서비스 구조

이번 포스트에서 사용한 spring-security-oauth2 라이브러리 2.3.3.RELEASE 버전을 사용하면 인증 서버와 리소스 서버 기능을 구현할 수 있습니다. 다음과 같은 서비스 인증 과정을 구현하였습니다.

  • 터미널에서 cURL 커맨드를 사용하였습니다.
  • cURL 커맨드를 통해 인증 서버로 사용자 정보를 전달합니다.
  • 인증 서버는 사용자 정보가 유효한지 확인합니다.
  • 사용자 정보가 유효하다면 JWT 토큰을 생성 후 클라이언트에게 전달합니다.
  • cURL 커맨드로 전달받은 토큰과 함께 리소스 서버에게 사용자 정보를 요청합니다.
  • 리소스 서버는 토큰이 유효한지, 권한은 충분한지 확인합니다.
  • 유효한 토큰인 경우 사용자 리소스를 클라이언트에게 전달합니다.

3. Authroization Server 구현

인증 서비스를 먼저 구현하겠습니다. 다음과 같은 패키지 구조를 가지며 주요 클래스들을 위주로 살펴보겠습니다.

./
├── Dockerfile
├── action-in-blog.iml
├── mvnw
├── mvnw.cmd
├── pom.xml
└── src
    ├── main
    │   ├── java
    │   │   └── blog
    │   │       └── in
    │   │           └── action
    │   │               ├── ActionInBlogApplication.java
    │   │               ├── converter
    │   │               │   └── StringListConverter.java
    │   │               ├── entity
    │   │               │   └── Member.java
    │   │               ├── repository
    │   │               │   └── MemberRepository.java
    │   │               ├── security
    │   │               │   ├── AuthorizationServer.java
    │   │               │   └── SecurityConfig.java
    │   │               └── service
    │   │                   └── MemberService.java
    │   └── resources
    │       └── application.yml
    └── test
        └── java
            └── blog
                └── in
                    └── action
                        └── ActionInBlogApplicationTests.java

3.1. application.yml

  • H2 메모리 데이터베이스를 사용하였습니다.
  • 8080 포트를 가집니다.
server:
  port: 8080
spring:
  h2:
    console:
      enabled: true
      path: /h2-console
  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
    username: sa
    password: 123

3.2. AuthorizationServer 클래스

  • @EnableAuthorizationServer 애너테이션을 사용해 인증 서버 설정을 위한 빈(bean)으로 등록합니다.
  • AuthorizationServerConfigurerAdapter 클래스를 상속받아 인증 서버 구현에 필요한 기능을 확장합니다.
  • 기타 설명은 가독성을 위해 코드에 주석으로 표시하였습니다.
    • AuthenticationManager 개념에 대한 이해가 부족한 분은 Spring Security Basic 포스트를 참고 바랍니다.
package blog.in.action.security;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
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.TokenEnhancerChain;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;

import java.util.Arrays;

@RequiredArgsConstructor
@Configuration
@EnableAuthorizationServer
public class AuthorizationServer extends AuthorizationServerConfigurerAdapter {

    private String clientId = "CLIENT_ID";

    private String clientSecret = "CLIENT_SECRET";

    private int ACCESS_TOKEN_VALID_SECONDS = 10 * 60 * 24;

    private int REFRESH_TOKEN_VALID_SECONDS = 60 * 60 * 24;

    private final PasswordEncoder passwordEncoder;

    private final AuthenticationManager authenticationManager;

    private final JwtAccessTokenConverter jwtAccessTokenConverter;

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        // 해당 인증 서버를 이용하는 클라이언트 어플리케이션 정보를 추가합니다.
        clients
                // 인증 서버 메모리에 추가합니다.
                .inMemory()
                // 클라이언트 어플리케이션에 미리 발급된 ID
                .withClient(clientId)
                // 클라이언트 어플리케이션에 미리 발급된 SECRET, 암호화하여 추가
                .secret(passwordEncoder.encode(clientSecret))
                // 인증 방법은 비밀번호와 리프레시 토큰
                .authorizedGrantTypes("password", "refresh_token")
                .scopes("read")
                // access token 유효 시간 등록
                .accessTokenValiditySeconds(ACCESS_TOKEN_VALID_SECONDS)
                // refresh token 유효 시간 등록
                .refreshTokenValiditySeconds(REFRESH_TOKEN_VALID_SECONDS);
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
        // JSON WEB TOKEN 을 사용하기 위한 컨버터 등록
        tokenEnhancerChain.setTokenEnhancers(Arrays.asList(jwtAccessTokenConverter));
        endpoints
                // Spring Security 프레임워크에서 사용하는 AuthenticationManager 등록
                .authenticationManager(authenticationManager)
                // 토큰 강화를 위한 TokenEnhancer 등록
                .tokenEnhancer(tokenEnhancerChain);
    }
}

3.4. SecurityConfig 클래스

  • @EnableWebSecurity 애너테이션을 통해 웹 암호화 설정 빈으로 등록합니다.
  • WebSecurityConfigurerAdapter 클래스를 상속하여 필요한 암호화에 필요한 기능을 확장합니다.
  • 기타 설명은 가독성을 위해 코드에 주석으로 표시하였습니다.
    • UserDetailsService 개념에 대한 이해가 부족한 분은 Spring Security Basic 포스트를 참고 바랍니다.
package blog.in.action.security;

import blog.in.action.service.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final MemberService memberService;

    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() throws Exception {
        // JWT 토큰을 만들기 위한 컨버터 생성
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setSigningKey("TEMP_SIGN_KEY");
        converter.afterPropertiesSet();
        return converter;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        // 암호화 인코더 
        return new BCryptPasswordEncoder();
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        // Spring Security 프레임워크에서 필요한 AuthenticationManager 등록
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // AuthenticationManager에서 사용하는 UserDetailsService 등록
        auth.userDetailsService(memberService);
    }
}

3.5. MemberService 클래스

  • UserDetailsService 클래스를 상속받아서 사용자 정보 조회 기능을 확장합니다.
  • AuthenticationManager 클래스가 인증 과정에서 loadUserByUsername 메소드를 호출하여 사용자 정보를 확인합니다.
package blog.in.action.service;

import blog.in.action.entity.Member;
import blog.in.action.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
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.Collection;
import java.util.Optional;
import java.util.stream.Collectors;

@RequiredArgsConstructor
@Service
public class MemberService implements UserDetailsService {

    private final MemberRepository memberRepository;

    private Collection<? extends GrantedAuthority> authorities(Member member) {
        return member.getAuthorities()
            .stream()
            .map(authority -> new SimpleGrantedAuthority(authority))
            .collect(Collectors.toList());
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<Member> option = memberRepository.findById(username);
        if (!option.isPresent()) {
            throw new UsernameNotFoundException(username);
        }
        Member member = option.get();
        return new User(member.getId(), member.getPassword(), authorities(member));
    }
}

3.6. ActionInBlogApplication 클래스

  • CommandLineRunner 클래스를 확장하여 서비스 테스트에 필요한 데이터를 미리 추가합니다.
package blog.in.action;

import blog.in.action.entity.Member;
import blog.in.action.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.security.crypto.password.PasswordEncoder;

import java.util.Collections;

@RequiredArgsConstructor
@SpringBootApplication
public class ActionInBlogApplication implements CommandLineRunner {

    private final MemberRepository memberRepository;

    private final PasswordEncoder passwordEncoder;

    public static void main(String[] args) {
        SpringApplication.run(ActionInBlogApplication.class, args);
    }

    @Override
    public void run(String... args) throws Exception {
        memberRepository.save(Member.builder()
                .id("Junhyunny")
                .password(passwordEncoder.encode("123"))
                .authorities(Collections.singletonList("ADMIN"))
                .build()
        );
    }
}

4. Resource Server 구현

리소스 서비스를 먼저 구현하겠습니다. 다음과 같은 패키지 구조를 가지며 주요 클래스들을 위주로 살펴보겠습니다.

./
├── Dockerfile
├── action-in-blog\ (1).iml
├── mvnw
├── mvnw.cmd
├── pom.xml
└── src
    ├── main
    │   ├── java
    │   │   └── blog
    │   │       └── in
    │   │           └── action
    │   │               ├── ActionInBlogApplication.java
    │   │               ├── controller
    │   │               │   └── MemberController.java
    │   │               ├── entity
    │   │               │   └── Member.java
    │   │               ├── repository
    │   │               │   └── MemberRepository.java
    │   │               ├── security
    │   │               │   ├── ResourceServer.java
    │   │               │   └── SecurityConfig.java
    │   │               └── service
    │   │                   └── MemberService.java
    │   └── resources
    │       └── application.yml
    └── test
        └── java
            └── blog
                └── in
                    └── action
                        └── ActionInBlogApplicationTests.java

4.1. application.yml

  • H2 메모리 데이터베이스를 사용하였습니다.
  • 8081 포트를 가집니다.
server:
  port: 8081
spring:
  h2:
    console:
      enabled: true
      path: /h2-console
  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
    username: sa
    password: 123

4.2. ResourceServer 클래스

  • @EnableResourceServer 애너테이션을 사용해 리소스 서버 설정을 위한 빈으로 등록합니다.
  • ResourceServerConfigurerAdapter 클래스를 상속받아 리소스 서버 구현에 필요한 기능을 확장합니다.
  • 기타 설명은 가독성을 위해 코드에 주석으로 표시하였습니다.
package blog.in.action.security;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
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;
import org.springframework.security.oauth2.provider.error.OAuth2AccessDeniedHandler;
import org.springframework.security.oauth2.provider.token.DefaultTokenServices;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;

@Configuration
@EnableResourceServer
public class ResourceServer extends ResourceServerConfigurerAdapter {

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        super.configure(resources);
        // 토큰 정보를 다루기 위한 토큰 서비스 객체 생성
        DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
        // JWT 토큰 변경을 위한 컨버터 생성
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        // JWT 토큰 인코딩과 디코딩에 사용되므로 인증 서버와 동일한 암호키를 사용합니다. - TEMP_SIGN_KEY
        converter.setSigningKey("TEMP_SIGN_KEY");
        converter.afterPropertiesSet();
        // JWT 토큰 컨버터와 JWT 토큰 스토어 등록
        defaultTokenServices.setTokenStore(new JwtTokenStore(converter));
        defaultTokenServices.setSupportRefreshToken(true);
        // 토큰 서비스 등록
        resources.tokenServices(defaultTokenServices);
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.cors().and()
                // 권한 확인이 필요한 요청 정보 등록
                .authorizeRequests()
                // /h2-console/** 경로는 모든 요청에 대해 허용
                .antMatchers("/h2-console/**").permitAll()
                // /member/user-info 경로는 ADMIN만 접근 가능
                .antMatchers("/member/user-info").hasAnyAuthority("ADMIN")
                // 나머지 요청은 인증만 필요
                .anyRequest().authenticated()
                .and()
                .exceptionHandling()
                .accessDeniedHandler(new OAuth2AccessDeniedHandler());
        http.csrf().disable();
        http.headers().frameOptions().disable();
    }
}

4.3. SecurityConfig 클래스

  • 암호화에 사용하는 PasswordEncoder 빈을 등록합니다.
package blog.in.action.security;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

4.4. MemberController 클래스

  • 사용자 정보 획득을 위한 /member/user-info API를 노출하고 있습니다.
package blog.in.action.controller;

import lombok.AllArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import blog.in.action.entity.Member;
import blog.in.action.service.MemberService;

@AllArgsConstructor
@RestController
@RequestMapping(value = "/member")
public class MemberController {

    private final MemberService memberService;

    @GetMapping("/user-info")
    public Member requestUserInfo(@RequestParam("id") String id) {
        return memberService.findById(id);
    }
}

4.5. MemberService 클래스

  • 사용자 ID를 통해 사용자 정보를 조회합니다.
package blog.in.action.service;

import blog.in.action.entity.Member;
import blog.in.action.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.Optional;

@RequiredArgsConstructor
@Service
public class MemberService {

    private final MemberRepository memberRepository;

    public Member findById(String id) {
        Optional<Member> option = memberRepository.findById(id);
        if (!option.isPresent()) {
            return null;
        }
        return option.get();
    }
}

4.6. ActionInBlogApplication 클래스

  • CommandLineRunner 클래스를 확장하여 서비스 테스트에 필요한 데이터를 미리 추가합니다.
package blog.in.action;

import blog.in.action.entity.Member;
import blog.in.action.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@RequiredArgsConstructor
@SpringBootApplication
public class ActionInBlogApplication implements CommandLineRunner {

    private final MemberRepository memberRepository;

    public static void main(String[] args) {
        SpringApplication.run(ActionInBlogApplication.class, args);
    }

    @Override
    public void run(String... args) throws Exception {
        memberRepository.save(Member.builder()
                .id("Junhyunny")
                .name("Junhyunny")
                .email("junhyunny@naver.com")
                .address("Seoul")
                .build()
        );
    }
}

5. 테스트하기

5.1. 서비스 실행

도커 컴포즈(docker compose)를 사용하여 인증 서버와 리소스 서버를 동시에 실행시킵니다.

  • docker-compose up 명령어를 사용합니다.
$ pwd
/Users/junhyunk/Desktop/workspace/blog/blog-in-action/2021-01-04-spring-security-example

$ docker-compose up  
Creating network "2021-01-04-spring-security-example_default" with the default driver
Creating 2021-01-04-spring-security-example_resource-server_1      ... done
Creating 2021-01-04-spring-security-example_authorization-server_1 ... done
Attaching to 2021-01-04-spring-security-example_authorization-server_1, 2021-01-04-spring-security-example_resource-server_1
authorization-server_1  | 
authorization-server_1  |   .   ____          _            __ _ _
authorization-server_1  |  /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
authorization-server_1  | ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
authorization-server_1  |  \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
authorization-server_1  |   '  |____| .__|_| |_|_| |_\__, | / / / /
authorization-server_1  |  =========|_|==============|___/=/_/_/_/
authorization-server_1  |  :: Spring Boot ::                (v2.4.1)
authorization-server_1  | 
resource-server_1       | 
resource-server_1       |   .   ____          _            __ _ _
resource-server_1       |  /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
resource-server_1       | ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
resource-server_1       |  \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
resource-server_1       |   '  |____| .__|_| |_|_| |_\__, | / / / /
resource-server_1       |  =========|_|==============|___/=/_/_/_/
resource-server_1       |  :: Spring Boot ::                (v2.4.1)
resource-server_1       | 
authorization-server_1  | 2022-08-19 18:49:17.871  INFO 1 --- [           main] blog.in.action.ActionInBlogApplication   : Starting ActionInBlogApplication v0.0.1-SNAPSHOT using Java 11.0.16 on 0df3df36e161 with PID 1 (/app/app.jar started by root in /app)
authorization-server_1  | 2022-08-19 18:49:17.874  INFO 1 --- [           main] blog.in.action.ActionInBlogApplication   : No active profile set, falling back to default profiles: default
resource-server_1       | 2022-08-19 18:49:17.884  INFO 1 --- [           main] blog.in.action.ActionInBlogApplication   : Starting ActionInBlogApplication v0.0.1-SNAPSHOT using Java 11.0.16 on bd0d9c3a927a with PID 1 (/app/app.jar started by root in /app)
resource-server_1       | 2022-08-19 18:49:17.887  INFO 1 --- [           main] blog.in.action.ActionInBlogApplication   : No active profile set, falling back to default profiles: default
resource-server_1       | 2022-08-19 18:49:18.775  INFO 1 --- [           main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode.
authorization-server_1  | 2022-08-19 18:49:18.838  INFO 1 --- [           main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode.
resource-server_1       | 2022-08-19 18:49:18.869  INFO 1 --- [           main] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 83 ms. Found 1 JPA repository interfaces.
authorization-server_1  | 2022-08-19 18:49:18.903  INFO 1 --- [           main] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 55 ms. Found 1 JPA repository interfaces.
resource-server_1       | 2022-08-19 18:49:19.655  INFO 1 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 8081 (http)
resource-server_1       | 2022-08-19 18:49:19.671  INFO 1 --- [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
resource-server_1       | 2022-08-19 18:49:19.671  INFO 1 --- [           main] org.apache.catalina.core.StandardEngine  : Starting Servlet engine: [Apache Tomcat/9.0.41]
resource-server_1       | 2022-08-19 18:49:19.733  INFO 1 --- [           main] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
resource-server_1       | 2022-08-19 18:49:19.733  INFO 1 --- [           main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 1771 ms
authorization-server_1  | 2022-08-19 18:49:19.752  INFO 1 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 8080 (http)
authorization-server_1  | 2022-08-19 18:49:19.768  INFO 1 --- [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
authorization-server_1  | 2022-08-19 18:49:19.769  INFO 1 --- [           main] org.apache.catalina.core.StandardEngine  : Starting Servlet engine: [Apache Tomcat/9.0.41]
resource-server_1       | 2022-08-19 18:49:19.812  INFO 1 --- [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
authorization-server_1  | 2022-08-19 18:49:19.833  INFO 1 --- [           main] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
authorization-server_1  | 2022-08-19 18:49:19.834  INFO 1 --- [           main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 1831 ms
authorization-server_1  | 2022-08-19 18:49:19.900  INFO 1 --- [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
resource-server_1       | 2022-08-19 18:49:20.041  INFO 1 --- [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
resource-server_1       | 2022-08-19 18:49:20.050  INFO 1 --- [           main] o.s.b.a.h2.H2ConsoleAutoConfiguration    : H2 console available at '/h2-console'. Database available at 'jdbc:h2:mem:testdb'
authorization-server_1  | 2022-08-19 18:49:20.137  INFO 1 --- [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
authorization-server_1  | 2022-08-19 18:49:20.145  INFO 1 --- [           main] o.s.b.a.h2.H2ConsoleAutoConfiguration    : H2 console available at '/h2-console'. Database available at 'jdbc:h2:mem:testdb'
resource-server_1       | 2022-08-19 18:49:20.265  INFO 1 --- [           main] o.hibernate.jpa.internal.util.LogHelper  : HHH000204: Processing PersistenceUnitInfo [name: default]
resource-server_1       | 2022-08-19 18:49:20.320  INFO 1 --- [           main] org.hibernate.Version                    : HHH000412: Hibernate ORM core version 5.4.25.Final
authorization-server_1  | 2022-08-19 18:49:20.351  INFO 1 --- [           main] o.hibernate.jpa.internal.util.LogHelper  : HHH000204: Processing PersistenceUnitInfo [name: default]
authorization-server_1  | 2022-08-19 18:49:20.404  INFO 1 --- [           main] org.hibernate.Version                    : HHH000412: Hibernate ORM core version 5.4.25.Final
resource-server_1       | 2022-08-19 18:49:20.508  INFO 1 --- [           main] o.hibernate.annotations.common.Version   : HCANN000001: Hibernate Commons Annotations {5.1.2.Final}
authorization-server_1  | 2022-08-19 18:49:20.613  INFO 1 --- [           main] o.hibernate.annotations.common.Version   : HCANN000001: Hibernate Commons Annotations {5.1.2.Final}
resource-server_1       | 2022-08-19 18:49:20.671  INFO 1 --- [           main] org.hibernate.dialect.Dialect            : HHH000400: Using dialect: org.hibernate.dialect.H2Dialect
authorization-server_1  | 2022-08-19 18:49:20.766  INFO 1 --- [           main] org.hibernate.dialect.Dialect            : HHH000400: Using dialect: org.hibernate.dialect.H2Dialect
resource-server_1       | 2022-08-19 18:49:21.226  INFO 1 --- [           main] o.h.e.t.j.p.i.JtaPlatformInitiator       : HHH000490: Using JtaPlatform implementation: [org.hibernate.engine.transaction.jta.platform.internal.NoJtaPlatform]
resource-server_1       | 2022-08-19 18:49:21.234  INFO 1 --- [           main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
authorization-server_1  | 2022-08-19 18:49:21.316  INFO 1 --- [           main] o.h.e.t.j.p.i.JtaPlatformInitiator       : HHH000490: Using JtaPlatform implementation: [org.hibernate.engine.transaction.jta.platform.internal.NoJtaPlatform]
authorization-server_1  | 2022-08-19 18:49:21.325  INFO 1 --- [           main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
resource-server_1       | 2022-08-19 18:49:21.615  WARN 1 --- [           main] JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
authorization-server_1  | 2022-08-19 18:49:21.734  WARN 1 --- [           main] JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning

... 

resource-server_1       | 2022-08-19 18:49:22.714  INFO 1 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8081 (http) with context path ''
resource-server_1       | 2022-08-19 18:49:22.725  INFO 1 --- [           main] blog.in.action.ActionInBlogApplication   : Started ActionInBlogApplication in 5.797 seconds (JVM running for 6.389)
authorization-server_1  | 2022-08-19 18:49:22.748  INFO 1 --- [           main] o.s.s.concurrent.ThreadPoolTaskExecutor  : Initializing ExecutorService 'applicationTaskExecutor'
authorization-server_1  | 2022-08-19 18:49:22.966  INFO 1 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
authorization-server_1  | 2022-08-19 18:49:22.976  INFO 1 --- [           main] blog.in.action.ActionInBlogApplication   : Started ActionInBlogApplication in 5.992 seconds (JVM running for 6.644)

5.2. 토큰 정보 받기

  • 인증 서버로 토큰 정보를 요청합니다.
    • POST 요청입니다.
    • /oauth/token는 Spring Security 프레임워크가 자동으로 생성한 API 경로입니다.
  • 인증 서버에 미리 등록된 클라이언트 IDSECRET 정보를 함께 전달합니다.
    • 클라이언트 IDSECRET 정보는 클라이언트 어플리케이션이 인증 서버로부터 미리 발급 받은 정보입니다.
  • 사용자임을 인증할 수 있도록 사용자 ID, 비밀번호, 인증 방식을 전달합니다.
$ curl -X POST http://localhost:8080/oauth/token\
   -H "Content-Type: application/x-www-form-urlencoded"\
   -u 'CLIENT_ID:CLIENT_SECRET'\
   -d "username=Junhyunny&password=123&grant_type=password" | jq .
결과
  • access_token - JWT 토큰
  • token_type - 토큰 타입
  • refresh_token - JWT 액세스 토큰이 만료된 경우 재발급을 받을 때 사용하는 리프레시 토큰
  • expires_in - 토큰 만료 시간
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   818    0   767  100    51   1356     90 --:--:-- --:--:-- --:--:--  1476
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NjA5NDk4MTIsInVzZXJfbmFtZSI6Ikp1bmh5dW5ueSIsImF1dGhvcml0aWVzIjpbIkFETUlOIl0sImp0aSI6IjlhMGZhOWVkLTk0MTgtNDkzYy1hNzgxLTFkMDNiNjljOGQxNSIsImNsaWVudF9pZCI6IkNMSUVOVF9JRCIsInNjb3BlIjpbInJlYWQiXX0.MTdH5OFPO4XhsVYd5lVFhL8ufOaPeZMWg9bSnaJ2lyE",
  "token_type": "bearer",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJKdW5oeXVubnkiLCJzY29wZSI6WyJyZWFkIl0sImF0aSI6IjlhMGZhOWVkLTk0MTgtNDkzYy1hNzgxLTFkMDNiNjljOGQxNSIsImV4cCI6MTY2MTAyMTgxMiwiYXV0aG9yaXRpZXMiOlsiQURNSU4iXSwianRpIjoiZDM1M2Y1NGQtZTBmNS00NjQ4LTg3NjMtY2UyMWI4N2VkMzNjIiwiY2xpZW50X2lkIjoiQ0xJRU5UX0lEIn0.xdwmp4C7hy3nEjIeD0IPIr1EK-076VlpHV5NnPk5LTI",
  "expires_in": 14399,
  "scope": "read",
  "jti": "9a0fa9ed-9418-493c-a781-1d03b69c8d15"
}

5.3. 사용자 리소스 정보 받기

  • 리소스 서버로 사용자 정보를 요청합니다.
    • 전달받은 토큰을 헤더 정보에 담아 전달합니다.
    • 헤더 키는 Authorization이며 토큰 앞에 Bearer 토큰 타입을 추가합니다.
$ curl http://localhost:8081/member/user-info\?id\=Junhyunny\
   -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NjA5NDk4MTIsInVzZXJfbmFtZSI6Ikp1bmh5dW5ueSIsImF1dGhvcml0aWVzIjpbIkFETUlOIl0sImp0aSI6IjlhMGZhOWVkLTk0MTgtNDkzYy1hNzgxLTFkMDNiNjljOGQxNSIsImNsaWVudF9pZCI6IkNMSUVOVF9JRCIsInNjb3BlIjpbInJlYWQiXX0.MTdH5OFPO4XhsVYd5lVFhL8ufOaPeZMWg9bSnaJ2lyE" | jq .
결과
  • 사용자 정보를 정상적으로 전달받습니다.
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    85    0    85    0     0    329      0 --:--:-- --:--:-- --:--:--   346
{
  "id": "Junhyunny",
  "name": "Junhyunny",
  "email": "junhyunny@naver.com",
  "address": "Seoul"
}

CLOSING

리소스 서버에서 /member/user-info 경로의 접근 권한을 USER 등으로 변경하면 사용자 정보 요청에 실패함을 확인할 수 있습니다. 이번 포스트를 참고하시는 분들은 아래 주의사항을 확인바랍니다.

Spring Security 진영 정책 변경

이번 포스트에서 사용한 2.3.3.RELEASE 버전까지는 인증 서버를 구현할 수 있지만, 최근 버전에선 인증 서버 구현을 위한 기능들이 모두 제거되었습니다. 현재 최신 Spring Security에서는 Authorization Server 구현을 지원하지 않습니다.(Deprecated)

2019/11/14 - Spring Security OAuth 2.0 Roadmap Update
No Authorization Server Support

Spring Security’s Authorization Server support was never a good fit. An Authorization Server requires a library to build a product. Spring Security, being a framework, is not in the business of building libraries or products. For example, we don’t have a JWT library, but instead we make Nimbus easy to use. And we don’t maintain our own SAML IdP, CAS or LDAP products.
In 2019, there are plenty of both commercial and open-source authorization servers available. Thus, the Spring Security team has decided to no longer provide support for authorization servers.
UPDATE: We’d like to thank everyone for your feedback on the decision to not support Authorization Server. Due to this feedback and some internal discussions, we are taking another look at this decision. We’ll notify the community on any progress.

보안 취약점 버전 확인
  • 2.3.3.RELEASE 버전은 보안 취약점이 발견된 버전입니다.

https://mvnrepository.com/artifact/org.springframework.security.oauth/spring-security-oauth2

TEST CODE REPOSITORY

RECOMMEND NEXT POSTS

REFERENCE

댓글남기기