[Spring Security + JWT] 회원 관리 구현

김강욱·2024년 4월 22일
0

Project-Evertrip

목록 보기
3/19

😃 Oauth을 이용하여 회원가입,로그인 기능 구현

해당 프로젝트에서 네이버 Oauth를 사용하여 로그인 기능을 구현하기로 하였습니다.

네이버 로그인 API 명세를 확인하고 싶다면 아래를 클릭하시면 됩니다.

네이버 로그인 API 명세

네이버 Oauth 흐름에 대해서 간단하게 설명드리도록 하겠습니다.

먼저 네이버의 로그인 API에 요청을 보내야하는데 아래와 같은 형식으로 요청을 보내게 됩니다.

https://nid.naver.com/oauth2.0/authorize?scope=~~&response_type=code&redirect_uri=~~&client_id=~~

https://nid.naver.com/oauth2.0/authorize는 네이버 로그인 API이며 쿼리 파라미터에 위의 변수에 대해서 추가하여 url을 작성하여 요청을 보내면 됩니다.

이러한 요청을 받은 네이버 API는 네이버 로그인 화면을 응답으로 보내주게 되고 사용자는 해당 화면에서 네이버 로그인을 진행하게 됩니다.

로그인을 하면 네이버는 아까 요청을 받았던 쿼리 파라미터 변수 중 redirect_uri에 담겨있던 주소로 응답을 주게 됩니다.

저희 프로젝트의 경우 위의 API에서 해당 응답을 수신하게 됩니다. 네이버 로그인 API 서버에서 보내주는 code를 이용하여 다시 네이버 접근 토큰 발행 요청 API에 요청을 보내게 됩니다.

@Override
    public ResponseEntity<String> requestAccessToken(String code) {

        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", NAVER_AUTHORIZATION_GRANT_TYPE);
        params.add("client_id", NAVER_SNS_CLIENT_ID);
        params.add("client_secret", NAVER_SNS_CLIENT_SECRET);
        params.add("code", code);


        System.out.println("params :" + params.toString());

        // HTTP 요청 헤더 설정
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        // HttpEntity 객체 생성
        HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(params, headers);


        ResponseEntity<String> responseEntity=restTemplate.postForEntity(NAVER_TOKEN_URI,
                requestEntity,String.class);

        if(responseEntity.getStatusCode()== HttpStatus.OK){
            return responseEntity;
        }
        return null;

    }

위의 코드에서 RestTemplate을 사용하여 네이버 접근 토큰 발행 요청 API에 비동기적으로 요청을 보내고 토큰 정보를 응답으로 받게 됩니다.

접근 토큰 발행 요청할 때 grant_type, client_id, client_secret 정보를 기입하고 위에서 받은 code 정보를 기입하여 요청을 보내시면 됩니다.

응답값은 아래와 같이 NaverOauthToken 객체로 받아왔습니다.

@AllArgsConstructor
@Getter
@NoArgsConstructor
public class NaverOauthToken {
    private String access_token;
    private int expires_in;
    private String token_type;
    private String refresh_token;
}

네이버에서 발급해준 access Token과 refresh Token을 이용하여 네이버 회원 프로필 조회 API에 요청하여 사용자의 정보들을 받아올 수 있습니다.

public ResponseEntity<String> requestUserInfo(NaverOauthToken oauthToken) {

        //header에 accessToken을 담는다.
        HttpHeaders headers = new HttpHeaders();
        headers.add("Authorization","Bearer "+oauthToken.getAccess_token());

        //HttpEntity를 하나 생성해 헤더를 담아서 restTemplate으로 서드파티(네이버)와 통신하게 된다.
        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity(headers);
        ResponseEntity<String> response=restTemplate.exchange(NAVER_USER_INFO_URI, HttpMethod.GET,request,String.class);
        System.out.println("responseUserInfo.getBody() = " + response.getBody());
        return response;
    }

요청 헤더에 토큰 정보를 넣어https://openapi.naver.com/v1/nid/me 주소에 요청을 보내게 되면 네이버 API는 사용자의 정보(정해둔 scope를 기반으로)를 응답해주게 됩니다.

받은 응답값을 아래의 형태로 역직렬화하여 사용하였습니다.

