허원철의 개발 블로그

Spring Boot - Security + JWT 본문

web

Spring Boot - Security + JWT

허원철 2017. 2. 13. 13:52

이번 편은 spring boot 와 security 조합에서 jwt를 더해 예제를 만들어 보았습니다.

Gradle 설정

1
2
3
4
5
6
7
8
9
dependencies {
    compile('org.springframework.boot:spring-boot-starter-data-jpa')
    compile('org.springframework.boot:spring-boot-starter-security')
    compile('com.auth0:java-jwt:3.1.0')
    compile('org.springframework.boot:spring-boot-starter-web')
    runtime('com.h2database:h2')
    compile('org.projectlombok:lombok')
    testCompile('org.springframework.boot:spring-boot-starter-test')
}
cs

spring security에 대략적인 플로우입니다.


기본적인 spring security 과정에서는 필터에서 spring session 정보를 불러와 해당 권한을 가지고 인증을 합니다. 하지만 jwt token방식에서는 session이 필요하지도 않고 사용하지도 않습니다. 오직 token을 이용하여 정보를 가져오고, 인증을 받고 권한을 줍니다.

이런 작업을 위해 별도의 필터를 만들어줍니다. 또한 비동기 통신이 트렌드이기 때문에.. loginProcessing에 대한 필터를 더 만들어서 성공시에 header에 Token을 추가하는 방식으로 진행하려 합니다.

1
2
3
LOGIN_END_POINT  = "/login"  // 로그인 뷰
TOKEN_END_POINT  = "/token"  // 로그인 인증
REFRESH_END_POINT = "/refresh" // 토큰 갱신
cs


Filter

- filter 생성시에는 RequestMatcher 인터페이스를 무조건 받게 되어 있습니다. 여기서 RequestMatcher는 해당 필터에 대한 Url, Method 설정 부분입니다.

- SecurityConfig에서 security에 전반적인 설정을 합니다.
- addFilterBefore()를 이용하여 필터를 추가해줍니다.

[ SecurityConfig.java ]
1
2
3
4
5
6
7
8
9
10
@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        .addFilterBefore(ajaxAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
        .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
        .csrf().disable()
        .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
   
    ....        
}
cs

1
2
3
4
5
6
7
8
9
10
11
12
private AntPathRequestMatcher antPathRequestMatcher() {
    return new AntPathRequestMatcher(TOKEN_END_POINT, HttpMethod.POST.name());
}
 
public AjaxAuthenticationFilter ajaxAuthenticationFilter() throws Exception {
    AjaxAuthenticationFilter filter = new AjaxAuthenticationFilter(antPathRequestMatcher(), objectMapper);
    filter.setAuthenticationManager(authenticationManager());
    filter.setAuthenticationSuccessHandler(securityHandler);
    filter.setAuthenticationFailureHandler(securityHandler);
    return filter;
}
 
cs

1
2
3
4
5
6
7
8
9
10
private SkipPathRequestMatcher skipPathRequestMatcher() {
    return new SkipPathRequestMatcher(Arrays.asList(LOGIN_END_POINT, TOKEN_END_POINT));
}
 
public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
    JwtAuthenticationFilter filter = new JwtAuthenticationFilter(skipPathRequestMatcher());
    filter.setAuthenticationManager(authenticationManager());
    filter.setAuthenticationFailureHandler(securityHandler);
    return filter;
}
cs

AjaxAuthenticationFilter (비동기 로그인 필터)

- 로그인에 대한 필터링는 URL이 LOGIN_END_POINT 이면서 Method가 POST인 request만 가능하게 합니다.(설정 코드 참고..)
- 해당 해당 필터에서 ContentType 이 application/json 인지 여부를 판단합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//..... 생략
@Override
public Authentication attemptAuthentication(HttpServletRequest request, 
                                            HttpServletResponse response) throws AuthenticationException,
                                                                                IOException,
                                                                                ServletException {
    if(request.getContentType().matches(MediaType.APPLICATION_JSON_VALUE)) {
        Member member = objectMapper.readValue(request.getReader(), Member.class);
        return getAuthenticationManager().authenticate(new AjaxAuthenticationToken(member.getId()));
    } else {
        throw new AccessDeniedException("Don't use content type for " + request.getContentType());
    }
}
//..... 생략
cs

JwtAuthenticationFilter (토큰이 필요한 필터)

