Web/Spring Security&OAuth 2019. 5. 4. 12:35

 

오늘 포스팅은 길지 않은 포스팅이 될 것같다. 오늘 포스팅할 내용은 이전 OAuth2.0 Authorization Server,Resource Server에 대하여 포스팅했던 내용에 Client 애플리케이션 소스코드를 추가한 최종 소스를 예제로 넣은 포스팅이다. 이전 포스팅들과 조금 달라진 소스가 있지만 아마 그대로 받아서 사용하면 간단한 OAuth2.0(Authorization Code Grant) 플로우를 테스트 해볼 수 있다. 혹시나 이전 포스팅을 보지 못했다면 밑 링크를 참고하자.

 

 

Spring Security OAuth2.0 파헤치기!(Authorization Server)

내부 챗봇 솔루션을 개발하면서, OAuth2.0을 이용하여 자체 인증서버를 구축할 일이 생겼다. 최근에 웹 또는 앱을 보면서 자주 접하게 되는 인증 방식이 OAuth2.0 방식이다. 대표적으로 네아로(네이버 아이디로..

coding-start.tistory.com

 

 

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

오늘은 이전 포스팅에서 다 마치지 못했던 Authorization Server와 나머지 Resource Server,Client 애플리케이션에 대해 포스팅 할 것이다. 사실 대부분 소스설명은 생략하였다. 사실 소스 설명이라고 할건 Sprin..

coding-start.tistory.com

오늘은 소스를 GitHub에 모두 업로드 해놓았다. 프로젝트의 구조에 대하여 조금 설명하자면, 전체 프로젝트 구성은 Maven Multi Module 프로젝트로 구성되어있다. 

<깃헙 주소>

 

yoonyeoseong/spring-oauth2.0

Contribute to yoonyeoseong/spring-oauth2.0 development by creating an account on GitHub.

github.com

<Git Repository>

authorizationserver module은 인증서버를, resourceserver는 리소스서버,client는 클라이언트 애플리케이션 프로젝트이다. common은 공통적으로 사용할 소스코드를 위한 모듈이지만 아마 아무것도 들어가있지 않을 것이다. 추후에 이 소스를 활용한다면 사용할 수도 있을 모듈이다.(공통적인 DB 액세스 클래스등) sql 폴더는 필요한 Oracle DB schema가 있다. 혹시나 메이븐 멀티 모듈 프로젝트를 잘모른다면 이전 포스팅들중 메이븐 멀티모듈 프로젝트에 대한 포스팅을 참고하자.

 

 

 

메이븐 멀티프로젝트(maven multi module) & SVN

maven multi module project 만드는 방법과 해당 프로젝트를 SVN에 올리고 다른 개발환경에서 checkout하는 방법 모든 환경은 Mac OS 환경입니다. 오늘 설명 할것은 maven multi module project를 만드는 방법과..

coding-start.tistory.com

 

 

Spring boot - Maven Multi Module project 만들기

오늘 다루어볼 포스팅 내용은 Maven Multi Module을 이용한 Spring Boot Project 만들기입니다. 우선 Maven Multi Module 프로젝트란 하나의 부모 Maven Project를 생성하고 그 밑으로 자식 Maven Module들을 가..

coding-start.tistory.com

 

내가 작성한 프로젝트들을 어느 다른 OAuth2.0 포스팅들처럼 PostMan 같은 툴을 사용하지 않는다. 왜냐하면 클라이언트 애플리케이션이 존재하기 때문에 직접 인증코드를 받고 액세스 토큰을 받을 일이 없기때문이다. 만약 직접 인증 코드와 액세스 토큰을 받는 플로우 테스트를 하고 싶다면 직접 툴을 이용해 Authorization Server와 Resource Server에게 요청을 보내보자. 소스 코드 중에서 하나 중요하게 집고 넘어갈 것이 하나 있다. 바로 클라이언트 프로젝트 소스코드이다.

 

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
@SpringBootApplication
public class ClientApplication  implements CommandLineRunner, ServletContextInitializer
{
    
    @Autowired private ResourceOwnerRepository repository;
    @Autowired private PasswordEncoder passwordEncoder;
    
    public static void main( String[] args )
    {
        SpringApplication.run(ClientApplication.class, args);
    }
    
    @Override
    public void run(String... args) throws Exception {
        
        ResourceOwner user = new ResourceOwner();
        user.setId(1l);
        user.setUsername("1223yys@naver.com");
        user.setPassword(passwordEncoder.encode("password"));
        user.setRole(UserRole.ROLE_USER);
        
        repository.save(user);
        
    }
 
    @Override
    public void onStartup(ServletContext servletContext) throws ServletException {
        servletContext.getSessionCookieConfig().setName("clientsession");
    }
    
}
cs

ServletContextInitializer를 구현하고 있는 부분이다. 메소드를 보면 HttpSession을 위한 Cookie 이름을 바꿔서 사용하고 있다. 그 이유는 우리는 Authorization Server와 Client 애플리케이션을 같은 localhost로 사용하고 있다. 그런데 두 애플리케이션 모두 HttpSession을 Security에서 사용하고 있다. 즉, 둘다 HttpSession을 위한 쿠키이름을 같은 걸 사용하면 계속 덮어쓰게 될 것임으로 Client애플리케이션의 쿠키이름을 바꾼것이다. 만약 추후 별도의 도메인을 갖는다면 해당 설정을 필요없다.

 

모든 프로젝트를 실행시켰다면 http://localhost:9000/ 로 접속하자 그러면 아마 클라이언트 애플리케이션의 화면이 하나 나올 것이고, 보호된 리소스를 사용하는 대시보드로 이동하는 링크가 하나 존재할 것이다. 클릭 이후 대시보드로 이동하며 OAuth2.0에 필요한 모든 과정을 OAuth2RestTemplate이 담당해준다. 모든 인증과 액세스 토큰 발급이 완료되면 Client 애플리케이션의 User DB스키마에는 액세스 토큰이 저장되어 있을 것이다. 그리고 해당 토큰을 이용해 Resource Server의 API를 사용하게 되는 것이다.

 

혹시나 해당 프로젝트 분석 중 궁금한 부분이나 잘못된 부분이 있다면 댓글 혹은 Git Issue에 글을 남겨주길 꼭 부탁드린다.

posted by 여성게
:
Web/Spring Security&OAuth 2019. 4. 29. 23:45

 

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

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

 

Spring Security OAuth2.0 파헤치기!(Authorization Server)

내부 챗봇 솔루션을 개발하면서, OAuth2.0을 이용하여 자체 인증서버를 구축할 일이 생겼다. 최근에 웹 또는 앱을 보면서 자주 접하게 되는 인증 방식이 OAuth2.0 방식이다. 대표적으로 네아로(네이버 아이디로..

coding-start.tistory.com

 

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
@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 = falseString clientId) {
 
        clientRegistrationService.removeClientDetails(clientId);
 
        ModelAndView mv = new ModelAndView("redirect:/client/dashboard");
        mv.addObject("applications",
                clientRegistrationService.listClientDetails());
        return mv;
    }
}
 