@AllArgsConstructor
@Getter
@ToString
@NoArgsConstructor
public class NaverUser {
    public String id;
    public String age;
    public String gender;
    public String email;
    public String mobile;
    public String mobile_e164;
    public String name;
    public String birthday;
    public String birthyear;
}

이후 아래의 코드와 같이 사용자 정보를 바탕으로 회원가입과 로그인을 진행하였습니다.

@Transactional
    public String oauthLogin(ConstantPool.SocialLoginType socialLoginType, String code, HttpHeaders httpHeaders , HttpServletRequest request) throws IOException {
        switch (socialLoginType) {
            case NAVER -> {
                // 네이버로 일회성 코드를 보내 액세스 토큰이 담긴 응답객체를 받아온다
                ResponseEntity<String> accessTokenResponse = naverOauth.requestAccessToken(code);
                // 응답 객체가 JSON 형식으로 되어 있으므로, 이를 역직렬화해서 자바 객체에 담는다.
                NaverOauthToken oauthToken = naverOauth.getAccessToken(accessTokenResponse);
                // 액세스 토큰을 다시 네이버로 보내 네이버에 저장된 사용자 정보가 담긴 응답 객체를 받아온다.
                ResponseEntity<String> userInfoResponse = naverOauth.requestUserInfo(oauthToken);
                // 다시 JSON 형식의 응답 객체를 자바 객체로 역직렬화한다.
                NaverUser naverUser = naverOauth.getUserInfo(userInfoResponse);

                // DB에 회원 이메일이 등록 되어있는지 확인 후 있으면 회원가입 처리 및 토큰 발급, 없으면 그냥 토큰 발급
                // 회원가입 처리
                if (memberRepository.findByEmail(symmetricCrypto.encrypt(naverUser.getEmail())).isEmpty()) {
                    log.info("신규 회원");
                    Role role = roleRepository.findByRoleName(Role.RoleName.USER).orElseThrow(() -> new ApplicationException(ErrorCode.AUTHORITY_NOT_FOUND));
                    Member member = new Member(symmetricCrypto.encrypt(naverUser.getEmail()),role);
                    Member findMember = memberRepository.save(member);

                    MemberProfile memberProfile = new MemberProfile(member, naverUser.getName());
                    memberProfileRepository.save(memberProfile);

                    MemberDetail memberDetail = new MemberDetail(member,naverUser.getName(), symmetricCrypto.encrypt(naverUser.getMobile()), naverUser.getGender(), naverUser.getAge());
                    memberDetailRepository.save(memberDetail);



                    log.info("회원 비교 : {}", (member==findMember));
                    log.info("회원 이메일(실제) : {}",naverUser.getEmail());
                    log.info("회원 이메일(DB 암호화) : {}" ,findMember.getEmail());
                    log.info("회원 이메일(DB 복호화) : {}" ,symmetricCrypto.decrypt(findMember.getEmail()));
                    log.info("회원 전화번호(DB 암호화) : {}" ,memberDetail.getPhone());
                    log.info("회원 전화번호(DB 복호화) : {}" ,symmetricCrypto.decrypt(memberDetail.getPhone()));
                }

                log.info("해당 회원 저장 여부 : {}",memberRepository.findByEmail(symmetricCrypto.encrypt(naverUser.getEmail())).get().getEmail());

                // JWT 토큰 발급
                // ----------------------------------------------------------
                String ipAddress = request.getRemoteAddr();

                UsernamePasswordAuthenticationToken authenticationToken =
                        new UsernamePasswordAuthenticationToken(symmetricCrypto.encrypt(naverUser.getEmail()),null);

                // authenticationToken을 이용해서 Authentication 객체를 생성하려고 authentication 메소드가 실행이 될 때
                // CustomUserDetailsService의 loadUserByUsername 메소드가 실행된다.
                Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

                // createToken 메소드를 통해서 JWT Token 생성
                String jwt = tokenProvider.createToken(authentication);
                String refresh = refreshTokenProvider.createToken(authentication, ipAddress);

                httpHeaders.add(AUTHORIZATION_HEADER, "Bearer " + jwt);
                httpHeaders.add(REFRESH_HEADER, "Bearer " + refresh);

                // REDIS에 Refresh Token 저장
                try {
                    // "refresh:암호화된IP_pk"을 key값으로 refreshToken을 Redis에 저장한다.
                    redisService.setRefreshToken("refresh:" + hmacAndBase64.crypt(ipAddress, "HmacSHA512")
                            + "_" + authentication.getName(), refresh);
                } catch (UnsupportedEncodingException | NoSuchAlgorithmException | InvalidKeyException e) {
                    throw new ApplicationException(ErrorCode.CRYPT_ERROR);
                }
                // ----------------------------------------------------------

                return authentication.getName();

            }
            default -> {
                throw new ApplicationException(INVALID_SOCIAL_LOGIN_TYPE);
            }
        }
    }