- Token에 대한 인증이 필요없는 LOGIN_END_POINT와 TOKEN_END_POINT 를 제외할 Custom RequestMatcher를 만들어줍니다.
- OrRequestMatcher를 이용하면 여러개의 RequestMatcher를 필터링 할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class SkipPathRequestMatcher implements RequestMatcher {
 
    private OrRequestMatcher skipRequestMatcher;
    
    public SkipPathRequestMatcher(List<String> skipPathList) {
        if(!skipPathList.isEmpty()) {
            List<RequestMatcher> requestMatcherList = skipPathList.stream()
                                                                    .map(skipPath -> new AntPathRequestMatcher(skipPath))
                                                                    .collect(Collectors.toList());
            skipRequestMatcher = new OrRequestMatcher(requestMatcherList);
        }
    }
    
    @Override
    public boolean matches(HttpServletRequest request) {
        if(skipRequestMatcher.matches(request)) {
            return false;
        } else {
            return true;
        }
    }
}
cs

- filter에서는 header에 token 정보가 담아있는지 판별합니다. 

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
                                            HttpServletResponse response) throws AuthenticationException,
                                                                                IOException, ServletException {
    String token = request.getHeader(JwtInfo.HEADER_NAME);
    
    if(!StringUtils.isEmpty(token)) {
        return getAuthenticationManager().authenticate(new JwtAuthenticationToken(token));
    } else {
        throw new AccessDeniedException("Not empty Token");
    }
}
cs


Provider

- 인증 및 권한 제어를 제공하는 클래스입니다. 
- 코드가 거의 흡사하여, JwtAuthenticationProvider만 발췌합니다.
 

1
2
3
4
5
6
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    String token = (String) authentication.getCredentials();
    UserDetailsImpl user = userDetailsService.loadUserByUsername(token);
    return new JwtAuthenticationToken(user.getMember(), user.getAuthorities());
}
cs
 
AjaxAuthenticationProvider

- filter로 부터 받은 cerdentials(로그인 정보)를 userDetailsService에 넘겨줍니다.
- user 정보에 대한 별도의 인증 처리를 추가할 수 있습니다.

JwtAuthenticationProvider

- filter로 부터 받은 cerdentials(token)를 userDetailsService에 넘겨줍니다.
- token 정보에 대한 별도의 인증 처리를 추가할 수 있습니다.


UserDetailsService

- UserDetailsService가 실질직인 인증 처리라고 생각하여, DB 조회 및 token에 대한 유효형 판단을 이 부분에서 처리하였습니다.

AjaxDetailsService

- MemberRepository에서 해당 ID 정보를 가져옵니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Component
public class AjaxUserDetailsService implements UserDetailsService {
 
    @Autowired MemberRepository repository;
    
    @Override
    public UserDetailsImpl loadUserByUsername(String username) {
        
        Member user = repository.findOne(username);
        
        if(user == null) {
            throw new UsernameNotFoundException(username + "라는 사용자가 없습니다.");
        }
        
        return new UserDetailsImpl(user, AuthorityUtils.createAuthorityList(user.getRole()));
    }
}
cs

JwtDetailsService

- token에 대한 유효형 판단을 합니다.(auth0에서 제공하는 JWT.verify()는 내부적으로 decode(), 알고리즘 검사, 날짜 유효성, claims 검사를 합니다.)

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
@Component
public class JwtUserDetailsService implements UserDetailsService {
 
    @Autowired ObjectMapper objectMapper;
    
    @Autowired JwtUtil jwtUtil;
    @Autowired JwtFactory jwtFactory;
    
    @Override
    public UserDetailsImpl loadUserByUsername(String token) {
        System.out.println(token);
        if(!jwtFactory.verifyToken(token)) {
            throw new BadCredentialsException("Not used Token");
        }
        
        JWT jwt = jwtUtil.tokenToJwt(token);
        Member member = getStringToMember(jwt.getClaim("member").asString());
        
        if(member == null) {
            throw new BadCredentialsException("Not used Token");
        }
        
        return new UserDetailsImpl(member, AuthorityUtils.createAuthorityList(member.getRole()));
    }
    
    private Member getStringToMember(String memberStr) {
        try {
            return objectMapper.readValue(memberStr, Member.class);
        } catch (IOException e) {
            return null;
        }
    }
}
cs


Handler

- 인증처리가 완료되고, header에 jwt Token을 할당 해주는 처리(successHandler), 로그인 실패 및 토큰 오류(?)  처리(failureHandler)를 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Component
public class BaseSecurityHandler implements AuthenticationSuccessHandler, AuthenticationFailureHandler {
 
    @Autowired JwtFactory JwtFactory;
    
