Spring Security OAuth2.0 파헤치기! - 2(Authorization Server + Resource Server)

2021. 4. 13. 13:07 Spring Framework/Spring security

 

오늘은 이전 포스팅에서 다 마치지 못했던 Authorization Server와 나머지 Resource Server,Client 애플리케이션에 대해 포스팅 할 것이다. 사실 대부분 소스설명은 생략하였다. 사실 소스 설명이라고 할건 Spring Security 포스팅에서 다루었던 클래스들이다. 이전 포스팅에서는 Authorization Server 설정과 토큰 발급까지 다루었다. 이번 포스팅은 동적 클라이언트 등록에 관한 설명으로 시작할 것이다. 다들 페이스북, 구글의 어떠한 기능을 우리의 애플리케이션에서 사용하기 위하여 앱등록을 해본 경험자들이 있을 것이다. 앱을 등록하면 ClientId와 Client Secret이라는 것을 발급받게 된다. 그것은 바로 OAuth2.0에서 나의 애플리케이션을 인증하고, 보호된 리소스를 사용하기 위한 Token을 발급받기위한 인증 정보인 것이다. 바로 예제로 들어간다.

https://coding-start.tistory.com/158

 

@Controller
@RequestMapping("/client")
public class ClientController {
    
    @Autowired private ClientRegistrationService clientRegistrationService;
    
    @GetMapping("/register")
    public ModelAndView registerPage(ModelAndView mav) {
        mav.setViewName("client/register");
        mav.addObject("registry", new RegisterClientInfo());
        return mav;
    }
    
    @GetMapping("/dashboard")
    public ModelAndView dashboard(ModelAndView mv) {
        mv.addObject("applications",
                clientRegistrationService.listClientDetails());
        return mv;
    }
    
    @PostMapping("/save")
    public ModelAndView save(@Valid RegisterClientInfo clientDetails,ModelAndView mav ,BindingResult bindingResult) {
        
        if(bindingResult.hasErrors()) {
            return new ModelAndView("client/register");
        }
        
        ClientDetailsImpl client = new ClientDetailsImpl();
        client.addAdditionalInformation("name", clientDetails.getName());
        client.setRegisteredRedirectUri(new HashSet<>(Arrays.asList("http://localhost:9000/callback")));
        client.setClientType(ClientType.PUBLIC);
        client.setClientId(UUID.randomUUID().toString());
        client.setClientSecret(Crypto.sha256(UUID.randomUUID().toString()));
        client.setAccessTokenValiditySeconds(3600);
        client.setScope(Arrays.asList("read","write"));
        clientRegistrationService.addClientDetails(client);
        
        mav.setViewName("redirect:/client/dashboard");
        
        return mav;
    }
    
    @GetMapping("/remove")
    public ModelAndView remove(
            @RequestParam(value = "client_id", required = false) String clientId) {
 
        clientRegistrationService.removeClientDetails(clientId);
 
        ModelAndView mv = new ModelAndView("redirect:/client/dashboard");
        mv.addObject("applications",
                clientRegistrationService.listClientDetails());
        return mv;
    }
}

 

동적클라이언트 등록에 사용될 컨트롤러 클래스이다. 클라이언트 아이디와 시크릿은 랜덤하게 생성하였고, 해당 애플리케이션의 scope는 하드코딩하여 넣어주었다. 그리고 액세스토큰의 유효시간은 한시간으로 설정해주었고, 인증코드와 토큰을 받기위한 리다이렉트 Url을 설정해준 후에 클라이언트를 저장하였다.

 

/**
 * 
 * @author yun-yeoseong
 *
 */
@Slf4j
@Primary
@Service
public class ClientDetailsServiceImpl extends JdbcClientDetailsService{
    
    public ClientDetailsServiceImpl(DataSource dataSource) {
        super(dataSource);
    }
 
    @Override
    public ClientDetails loadClientByClientId(String clientId) throws ClientRegistrationException {
        log.info("ClientDetailsServiceImpl.loadClientByClientId :::: {}",clientId);
        return super.loadClientByClientId(clientId);
    }
 
    @Override
    public void addClientDetails(ClientDetails clientDetails) throws ClientAlreadyExistsException {
        log.info("ClientDetailsServiceImpl.addClientDetails :::: {}",clientDetails.toString());
        super.addClientDetails(clientDetails);
    }
 
