Spring Security + JWT + Redis 회원가입/로그인 기능 구현

박채원io·2024년 5월 31일
2
post-thumbnail

이제 본격적으로 로그인/회원가입 기능을 코드를 구현해보려한다.

Task: 유저는 username, email, password를 입력해서 회원가입을 한다.
email, password로 로그인을 해서 토큰을 받고, 로그인 성공 시 username으로 서비스를 이용한다.
• 이 때, 이메일 양식과 중복 확인은 필수이다.
• 기본 권한은 member로 부여하고 추후 추가 기능으로 admin 권한을 부여할 것이다.

추후 계획: 로그아웃, 회원 탈퇴, 관리자는 챌린지 요구사항을 반영 후 도입할 계획이다.

1️⃣ JWT와 시큐리티를 활용한 회원가입 플로우

큰 흐름은 아래와 같다.

• 스프링 시큐리티를 통해 비밀번호를 암호화하여 DB에 저장 및 DB에 저장된 사용자의 계정과 비밀번호로 로그인
• JWT를 사용하여 로그인한 사용자에게 토큰 발급
• 인가된 토큰의 권한에 따라 API 접근 권한 제어

a. 요청 처리 및 관리
서버로 들어오는 모든 요청을 JwtFilter가 처리한다.
스프링 시큐리티JwtFilter 와 연계되어 사용자의 인증 및 권한 관리를 담당한다.
• 인증 및 권한 관리 / 예외 처리(유효성 검사 과정에서의 예외 처리) / 보안 설정(접근 권한) / 필터 설정

b. 토큰 생성 및 유효성 검사
TokenProvider가 토큰 생성, 유효성 검사, 인증 정보 추출을 하고,
• 만약 토큰 유효성을 통과하지 못했다면 예외를 발생시키고, JwtFilter에서 예외를 처리한다.
• 만약 토큰 유효성을 통과했다면, TokenProvider에서 인증 정보를 추출해 시큐리티 컨텍스트에 저장하고 로그인 인증 과정으로 넘어간다.

c. 로그인 인증 과정
SecurityContext에 저장된 인증 정보를 기반으로 사용자의 인증 상태와 권한을 확인하는데,
AuthenticationEntryPoint(인증되지 않은 사용자 401)
AccessDeniedHandler(권한 없는 사용자 403)
이 예외가 발생하지 않는다면 로그인 인증에 성공!


📌 Redis

왜 레디스를 사용했는가?

로그인한 사용자의 요청이 들어올 때마다 토큰을 검증해야 하기 때문에, 레디스를 사용해서 빠르게 검색하고, 토큰이 탈취될 경우를 대비해 도입하였다.

왜 레디스에 refreshToken만 저장하는가?

토큰이 탈취되거나 악용될 경우를 대비해서 서버 측에서 해당 토큰을 무효화할 수 있는 수단을 갖기 위함

보안: accessToken이 탈취당하더라도, refreshToken이 없다면 새로운 accessToken을 발급받을 수 없기 때문에 refreshToken을 안전하게 보관하기 위해 레디스에 저장하였다.

토큰 재발급 관리: accessToken이 만료되었을 때, 서버는 저장해둔 refreshToken과 클라이언트가 보내는 refreshToken을 비교하여 유효성을 검사하고 새로운 accessToken을 발급한다. 이를 위해 refreshToken은 서버에 저장되어 있어야 한다.

그럼 refreshToken이 만료되었을 경우, 레디스에서 어떻게 삭제하는가?

TTL(Time To Live) 설정으로 해결

만약 refreshToken 이 만료된 경우, 사용자는 재로그인을 통해 새로운 accessTokenrefreshToken 을 받게 된다. 이때 새로운 refreshToken 은 레디스에 저장되며, 기존 refreshToken 은 자동으로 만료된다.

이때, 레디스에서 키-값 쌍에 대해 TTL(Time To Live) 설정을 해서 TTL이 만료되면 해당 키-값 쌍은 자동으로 삭제되도록 구현하였다.

적용한 코드