cs

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

 

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
/**
 * 
 * @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;
    }
    
}
 
cs

실제 비지니스로직이 담기는 서비스 클래스이다. 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
48
<!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
65
<!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

 

<결과>

1
2
3
4
5
6
7
8
{
    "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"
}
cs

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

 

Resource Server

 

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

 

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
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
/*
 * 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;
    }
    
    
}
 
cs

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 포스팅을 확인하자.

 

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
@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;
        }
    }
}
 
cs

 

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

 

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 인증을 사용해 보자!

 

<깃헙 주소>

 

yoonyeoseong/spring-oauth2.0

Contribute to yoonyeoseong/spring-oauth2.0 development by creating an account on GitHub.

github.com

 

posted by 여성게
:
Web/Spring Security&OAuth 2019. 4. 17. 23:14

오늘 포스팅할 내용은 Spring Security이다. 사실 필자는 머리가 나빠서 그런지 모르겠지만, 아무리 구글링을 통해 스프링 시큐리티를 검색해도 이렇다할 명쾌한 해답을 얻지 못했다. 대부분 이론적인 설명들은 잘 해주시는 분들이 많지만 실사례로 묵은 채증을 내려주지는 못했다.레퍼런스 또한 마찬가지이다. 영어를 잘 못하는 나로써는 잘 안되는 머릿속 몇 안되는 영어단어를 떠올리고 혹은 정말 모르는 문장은 번역기를 돌려서 보니 오히려 혼란만 가중되었다. 그래서 맘먹고 몇일을 스프링 시큐리티를 파고 들어보니 어느정도 사용할 정도가 되어서 이렇게 포스팅하게 된다. 아마 포스팅은 3~4개 정도로 이어질 것같다. 이번 포스팅에서는 토큰 기반 인증 직전까지의 포스팅이 될 것같고, 다음 포스팅은 이어서 JWT 토큰을 이용할 것이며 마지막으로는 OAuth2.0 포스팅으로 마무리를 지을 것같다. 우선 예제를 들어가기전에 스프링 시큐리티(Spring Security)란 무엇이고, 어떠한 아키텍쳐와 사상으로 이루어져있는지 설명할 것이다. 마지막으로 이번 포스팅을 보며 필자와 같이 스프링 시큐리티에 대해 헤매고 있는 분의 묵은 채증이 쑥 하고 내려갔으면 한다. 모든 예제는 spring boot 2.1.3.RELEASE으로 진행하였고, 모든 설정은 Java Config로 진행하였다.

 

보안 용어

필자가 솔직하게 스프링 시큐리티에 대해 알아보려고 많은 블로그를 돌아다니고 레퍼런스를 보면서 이해하지 못했던 이유중 하나는 바로 보안 용어에 대한 이해가 부족했기 때문이라고 생각한다. 스프링 시큐리티를 접하기 이전 밑에서 나열할 보안 용어들은 정말 필수로 숙지해야 할 것 같다. 

 

  • 접근 주체(Principal) : 보호된 리소스에 접근하는 대상
  • 인증(Authentication) : 보호된 리소스에 접근한 대상에 대해 이 유저가 누구인지, 애플리케이션의 작업을 수행해도 되는 주체인지 확인하는 과정(ex. Form 기반 Login)
  • 인가(Authorize) : 해당 리소스에 대해 접근 가능한 권한을 가지고 있는지 확인하는 과정(After Authentication, 인증 이후)
  • 권한 : 어떠한 리소스에 대한 접근 제한, 모든 리소스는 접근 제어 권한이 걸려있다. 즉, 인가 과정에서 해당 리소스에 대한 제한된 최소한의 권한을 가졌는지 확인한다.

Spring Security(스프링 시큐리티)란?

스프링 시큐리티는 스프링 기반의 애플리케이션의 보안(인증과 권한,인가 등)을 담당하는 스프링 하위 프레임워크이다. 주로 서블릿 필터와 이들로 구성된 필터체인으로의 위임모델을 사용한다. 그리고 보안과 관련해서 체계적으로 많은 옵션을 제공해주기 때문에 개발자 입장에서는 일일이 보안관련 로직을 작성하지 않아도 된다.

 

 

위의 그림은 Form 기반 로그인에 대한 플로우를 보여주는 그림이다.

  1. 사용자가 Form을 통해 로그인 정보를 입력하고 인증 요청을 보낸다.
  2. AuthenticationFilter(사용할 구현체 UsernamePasswordAuthenticationFilter)가 HttpServletRequest에서 사용자가 보낸 아이디와 패스워드를 인터셉트한다. 프론트 단에서 유효성검사를 할 수도 있지만, 무엇보다 안전! 안전을 위해서 다시 한번 사용자가 보낸 아이디와 패스워드의 유효성 검사를 해줄 수 있다.(아이디 혹은 패스워드가 null인 경우 등) HttpServletRequest에서 꺼내온 사용자 아이디와 패스워드를 진짜 인증을 담당할 AuthenticationManager 인터페이스(구현체 - ProviderManager)에게 인증용 객체(UsernamePasswordAuthenticationToken)로 만들어줘서 위임한다.
  3. AuthenticationFilter에게 인증용 객체(UsernamePasswordAuthenticationToken)을 전달받는다.
  4. 실제 인증을 할 AuthenticationProvider에게 Authentication객체(UsernamePasswordAuthenticationToken)을 다시 전달한다.
  5. DB에서 사용자 인증 정보를 가져올 UserDetailsService 객체에게 사용자 아이디를 넘겨주고 DB에서 인증에 사용할 사용자 정보(사용자 아이디, 암호화된 패스워드, 권한 등)를 UserDetails(인증용 객체와 도메인 객체를 분리하지 않기 위해서 실제 사용되는 도메인 객체에 UserDetails를 상속하기도 한다.)라는 객체로 전달 받는다.
  6. AuthenticationProvider는 UserDetails 객체를 전달 받은 이후 실제 사용자의 입력정보와 UserDetails 객체를 가지고 인증을 시도한다.
  7. 8. 9. 10. 인증이 완료되면 사용자 정보를 가진 Authentication 객체를 SecurityContextHolder에 담은 이후 AuthenticationSuccessHandle를 실행한다.(실패시 AuthenticationFailureHandler를 실행한다.)

여기까지 간단히 Form 로그인에 대한 플로우를 설명했다. 사실 글로 보면 이해가 잘되지 않을 수 있다. 이후에 실제 코드를 예를 들어서 설명할 것이다.

 

 

  • SecurityContextPersistenceFilter : SecurityContextRepository에서 SecurityContext를 가져오거나 저장하는 역할을 한다. (SecurityContext는 밑에)
  • LogoutFilter : 설정된 로그아웃 URL로 오는 요청을 감시하며, 해당 유저를 로그아웃 처리
  • (UsernamePassword)AuthenticationFilter : (아이디와 비밀번호를 사용하는 form 기반 인증) 설정된 로그인 URL로 오는 요청을 감시하며, 유저 인증 처리
    • AuthenticationManager를 통한 인증 실행
    • 인증 성공 시, 얻은 Authentication 객체를 SecurityContext에 저장 후 AuthenticationSuccessHandler 실행
    • 인증 실패 시, AuthenticationFailureHandler 실행
  • DefaultLoginPageGeneratingFilter : 인증을 위한 로그인폼 URL을 감시한다.
  • BasicAuthenticationFilter : HTTP 기본 인증 헤더를 감시하여 처리한다.
  • RequestCacheAwareFilter : 로그인 성공 후, 원래 요청 정보를 재구성하기 위해 사용된다.
  • SecurityContextHolderAwareRequestFilter : HttpServletRequestWrapper를 상속한 SecurityContextHolderAwareRequestWapper 클래스로 HttpServletRequest 정보를 감싼다. SecurityContextHolderAwareRequestWrapper 클래스는 필터 체인상의 다음 필터들에게 부가정보를 제공한다.
  • AnonymousAuthenticationFilter : 이 필터가 호출되는 시점까지 사용자 정보가 인증되지 않았다면 인증토큰에 사용자가 익명 사용자로 나타난다.
  • SessionManagementFilter : 이 필터는 인증된 사용자와 관련된 모든 세션을 추적한다.
  • ExceptionTranslationFilter : 이 필터는 보호된 요청을 처리하는 중에 발생할 수 있는 예외를 위임하거나 전달하는 역할을 한다.
  • FilterSecurityInterceptor : 이 필터는 AccessDecisionManager 로 권한부여 처리를 위임함으로써 접근 제어 결정을 쉽게해준다.

 

2-3. Authentication

접근 주체는 Authentication 객체를 생성한다. 이 객체는 SecurityContext(내부 메모리)에 보관되고 사용되어진다.

 

1
2
3
4
5
6
7
8
public interface Authentication extends Principal, Serializable { 
    Collection<extends GrantedAuthority> getAuthorities(); // Authentication 저장소에 의해 인증된 사용자의 권한 목록 
    Object getCredentials(); // 주로 비밀번호 
    Object getDetails(); // 사용자 상세정보 
    Object getPrincipal(); // 주로 ID 
    boolean isAuthenticated(); //인증 여부 
    void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException; 
}
cs

 

 

유저의 요청을 AuthenticationFilter에서 Authentication 객체로 변환해 AuthenticationManager(ProviderManager)에게 넘겨주고, AuthenticationProvider(DaoAuthenticationProvider)가 실제 인증을 한 이후에 인증이 완료되면 Authentication객체를 반환해준다.

  • AbstractAuthenticationProcessingFilter : 웹 기반 인증요청에서 사용되는 컴포넌트로 POST 폼 데이터를 포함하는 요청을 처리한다. 사용자 비밀번호를 다른 필터로 전달하기 위해서 Authentication 객체를 생성하고 일부 프로퍼티를 설정한다.
  • AuthenticationManager : 인증요청을 받고 Authentication을 채워준다.
  • AuthenticationProvider : 실제 인증이 일어나고 만약 인증 성공시 Authentication 객체의 authenticated = true로 설정해준다.

Spring Security 는 ProviderManager 라는 AuthenticationManager 인터페이스의 유일한 구현체를 제공한다. ProviderManager 는 하나 또는 여러 개의 AuthenticationProvider 구현체를 사용할 수 있다. AuthenticationProvider는 많이 사용되고 ProviderManager(AuthenticationManager 의 구현체) 와도 잘 통합되기 때문에 기본적으로 어떻게 동작하는 지 이해하는 것이 중요하다.

 

 

2-5. 비밀번호 인증과정

DaoAuthenticationProvider 는 UserDetailsService 타입 오브젝트로 위임한다. UserDetailsService 는 UserDetails 구현체를 리턴하는 역할을 한다. UserDetails 인터페이스는 이전에 설명한 Authentication 인터페이스와 상당히 유사하지만 서로 다른 목적을 가진 인터페이스이므로 헷갈리면 안된다. 

  • Authentication : 사용자 ID, 패스워드와 인증 요청 컨텍스트에 대한 정보를 가지고 있다. 인증 이후의 사용자 상세정보와 같은 UserDetails 타입 오브젝트를 포함할 수도 있다. 
  • UserDetails : 이름, 이메일, 전화번호와 같은 사용자 프로파일 정보를 저장하기 위한 용도로 사용한다.

2-6. 인증예외

인증과 관련된 모든 예외는 AuthenticationException 을 상속한다. AuthenticationException 은 개발자에게 상세한 디버깅 정보를 제공하기위한 두개의 멤버 필드를 가지고 있다. 

  • authentication : 인증 요청관련 Authentication 객체를 저장하고 있다.
  • extraInformation : 인증 예외 관련 부가 정보를 저장한다. 예를 들어 UsernameNotFoundException 예외는 인증에 실패한 유저의 id 정보를 저장하고 있다.

많이 발생하는 예외들.

  • BadCredentialsException : 사용자 아이디가 전달되지 않았거나 인증 저장소의 사용자 id 에 해당하는 패스워드가 일치하지 않을 경우 발생한다.
  • LockedException : 사용자 계정이 잠긴경우 발생한다.
  • UsernameNotFoundException : 인증 저장소에서 사용자 ID를 찾을 수 없거나 사용자 ID에 부여된 권한이 없을 경우 발생한다.

접근권한 부여

자동으로 설정된 Spring Security 필터 체인의 마지막 서블릿 필터는 FilterSecurityInterceptor 이다. 이 필터는 해당 요청의 수락 여부를 결정한다. FilterSecurityInterceptor 가 실행되는 시점에는 이미 사용자가 인증되어 있을 것이므로 유효한 사용자인지도 알 수 있다. Authentication 인터페이스에는 List<GrantedAuthority> getAuthorities() 라는 메소드가 있다는 것을 상기해 보자. 이 메소드는 사용자 아이디에 대한 권한 목록을 리턴한다. 권한처리시에 이 메소드가 제공하는 권한정보를 참조해서 해당 요청의 승인 여부를 결정하게 된다.

Access Decision Manager 라는 컴포넌트가 인증 확인을 처리한다. AccessDecisionManager 인터페이스는 인증 확인을 위해 두 가지 메소드를 제공한다.

  • supports : AccessDecisionManager 구현체는 현재 요청을 지원하는지의 여부를 판단하는 두개의 메소드를 제공한다. 하나는 java.lang.Class 타입을 파라미터로 받고 다른 하나는 ConfigAttribute 타입을 파라미터로 받는다.
  • decide : 이 메소드는 요청 컨텍스트와 보안 설정을 참조하여 접근 승인여부를 결정한다. 이 메소드에는 리턴값이 없지만 접근 거부를 의미하는 예외를 던져 요청이 거부되었음을 알려준다.

인증과정에서 발생할 수 있는 예상 가능한 에러를 처리하는 AuthenticationException 과 하위 클래스를 사용했던 것처럼 특정 타입의 예외 클래스들을 사용하면 권한처리를 하는 애플리케이션의 동작을 좀더 세밀하게 제어할 수 있다. 

AccessDecisionManager 는 표준 스프링 빈 바인딩과 레퍼런스로 완벽히 설정할 수 있다.디폴트 AccessDecisionManager 구현체는 AccessDecisionVoter 와 Vote 취합기반 접근 승인 방식을 제공한다.

Voter 는 권한처리 과정에서 다음 중 하나 또는 전체를 평가한다.

  • 보호된 리소스에 대한 요청 컨텍스트 (URL 을 접근하는 IP 주소)
  • 사용자가 입력한 비밀번호
  • 접근하려는 리소스
  • 시스템에 설정된 파라미터와 리소스

AccessDecisionManager 는 요청된 리소스에 대한 access 어트리뷰트 설정을 보터에게 전달하는 역할도 하므로 보터는 웹 URL 관련 access 어트리뷰트 설정 정보를 가지게 된다. 

Voter는 사용할 수 있는 정보를 사용해서 사용자의 리소스에 대한 접근 허가 여부를 판단한다. 보터는 접근 허가 여부에 대해서 세 가지 중 한 가지로 결정하는데, 각 결정은 AccessDecisionVoter 인터페이스에 다음과 같이 상수로 정의되어 있다.

  • Grant(ACCESS_GRANTED) : Voter 가 리소스에 대한 접근 권한을 허가하도록 권장한다.
  • Deny(ACCESS_DENIED) : Voter 가 리소스에 대한 접근 권한을 거부하도록 권장한다.
  • Abstain(ACCESS_ABSTAIN) : Voter 는 리소스에 대한 접근권한 결정을 보류한다. 이 결정 보류는 다음과 같은 경우에 발생할 수 있다.

           1. Voter 가 접근권한 판단에 필요한 결정적인 정보를 가지고 있지 않은 경우

           2.Voter 가 해당 타입의 요청에 대해 결정을 내릴 수 없는 경우

 

 

Java Config 기반 코드 설명

 

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
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
/**
 * 
 * @author yun-yeoseong
 *
 */