    @Override
    public void updateClientDetails(ClientDetails clientDetails) throws NoSuchClientException {
        log.info("ClientDetailsServiceImpl.updateClientDetails :::: {}",clientDetails.toString());
        super.updateClientDetails(clientDetails);
    }
 
    @Override
    public void updateClientSecret(String clientId, String secret) throws NoSuchClientException {
        log.info("ClientDetailsServiceImpl.updateClientSecret :::: {},{}",clientId,secret);
        super.updateClientSecret(clientId, secret);
    }
 
    @Override
    public void removeClientDetails(String clientId) throws NoSuchClientException {
        log.info("ClientDetailsServiceImpl.removeClientDetails :::: {}",clientId);
        super.removeClientDetails(clientId);
    }
 
    @Override
    public List<ClientDetails> listClientDetails() {
        List<ClientDetails> list = super.listClientDetails();
        log.info("ClientDetailsServiceImpl.listClientDetails :::: count = {}",list.size());
        return list;
    }
    
}

실제 비지니스로직이 담기는 서비스 클래스이다. JdbcClientDetailsService를 상속하여 사용하였다.

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
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <title>oauth2server</title>
    <link href="../webjars/bootstrap/3.3.5/css/bootstrap.min.css" rel="stylesheet" media="screen"></link>
    <link href="/bootstrap-select.min.css" rel="stylesheet"></link>
</head>
 
<body>
 
<br/>
<div class="container">
 
    <div class="jumbotron">
        <h1>OAuth2 Provider</h1>
    </div>
 
    <h2>Registered applications</h2>
 
    <div th:if="${applications != null}">
        <table class="table">
            <tr>
                <td>Application name</td>
                <td>client type</td>
                <td>client ID</td>
                <td>client secret</td>
                <td>Delete app</td>
            </tr>
            <tr th:each="app : ${applications}">
                <td th:text="${app.additionalInformation['name']}"></td>
                <td th:text="${app.additionalInformation['client_type']}"></td>
                <td th:text="${app.clientId}">client_id</td>
                <td th:text="${app.clientSecret}">client_secret</td>
                <td><a class="btn btn-danger" href="#" th:href="@{/client/remove(client_id=${app.clientId})}">Delete</a></td>
            </tr>
        </table>
    </div>
 
    <a class="btn btn-default" href="/client/register">Create a new app</a>
</div>
</body>
 
<script src="/jquery.min.js"></script>
<script src="/bootstrap-select.min.js"></script>
<script src="../webjars/bootstrap/3.3.5/js/bootstrap.min.js"></script>
 
</html>
cs

 

클라이언트 리스트가 뿌려질 Dashboard 클라이언트 소스이다.

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
61
62
63
64
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<title>oauth2server</title>
<link href="../webjars/bootstrap/3.3.5/css/bootstrap.min.css"
  rel="stylesheet" media="screen"></link>
<link href="/bootstrap-select.min.css" rel="stylesheet"></link>
</head>
 
<body>
 
  <br />
  <div class="container">
 
    <div class="jumbotron">
      <h1>OAuth2 Provider</h1>
    </div>
 
    <h2>Create your application (client registration)</h2>
 
    <form action="#" th:action="@{/client/save}" th:object="${registry}"
      method="post">
      <div class="form-group">
        <label for="nome">Name:</label> <input class="form-control"
          id="name" type="text" th:field="*{name}" />
        <div th:if="${#fields.hasErrors('name')}" th:errors="*{name}">application
          name</div>
      </div>
 
      <div class="form-group">
        <label for="redirectUri">Redirect URL:</label> <input
          class="form-control" id="redirectUri" type="text"
          th:field="*{redirectUri}" />
        <div th:if="${#fields.hasErrors('redirectUri')}"
          th:errors="*{redirectUri}">Callback URL to receive the
          authorization code</div>
      </div>
 
      <div class="form-group">
        <label for="clientType">Type of application:</label>
        <div>
          <select id="clientType" class="selectpicker"
            th:field="*{clientType}">
            <option value="PUBLIC">Public</option>
            <option value="CONFIDENTIAL">Confidential</option>
          </select>
        </div>
      </div>
 
      <div class="form-group">
        <button class="btn btn-primary" type="submit">Register</button>
        <button class="btn btn-default" type="button"
          onclick="javascript: window.location.href='/'">Cancel</button>
      </div>
    </form>
 
  </div>
</body>
 
