[Shopping Mall] Spring Security + JWT 로그인

이정민·2023년 12월 7일
1

쇼핑몰 프로젝트

목록 보기
1/5
post-thumbnail
post-custom-banner

프론트 동료들과 어떤 로그인 방식을 사용할지 논의한 결과, jwt 구현 경험이 없어 jwt 방식을 택했습니다.


패키지 구조

주요 로직은 JwtFilter, JwtService, JsonLoginService class


SecurityConfig

SecurityConfig class의 FilterChain 부분입니다. 기존에 사용하던 websecurityconfigureradapter class는 deprecated 되어 필요한 메소드를 @Bean으로 등록해 사용했습니다.

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .cors()
                .and()
                .csrf().disable()
                .headers().frameOptions().disable()
                .and()
                .httpBasic().disable()
                .formLogin().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .antMatchers("/", "/logout", "/login/oauth2/code/**", "/user/signup", "/login",
                        "/swagger-ui/**", "/v3/**", // /v3/api~ : swagger 리소스 url
                        "/product/get/**", "/brand/get/**" // 서비스 기능 )
                .permitAll()
                .anyRequest().authenticated() // denyAll() 옵션을 주면 토큰이 있어도 막아버림
                .and()
                // OAuth2 Login
                .oauth2Login()
                .successHandler(oAuth2LoginSuccessHandler)
                .failureHandler(oAuth2LoginFailureHandler)
                .userInfoEndpoint().userService(customOAuth2UserService);


        httpSecurity.addFilterAfter(customJsonUsernamePasswordAuthenticationFilter(), LogoutFilter.class);
        httpSecurity.addFilterBefore(jwtFilter(), CustomUsernamePasswordAuthenticationFilter.class);

        return httpSecurity.build();
    }

Jwt 인증 과정과 구현

jwt 인증 과정 이미지

  1. 클라이언트는 서버로 로그인 JSON 데이터(id, password) 전송

  2. 서버는 검증 후 클라이언트로 Response(Header: Access Token, Cookie: Refresh Token) 전송

  3. 클라이언트는 서버로 요청을 보낼 때, Access Token을 Request Header에 포함하여 전송
    ※ 요청에 Access Token이 없다면 로그인하지 않은 상태로 간주

  4. 서버는 Access Token 검증(사용자 Email, 만료 기간)
    검증 성공 시: body + status code 반환

    검증 실패 시: 401 status + exception 반환

    4-1. 클라이언트는 Request Header에 Access Token과 Cookie의 Refresh Token을 포함해 재요청

    4-2. 서버는 Refresh Token을 검증해 유효하다면 201 status + Access Token + Refresh Token 재발급

    4-3. 클라이언트는 재발급된 Access Token과 함께 재요청

    4-4. body + status code 반환

  • Q). 상태 코드를 사용하는 이유?

문제 상황

  • 기존 코드는 인증이 실패하면 filter를 거쳐 failureHandler를 통해 302코드를 반환했습니다. 하지만 프론트의 Axios에서는 300번대 상태 코드를 잡아낼 수 없어 access token의 만료 시간이 지나버리면 서버에서는 재로그인을 해야하는 상황이 되고 프론트는 재로그인을 해야 하는 상황이라는 것 자체를 모르게 됩니다.

해결 방법

  • 302 코드는 프론트 Axios에서 잡을 수 없을 뿐더러 인증 실패를 하나의 예외로 처리해버리는 것은 좋지 않다고 생각하여 구체적인 Exception을 구현하고 상태 코드를 활용해 예외 발생 시 filter를 통해 끝까지 보내지 않고 early return으로 Response에 상태 코드를 저장하여 failureHandler에서 예외의 종류와 함께 클라이언트에 전달하도록 했습니다.

구현 코드의 주요 부분