로그아웃과 블랙리스트 처리를 구현할 때 RedisUtil 에 설정을 추가해주겠지만 현재는 로그인해서 발급받은 refreshToken 을 email을 key값으로 하여 저장하는 흐름이기에 생각보다 간단하게 구현이 가능하다.

  • Redis
    @RedisHash(value = "MemberToken", timeToLive = 3600 * 24 * 14)
    @AllArgsConstructor
    @Getter
    @Setter
    public class Redis {
    
        @Id
        private String email;
        private String refreshToken;
    }
  • RedisConfig
    @RequiredArgsConstructor
    @Configuration
    @EnableRedisRepositories
    public class RedisConfig {
    
        private final RedisProperties redisProperties;
    
        @Bean
        public RedisConnectionFactory redisConnectionFactory() {
            RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
            config.setHostName(redisProperties.getHost());
            config.setPort(redisProperties.getPort());
            return new LettuceConnectionFactory(config);
        }
    
        @Bean
        public RedisTemplate<?, ?> redisTemplate() {
            RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
            redisTemplate.setConnectionFactory(redisConnectionFactory());
            redisTemplate.setKeySerializer(new StringRedisSerializer());
            redisTemplate.setValueSerializer(new StringRedisSerializer());
            return redisTemplate;
        }
    }
  • RedisProperties
    @Component
    @Getter
    @PropertySource("application.yml")
    public class RedisProperties {
        @Value("${spring.data.redis.port}")
        private int port;
        @Value("${spring.data.redis.host}")
        private String host;
    }
  • RedisUtil
    @Component
    @RequiredArgsConstructor
    public class RedisUtil {
    
        private final RedisTemplate<String, String> redisTemplate;
        
        public void save(String key, String value) {
            redisTemplate.opsForValue().set(key, value);
        }
    }
    

📌 스프링 시큐리티

JwtFilter 는 사용자 요청을 받아 토큰의 유효성을 검증 및 사용자 정보 추출을 담당하고,
스프링 시큐리티는 이를 바탕으로 인증 및 권한 관리, 예외 처리, 보안 설정 등의 기능을 제공하도록 할 계획이기에 관련 코드를 작성해준다.

스프링 시큐리티에 필요한 설정 | SecurityConfig

• 필터 체인에 세션 방식을 사용하는 것이 아니기 때문에 CSRF 공격 방어 비활성화하고, 시큐리티가 기본적으로 세션 방식을 사용하기 때문에 STATELESS 설정을 해주었다.
• JwtFilter와 연계할 수 있도록 인증/인가 관련 예외 핸들링과 커스텀 컨피그 파일을 필터 체인에 추가해주었다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final TokenProvider tokenProvider;
    private final CorsFilter corsFilter;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;

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

        http
                .csrf(csrf -> csrf.disable())

                .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
                .exceptionHandling(exception -> {
                    exception.accessDeniedHandler(jwtAccessDeniedHandler);
                    exception.authenticationEntryPoint(jwtAuthenticationEntryPoint);
                })

                .sessionManagement(sessionManagement ->
                        sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )

                .authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
                        .requestMatchers("/api/v2/admin/**").hasAuthority("ROLE_ADMIN") // 관리자 페이지 role 추가
                        .requestMatchers("/auth/**") // 로그인, 회원가입은 열어주기
                        .permitAll()
                        .anyRequest().authenticated()
                )
                // JwtFilter 를 addFilterBefore 로 등록했던 JwtSecurityConfig 클래스를 적용
                .with(new JwtSecurityConfig(tokenProvider), customizer -> customizer.getClass());

        return http.build();
    }

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

인증 및 인가 | JwtAccessDeniedHandler & JwtAuthenticationEntryPoint

로그인 인증 과정에서 SecurityContext에 저장된 인증 정보를 기반으로 사용자의 인증 상태와 권한을 확인해야하기 때문에 예외 처리 클래스를 추가했다.

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException {
        // 접근 권한 없을 때 403 에러
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
}
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        // 인증 정보 없을 때 401 에러
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}