    @Autowired ObjectMapper objectMapper;
    
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) throws IOException, ServletException {
        Member member = (Member)authentication.getPrincipal();
        String jsonToMember = objectMapper.writeValueAsString(member);
        response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
        response.setHeader(JwtInfo.HEADER_NAME, JwtFactory.createToken(jsonToMember));
    }
    
    @Override
    public void onAuthenticationFailure(HttpServletRequest request,
                                        HttpServletResponse response,
                                        AuthenticationException exception) throws IOException, ServletException {
        throw exception;
    }
}
cs


해당 User에 대한 권한 처리

- jwtAuthenticationFilter에 보면 successfulAuthentication(...), unsuccessfulAuthentication(...) 찾아 해볼 수 있습니다. 이 부분은 Filter에 대해 인증 여부에 따라 각각에 메소드를 타게 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 생략 ....
@Override
protected void successfulAuthentication(HttpServletRequest request,
                                        HttpServletResponse response, 
                                        FilterChain chain,
                                        Authentication authResult) throws IOException, ServletException {
    SecurityContext context = SecurityContextHolder.createEmptyContext();
    context.setAuthentication(authResult);
    SecurityContextHolder.setContext(context);
    chain.doFilter(request, response);
}
 
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request,
                                            HttpServletResponse response,
                                            AuthenticationException failed) throws IOException, ServletException {
    SecurityContextHolder.clearContext();
    getFailureHandler().onAuthenticationFailure(request, response, failed);
}
// 생략 ....
cs

- 인증에 성공한 경우 해당 사용자에게 권한을 할당해줍니다. 그러면 Spring Security에서 가능했던 각 Controller에 @PreAuthorize @PostAuthorize를 사용할 수 있습니다.

- SecurityConfig.java에 @EnableGlobalMethodSecurity(prePostEnabled = true) 를 추가해야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@RestController
public class RoleController {
 
    @PostAuthorize("hasAuthority('USER')")
    @GetMapping("/user")
    public String user(Authentication authentication) {
        System.out.println("RoleController : " + authentication.getAuthorities().toString());
        System.out.println("RoleController : " + authentication.getPrincipal());
        return "I'm Jwt Token User!";
    }
    
    @PreAuthorize("hasAuthority('ADMIN')")
    @GetMapping("/admin")
    public String admin(Authentication authentication) {
        System.out.println("RoleController : " + authentication.getAuthorities().toString());
        System.out.println("RoleController : " + authentication.getPrincipal());
        return "I'm Jwt Token Admin!";
    }
}
cs

※ @xxxxAuthorize() 사용할 때, Authority를 "ROLE_"를 포함하여 권한을 주었는지 확인합니다. 포함하지 않았다면, hasRole() 를 사용하지 못하므로 hasAuthority를 사용합니다. (이것 말고도 좀 더 많은 설정이 있으니 확인해보시면 좋습니다. 레퍼런스-참고)


토큰 재갱신

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
@RestController
@RequestMapping("/refresh")
public class RefreshController {
    
    @Autowired JwtFactory jwtFactory;
    @Autowired ObjectMapper objectMapper;
    
    @GetMapping
    public ResponseEntity<String> refreshToken(Authentication authentication) {
        System.out.println("refreshToken");
        Member member = (Member)authentication.getPrincipal();
        
        try {
            String token = jwtFactory.refreshToken(objectMapper.writeValueAsString(member));
            
            MultiValueMap<StringString> headers = new LinkedMultiValueMap<>();
            headers.add(JwtInfo.HEADER_NAME, token);
            
            return new ResponseEntity<String>("success refresh token", headers, HttpStatus.OK);
        } catch (JsonProcessingException e) {
            return new ResponseEntity<String>("fail refresh token", HttpStatus.FORBIDDEN);
        }
    }
}
 
cs

- RefreshController 또한 Security 필터를 거쳐야만 하기 때문에 별도의 인증 작업이 필요없어(개인적인 판단입니다..) Authentication를 이용하여 갱신된 token을 header에 넣어줍니다.


참고


- 한페이지에 모든 내용을 풀어나가기가 어려우므로, 깃헙소스를 찾고하시기 바랍니다.

- 상당히 많은 부분을 https://github.com/svlada/springboot-security-jwt 이 소스를 참고하였고, 부분적으로 코드를 수정해보았습니다. 제 소스가 안맞으시거나 틀린 부분이 있다면, 해당 깃헙 소스를 참고하시면 되겠습니다. (물론, 저에게도 피드백 주시면 감사하겠습니다.)


https://github.com/svlada/springboot-security-jwt 

http://heowc.tistory.com/45

https://github.com/auth0/java-jwt

- 깃헙소스

Comments