<script src="/jquery.min.js"></script>
<script src="/bootstrap-select.min.js"></script>
<script src="../webjars/bootstrap/3.3.5/js/bootstrap.min.js"></script>
 
</html>
cs

 

클라이언트 등록화면 소스이다.

 

http://localhost:8080/client/dashboard로 접속해보자. Authorization Server 설정을 생각해보자. 우리는 로그인과 로그아웃 말고는 모두 인증된 사용자만 접근할 수 있게 Spring Security 룰을 정해주었다. 그렇다면 대시보드로 접속하면 로그인화면이 뜨고 로그인을 해주어야한다. 로그인이 완료되었다면 보이는 화면이다.

필자는 미리 클라이언트 몇개를 만들어놓았다. Create a new app을 눌러보자.

 

리다이렉트 Url은 다음 예제를 위해 동일하게 설정해준다. Register 버튼을 누른다.

 

클라이언트 등록이 완료되었다. 저번 포스팅에서 필자가 미리 등록한 클라이언트로 토큰 발급을 해보았는데, 이번 포스팅에서 우리가 직접 만든 클라이언트로 토큰을 만들어보자.

 

http://localhost:8080/oauth/authorize?client_id=315ce741-08cc-4fbb-a3c1-b45a72fc2da4&redirect_uri=http://localhost:9000/callback&response_type=code&scope=read,write&state=xyz 으로 접속해보자.

 

client_id에는 방금 생성한 client의 아이디를 넣어준다. 그리고 scope는 컨트롤러에서 하드코딩된 read를 넣어주고, 리다이렉트 Url 또한 방금 등록한 Url과 동일하게 넣어준다.

 

 

우리는 방금 로그인을 한후에 클라이언트를 등록하였기 때문에 별도 로그인 창은 뜨지 않을 것이다. 이 애플리케이션이 리소스를 사용할 수 있도록 Approval해준다. 그러면 리다이렉트된 Url에 code가 삽입되어 있을 것이다. 그리고 DB의 OAUTH_APPROVALS 테이블과 OAUTH_CODE 테이블을 확인해보자. 데이터가 삽입된 것을 확인할 수 있다. 중요한 것은 오라클을 사용중이라면 OAUTH_CODE의 authentication 칼럼은 blob으로 설정해주어야한다.

 

->http://localhost:9000/callback?code=sIqzvL&state=xyz

 

해당 인증코드를 복사 한 후에 토큰을 요청해보자.

 

Basic Auth : username - 315ce741-08cc-4fbb-a3c1-b45a72fc2da4 / password - e2c5128a-cdd4-464c-a812-43dcd890642d

Headers : Content-Type - application/x-www-form-urlencoded

Method : POST

Url : http://localhost:8080/oauth/token?code=sIqzvL&grant_type=authorization_code&scope=read_profile&redirect_uri=http://localhost:9000/callback

 

<결과>

{
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NTY1NTE0OTIsInVzZXJfbmFtZSI6IjEyMjN5eXMiLCJhdXRob3JpdGllcyI6WyJST0xFX1VTRVIiXSwianRpIjoiMDk0MzAwMjMtMDcwNC00MTYzLWJhOGEtYmEyOTFhZjZlODEwIiwiY2xpZW50X2lkIjoiMzE1Y2U3NDEtMDhjYy00ZmJiLWEzYzEtYjQ1YTcyZmMyZGE0Iiwic2NvcGUiOlsicmVhZCJdfQ.HlxTqzftR9Bc75gRANgXiZHgROGoBH-fGje0D3Uhlcs",
    "token_type": "bearer",
    "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiIxMjIzeXlzIiwic2NvcGUiOlsicmVhZCJdLCJhdGkiOiIwOTQzMDAyMy0wNzA0LTQxNjMtYmE4YS1iYTI5MWFmNmU4MTAiLCJleHAiOjE1NTkxMzk4OTIsImF1dGhvcml0aWVzIjpbIlJPTEVfVVNFUiJdLCJqdGkiOiI2NTIxZjUyMC1kNTJkLTRhZmMtYTA4MC1hZTE3ZWI0NjBhN2UiLCJjbGllbnRfaWQiOiIzMTVjZTc0MS0wOGNjLTRmYmItYTNjMS1iNDVhNzJmYzJkYTQifQ.HtvEB6MgTrIGg0EtVM6MBWCQq_rVXYxuWoIf4zzdlLM",
    "expires_in": 3599,
    "scope": "read",
    "jti": "09430023-0704-4163-ba8a-ba291af6e810"
}

 

