j00r6 / SPYAIR-Fansite-Project

1 stars 1 forks source link

[BE] Fix : Member #04_04 FreeBoard 와의 연동 Resolver & SecurityConfiguration 수정 #49

Open j00r6 opened 8 months ago

j00r6 commented 8 months ago

주요기능

오류내용 및 해결

98c4e4fb879df27e3af74fc8573e06ef631d32d1

Resolver 동작 오류

Member 패키지에서 테스트로 진행했던 API test-resolver 가 정상작동을 확인했으나 ( #38 ) FreeBoard 패키지에선 Resolver 를 통해 토큰에서 유저정보 조회하는데 실패..



상세 로그

[2024-01-24 14:33:05] [http-nio-8080-exec-4] [DEBUG] [o.s.web.servlet.DispatcherServlet  ] 
Failed to complete request java.lang.NullPointerException: Cannot invoke "org.springframework.security.core.Authentication.getName()" 
because the return value of "org.springframework.security.core.context.SecurityContext.getAuthentication()" is null

java.lang.NullPointerException: Cannot invoke "org.springframework.security.core.Authentication.getName()" 
because the return value of "org.springframework.security.core.context.SecurityContext.getAuthentication()" is null
    at pair.boardspring.resolver.LoginUserIdArgumentResolver.resolveArgument(LoginUserIdArgumentResolver.java:33)

Resolver

    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        Object principal = SecurityContextHolder.getContext().getAuthentication().getName();
        // 그외의 코드들
        ...
        ...
}

Resolver 내부에서 작동되는 Authentication.getName() 이 Null 값으로 출력

상세 로그를 좀더 살펴보면 "org.springframework.security.core.context.SecurityContext.getAuthentication()" is null SecurityContext 안에 getAuthentication 이 null 값을 리턴 즉, Security 의 인증처리 방식이 작동하지 않음을 알수 있다.



원인 및 해결

security.config.SecurityConfiguration.class 에서 filterchain 구성 항목중 securitymatchermember 영역만 권한 허용을 해놓은것이 원인 상세 내용은 최하단 참조 이동

기존

.securityMatcher("/members")

변경

.securityMatcher("/**")



기존 프로젝트와 변경점 분석

💡 참고사항 기존 프로젝트의 Security 구성 및 구현은 본인이 아닌 기존의 팀원중 한명이 구현한 것으로 분석에 오류가 있을수 있습니다.

기존

SecurityConfiguration.calss

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
        http.headers().frameOptions().sameOrigin()
                //기타 설정파일들 ..
                ...
                ...
                .authorizeHttpRequests(authorize -> authorize
                        .antMatchers(HttpMethod.POST, "/members/**").permitAll()
                        .antMatchers(HttpMethod.PATCH, "/members/**").permitAll()
                        .antMatchers(HttpMethod.DELETE, "/members/**").permitAll()
                        .antMatchers(HttpMethod.GET, "/members/**").permitAll()
                        .anyRequest().permitAll());
        return http.build();
    }

기존의 프로젝트를 떠올려보니 SecurityConfiguration 에서 위와같은 설정으로 똑같이 Resolver 를 사용하여서 문제점을 찾기 힘들었습니다.

토큰발급과정에서 현재 프로젝트와 기존 프로젝트의 차이점을 발견하고 실마리를 잡을 수 있었습니다.

JwtTokenizer.class

    public String generateAccessToken(Map<String, Object> claims,
                                      String subject,
                                      Date expiration,
                                      String base64EncodedSecretKey,
                                      Long memberId) {
        Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
        claims.put("memberId", memberId);
        return Jwts.builder()
                .setClaims(claims)
                .setSubject(subject)
                .setIssuedAt(Calendar.getInstance().getTime())
                .setExpiration(expiration)
                .signWith(key)
                .compact();
    }

기존의 프로젝트에서 JwtTokenizer.class 를 통해 토큰 발급이 이루어지고 있지만 위 클래스는 단순히 토큰만 발급할뿐 토큰에 인증정보를 추가하거나 유효기간 설정등 해당 작업들이 이루어지고 있지 않습니다.



JwtAuthenticationFilter.class

public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

@Override
    protected void successfulAuthentication(HttpServletRequest request,
                                            HttpServletResponse response,
                                            FilterChain chain,
                                            Authentication authResult) throws IOException {
        Member member = (Member) authResult.getPrincipal();
        String accessToken = delegateAccessToken(member);

        Long memberId = member.getMemberId();

        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenTime());
        String TokenExpirationDate = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(expiration);
        response.setHeader("TokenExpiration", TokenExpirationDate);

        String refreshToken = delegateRefreshToken(member);
        String nickName = member.getNickName();
        String profileImage = member.getProfileImage();
        response.setHeader("Authorization", "Bearer " + accessToken);
        response.setHeader("Refresh", refreshToken);
        response.setHeader("memberId", String.valueOf(memberId));
        Map<String, Object> responseMessage = new HashMap<>();
        responseMessage.put("nickName", nickName);
        responseMessage.put("profileImage", profileImage);
        String responseBody = new ObjectMapper().writeValueAsString(responseMessage);
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        response.getWriter().write(responseBody);
    }