해당 사용자 정보가 DB에 없을 시 회원가입을 진행하였고 공통 코드로 JWT 토큰을 만들어 사용자에게 전달하고 Redis에 Refresh Token을 저장하여 토큰 관리를 하도록 구현하였습니다.


😃 시큐리티 설정 및 JWT 필터를 통한 사용자 인증, 권한 처리

스프링 시큐리티에 기본적으로 여러가지 필터가 존재합니다. 그 중에서 사용자 인증을 담당하는 필터는 UsernamePasswordAuthenticationFilter입니다.

해당 필터는 아이디, 패스워드 기반으로 인증을 담당하고 있는데 저희 프로젝트에서는 아이디, 패스워드 기반이 아닌 JWT 토큰에 있는 사용자 정보를 바탕으로 인증을 하기 때문에 JwtFilter를 따로 커스터마이징하여 시큐리티 필터 체인에 추가해주었습니다.

그리고 필터 체인의 설정을 이용하여 UsernamePasswordAuthenticationFilter 전에 JwtFilter를 지나가도록 설정해주었습니다.

인증 실패와 인가(권한) 실패 시 401, 403 예외를 응답으로 주기 위해서 JwtAuthenticationEntryPoint, JwtAccessDeniedHandler 클래스를 만들어 시큐리티 ExceptionHandling 설정을 해주었습니다.

아래는 Security 설정 코드 및 JwtAuthenticationEntryPoint, JwtAccessDeniedHandler 클래스입니다.

Spring Security Config

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {



    private final TokenProvider tokenProvider;

    private final RefreshTokenProvider refreshTokenProvider;

    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;

    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

    private final HmacAndBase64 hmacAndBase64;

    private final RedisService redisService;

    private final CorsFilter corsFilter;

    private final UserDetailsService userDetailsService;


    public SecurityConfig(TokenProvider tokenProvider, JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
                          JwtAccessDeniedHandler jwtAccessDeniedHandler, RedisService redisService, CorsFilter corsFilter,
                          HmacAndBase64 hmacAndBase64, RefreshTokenProvider refreshTokenProvider, UserDetailsService userDetailsService) {
        this.tokenProvider = tokenProvider;
        this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
        this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
        this.redisService = redisService;
        this.corsFilter = corsFilter;
        this.hmacAndBase64 = hmacAndBase64;
        this.refreshTokenProvider = refreshTokenProvider;
        this.userDetailsService = userDetailsService;
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(customAuthenticationProvider());
    }

    @Bean
    public AuthenticationProvider customAuthenticationProvider() {
        return new CustomAuthenticationProvider(userDetailsService);
    }


    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        // 로그인 API, 회원가입 API는 토큰이 없는 상태에서 요청이 들어오기 때문에 모두 permitAll 설정을 한다.
        http
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers("/auth/**").permitAll()
                        .requestMatchers("/error").permitAll()
                        .requestMatchers("/api/test").hasAuthority("ADMIN")
                        .requestMatchers(HttpMethod.GET,"/**.css", "/**.js", "/**.png").permitAll()
                        .anyRequest().authenticated());

        http
                // token을 사용하는 방식이기 때문에 csrf를 disable합니다.
                .csrf(csrf -> csrf.disable())
                .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(
                        new JwtFilter(tokenProvider,refreshTokenProvider,redisService, hmacAndBase64),
                        UsernamePasswordAuthenticationFilter.class
                )
                .exceptionHandling(exceptionHandling -> exceptionHandling
                        .accessDeniedHandler(jwtAccessDeniedHandler)
                        .authenticationEntryPoint(jwtAuthenticationEntryPoint)

                );

        // 세션을 사용하지 않기 때문에 STATELESS로 설정
        http.sessionManagement(sessionManagement ->
                sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        );


        return http.build();
    }

    @Order
    @Bean
    public WebSecurityCustomizer configure() {
        return (web) -> {
            web.ignoring().requestMatchers(request -> request.getRequestURI().startsWith("/h2-console"));
            web.ignoring().requestMatchers(request -> request.getRequestURI().startsWith("/favicon.ico"));
        };
    }
}

