Spring Security (6) - 인가(Authorization)

hyozkim·2020년 2월 24일
1

Spring Security

목록 보기
6/6
post-thumbnail

Spring Security 인가 요약

인가(Authorization) 처리를 위한 핵심 컴포넌트

인가를 처리하기 위해 Spring Security Filter 중 FilterSecurityInterceptor가 사실상 권한부여 처리를 AccessDecisionManager에게 위임함으로써 접근 제어 결정을 쉽게 해준다.

전체 코드

    @Bean
    public ConnectionBasedVoter connectionBasedVoter() {
        Pattern pattern = Pattern.compile( "^/api/user/(\\d+)/post/.*$");
        RequestMatcher requiresAuthorizationRequestMatcher = new RegexRequestMatcher(pattern.pattern(), null);
        return new ConnectionBasedVoter(requiresAuthorizationRequestMatcher, (String url) -> {
            /* url에서 targetId를 추출하기 위해 정규식 처리 */
            Matcher matcher = pattern.matcher(url);
            long id = matcher.find() ? toLong(matcher.group(1), -1) : -1;
            return Id.of(User.class, id);
        });
    }

    @Bean
    public AccessDecisionManager accessDecisionManager() {
        List<AccessDecisionVoter<?>> decisionVoters = new ArrayList<>();
        decisionVoters.add(new WebExpressionVoter());
        decisionVoters.add(connectionBasedVoter());
        // 모든 voter가 승인해야 해야한다.
        return new UnanimousBased(decisionVoters);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .accessDeniedHandler(accessDeniedHandler)
                
                //...
                
                .antMatchers("/api/_hcheck").permitAll()
                .antMatchers("/api/auth").permitAll()
                .antMatchers("/api/user/join").permitAll()
                .antMatchers("/api/user/exists").permitAll()
                .antMatchers("/api/**").hasRole("USER")
                
                //...
                
                .accessDecisionManager(accessDecisionManager())
                
                // ...
        http
                .addFilterBefore(jwtAuthenticationTokenFilter(), UsernamePasswordAuthenticationFilter.class);
    }

    @Override
    public void configure(WebSecurity web) {
        web.ignoring().antMatchers("/swagger-resources", "/webjars/**", "/templates/**", "/h2/**");
    }

설명

addFilterBefore()

configure() 메소드에서 addFilterBefore()에 정의된 jwtAuthenticationTokenFiler(), UsernamePasswordAuthenticationFilter.class Filter 먼저 실행
-> 여러 Filter 단계를 거쳐 controller가 호출됨.

  • JwtAuthenticationTokenFilter.java

SecurityContextHolder에 저장된 Authentication이 없다면 암호화 된 JWT를 가져와서 (obtainAuthorizationToken 함수) JwtAuthenticationToken을 생성하고, SecurityContextHolder에 저장 (SecurityContext에 Authentication 클래스 객체를 setAuthentication)

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) req;
        HttpServletResponse response = (HttpServletResponse) res;

        if (SecurityContextHolder.getContext().getAuthentication() == null) {
            String authorizationToken = obtainAuthorizationToken(request);
            if (authorizationToken != null) {
                try {
                    JWT.Claims claims = verify(request, authorizationToken);
                    log.debug("Jwt parse result: {}", claims);

                    // 만료 10분 전
                    if (canRefresh(claims, 6000 * 10)) {
                        String refreshedToken = jwt.refreshToken(authorizationToken);
                        response.setHeader(tokenHeader, refreshedToken);
                    }

                    Long userKey = claims.userKey;
                    Email email = claims.email;
                    String name = claims.name; // 이름 프로퍼티 가져오기

                    List<GrantedAuthority> authorities = obtainAuthorities(claims);

                    if (userKey != null && email != null && authorities.size() > 0) {
                        JwtAuthenticationToken authentication =
                                new JwtAuthenticationToken(new JwtAuthentication(userKey, email, name), null, authorities);
                        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                        SecurityContextHolder.getContext().setAuthentication(authentication);
                    }
                } catch (Exception e) {
                    log.warn("Jwt processing failed: {}", e.getMessage());
                }
            }
        }
        else {
            log.debug("SecurityContextHolder not populated with security token, as it already contained: '{}'",
                    SecurityContextHolder.getContext().getAuthentication());
        }

        chain.doFilter(request, response);
    }
    
    //...
    
    private boolean canRefresh(JWT.Claims claims, long refreshRangeMillis) {
        long exp = claims.exp();
        if (exp > 0) {
            long remain = exp - System.currentTimeMillis();
            return remain < refreshRangeMillis;
        }
        return false;
    }

    private List<GrantedAuthority> obtainAuthorities(JWT.Claims claims) {
        String[] roles = claims.roles;
        return roles == null || roles.length == 0 ?
                Collections.emptyList() :
                Arrays.stream(roles).map(SimpleGrantedAuthority::new).collect(toList());
    }

    private String obtainAuthorizationToken(HttpServletRequest request) {
        String token = request.getHeader(tokenHeader);
        if (token != null) {
            if (log.isDebugEnabled())
                log.debug("Jwt authorization api detected: {}", token);
            try {
                token = URLDecoder.decode(token, "UTF-8");
                String[] parts = token.split(" ");
                if (parts.length == 2) {
                    String scheme = parts[0];
                    String credentials = parts[1];
                    return BEARER.matcher(scheme).matches() ? credentials : null;
                }
            } catch (UnsupportedEncodingException e) {
                log.error(e.getMessage(), e);
            }
        }

        return null;
    }

    private JWT.Claims verify(HttpServletRequest request, String token) {
        return jwt.verify(token);
    }
  • UsernamePasswordAuthenticationFilter.class
    구현체 UsernamePasswordAuthenticationFilter가 HttpServletRequest에서 사용자가 보낸 아이디와 패스워드를 인터셉트한다. 프론트 단에서 유효성검사를 할 수도 있지만, 무엇보다 안전을 위해서 다시 한번 사용자가 보낸 아이디와 패스워드의 유효성 검사를 해줄 수 있다.