@EnableWebSecurity
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter{
    
//    private UserDetailsService userDetailsService;
//    private PasswordEncoder passwordEncoder;
    private AuthenticationProvider authenticationProvider;
    
    public SpringSecurityConfig(/*UserDetailsService userDetailsService, 
                                PasswordEncoder passwordEncoder,*/
                                AuthenticationProvider authenticationProvider) {
//        this.userDetailsService = userDetailsService;
//        this.passwordEncoder = passwordEncoder;
        this.authenticationProvider = authenticationProvider;
    }
    
    /*
     * 스프링 시큐리티가 사용자를 인증하는 방법이 담긴 객체.
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        /*
         * AuthenticationProvider 구현체
         */
        auth.authenticationProvider(authenticationProvider);
//        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder);
    }
    
    /*
     * 스프링 시큐리티 룰을 무시하게 하는 Url 규칙.
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring()
            .antMatchers("/resources/**")
            .antMatchers("/css/**")
            .antMatchers("/vendor/**")
            .antMatchers("/js/**")
            .antMatchers("/favicon*/**")
            .antMatchers("/img/**")
        ;
    }
    
    /*
     * 스프링 시큐리티 룰.
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            .antMatchers("/login*/**").permitAll()
            .antMatchers("/logout/**").permitAll()
            .antMatchers("/chatbot/**").permitAll()
            .anyRequest().authenticated()
        .and().logout()
              .logoutUrl("/logout")
              .logoutSuccessHandler(logoutSuccessHandler())
        .and().csrf()
              .disable()
        .addFilter(jwtAuthenticationFilter())
        .addFilter(jwtAuthorizationFilter())
        .exceptionHandling()
              .accessDeniedHandler(accessDeniedHandler())
              .authenticationEntryPoint(authenticationEntryPoint())