서버 환경에서 자원 공유 | CorsConfig

현재는 백엔드 개발에 중점을 두었지만 추후 확장성을 고려해서 cors 설정을 해주었다.
서로 다른 서버 환경에서 자원을 공유할 때 발생할 오류에 대응하기 위함이 목적이다.

@Configuration
public class CorsConfig {

    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");

        source.registerCorsConfiguration("/api/**", config);
        return new CorsFilter(source);
    }
}

JwtSecurityConfig

토큰을 생성하는 TokenProvider 와 JwtFilter를 SecurityConfig에 필터 등록할 때 사용된다.

// 직접 만든 TokenProvider 와 JwtFilter 를 SecurityConfig 에 적용할 때 사용
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    private final TokenProvider tokenProvider;

    @Override
    public void configure(HttpSecurity http) {
        JwtFilter customFilter = new JwtFilter(tokenProvider);
        http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

참고
→ apply 대신 with 사용 코드 (https://www.inflearn.com/questions/1186827/스프링-시큐리티-6-2-버전-이후로-apply-메서드를-이용한-jwtauthenticationfilter-가-등록이-안됩니다?commentId=320431)

사용자 인증 | CustomUserDetailService

Spring Security에서 사용자 인증을 처리하기 위해 사용된다. 즉, 로그인 시 사용자의 정보를 로드하고, 해당 정보를 기반으로 인증을 수행하는 역할을 한다.
로그인 과정에서 이 과정이 없다면 시큐리티가 사용자 정보를 로드할 수 없기에 인증을 할 수 없고 토큰도 생성할 수 없게 된다.

동작 과정

  1. 유저가 로그인을 시도할 때, 이메일을 기반으로 DB에서 유저의 정보를 조회한다.
  2. loadUserByUsername 메서드가 호출되어 DB에서 이메일로 유저를 찾는다.
    a. 해당 유저가 존재하지 않으면 UsernameNotFoundException을 던집니다.
    b. 해당 유저가 존재하면, createUserDetails 메서드를 통해 UserDetails 객체를 생성한다.

CustomUserDetailService

이메일을 key 값으로 가질 것이기 때문에 이메일로 유저를 찾도록 하였다.

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    @Transactional
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        return memberRepository.findByEmail(email)
                .map(this::createUserDetails)
                .orElseThrow(() -> new UsernameNotFoundException(email + " -> 데이터베이스에서 찾을 수 없습니다."));
    }

    // DB에 User 값이 존재한다면 UserDetails 객체로 만들어서 리턴
    private UserDetails createUserDetails(Member member) {
        GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(member.getAuthority().toString());

        return new User(
                member.getEmail(), // 사용자 식별자로 이메일을 사용
                member.getPassword(),
                Collections.singleton(grantedAuthority)
        );
    }
}

📌 JWT 도입

TokenProvider로 JWT 토큰을 만들거나 토큰을 바탕으로 유저 정보를 가져올 수 있다.
JwtFilter는 Request 앞단에 붙일 커스텀 필터로, 인증 처리를 담당한다.

토큰 생성 및 유효성 검사 | TokenProvider

유저의 인증 정보를 바탕으로 JWT 토큰을 생성하고, 이를 검증하는 역할을 한다.
즉, JWT 토큰 관련된 암호화, 복호화, 검증 로직이 모두 이 곳에서 이루어진다.

각 메소드를 좀 더 보면

토큰 생성 | generateTokenDto
• 인증된 사용자의 정보를 받아서 accessTokenrefreshToken 을 생성한다.
• 생성된 토큰들은 TokenDto 객체에 담겨 반환되고 유저는 유효한 토큰을 얻게 된다.

재발급 | reissueAccessToken
• 기존 refreshToken 을 검증하고 유효한 경우 새로운 accessTokenrefreshToken 을 생성한다. → 이 때 accessToken 으로 토큰을 재발급하려는 시도가 보이면 예외를 던진다.
• 만약 refreshToken 이 만료되면 함께 생성하여 TokenDto 객체에 담겨 반환된다.