JwtAuthenticationEntryPoint

// 유효한 자격증명을 제공하지 않고 접근하려할 때 401 Unauthorized 에러를 리턴할 JwtAuthenticationEntryPoint 클래스
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {


    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {

        ObjectMapper mapper = new ObjectMapper();
        String jsonInString = mapper.writeValueAsString(ApiResponse.error(ErrorResponse.of(ErrorCode.UNAUTHORIZED)));

        response.setContentType("application/json");

        //유효한 토큰이 아닐 시 401
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.getWriter().write(jsonInString);
    }
}

JwtAccessDeniedHandler

// 필요한 권한이 존재하지 않는 경우에 403 Forbidden 에러를 리턴하기 위한 JwtAccessDeniedHandler 클래스
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {


    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        ObjectMapper mapper = new ObjectMapper();
        String jsonInString = mapper.writeValueAsString(ApiResponse.error(ErrorResponse.of(ErrorCode.ACCESS_FORBIDDEN)));

        response.setContentType("application/json");
        //필요한 권한이 없이 접근하려 할때 403
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.getWriter().write(jsonInString);
    }
}


JWTFilter

위에서 언급했듯이 JWTFilter에서 사용자 인증 여부를 확인합니다. 클라이언트에서 요청 헤더에 Access Token과 Refresh Token을 보내게 되고 서버에서는 해당 요청의 헤더 정보에 있는 토큰들을 꺼내와서 유효성 검사를 진행하게 됩니다.

순서는 다음과 같습니다.

  1. 요청 헤더에 Access Token을 가져옵니다.

  2. 해당 Access Token의 유효성 검사를 진행합니다.

    유효성 검사는 해당 서버가 발행한 토큰인지, 토큰이 만료되었는지 에 대한 검사와 토큰을 복호화하여 얻어낸 사용자 정보(사용자 PK)가 실제 DB에 저장된 사용자 정보와 일치하는지에 대한 검사, 두가지로 진행됩니다.

  3. 만약 AccessToken이 유효하다면 해당 토큰을 이용하여 Authentication 객체를 생성하여 SecurityContextHolder를 사용하여 SecurityContext에 저장합니다.

  4. AccessToken이 유효하지 않을 경우 Refresh Token이 헤더에 들어있는지 확인합니다. 유효성 검사는 Access Token과 유사하며 Redis에 있는 Refresh Token과 일치하는지를 추가하여 검사합니다.

    Redis에 Refresh Token을 저장할 때 토큰 뒤에 ip 주소를 암호화한 값을 더해서 같이 저장하게 됩니다. 그 이유는 Refresh Token을 탈취당했을 때 해당 요청의 IP(해커 IP 주소)와 기존에 저장된 Refresh Token(로그인 요청을 한 IP 주소를 포함한) 값과 비교하여 인증을 막기 위함입니다.

  5. Refresh Token의 유효성 검사를 실패할 경우 JwtAuthenticationEntryPoint 객체를 거쳐 401 인증 실패 에러를 반환하게 됩니다.

JwtFilter 코드