//        .and().sessionManagement()
//              .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        ;
    }
    
    /*
     * SuccessHandler bean register
     */
    @Bean
    public AuthenticationSuccessHandler authenticationSuccessHandler() {
        CustomAuthenticationSuccessHandler successHandler = new CustomAuthenticationSuccessHandler();
        successHandler.setDefaultTargetUrl("/index");
        return successHandler;
    }
    
    /*
     * FailureHandler bean register
     */
    @Bean
    public AuthenticationFailureHandler authenticationFailureHandler() {
        CustomAuthenticationFailureHandler failureHandler = new CustomAuthenticationFailureHandler();
        failureHandler.setDefaultFailureUrl("/loginPage?error=error");
        return failureHandler;
    }
    
    /*
     * LogoutSuccessHandler bean register
     */
    @Bean
    public LogoutSuccessHandler logoutSuccessHandler() {
        CustomLogoutSuccessHandler logoutSuccessHandler = new CustomLogoutSuccessHandler();
        logoutSuccessHandler.setDefaultTargetUrl("/loginPage?logout=logout");
        return logoutSuccessHandler;
    }
    
    /*
     * AccessDeniedHandler bean register
     */
    @Bean
    public AccessDeniedHandler accessDeniedHandler() {
        CustomAccessDeniedHandler accessDeniedHandler = new CustomAccessDeniedHandler();
        accessDeniedHandler.setErrorPage("/error/403");
        return accessDeniedHandler;
    }
    
    /*
     * AuthenticationEntryPoint bean register
     */
    @Bean
    public AuthenticationEntryPoint authenticationEntryPoint() {
        return new CustomAuthenticationEntryPoint("/loginPage?error=e");
    }
    
    /*
     * Form Login시 걸리는 Filter bean register
     */
    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
        JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager());
        jwtAuthenticationFilter.setFilterProcessesUrl("/login");
        jwtAuthenticationFilter.setUsernameParameter("username");
        jwtAuthenticationFilter.setPasswordParameter("password");
        
        jwtAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler());
        jwtAuthenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler());
        
        jwtAuthenticationFilter.afterPropertiesSet();
        
        return jwtAuthenticationFilter;
    }
    
    /*
     * Filter bean register
     */
    @Bean
    public JwtAuthorizationFilter jwtAuthorizationFilter() throws Exception {
        JwtAuthorizationFilter jwtAuthorizationFilter = new JwtAuthorizationFilter(authenticationManager());
        return jwtAuthorizationFilter;
    }
}
 