인증 객체 생성 | getAuthentication
• 스프링 시큐리티에서 사용할 수 있는 인증 객체를 생성하는 메소드이다.
• UserDetails 객체를 생성해서 UsernamePasswordAuthenticationToken 형태로 리턴하는데 SecurityContext를 사용하기 위한 절차이다.
accessToken 을 통해 해당 토큰에 담긴 사용자 인증 정보를 Authentication 객체로 반환한다.

토큰 검증 | validateToken
• 토큰의 유효성을 검증한다.

@Slf4j
@Component
public class TokenProvider {

    private static final String AUTHORITIES_KEY = "auth";
    private static final String BEARER_TYPE = "Bearer";
    private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30;            // 30분
    private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7;  // 7일
    private final Key key;
    private final RedisUtil redisUtil;

    public TokenProvider(@Value("${custom.jwt.secret}") String secretKey, RedisUtil redisUtil) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
        this.redisUtil = redisUtil;
    }

    public TokenDto generateTokenDto(Authentication authentication) {
        // 권한들 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        String accessToken = generateAccessToken(authentication.getName(), authorities);
        String refreshToken = generateRefreshToken(authentication.getName(), authorities);

        long now = (new Date()).getTime();

        return TokenDto.builder()
                .grantType(BEARER_TYPE)
                .accessToken(accessToken)
                .accessTokenExpiresIn(new Date(now + ACCESS_TOKEN_EXPIRE_TIME).getTime())
                .refreshToken(refreshToken)
                .build();
    }

    public TokenDto reissueAccessToken(String refreshToken) {

        // 리프레시 토큰에서 사용자 정보 추출 -> 클레임 확인
        Claims claims = parseClaims(refreshToken);

        // Refresh Token 검증 및 클레임에서 Refresh Token 여부 확인
        if (!validateToken(refreshToken) || claims.get("isRefreshToken") == null || !Boolean.TRUE.equals(claims.get("isRefreshToken"))) {
            throw new InvalidTokenException("유효하지 않은 리프레시 토큰입니다.");
        }

        String email = claims.getSubject();
        String authorities = claims.get(AUTHORITIES_KEY).toString();

        String newAccessToken = generateAccessToken(email, authorities);
        String newRefreshToken = generateRefreshToken(email, authorities);

        redisUtil.save(email, newRefreshToken);

        return TokenDto.builder()
                .grantType(BEARER_TYPE)
                .accessToken(newAccessToken)
                .accessTokenExpiresIn(new Date((new Date()).getTime() + ACCESS_TOKEN_EXPIRE_TIME).getTime())
                .refreshToken(newRefreshToken)
                .build();
    }

    private String generateAccessToken(String email, String authorities) {
        long now = (new Date()).getTime();
        Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
        return Jwts.builder()
                .setSubject(email)
                .claim(AUTHORITIES_KEY, authorities)
                .setExpiration(accessTokenExpiresIn)
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();
    }

    private String generateRefreshToken(String email, String authorities) {
        long now = (new Date()).getTime();
        return Jwts.builder()
                .setSubject(email)
                .claim(AUTHORITIES_KEY, authorities)
                .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
                .claim("isRefreshToken", true) // refreshToken 임을 나타내는 클레임 추가
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();
    }

    public Authentication getAuthentication(String accessToken) {
        // 토큰 복호화
        Claims claims = parseClaims(accessToken);

        if (claims.get(AUTHORITIES_KEY) == null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }

        // 클레임에서 권한 정보 가져오기
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        // UserDetails 객체를 만들어서 Authentication 리턴
        UserDetails principal = new User(claims.getSubject(), "", authorities);

        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

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

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}

인증 처리 | JwtFilter

JwtFilter 필터는 Spring Security를 활용하여 JWT 토큰을 검증하고, 유효한 토큰의 경우 사용자의 인증 정보를 SecurityContext에 저장하는 역할을 한다. 이를 통해 인증된 사용자의 요청만 처리할 수 있도록 한다.

OncePerRequestFilter를 상속받은 이유