허나 위코드와 같이 UsernamePasswordAuthenticationFilter 를 인터셉트 하여

response.setHeader("memberId", String.valueOf(memberId));

위 코드와 같이 인증정보와 토큰에 추가할 회원정보를 하나하나 추가하는 코드들을 확인 할 수 있었습니다.



MemberDetailsService 의 역할?

    private final class MemberDetails extends Member implements UserDetails {
        MemberDetails(Member member) {
            setMemberId(member.getMemberId());
            setEmail(member.getEmail());
            setPassword(member.getPassword());
            setNickName(member.getNickName());
            setProfileImage(member.getProfileImage());
        }

기존 프로젝트에서도 MemberDetailsService 에서 위와같이 Member Entity 의 객체들을 UserDetails 를 활용해 MemberDetails 로 커스텀한 코드를 확인 할수 있었으나 JwtTokenizer.class 에서 MemberDetails 의 객체들을 추가하지 않고 JwtAuthenticationFilter.class 에서 수동으로 추가를 한 이유는 확인하지 못했습니다.



현재

TokenProvider.class

    public String createAccessToken(Authentication authentication) {

        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();
        Date actExpiryDate = new Date(new Date().getTime() + accessTokenTime);

        return Jwts.builder()
                .setSubject(customUserDetails.getUsername())
                .claim("memberId", customUserDetails.getMemberId())
                .claim("email", customUserDetails.getUsername())
                .claim("nickName", customUserDetails.getNickName())
                .claim("roles", customUserDetails.getRoles())
                .claim("auth", authorities)
                .setIssuedAt(new Date())
                .setExpiration(actExpiryDate)
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();

현재 개발중인 프로젝트에서는 토큰 발급이 이루어지는 TokenProvider.class 내부의 메서드 createAccessToken 에서 CustomUserDetails 의 객체들을 활용해서 토큰에 인증정보를 추가하고 있습니다.

JwtFilter.class

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        String jwt = resolveToken(httpServletRequest);
        String requestURI = httpServletRequest.getRequestURI();

        if (StringUtils.hasText(jwt) && tokenProvider.validateAcessToken(jwt)) {
            Authentication authentication = tokenProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            log.info("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
        } else {
            log.info("유효한 JWT 토큰이 없습니다, uri: {}", requestURI);
        }

        filterChain.doFilter(servletRequest, servletResponse);
    }

JwtFilter.class 에서는 인증이 끝난 정보들을 가져와서 SecurityContextHolder 에 저장하고 있습니다.

최종적으로 #38 의 Resolver 구현을 통해 컨트롤러 상에서 어노테이션 @LoginMemberId 을 사용하여 필요한 정보들을 가져오고 있습니다. 현재는 Resolver 가 CustomUserDetails 의 Username 인 Email 을 기반으로 유저정보를 조회하여 가져오고 있습니다.

결론

기존 프로젝트의 Security 5 버전 상의 antMachers 를 활용하지 않고 구현한것과 다르게

현재 프로젝트 에서는 Security 6 버전 상의 securityMathcer 를 활용하여 Security 내부의 인증/인가 처리흐름이 적용되는 URI를 지정해줌으로써 해결 할 수 있었습니다.

j00r6 commented 8 months ago

차이점 분석

기존의 프로젝트에서는 Security 가 자동으로 처리해주는 인증절차를 거치지 않고 수동으로 filter를 인터셉트하여 구현을 하였기에 filterchain 구성에서 antMachers 가 제 역할을 하지 못했지만

현재의 프로젝트에서는 UserDetails 구현체인 CustomUserDetails 를 통해 Member Entity 의 객체 정보를 Security 가 자동으로 변환하고 있고 변환된 CustomUserDetails 의 객체를 활용하여 토큰에 유저의 정보를 추가했고 이를 SecurityContextHolder 에 저장했습니다.

또한 Security Filterchain 구성에서 securityMatcher 로 Member 패키지영역만 지정해주었기에 FreeBoard 패키지에서 Resolver 를 작동시킬 경우 오류를 발생시켰습니다.

위 과정을 통해서 Resolver 가 FreeBoard 상에서 이루어진 인증/인가 처리를 하지 못하는걸 발견하고 Security 6 버전에서 Security Filterchain 작성에 활용되는 securityMathcer 의 역할은 Security 가 해당 프로젝트 내에서 인증/인가 처리를 하는 영역을 지정해준다고 이해했습니다.