JwtService.java

  • Access Token은 사용자의 email 정보를 포함합니다
  • Token을 만들고 Response에 세팅하고 검증하는 메소드입니다.
    public String createAccessToken(String email) {
        return JWT.create()
                .withSubject(ACCESS_TOKEN)
                .withExpiresAt(Instant.now().plusMillis(accessTokenExpiration))
                .withClaim(CLAIM, email)
                .sign(Algorithm.HMAC512(secretKey));
    }
    
	public String createRefreshToken() {
        return JWT.create()
                .withSubject(REFRESH_TOKEN)
                .withExpiresAt(Instant.now().plusMillis(refreshTokenExpiration))
                .sign(Algorithm.HMAC512(secretKey));
    }
    
    public boolean verifyToken(String token) throws TokenExpiredException {
        try {
            JWT.require(Algorithm.HMAC512(secretKey)).build().verify(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    public Cookie findCookie(HttpServletRequest request) {
        Cookie[] cookies =  request.getCookies();
        if (cookies == null) {
            return null;
        }

        return Arrays.stream(cookies)
                .filter(c -> c.getName().equals(refreshHeader))
                .findAny()
                .orElse(null);
    }

    private void setAccessTokenHeader(HttpServletResponse response, String accessToken) {
        response.setHeader(accessHeader, accessToken);
    }

    private void setRefreshTokenHeader(HttpServletResponse response, String refreshToken) {
        createCookie(response, refreshToken);
    }

    private void createCookie(HttpServletResponse response, String refreshToken) {
        ResponseCookie cookie = ResponseCookie.from(refreshHeader, refreshToken)
                .path("/")
                .secure(true)
                .sameSite("None")
                .httpOnly(true)
                .maxAge(60 * 60 * 24)
                .domain("내 서버 도메인")
                .build();

        response.setHeader("Set-Cookie", cookie.toString());
    }

JsonLoginService.java

Spring Security에게 통행증을 발급 받기 위해 UserDetailsService를 구현했습니다.

  • 아래의 SocialType에 관한 부분은 서버에서 Oauth2 로그인을 통해 회원가입한 사용자와 직접 회원가입한 사용자를 구분하기 위한 로직입니다.
@RequiredArgsConstructor
@Service
@Slf4j
public class JsonLoginService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws EmailNotFoundException {
        var user = userRepository.findByEmail(email)
                .orElseThrow(() -> new EmailNotFoundException(email, ErrorCode.ENTITY_NOT_FOUND));

        if (!user.getSocialType().equals(SocialType.WEB)) {
            throw new EmailTypeSocialException(email, ErrorCode.EMAIL_TYPE_SOCIAL);
        }

        return User.builder()
                .username(user.getEmail())
                .password(user.getPassword())
                .roles(user.getRole().name())
                .build();
    }
}

JwtFilter.java

OncePerRequestFilter를 상속 받아 한 요청에 대해 한 번만 동작합니다.

@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
    private final JwtService jwtService;
    private final UserRepository userRepository;
    private final GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();

    private static final List<String> NO_CHECK_PATHS = Arrays.asList(
            "/login", "/", "/logout",
            "/login/oauth2/code/**",
            "/oauth2/authorization/google",
            "/user/signup", "/swagger-ui/**", "/v3/**"
    );

    private static final List<String> NO_CHECK_SERVICE_PATHS = Arrays.asList(
            "/product/get/**",
            "/brand/get/**"
    );

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException
    {
        String requestURI = request.getRequestURI();
        Optional<String> accessToken = jwtService.getAccessToken(request);

        for (String path : NO_CHECK_PATHS) {
            if (new AntPathMatcher().match(path, requestURI)) {
                filterChain.doFilter(request, response);
                return;
            }
        }

        for (String servicePath : NO_CHECK_SERVICE_PATHS) {
            if (new AntPathMatcher().match(servicePath, requestURI) && accessToken.isEmpty()) {
                filterChain.doFilter(request, response);
                return;
            }
        }

        authenticationAccessToken(request, response, filterChain);
    }



    private void authenticationAccessToken(HttpServletRequest request,
                                           HttpServletResponse response,
                                           FilterChain filterChain) throws ServletException, IOException {
        Optional<String> accessToken = jwtService.getAccessToken(request);

        if (accessToken.isEmpty()) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            log.error("Access token is Null");
            return;
        }

        if (!jwtService.verifyToken(accessToken.get())) {
            String refreshToken = jwtService.findCookie(request).getValue();

            if (refreshToken == null) {
                log.error("Refresh Token is Null");
                return;
            }

            validateAndRenewAccessToken(request, response, refreshToken);
            return;
        }

        jwtService.getEmail(accessToken.get())
                        .flatMap(userRepository::findByEmail)
                                .ifPresent(this::saveAuthentication);

        filterChain.doFilter(request, response);
    }

    private void validateAndRenewAccessToken(HttpServletRequest request, HttpServletResponse response, String refreshToken) {

        userRepository.findByRefreshToken(refreshToken)
                .ifPresent(user -> {
                    String renewRefreshToken = renewRefreshToken(user);
                    log.info("renewRefreshToken: {}", renewRefreshToken);
                    try {
                        jwtService.sendAccessTokenAndRefreshToken(response,
                                jwtService.createAccessToken(user.getEmail()),
                                renewRefreshToken);
                    } catch (IOException e) {
                        throw new RuntimeException(e);
                    }
                    log.info("AccessToken 재발급");
                });

    }

    private String renewRefreshToken(User user) {
        String renewRefreshToken = jwtService.createRefreshToken();
        user.updateRefreshToken(renewRefreshToken);
        userRepository.saveAndFlush(user);

        return renewRefreshToken;
    }

    private void saveAuthentication(User user) {
        String password = user.getPassword();
        if (password == null) {
            password = PasswordUtil.generateRandomPassword();
        }
        UserDetails userDetails = org.springframework.security.core.userdetails.User.builder()
                .username(user.getEmail())
                .password(password)
                .roles(user.getRole().name())
                .build();

        Authentication authentication = new UsernamePasswordAuthenticationToken(
                userDetails, null, authoritiesMapper.mapAuthorities(userDetails.getAuthorities())
        );

        SecurityContextHolder.getContext().setAuthentication(authentication);
    }
}
  • NO_CHECK_PATHS는 Filter를 거치지 않고 무조건 통과시킨다는 의미이고, NO_CHECK_SERVICE_PATHS는 로그인과 로그아웃을 구분해야 하기 때문에 path에 더해 access token이 있는지 없는지에 대한 것으로 검증 로직을 시작합니다.
        for (String path : NO_CHECK_PATHS) {
            if (new AntPathMatcher().match(path, requestURI)) {
                filterChain.doFilter(request, response);
                return;
            }
        }

        for (String servicePath : NO_CHECK_SERVICE_PATHS) {
            if (new AntPathMatcher().match(servicePath, requestURI) && accessToken.isEmpty()) {
                filterChain.doFilter(request, response);
                return;
            }
        }

  • access token이 없다면 통행증 없이 반환 -> 인증 실패
  • access token이 있지만 유효기간 만료 시 -> cookie에 있는 refresh token 검사 -> 있다면 검증 후 access token과 refresh token 재발급, 없다면 인증 실패