토큰이 발급되었다. 이제는 토큰을 이용하여 Resource Server의 API를 호출하는 예제를 진행해보자.

 

Resource Server

 

설정이 Authorization Server보다 적다. 코드를 바로 보자.

/*
 * ResourceServerConfigurerAdapter를 상속받아 구현하고, @EnableResourceServer를 선언함으로써
 * OAuth2AuthenticationProcessingFilter를 추가하는 몇 가지 설정이 임포트되어 리소스 서버의 액세스토큰
 * 유효성 검증이 수행된다.
 * 
 * OAuth2AuthenticationProcessingFilter는 "/**"패턴과 매칭되는 엔드포인트에 대한
 * 액세스 토큰 유효성 검사 프로세스 시작을 담당하는 필터이다.(헤더에서 Bearer 토큰을 분리하여 인증한다.)
 * Authorization 헤더(bearer)에서 토큰을 추출하여 없으면 QueryString(access_token)을 뒤져본다.
 * 토큰을 찾았으면 Token value를 principal, "" 빈문자열을 credentials로 넣은 Authentication객체를
 * 리턴한다. 그리고 HttpServletRequest에 Token Type : Bearer Token value : token string을 넣어
 * tokenvalue,tokentype,사용자remoteaddress,http sessionid 등의 정보를 담은 OAuth2AuthenticationDetails객체에
 * 넣어서 AbstractAuthenticationToken객체의 details Object에 넣어준다. 그리고 해당 객체를 매개변수로
 * AuthenticationManager.authenticate 메소드로 토큰의 유효성을 검증한다.(jwt 토큰이라면 자체 검증)
 * 만약 토큰 유효성 검사에서 실패하면 별도 리다이렉트 없이 예외 정보가 담긴 응답을 받는다.
 */
@Configuration
@EnableResourceServer
public class OAuth2ResourceServerConfig extends ResourceServerConfigurerAdapter{
    
    /*
     * 리소스 서버 엔드포인트 보호를 위한 보안 룰 적용
     */
    @Override
    public void configure(HttpSecurity http) throws Exception {
        // TODO Auto-generated method stub
        http.authorizeRequests()
            .anyRequest().authenticated()
            .and()
            //OAuth2.0 토큰 인증을 받아야하는 요청들 규칙정리
            .requestMatchers().antMatchers("/**")
        ;
    }
 
    /*
     * ResourceTokenService는 Resource Server가 액세스 토큰의 유효성을 검사하기 위해
     * 사용된다. 해당 서비스 클래스는 tokenStore()에 어떠한 스토어가 설정되냐에 의존적으로 수행된다.
     */
    
    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources
            .tokenStore(tokenStore())
//            .tokenExtractor(new BearerTokenExtractor())
//            .authenticationManager(new OAuth2AuthenticationManager())
            .authenticationEntryPoint(new AuthenticationEntryPoint() {
                @Override
                public void commence(HttpServletRequest request, HttpServletResponse response,
                        AuthenticationException authException) throws IOException, ServletException {
                    PrintWriter writer = response.getWriter();
                    writer.println("requeired token !");
                    
                }
            })
            .accessDeniedHandler(new AccessDeniedHandler() {
                
                @Override
                public void handle(HttpServletRequest request, HttpServletResponse response,
                        AccessDeniedException accessDeniedException) throws IOException, ServletException {
                    PrintWriter writer = response.getWriter();
                    writer.println("Access Denied !");
                }
            });
    }
    
    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setSigningKey("non-prod-signature");
        return converter;
    }
    
    @Bean
    public TokenStore tokenStore() {
        JwtTokenStore tokenStore = new JwtTokenStore(jwtAccessTokenConverter());
        return tokenStore;
    }
    
    
}

 