cs

우선 @EnableWebSecurity 어노테이션을 통해 스프링 시큐리티를 사용하겠다라고 선언한다. 그리고 해당 클래스는 WebSecurityConfigurerAdapter를 상속받는다. 첫번째 AuthenticationManagerBuilder를 인자로 받는 configure 메소드는 인증을 담당할 프로바이더 구현체를 설정하는 메소드이다. 현재는 Custom한 Provider를 직접구현하였지만, 밑에 주석에서 보이듯 UserDetailsService 구현체와 PasswordEncoder 구현체만 설정하여서 기본 DaoAuthenticationProvider를 사용하게 할 수도 있다. 그리고 위에서도 설명했듯 AuthenticationProvider는 여러개를 등록가능하다. 두번째 WebSecurity를 인자로 받는 configure 메소드는 스프링 시큐리티 필터에서 제외될 URI를 설정하는 메소드이다. 세번째 HttpSecurity를 인자로 받는 configure메소드가 가장 중요하다. 해당 메소드에는 요청 URI에 대한 권한 설정, 특정 기능 결과에 대한 Handler 등록, Custom Filter 등록(ex. AuthenticationFilter 재정의) 그리고 예외 핸들러 등을 등록 하는 메소드이다. 사실 여기에 왠만한 설정들이 다 들어간다고 해도 무방하다. 가장 중요한 만큼 세번째 메소드 설정은 짚고 넘어갈 것이다.

 

  • 53~57 Line : 보호된 리소스 URI에 접근할 수 있는 권한을 설정해주는 설정이다. AccessDecisionManager에 설정되는 access 정보이다. 이 설정들은 추후 FilterSecurityInterceptor에서 권한 인증에 사용될 정보들이다.
  • 58~60 Line : 로그아웃 기능에 대한 설명이다. logoutUrl()을 통해 로그아웃 기능에 대한 RequestUri정보를 전달한다. 그리고 .logoutSuccessHandler()를 통해 로그아웃이 성공적으로 끝나면 수행될 핸들러를 등록해준다.
  • 61~62 Line : csrf().disable()을 통하여 csrf 보안 설정을 비활성화한다.(해당 기능을 사용하기 위해서는 프론트에서 csrf토큰값을 보내주어야함. 예제라 생략)
  • 63 Line : Form Login에 사용되는 custom AuthenticationFilter 구현체를 등록해준다.
  • 64 Line : Header 인증에 사용되는 BasicAuthenticationFilter 구현체를 등록해준다.(다음 포스팅에서 JWT Token 기반 인증에 본격적으로 사용될 필터이다.)
  • 65~ Line : 예외 핸들러를 등록해주는 설정이다. accessDeniedHandler()는 권한 체크에서 실패할 때 수행되는 핸들러를 등록하고, authenticationEntryPoint()는 현재 인증된 사용자가 없는데(SecurityContext에 인증사용자가 담기지 않음) 인증되지 않은 사용자가 보호된 리소스에 접근하였을 때, 수행되는 핸들러(EntryPoint)를 등록해준다.
  • 124~137 Line : AuthenticationFilter 설정이다. 우선 위에서 말했듯 AuthenticationFilter는 AuthenticationManager에게 위임한다했다. 그렇기 때문에 AuthenticationManager를 생성자로 전달한다. 그리고 setFilterProcessesUrl()을 통해 로그인 요청 URI를 정의해준다.(스프링이 제공하므로 따로 컨트롤러 등록할 필요가 없다. logout URI도 똑같이 스프링에서 제공한다. 하지만 SuccessHandler나 FailureHandler,EntryPoint는 컨트롤러에 등록된 URI로 설정해야한다.) setUsernameParameter(),setPasswordParameter()를 통해 폼으로 넘어오는 사용자 아이디,패스워드 변수값을 설정한다.(<input>의 name속성이라고 생각하면된다.) setAuthenticationSuccess,FailureHandler()를 통해 결과에 대해 수행할 핸들러를 등록한다.