이 필터가 요청당 한 번씩만 실행되도록 하기 위해서이다.
doFilterInternal 메서드가 실제 필터링 로직을 구현하는 곳으로, 요청이 들어올 때 마다 실행된다.

필터링 과정

  1. 요청 헤더에서  'Authorization' 필드를 찾아, 그 값이 'Bearer '로 시작하는 경우, 토큰을 추출한다.
  2. 추출한 토큰이 유효한지 tokenProvider를 통해 검사하고, 유효하면 Authentication 객체를 생성한다.
  3. Authentication 객체를 SecurityContextHolder의 SecurityContext에 저장하여, 다음 필터나 요청 처리 과정에서 현재 사용자가 인증되었음을 알린다.
  4. filterChain.doFilter를 호출하여 요청 및 응답을 다음 필터로 넘기거나 리소스에 도달한다.
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String BEARER_PREFIX = "Bearer ";

    private final TokenProvider tokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws IOException, ServletException {

        // 1. Request Header 에서 토큰을 꺼냄
        String jwt = resolveToken(request);

        // 2. validateToken 으로 토큰 유효성 검사
        if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
            Authentication authentication = tokenProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(7);
        }
        return null;
    }

}

📌 서비스 로직

이렇게 시큐리티를 통한 인증과 JWT 토큰을 통한 인가 그리고, refreshToken을 저장하는 레디스를 활용해서 서비스 로직은 어떻게 구현할 수 있을까

회원 가입

회원 가입 로직은 간단하지만, 한 가지 이메일 중복 체크 로직에서 고민이 되었던 부분을 정리해보았다.

이 때 existsByEmail로 이메일이 존재하는지 여부만 확인하는 것이 추후 유지보수성이 떨어지지 않을까? 라는 생각에 findByEmail해서 orElseThrow로 커스텀 예외를 던지려고 하였다. 그러나 현재 서비스 단계에서 수정할 여부가 없을 것으로 판단하여 굳이 엔티티 조회를 하는 것이 아닌, boolean 으로 체크하도록 하였다.

    @Transactional
    public void signup(SignUpDto signUpDto) {
        if (memberRepository.existsByEmail(signUpDto.getEmail())) {
            throw new MultipleLoginException("이미 가입되어 있는 유저입니다");
        }

        Member member = Member.createMember(signUpDto, passwordEncoder);
        memberRepository.save(member);
    }

로그인

  1. AuthenticationToken을 생성해주고,
  2. CustomUserDetailsService의 loadUserByUsername가 실행되면서 검증이 이루어진다.
  3. 인증 정보를 기반으로 JWT 토큰을 생성(generateTokenDto) 해주고,
  4. 레디스에 refreshToken을 저장한다.
    @Transactional
    public TokenDto signIn(SignInDto signInDto) {
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(signInDto.getEmail(), signInDto.getPassword());

        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
        TokenDto tokenDto = tokenProvider.generateTokenDto(authentication);

        // Redis에 리프레시 토큰 저장
        redisUtil.save(signInDto.getEmail(), tokenDto.getRefreshToken());

        return tokenDto;
    }

토큰 재발급

  1. 클라이언트로부터 받은 refreshToken 의 유효성을 검사한다.
    a. null이거나 “Bearer”로 시작하지 않으면, InvalidRefreshTokenException 예외를 던진다.
    b. 이를 통과하면 “Bearer”를 제거하고 실제 토큰 값을 반환한다.
    @Transactional(readOnly = true)
    public String resolveRefreshToken(String refreshToken) {
        if (refreshToken == null || !refreshToken.startsWith("Bearer ")) {
            throw new InvalidRefreshTokenException("리프레시 토큰이 누락되었거나 올바르지 않습니다.");
        }
        return refreshToken.substring(7);
    }

🚀 결과

회원가입

로그인

토큰재발급


이렇게 스프링 시큐리티와 JWT, Redis를 사용해서 회원가입/로그인/토큰 재발급 API를 구현해보았다.

다음 포스트에서 챌린지 요구사항을 구현해 볼 것이다.

0개의 댓글