private void authenticationAccessToken(HttpServletRequest request,
                                           HttpServletResponse response,
                                           FilterChain filterChain) throws ServletException, IOException {
        Optional<String> accessToken = jwtService.getAccessToken(request);

        if (accessToken.isEmpty()) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            log.error("Access token is Null");
            return;
        }

        if (!jwtService.verifyToken(accessToken.get())) {
            String refreshToken = jwtService.findCookie(request).getValue();

            if (refreshToken == null) {
                log.error("Refresh Token is Null");
                return;
            }

            validateAndRenewAccessToken(request, response, refreshToken);
            return;
        }

        jwtService.getEmail(accessToken.get())
                        .flatMap(userRepository::findByEmail)
                                .ifPresent(this::saveAuthentication);

        filterChain.doFilter(request, response);
    }

  • 모든 검증을 통과한다면 access token에 포함된 email 정보를 통해 DB에서 사용자 데이터를 읽어와 이를 통해 UserDetails를 구현 후 SecurityContextHolder가 관리하는 통행증을 발급합니다.
    private void saveAuthentication(User user) {
        String password = user.getPassword();
        if (password == null) {
            password = PasswordUtil.generateRandomPassword();
        }
        UserDetails userDetails = org.springframework.security.core.userdetails.User.builder()
                .username(user.getEmail())
                .password(password)
                .roles(user.getRole().name())
                .build();

        Authentication authentication = new UsernamePasswordAuthenticationToken(
                userDetails, null, authoritiesMapper.mapAuthorities(userDetails.getAuthorities())
        );

        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

후기

Jwt 방식을 사용하는 이유는 트래픽이 몰렸을 때, Session 방식보다 가볍기에 서버 부담이 덜하고, MSA식 아키텍쳐에서 Session 방식은 클러스터링을 해야하지만 Jwt 방식은 토큰이 정보를 담고 있어 서로 다른 서비스에서 상태를 공유하지 않아도 되는 등의 장점을 가지고 있기 때문이라고 생각합니다.
토큰 보안을 위해 access token을 재발급하면 refresh token도 재발급하는 refresho token rotation 방식을 사용했습니다. 물론 현재의 토큰 검증 방식이 부족한 점이 많지만 이를 다 커버하려 한다면 여러 로직들이 추가되고 이는 결국 Session 방식보다 가볍다는 장점이 사라지는 것이 아닐까 하는 생각을 했습니다.

현재는 DB에 refresh token을 저장하여 계속해서 DB입출력이 일어고 있지만 이후 Redis를 도입하여 refresh token을 캐쉬로 저장하도록 리팩토링 해볼 것입니다.


+ 추가(버그 수정)

문제 상황2

  • 서비스 페이지를 로그인 해두고 며칠이 지나 다시 접속을 했을 때 로그인이 된 상태에서 로그아웃도 되지 않고 모든 서비스가 막혀 있는 인증되지 않은 로그인 된 사용자라는 이상한 상태에 빠졌다.

해결 방법

  • 브라우저에서는 access token이 만료 상태였고 서버 로그를 확인해보니 Cookie의 만료기간을 1일로 설정했었기 때문에 며칠이 지난 상태에서는 Cookie가 사라지고 Cookie를 찾는 과정에서 NullPointException을 발생시키게 되었습니다. 이는 아래와 같이 Optional 타입을 통해 처리했습니다.

JwtService.java

    public Cookie findCookie(HttpServletRequest request) {
        Optional<Cookie[]> settingCookie = Optional.ofNullable(request.getCookies());

        if (settingCookie.isEmpty()) {
            return null;
        }

        Cookie[] cookies = settingCookie.get();

        return Arrays.stream(cookies)
                .filter(c -> c.getName().equals(refreshHeader))
                .findAny()
                .orElse(null);
    }

JwtFilter.java

    private void authenticationAccessToken(HttpServletRequest request,
                                           HttpServletResponse response,
                                           FilterChain filterChain) throws ServletException, IOException {
        Optional<String> accessToken = jwtService.getAccessToken(request);

        if (accessToken.isEmpty()) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            log.error("Access token is null");
            return;
        }

        if (!jwtService.verifyToken(accessToken.get())) {
            Optional<Cookie> cookie = Optional.ofNullable(jwtService.findCookie(request));

            if (cookie.isEmpty()) {
                log.error("Refresh token is null");
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                return;
            }

            String refreshToken = cookie.get().getValue();

            validateAndRenewAccessToken(request, response, refreshToken);
            return;
        }

        jwtService.getEmail(accessToken.get())
                        .flatMap(userRepository::findByEmail)
                                .ifPresent(this::saveAuthentication);

        filterChain.doFilter(request, response);
    }

  • 하지만 근본적인 문제는 인증되지 않은 로그인 된 사용자인 것이고 이는 Cookie의 만료시간을 refresh token과 같이 가져가는 것으로 해결되었습니다.
private void createCookie(HttpServletResponse response, String refreshToken) {
    ResponseCookie cookie = ResponseCookie.from(refreshHeader, refreshToken)
            .path("/")
            .secure(true)
            .sameSite("None")
            .httpOnly(true)
            .maxAge(refreshTokenExpiration)
            .domain("내 도메인")
            .build();

    response.setHeader("Set-Cookie", cookie.toString());
}

전체 코드

https://github.com/SudalKing/Shopping_mall/tree/main

post-custom-banner

0개의 댓글