나머지 밑의 빈등록을 위한 메소드들은 직접 custom하게 구현한 클래스들이다. 사실 직접 구현하지 않아도 될 것까지 구현할 것들도 있지만, 직접 구현함으로써 "아~ 이런 클래스가 이런 역할을 하는구나"를 이해하기 위해(디버깅 혹은 행동 재정의) 직접 구현체를 생성해주었다.

 

  • AuthenticationSuccessHandler : Form Login(AuthenticationFilter)에서 인증이 성공했을 때 수행될 핸들러이다. 예제에서는 SimpleUrlAuthenticationSuccessHandler를 상속한 SavedRequestAwareAuthenticationSuccessHandler를 다시 상속한 CustomAuthenticationSuccessHandler를 등록해주었다. 이것은 현재 단순히 성공시 index 페이지로 리다이렉트 하는 역할을 수행한다.
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
/**
 * 실패시 행동을 재정의할 클래스(추상 클래스가 아닌 인터페이스를 구현해도 된다.)
 * Or Interface - AuthenticationSuccessHandler
 * @author yun-yeoseong
 *
 */
@Slf4j
public class CustomAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler implements ExceptionProcessor{
    
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
            Authentication authentication) throws ServletException, IOException {
        log.debug("CustomAuthenticationSuccessHandler.onAuthenticationSuccess ::::");
        /*
         * 쿠키에 인증 토큰을 넣어준다.
         */
        super.onAuthenticationSuccess(request, response, authentication);
    }
 
    @Override
    public void makeExceptionResponse(HttpServletRequest request, HttpServletResponse response,
            Exception exception) {
    }
    
}
 
cs
  • AuthenticationFailureHandler : Form Login 실패시 수행되는 핸들러이다. SimpleUrlAuthenticationFailureHandler를 상속한 CustomAuthenticationFailureHandler를 등록해주었다. 이 또한 단순히 실패시 로그인 페이지로 리다이렉트 하는 역할을 수행한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
 * 실패시 행동을 재정의할 클래스(추상 클래스가 아닌 인터페이스를 구현해도 된다.)
 * Or Interface - AuthenticationFailureHandler
 * @author yun-yeoseong
 *
 */
@Slf4j
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler implements ExceptionProcessor{
    
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
            AuthenticationException exception) throws IOException, ServletException {
        log.debug("CustomAuthenticationFailureHandler.onAuthenticationFailure ::::");
        super.onAuthenticationFailure(request, response, exception);
    }
 
    @Override
    public void makeExceptionResponse(HttpServletRequest request, HttpServletResponse response,
            Exception exception) {
    }
    
}
 
cs
  • LogoutSuccessHandler : 로그아웃에 성공했을 시 수행되는 핸들러이다. SimpleUrlLogoutSuccessHandler를 상속한 CustomLogoutSuccessHandler를 등록해주었다. 역시 성공시 로그인 페이지로 리다이렉트 하는 역할을 수행한다.
1
2
3
4
5
6
7
8
9
10
11
@Slf4j
public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler{
    
    @Override    
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
            throws IOException, ServletException {
        log.debug("CustomLogoutSuccessHandler.onLogoutSuccess ::::");
        super.onLogoutSuccess(request, response, authentication);
    }
    
}
cs

  • AccessDeniedHandler : 권한 체크 실패시 수행되는 핸들러이다.AccessDeniedHandlerImpl를 상속한 CustomAccessDeniedHandler를 등록하였다. 권한 체크 실패시 적절한 에러코드와 메시지를 HttpServletResponse에 담아서 반환하는 역할을 한다.

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
/**
 * AccessDenied시 행동을 재정의하는 핸들러 클래스이다.(구현체가 아니라 인터페이스를 직접 구현해도된다.)
 * Or Interface - AccessDeniedHandler
 * @author yun-yeoseong
 *
 */