@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends GenericFilterBean {

    private final TokenProvider tokenProvider;

    private final RefreshTokenProvider refreshTokenProvider;

    private final RedisService redisService;

    private final HmacAndBase64 hmacAndBase64;

    // doFilter는 토큰의 인증정보를 SecurityContext에 저장하는 역할을 수행한다.
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {

        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        HttpServletResponse httpServletResponse = (HttpServletResponse) servletResponse;

        // resolveToken을 통해 토큰을 받아와서 유효성 검증을 하고 정상 토큰이면 SecurityContext에 저장한다.
        String jwt = resolveToken(httpServletRequest);
        String refreshToken = resolveRefreshToken(httpServletRequest);

        String requestURI = httpServletRequest.getRequestURI();
        String ipAddress = httpServletRequest.getRemoteAddr();

        try {
            if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
                Authentication authentication = tokenProvider.getAuthentication(jwt);
                SecurityContextHolder.getContext().setAuthentication(authentication);
                log.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
            } else {
                log.debug("유효한 JWT 토큰이 없습니다, uri: {}", requestURI);
                throw new NoJwtTokenException("JWT 토큰이 없습니다. Refresh 토큰을 확인하겠습니다.");
            }
        } catch (ExpiredTokenException | NoJwtTokenException e) {

            try {
                if (StringUtils.hasText(refreshToken) && refreshTokenProvider.validateToken(refreshToken, ipAddress)) {
                    Authentication authentication = tokenProvider.getAuthentication(refreshToken);

                    // Redis에 저장된 refresh 토큰과 비교
                    if (redisService.getRefreshToken("refresh:"+
                            hmacAndBase64.crypt(ipAddress,"HmacSHA512")+"_"+authentication.getName()).equals(refreshToken)) {


                        String renewToken = tokenProvider.createToken(authentication);

                        httpServletResponse.setHeader(AUTHORIZATION_HEADER,"Bearer " + renewToken);
                    }


                } else {
                    log.debug("유효한 Refresh 토큰이 없습니다, uri: {}", requestURI);
                }


            } catch (ExpiredTokenException ete) {
                log.warn("Refresh 토큰의 만료기간이 지났습니다.");
            } catch (ApplicationException ae) {
                log.warn("Refresh 토큰의 인증이 잘못됐습니다.");
            } catch (NoSuchAlgorithmException | InvalidKeyException | UnsupportedEncodingException niu) {
                log.warn("ipAddress 암호화 실패");
            }
        } catch (ApplicationException e) {
            log.warn("{}",e);
            log.warn("JWT 토큰 인증에 실패하였습니다.");
        }


        filterChain.doFilter(servletRequest, servletResponse);
    }

    // Request Header에서 토큰 정보를 꺼내오기 위한 resolveToken 메소드 추가
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);

        log.debug("헤더의 Access 토큰 : {}", bearerToken);

        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }

        return null;
    }

    // Request Header에서 리프레쉬 토큰 정보를 꺼내오기 위한 resolveRefreshToken 메소드 추가
    private String resolveRefreshToken(HttpServletRequest request) {
        String refreshToken = request.getHeader(REFRESH_HEADER);


        log.debug("헤더의 Refresh 토큰 : {}", refreshToken);

        if (StringUtils.hasText(refreshToken) && refreshToken.startsWith("Bearer ")) {
            return refreshToken.substring(7);
        }

        return null;
    }
}

그리고 JwtFilter 내부에서 Authentication 객체를 직접 생성해주었는데 이때 사용한 클래스는 TokenProvider 클래스입니다.

TokenProvider 클래스

@Component
@Slf4j
public class TokenProvider implements InitializingBean {
    private final String secret;
    private final long tokenValidityInMilliseconds;
    private Key key;

    private final MemberRepository memberRepository;

    public TokenProvider(
            @Value("${jwt.secret}") String secret,
            @Value("${jwt.token-validity-in-seconds}") long tokenValidityInSeconds,
            MemberRepository memberRepository) {
        this.secret = secret;
        this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000;
        this.memberRepository = memberRepository;
    }