잠깐! 여기서 @AuthenticationPrincipal도 컨트롤러 단에서 세션의 정보들에 접근할게 될때 사용하는 어노테이션인데, 사용자가 보낸 아이디와 패스워드를 인터셉트하는가??

@GetMapping(path = "user/me")
public ApiResult<User> me(@AuthenticationPrincipal JwtAuthentication authentication) {
    return OK(
            userService.findById(authentication.id)
                        .orElseThrow(() -> new NotFoundException(User.class, authentication.id))
    );
}

정답은? @AuthenticationPrincipal 은 SecurityContextHolder.getContext().getAuthentication().getPrincipal() 와 같다. 접근 주체를 받기 때문에 Filter에 걸린다.

accessDecisionManager()

configure() 함수 내 accessDecisionManager()를 간략하게 설명하면 AuthenticationManager가의 역할은 인증된 사용자가 리소스에 접근을 허용할지 안할지 Voter들에게 물어본다. (마치 아까 AuthenticationManager가 Provider들에게 요청하듯이)
Voter들의 결정을 모아서 최종 결정을 해준다.


AccessDecisionManager

코드

  • SecurityConfigure.java
@Bean
public AccessDecisionManager accessDecisionManager() {
	List<AccessDecisionVoter<?>> decisionVoters = new ArrayList<>();
        decisionVoters.add(new WebExpressionVoter());
        decisionVoters.add(connectionBasedVoter());
        // 모든 voter가 승인해야 해야한다.
        return new UnanimousBased(decisionVoters);
}

설명

List<AccessDecisionVoter<>> (투표자들의 리스트)에 권한을 줄지 안줄지 기준인 Voter(투표자)들을 담는다. 등록된 Voter들을 가지고 권한을 줄지 안줄지 Manager가 최종 결정한다.

다음 기준을 가지고 결정한다.

  • AffirmativeBased(승인 Voter가 1개 이상)
  • ConsensusBased(과반수)
  • UnanimouseBased(모든 Voter 승인)

new UnanimousBased()으로 생성했기 때문에 모든 Voter가 승인해야 권한을 주게 된다.

ConnectionBasedVoter

코드

@Bean
public ConnectionBasedVoter connectionBasedVoter() {
	Pattern pattern = Pattern.compile( "^/api/user/(\\d+)/post/.*$");
        RequestMatcher requiresAuthorizationRequestMatcher = new RegexRequestMatcher(pattern.pattern(), null);
        return new ConnectionBasedVoter(requiresAuthorizationRequestMatcher, (String url) -> {
            /* url에서 targetId를 추출하기 위해 정규식 처리 */
            Matcher matcher = pattern.matcher(url);
            long id = matcher.find() ? toLong(matcher.group(1), -1) : -1;
            return Id.of(User.class, id);
	});
}

설명

다음 정규식(^/api/user/(\\d+)/post/.*$)이 가지는 RESTURI 의 경우, 인가 처리를 실행한다. 이때, 정규식에서 숫자(\d+)부분을 찾아 targetId를 생성하여 매개변수로 return 함으로써 ConnectionBasedVoter을 생성한다.
ConnectionBasedVoter은 AccessDecisionVoter 클래스를 구현체로 커스터마이징 개발하여 권한을 줄 기준을 작성한다.


참고

spring-security github source

(프로그래머스) 단순 CRUD는 그만! 웹 백엔드 시스템 구현 온라인 스터디(Java반) 강의를 수강하고 제가 이해한대로 정리했습니다. 문제가 될시 삭제하겠습니다!

profile
차근차근 develog

0개의 댓글