@Slf4j
public class CustomAccessDeniedHandler extends AccessDeniedHandlerImpl implements ExceptionProcessor{
    
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
            AccessDeniedException accessDeniedException) throws IOException, ServletException {
        log.debug("CustomAccessDeniedHandler.handle");
        this.makeExceptionResponse(request, response, accessDeniedException);
    }
 
    @Override
    public void makeExceptionResponse(HttpServletRequest request, HttpServletResponse response,
            Exception exception) throws IOException {
        log.debug("CustomAccessDeniedHandler.makeExceptionResponse :::: {}",exception.getMessage());
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, exception.getMessage());
    }
    
}
 
cs

AuthenticationEntryPoint : 인증된 사용자가 SecurityContext에 존재하지도 않고, 어떠한 인증되지 않은 익명의 사용자가 보호된 리소스에 접근하였을 때, 수행되는 EntryPoint 핸들러이다. LoginUrlAuthenticationEntryPoint를 상속한 CustomAuthenticationEntryPoint를 등록하였다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Slf4j
public class CustomAuthenticationEntryPoint extends LoginUrlAuthenticationEntryPoint{
 
    public CustomAuthenticationEntryPoint(String loginFormUrl) {
        super(loginFormUrl);
    }
 
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
            AuthenticationException authException) throws IOException, ServletException {
        log.debug("CustomAuthenticationEntryPoint.commence ::::");
        super.commence(request, response, authException);
    }
    
}
cs
  • JwtAuthenticationFilter :  Form Login시 걸리는 Filter이다. UsernamePasswordAuthenticationFilter를 상속한 JwtAuthenticationFilter을 등록하였다. 이 필터는 HttpServletRequest에서 사용자가 Form으로 입력한 로그인 정보를 인터셉트해서 AuthenticationManager에게 Authentication 객체를 넘겨준다.
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
/**
 * 
 * @author yun-yeoseong
 *
 */
@Slf4j
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter{
    
    private boolean postOnly = true;
    
    public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
        super.setAuthenticationManager(authenticationManager);
    }
    
    /*
     * 해당 필터에서 인증 프로세스 이전에 요청에서 사용자 정보를 가져와서
     * Authentication 객체를 인증 프로세스 객체에게 전달하는 역할
     */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {
        log.debug("JwtAuthentication.attemptAuthentication ::::");
        
        /*
         * POST로 넘어왔는지 체크
         */
        if(postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException(
                    "Authentication method not supported: " + request.getMethod());
        }
        
        String username = obtainUsername(request);
        String password = obtainPassword(request);
        
        if(StringUtils.isEmpty(username)) {
            username = "";
        }
        if(StringUtils.isEmpty(password)) {
            password = "";
        }
        
        username = username.trim();
        
        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
        
        setDetails(request, authRequest);
        
        return this.getAuthenticationManager().authenticate(authRequest);
    }
    
    
}
cs
  • JwtAuthorizationFilter : Form Login에서 인증된 이후의 요청에 대해 Header 인증을 담당할 Filter이다. BasicAuthenticationFilter를 상속한 JwtAuthorizationFilter를 등록하였다. 이번 포스팅은 아니고 다음 포스팅에서 다룰 JWT 기반 인증에서 실제 JWT 토큰의 인증이 이루어질 필터 부분이다.
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
/**
 * HTTP 기본 인증 헤더를 감시하고 이를 처리하는 역할의 필터이다.
 * @author yun-yeoseong
 *
 */
@Slf4j
public class JwtAuthorizationFilter extends BasicAuthenticationFilter{
    
    public JwtAuthorizationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
        // TODO Auto-generated constructor stub
    }
 
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        log.debug("JwtAuthorizationFilter.doFilterInternal ::::");
        /*
         * 쿠키 인증 토큰을 검사한다.
         * 만약 토큰 및 헤더에 대한 검사에 실패한다면,
         * AuthenticationEntryPoint에 위임하거나 혹은 HttpResponse에 적절한
         * 상태코드와 메시지를 담아서 리턴해준다.
         */
        super.doFilterInternal(request, response, chain);
    }
    
    /*
     * 성공시 처리 메소드
     */
    @Override
    protected void onSuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
            Authentication authResult) throws IOException {
        // TODO Auto-generated method stub
        super.onSuccessfulAuthentication(request, response, authResult);
    }
    
    /*
     * 실패시 처리 메소드
     */
    @Override
    protected void onUnsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
            AuthenticationException failed) throws IOException {
        // TODO Auto-generated method stub
        super.onUnsuccessfulAuthentication(request, response, failed);
    }
    
    
    
}
 
cs
  • UserDetailsService : AuthenticationProvider 구현체에서 인증에 사용할 사용자 인증정보를 DB에서 가져오는 역할을 하는 클래스이다. UserDetailsService를 구현한 UserDetailsServiceImpl를 사용한다.
  • PasswordEncoder : 실제 DB에는 비밀번호가 적절한 암호화 알고리즘으로 암호화되 저장되어있다. 폼에서 넘어오는 평문의 사용자 입력정보를 이용해 인증을 하려면 DB에 저장한 암호화 알고리즘 엔코더가 필요하다. 해당 역할을 하는 클래스이다. PasswordEncoder를 구현한 ShaPasswordEncoder를 사용한다.
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
@Slf4j
@Component
public class UserDetailsServiceImpl implements UserDetailsService{
    
    @Autowired private ChatbotUserService userService;
    
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.debug("UserDetailsServiceImpl.loadUserByUsername :::: {}",username);
        