    @Override
    public void afterPropertiesSet() {
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    public String createToken(Authentication authentication) {

        long now = System.currentTimeMillis();
        Date validity = new Date(now + this.tokenValidityInMilliseconds);

        // JWT 토큰에 Subject로 멤버의 id(pk)를 넣어준다.
        return Jwts.builder()
                .setSubject((authentication.getName()))
                .signWith(key, SignatureAlgorithm.HS512)
                .setExpiration(validity)
                .compact();
    }

    public Authentication getAuthentication(String token) {
        Claims claims = Jwts
                .parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();

        // 서버에서 사용하는 Principal 객체에는 member의 pk 값만 저장해서 사용한다.
        MemberDetails principal = new MemberDetails(memberRepository.findById(Long.parseLong(claims.getSubject())).get());

        return new UsernamePasswordAuthenticationToken(principal, token, principal.getAuthorities());
    }


    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.debug("잘못된 JWT 서명입니다.");
            throw new ApplicationException(ErrorCode.INVALID_TOKEN);
        } catch (ExpiredJwtException e) {
            log.debug("만료된 JWT 토큰입니다.");
            throw new ExpiredTokenException("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.debug("지원되지 않는 JWT 토큰입니다.");
            throw new ApplicationException(ErrorCode.INVALID_TOKEN);
        } catch (IllegalArgumentException e) {
            log.debug("JWT 토큰이 잘못되었습니다.");
            throw new ApplicationException(ErrorCode.INVALID_TOKEN);
        } catch (Exception e) {
            return false;
        }
    }
}

TokenProvider 클래스는 토큰 생성, 토큰 유효성 검사, 토큰을 이용하여 Authentication 객체 생성의 역할을 맡고 있습니다.

그 중 getAuthentication 메서드를 살펴보도록 하겠습니다.

이 메서드는 토큰 정보를 파라미터로 받아 복호화하여 토큰 내부에 저장된 사용자 정보(pk)를 바탕으로 UsernamePasswordAuthenticationToken 객체를 생성하여 반환하게 됩니다.

UsernamePasswordAuthenticationToken 클래스는 Authentication 인터페이스의 자식이며 내부에 사용자 객체(UserDetails 혹은 Principal 인터페이스의 자식들)을 저장합니다.

저희 프로젝트에서는 UserDetails를 상속받아 만든 MemberDetails를 사용자 객체로 넣어주었습니다.

MemberDetails

  public class MemberDetails implements UserDetails {
    private Member member;

    public Member getMember() {
        return this.member;
    }

    public MemberDetails(Member member) {
        this.member = member;
    }



    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {

        Role role = member.getRole();

        List<SimpleGrantedAuthority> authorities = new ArrayList<>();

        authorities.add(new SimpleGrantedAuthority(role.getRoleName().name()));

        return authorities;
    }

    @Override
    public String getPassword() {
        return null;
    }

    @Override
    public String getUsername() {
        return member.getId().toString();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

위에서 MemberController에서 Principal 파라미터를 받아 principal.getName()을 이용하여 회원 pk 정보를 받아올 수 있었던 건 JwtFilter에서 토큰 정보를 이용하여 생성한 UsernamePasswordAuthenticationToken 객체를 SpringContext에 저장해두었기 때문입니다.

위에서 Principal은 실제 UsernamePasswordAuthenticationToken객체를 참조하고 있고 principal.getName()를 호출하면 실제로 UsernamePasswordAuthenticationToken.getName()을 호출하게 됩니다.

실제로는 UsernamePasswordAuthenticationToken의 부모인 AbstractAuthenticationToken.getName()을 호출하게 되고

위의 메서드에서 UsernamePasswordAuthenticationToken에 저장된 사용자 객체는 UserDetails의 자식인 MemberDetails이므로 첫번째 조건문에 걸려 MemberDetails.getUsername()의 결과값(사용자 pk)를 반환하게 되기 때문에 컨트롤러에서 편하게 사용자 pk를 뽑아올 수 있었습니다.

권한 필터 설정

회원 권한에 따른 필터 설정은 시큐리티 필터 설정에서 hasAuthority를 이용하여 ADMIN일 시 통과되도록 구현하였습니다.

스프링 시큐리티는 요청한 사용자의 권한 정보를 Authentication 객체 내부에 있는 사용자 객체의 Authority와 비교하여 통과를 결정하게 됩니다.

TokenProvider.getAuthentication 메서드를 통해 토큰 유효성 검사를 정상적으로 마쳤을 시 Authentication 객체 생성를 생성하였고 이때 사용자의 권한을 부여했기 때문에 스프링 시큐리티에서 사용자의 권한을 읽어올 수 있습니다.

profile
TO BE DEVELOPER

0개의 댓글

관련 채용 정보