HttpSecurity http를 매개변수로 받는 메소드는 Spring Security에서 설정했던 것과 동일하다. 리소스를 보호하기 위한 ant 패턴식을 적용한다. 두번째 ResourceServerSecurityConfigurer를 매개변수로 갖는 메소드는 토큰을 어떠한 종류의 토큰을 사용할 것인지?(tokenStore) 권한이 부족했을때의 행동(accessDeniedHandler), 토큰이 유효하지 않았을 때(authenticationEntryPoint)의 행동 등을 정의하는 설정이다. Authorization Server와 동일하게 사용할 토큰의 TokenStore를 지정해주면 토큰의 유효성을 검사할때 해당 토큰으로 인증을 진행한다. 우리는 Jwt Token을 사용할 것임으로 JwtTokenStore와 JwtAccessTokenConverter를 빈으로 등록해주었다. 권한이 부족하거나 토큰이 유효하지 않을때는 단순히 메시지를 뿌려주도록 설정하였다. 여기서 하나 집고 넘어가야할 것은 Jwt 토큰 유효성 검사이다. 사실 일반 토큰을 사용한다면 Resource Server는 토큰의 유효성을 검사하기 위하여 Authorization Server와 DB를 공유하여 DB에 담긴 토큰을 가져와 비교하던가 혹은 check-token 요청을 Authorization Server에 보내 토큰의 유효성을 검사한다. 하지만 Jwt는 자체적으로 유효성검사가 가능하기 때문에 Authorization Server에 다녀올 필요가 없다. 만약 Jwt 인증 방법에 대해 모른다면 이전 포스팅 Jwt 포스팅을 확인하자.

 

@RestController
@SpringBootApplication
public class OauthResourceServerApplication {
 
    public static void main(String[] args) {
        SpringApplication.run(OauthResourceServerApplication.class, args);
    }
 
    
    @GetMapping
    public String test() {
        return "test";
    }
    
    @GetMapping("/api/profile")
    public ResponseEntity<UserProfile> myProfile() {
        String username = (String) SecurityContextHolder.getContext()
                .getAuthentication().getPrincipal();
        String email = username + "@mailinator.com";
 
        UserProfile profile = new UserProfile(username, email);
 
        return ResponseEntity.ok(profile);
    }
    
    static class UserProfile{
        private String name;
 
        private String email;
 
        public UserProfile(String name, String email) {
            super();
            this.name = name;
            this.email = email;
        }
 
        public String getName() {
            return name;
        }
 
        public String getEmail() {
            return email;
        }
    }
}

 

간단히 보호된 리소스를 등록했다. 이제 아까 받은 액세스 토큰을 이용하여 접근해보자.

 

GET - http://localhost:8081/api/profile

Headers : Authorization Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1NTY1NTE0OTIsInVzZXJfbmFtZSI6IjEyMjN5eXMiLCJhdXRob3JpdGllcyI6WyJST0xFX1VTRVIiXSwianRpIjoiMDk0MzAwMjMtMDcwNC00MTYzLWJhOGEtYmEyOTFhZjZlODEwIiwiY2xpZW50X2lkIjoiMzE1Y2U3NDEtMDhjYy00ZmJiLWEzYzEtYjQ1YTcyZmMyZGE0Iiwic2NvcGUiOlsicmVhZCJdfQ.HlxTqzftR9Bc75gRANgXiZHgROGoBH-fGje0D3Uhlcs

 

이렇게 요청을 보내게 되면  아래와 같은 결과를 얻을 수 있다.

 

{
    "name": "1223yys",
    "email": "1223yys@mailinator.com"
}

 

만약 토큰을 변조해서 보내보면 어떨까? 토큰 문자열의 일부를 지워본 후 요청을 보내보자.

 

"requeired token !" 라는 결과가 보일 것이다. 토큰이 유효하지 않아서 우리가 Resource Server 설정에 넣었던 authenticationEntryPoint가 동작하여 문자열을 리턴하였다.

 

이제는 권한이 없을 상황을 시뮬레이션 해보자. 우선 Resource Server 설정 클래스에 @EnableGlobalMethodSecurity(prePostEnabled=true) 어노테이션을 달아준다. 그리고 우리가 호출할 @GetMapping에 @PreAuthorize("#oauth2.hasScope('write')") 어노테이션을 달아본 후에 API을 호출해보자

 

"Access Denied !" 라는 결과가 보일 것이다. 토큰은 유효하지만 권한이 부족하여 우리가 등록한 accessDeniedHandler가 작동하였다.

 

여기까지 Resource Server를 간단하게 다루어보았다. 마지막으로 Client 애플리케이션이다. 우린 지금 수동으로 인증코드를 얻어오고, 토큰을 발급 받은 후에 직접 API를 호출하였다. 이제는 직접 Client 애플리케이션을 만들어서 OAuth2.0 인증을 사용해 보자!

 

<깃헙 주소>

github.com/levi-yo/spring-oauth2.0

출처: https://coding-start.tistory.com/160?category=869723 [코딩스타트]