        ChatbotUserDto userDto = userService.findByUsername(username);
        
        if(ObjectUtils.isEmpty(userDto)) {
            throw new UsernameNotFoundException("Invalid username");
        }
        
        userDto.setAuthorities(AuthoritiesUtils.createAuthorities(userDto));
        
        return userDto;
    }
    
}
 
@Slf4j
@Component
public class ShaPasswordEncoder implements PasswordEncoder{
 
    @Override
    public String encode(CharSequence rawPassword) {
        log.debug("ShaPasswordEncoder.encode :::: {}",rawPassword);
        return Crypto.sha256(rawPassword.toString());
    }
 
    @Override
    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        log.debug("ShaPasswordEncoder.matches :::: {} <-> {}",rawPassword,encodedPassword);
        return Crypto.sha256(rawPassword.toString()).equals(encodedPassword);
    }
    
 
}
cs
  • AuthenticationProvider : 실제 인증이 일어나는 클래스이다. AuthenticationManager(ProviderManager)는 실제로 많은 AuthenticationProvider 구현체들을 가질 수 있다고 이야기 했듯 저 AuthenticationProvider를 가지고 ProviderManager는 인증 로직을 태우게된다. AuthenticationProvider를 구현한 CustomAuthenticationProvider를 사용한다. 해당 클래스 내부적으로는 UserDetailsService에게 입력받은 사용자 아이디를 넘겨 DB에서 사용자 인증 정보를 받고 암호화된 패스워드를 비교하기 위하여 PasswordEncoder에게 사용자가 입력한 평문 패스워드를 전달해 암호화된 형태로 받아서 암호화<->암호화 형태로 비밀번호를 비교한다(평문<->평문 아님). 만약 인증이 완료되면 Authentication객체를 구현한 UsernamePasswordAuthenticationToken객체를 반환한다.
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
/**
 * 이걸 사용해도 되고 시큐리티에서 기본적으로 제공해주는 DaoAuthenticationProvider를 사용해도 무방.
 * @author yun-yeoseong
 *
 */
@Slf4j
@Component
public class CustomAuthenticationProvider implements AuthenticationProvider{
    
    private UserDetailsService userDetailsService;
    private PasswordEncoder encoder;
    
    public CustomAuthenticationProvider(UserDetailsService userDetailsService,PasswordEncoder encoder) {
        this.userDetailsService = userDetailsService;
        this.encoder = encoder;
    }
    
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        log.debug("CustomUserAuthenticationProvider.authenticate :::: {}",authentication.toString());
        
        UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken)authentication;
        
        String userId = token.getName();
        
        ChatbotUserDto user = null;
        
        if(!StringUtils.isEmpty(userId)) {
            user = (ChatbotUserDto) userDetailsService.loadUserByUsername(userId);
        }
        
        if(ObjectUtils.isEmpty(user)) {
            throw new UsernameNotFoundException("Invalid username");
        }
        
        user.setUsername(user.getUsername());
        user.setPassword(user.getPassword());
        
        String password = user.getPassword();
        
        if(!StringUtils.equals(password, encoder.encode(String.valueOf(token.getCredentials())))) {
            throw new BadCredentialsException("Invalid password");
        }
        
        return new UsernamePasswordAuthenticationToken(user, password, user.getAuthorities());
        
    }
 
    @Override
    public boolean supports(Class<?> authentication) {
        // TODO Auto-generated method stub
        log.debug("CustomUserAuthenticationProvider.supports ::::");
        return UsernamePasswordAuthenticationToken
                .class.equals(authentication);
    }
 
}
 
cs

 

4-4. access

  • hasRole(Role) : 해당 Role 을 갖고있는 사용자 허용
  • hasAnyRole(Role1, Role2, ...) : 해당 Role 중에 1개이상 갖고있는 사용자 허용
  • anonymous : 익명 사용자 허용
  • authenticated : 인증된 사용자 허용
  • permitAll : 모든 사용자 허용
  • denyAll : 모든 사용자 차단

5. 유용한 annotation

  • @Secured : 각 요청경로에 따른 권한 설정은 위의 xml에서도 할 수 있지만, 메소드레벨에서 @Secured 를 통해서도 할 수 있다. @EnableGlobalMethodSecurity(securedEnabled=true) 를 추가하면 된다.
    • - @Secured("ROLE_ADMIN") , @Secured({"ROLE_ADMIN","ROLE_USER"})
    • - 비인가자 접근 시 AccessDeniedException 던짐
  • @PreAuthorize : 위와 비슷하지만 spEL 을 사용할 수 있다. @EnableGlobalMethodSecurity(prePostEnabled=true)를 설정한다.
    • - @PreAuthorize("hasRole('ADMIN')")
  • @PostAuthorize : 위와 동일
  • @AuthenticationPrincipal : 컨트롤러단에서 세션의 정보들에 접근하고 싶을 때 파라미터에 선언해준다.
    • - public ModelAndView userInfo(@AuthenticationPrincipal User user)
    • - 이거 안쓰고 확인하려면 (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); 이런식으로 써야한다.
    • - 4.x 부터는 org.springframework.security.core.annotation.AuthenticationPrincipal 을 import 해야한다.
    • - mvc:annotation-driven.mvc:argument-resolvers 에 bean 으로 org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver 를 등록한다.

 

여기까지 폼로그인 기반 스프링 시큐리티 예제였다. 다음 포스팅에서는 해당 코드를 이어서 JWT 토큰기반 인증을 다루어볼 예정이다. 혹시 틀린점이나 부족한 점이 있으면 꼭 코멘트를 부탁합니다.

 

<소스코드>

 

 

yoonyeoseong/spring-security

Contribute to yoonyeoseong/spring-security development by creating an account on GitHub.

github.com

 

posted by 여성게
: