Spring

[Spring] JWT AccessToken/RefreshToken ๊ตฌํ˜„ (with. Redis)

DAHLIA CHOI 2024. 3. 12. 04:08

 

 

ํ”„๋กœ์ ํŠธ์—์„œ ๊ฐ„๋‹จํžˆ jwt๋ฅผ ์ด์šฉํ•œ ๋กœ๊ทธ์ธ์„ ๊ตฌํ˜„ํ•ด ๋ดค๋‹ค!

 

 

๐ŸŒฑ Spring Security๋ž€?

์ธ์ฆ(Authentication), ๊ถŒํ•œ(Authorize) ๋ถ€์—ฌ ๋ฐ ๋ถ€ํ˜ธ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•˜๋Š” ํ”„๋ ˆ์ž„์›Œํฌ์ด๋‹ค.

 

 

์ธ์ฆ vs ์ธ๊ฐ€

๋ณดํ†ต ์ธ์ฆ ์ ˆ์ฐจ๋ฅผ ๊ฑฐ์นœ ํ›„ ์ธ๊ฐ€ ์ ˆ์ฐจ๋ฅผ ๊ฑฐ์นœ๋‹ค. 

 

์ธ์ฆ

  • ํ˜„์žฌ ์œ ์ €๊ฐ€ ๋ˆ„๊ตฌ์ธ์ง€ ํ™•์ธํ•˜๋Š” ๊ณผ์ • (๋กœ๊ทธ์ธ)
  • ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์˜ ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•  ์ˆ˜ ์žˆ๋Š”์ง€ ์ฆ๋ช…

 

์ธ๊ฐ€

  • ํ˜„์žฌ ์œ ์ €์˜ ๊ถŒํ•œ์„ ๊ฒ€์‚ฌํ•˜๋Š” ๊ณผ์ •
  • ํŽ˜์ด์ง€๋‚˜ ๋ฆฌ์†Œ์Šค ๋“ฑ์— ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ๋Š”์ง€ ๊ฒ€์‚ฌ

 

 

Spring Security ๊ตฌ์กฐ

 

 

 

 

1. Http Request ์ˆ˜์‹ 

์‚ฌ์šฉ์ž๊ฐ€ ๋กœ๊ทธ์ธ ์ •๋ณด์™€ ํ•จ๊ป˜ ์ธ์ฆ ์š”์ฒญ์„ ํ•œ๋‹ค.

 

2. ์œ ์ € ์ž๊ฒฉ ๊ธฐ๋ฐ˜์œผ๋กœ ์ธ์ฆ ํ† ํฐ ์ƒ์„ฑ

AuthenticationFilter๊ฐ€ ์š”์ฒญ์„ ๊ฐ€๋กœ์ฑ„๊ณ , ๊ฐ€๋กœ์ฑˆ ์ •๋ณด๋ฅผ ํ†ตํ•ด UsernamePasswordAuthenticationToken์˜ ์ธ์ฆ์šฉ ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•œ๋‹ค.

 

3. Filter๋ฅผ ํ†ตํ•ด AuthenticationToken์„ AuthenticationManager๋กœ ์œ„์ž„

AuthenticationManager์˜ ๊ตฌํ˜„์ฒด์ธ ProviderManager์—๊ฒŒ ์ƒ์„ฑํ•œ UsernamePasswordToken ๊ฐ์ฒด๋ฅผ ์ „๋‹ฌํ•œ๋‹ค.

 

4. AuthenticationProvider์˜ ๋ชฉ๋ก์œผ๋กœ ์ธ์ฆ ์‹œ๋„

AuthenticationManager๋Š” ๋“ฑ๋ก๋œ AuthenticationProvider๋ฅผ ์กฐํšŒํ•˜๋ฉฐ ์ธ์ฆ์„ ์š”๊ตฌํ•œ๋‹ค.

 

5. UserDetailsService ์š”๊ตฌ

์‹ค์ œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ์‚ฌ์šฉ์ž ์ธ์ฆ ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ค๋Š” UserDetailsService์— ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๋„˜๊ฒจ์ค€๋‹ค.

 

6. UserDetails๋ฅผ ์ด์šฉํ•ด User ๊ฐ์ฒด์— ๋Œ€ํ•œ ์ •๋ณด ํƒ์ƒ‰

๋„˜๊ฒจ๋ฐ›์€ ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ํ†ตํ•ด ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ์ฐพ์•„๋‚ธ ์‚ฌ์šฉ์ž ์ •๋ณด์ธ UserDetails ๊ฐ์ฒด๋ฅผ ๋งŒ๋“ ๋‹ค.

 

7. User ๊ฐ์ฒด์˜ ์ •๋ณด๋“ค์„ UserDetails๊ฐ€ UserDetailsService(LoginService)๋กœ ์ „๋‹ฌ

AuthenticationProvider๋“ค์€ UserDetails๋ฅผ ๋„˜๊ฒจ๋ฐ›๊ณ  ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๋น„๊ตํ•œ๋‹ค.

 

8. ์ธ์ฆ ๊ฐ์ฒด or AuthenticationException

์ธ์ฆ์ด ์™„๋ฃŒ๋˜๋ฉด ๊ถŒํ•œ ๋“ฑ์˜ ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๋‹ด์€ Authentication ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.

 

9. ์ธ์ฆ ์™„๋ฃŒ

๋‹ค์‹œ ์ตœ์ดˆ์˜ AuthenticationFilter์— Authentication ๊ฐ์ฒด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.

 

10. SpringContext์— ์ธ์ฆ ๊ฐ์ฒด ์„ค์ •

Authentication ๊ฐ์ฒด๋ฅผ Security Context์— ์ €์žฅํ•œ๋‹ค.

 

 

 ์ตœ์ข…์ ์œผ๋กœ๋Š” SpringContext์— ์ €์žฅ์ด ๋˜๊ณ , ์—ฌ๊ธฐ์„œ๋Š” ์„ธ์…˜/์ฟ ํ‚ค ๋ฐฉ์‹์„ ์‚ฌ์šฉํ•˜๊ฒŒ ๋œ๋‹ค. 

๊ทผ๋ฐ ์šฐ๋ฆฌ๋Š” jwt๋ฅผ ์ด์šฉํ•  ๊ฒƒ์ด๊ธฐ ๋•Œ๋ฌธ์— ์ถ”๊ฐ€์ ์ธ ์ฝ”๋“œ ์ž‘์—…์ด ํ•„์š”ํ•˜๋‹ค!

 

 

 

โš™๏ธ ์ฝ”๋“œ

1. ํด๋ผ์ด์–ธํŠธ ๋กœ๊ทธ์ธ

2. ์ •๋ณด ๊ฒ€์ฆ ํ›„ access token/ refresh token ๋ฐœ๊ธ‰

3. refresh token์€ redis์— ์ €์žฅ ํ›„ ํด๋ผ์ด์–ธํŠธ์—๊ฒŒ access token, refresh token ์ „๋‹ฌ

4. access token์ด ๋งŒ๋ฃŒ๋˜์—ˆ์œผ๋ฉด refresh token์„ ํ†ตํ•ด access token, refresh token ์žฌ์ƒ์„ฑ

 

 

 

JwtTokenProvider 

  • ์œ ์ € ์ •๋ณด๋กœ jwt access/refresh token ์ƒ์„ฑ ๋ฐ ์žฌ๋ฐœ๊ธ‰
  • ํ† ํฐ์œผ๋กœ๋ถ€ํ„ฐ ์œ ์ € ์ •๋ณด ๋ฐ›์•„์˜ด

JwtFilter

  • request ์•ž๋‹จ์— ๋ถ™์ด๋Š” ํ•„ํ„ฐ๋กœ http request์—์„œ ํ† ํฐ์„ ๋ฐ›์•„์™€ ์ •์ƒ ํ† ํฐ์ผ ๊ฒฝ์šฐ์— securityContext์— ์ €์žฅ

JwtSecurityConfig

  • JwtFilter๋ฅผ Spring Security Filter Chain์— ์ถ”๊ฐ€ํ•˜๊ธฐ ์œ„ํ•œ ์„ค์ •

SecurityConfig

  • ์Šคํ”„๋ง์—์„œ ๊ธฐ๋ณธ์ ์œผ๋กœ ํ•„์š”ํ•œ ์‹œํ๋ฆฌํ‹ฐ ์„ค์ • 
  • jwt ์ ์šฉ ๋ฐ authentication ํ•„์š”ํ•œ API ์ฃผ์†Œ ์„ค์ •

JwtAccessDeniedHandler

  • ์ ‘๊ทผ ๊ถŒํ•œ ์—†์„ ๋•Œ 403 ์—๋Ÿฌ

JwtAuthenticationEntryPoing

  • ์ธ์ฆ ์ •๋ณด ์—†์„ ๋•Œ 401 ์—๋Ÿฌ

CorsConfig

  • ์„œ๋กœ ๋‹ค๋ฅธ ์„œ๋ฒ„ ํ™˜๊ฒฝ์—์„œ ์ž์›์„ ๊ณต์œ ํ•˜๊ธฐ ์œ„ํ•ด ํ•„์š”ํ•œ ์„ค์ •

 

๊ฐœ๋ฐœ ํ™˜๊ฒฝ

  • SpringBoot 3.2.1
  • Java 17
  • Gradle

 

build.gradle ์˜์กด์„ฑ ์ถ”๊ฐ€

    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    implementation 'org.springframework.boot:spring-boot-starter-security'

    // JWT
    implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'

 

 

 

application-security.yml

jwt:
  #HS512 ์•Œ๊ณ ๋ฆฌ์ฆ˜์„ ์‚ฌ์šฉํ•  ๊ฒƒ์ด๊ธฐ ๋•Œ๋ฌธ์— 512bit, ์ฆ‰ 64byte ์ด์ƒ์˜ secret key๋ฅผ ์‚ฌ์šฉํ•ด์•ผ ํ•œ๋‹ค.
  secret: lkasjfljadakldjflkajefijalefjjahfglaiefjkleajdflkdklafjdskljflasjeifjaefjfdasfdagega
  access-token-expire-time: 43200000 # 12์‹œ๊ฐ„
  refresh-token-expire-time: 604800000 # 7์ผ

 

 

RedisConfig

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {

    @Value("${spring.data.redis.port}")
    private int port;

    @Value("${spring.data.redis.host}")
    private String host;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }

    @Bean
    public RedisTemplate<String, String> redisTemplate() {
        // redisTemplate๋ฅผ ๋ฐ›์•„์™€์„œ set, get, delete๋ฅผ ์‚ฌ์šฉ
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
        // setKeySerializer, setValueSerializer ์„ค์ •
        // redis-cli์„ ํ†ตํ•ด ์ง์ ‘ ๋ฐ์ดํ„ฐ๋ฅผ ์กฐํšŒ ์‹œ ์•Œ์•„๋ณผ ์ˆ˜ ์—†๋Š” ํ˜•ํƒœ๋กœ ์ถœ๋ ฅ๋˜๋Š” ๊ฒƒ์„ ๋ฐฉ์ง€
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setConnectionFactory(redisConnectionFactory());

        return redisTemplate;
    }

}

 

 

JwtTokenProvider

import com.study.footprint.common.exception.CommonBadRequestException;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;


@Slf4j
@Component
public class JwtTokenProvider {

    private static final String AUTHORITIES_KEY = "auth";
    private static final String BEARER_TYPE = "Bearer";

    @Value("${jwt.access-token-expire-time}")
    private long accessExpirationTime;

    @Value("${jwt.refresh-token-expire-time}")
    private long refreshExpirationTime;

    private final Key key;
    private final RedisTemplate<String, String> redisTemplate;


    public JwtTokenProvider(@Value("${jwt.secret}") String secretKey, RedisTemplate<String, String> redisTemplate) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
        this.redisTemplate = redisTemplate;
    }

    // Access Token ์ƒ์„ฑ
    public String createAccessToken(Authentication authentication) {
        // ๊ถŒํ•œ๋“ค ๊ฐ€์ ธ์˜ค๊ธฐ
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

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

        // Access Token ์ƒ์„ฑ
        Date accessTokenExpiresIn = new Date(now + accessExpirationTime);
        return Jwts.builder()
                .setSubject(authentication.getName())       // payload "sub": "name"
                .claim(AUTHORITIES_KEY, authorities)        // payload "auth": "ROLE_USER"
                .setExpiration(accessTokenExpiresIn)        // payload "exp": 151621022 (ex)
                .signWith(key, SignatureAlgorithm.HS512)    // header "alg": "HS512"
                .compact();
    }

    // Refresh Token ์ƒ์„ฑ
    public String createRefreshToken(Authentication authentication) {

        long now = (new Date()).getTime();
        String refreshToken = Jwts.builder()
                .setExpiration(new Date((new Date()).getTime() + refreshExpirationTime))
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();

        //redis ์ €์žฅ
        redisTemplate.opsForValue().set(
                authentication.getName(),
                refreshToken,
                refreshExpirationTime,
                TimeUnit.MICROSECONDS
        );

        return refreshToken;
    }

    public Authentication getAuthentication(String accessToken) {
        // ํ† ํฐ ๋ณตํ˜ธํ™”
        Claims claims = parseClaims(accessToken);

        if (claims.get(AUTHORITIES_KEY) == null) {
            throw new CommonBadRequestException("invalidToken");
        }

        // ํด๋ ˆ์ž„์—์„œ ๊ถŒํ•œ ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ
        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 (ExpiredJwtException e) {
            log.info("๋งŒ๋ฃŒ๋œ JWT ํ† ํฐ์ž…๋‹ˆ๋‹ค.");
            throw new CommonBadRequestException("expiredToken");

        } catch (Exception e) {
            log.info("์œ ํšจํ•˜์ง€ ์•Š์€ JWT ํ† ํฐ์ž…๋‹ˆ๋‹ค.");
            throw new CommonBadRequestException("invalidToken");
        }
    }

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

 

createAccessToken, createRefreshToken

  • ์œ ์ € ์ •๋ณด๋ฅผ ๋„˜๊ฒจ๋ฐ›์•„์„œ ํ† ํฐ์„ ์ƒ์„ฑํ•œ๋‹ค.
  • ๋„˜๊ฒจ๋ฐ›์€ authentication์˜ getName() ๋ฉ”์„œ๋“œ๋ฅผ ํ†ตํ•ด username์„ ๊ฐ€์ ธ์˜จ๋‹ค. 
  • ๋งŒ๋ฃŒ์‹œ๊ฐ„์„ ์„ค์ •ํ•œ๋‹ค.

 

getAuthentication

  • ํ† ํฐ์„ ๋ณตํ˜ธํ™”ํ•˜์—ฌ ํ† ํฐ์— ๋“ค์–ด์žˆ๋Š” ์œ ์ € ์ •๋ณด๋ฅผ ๊บผ๋‚ธ ํ›„ authentication ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.

validateToken

  • ํ† ํฐ ์ •๋ณด๋ฅผ ๊ฒ€์ฆํ•œ๋‹ค.

 

 

JwtFilter

import com.study.footprint.common.exception.CommonServerException;
import com.study.footprint.config.auth.JwtTokenProvider;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.data.redis.RedisConnectionFailureException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

public class JwtFilter extends OncePerRequestFilter {

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

    private final JwtTokenProvider jwtTokenProvider;

    public JwtFilter(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    // ์‹ค์ œ ํ•„ํ„ฐ๋ง ๋กœ์ง์€ doFilterInternal ์— ๋“ค์–ด๊ฐ
    // JWT ํ† ํฐ์˜ ์ธ์ฆ ์ •๋ณด๋ฅผ ํ˜„์žฌ ์“ฐ๋ ˆ๋“œ์˜ SecurityContext ์— ์ €์žฅํ•˜๋Š” ์—ญํ•  ์ˆ˜ํ–‰
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {

        //Request Header ์—์„œ ํ† ํฐ์„ ๊บผ๋ƒ„
        String jwt = resolveToken(request);

        try {
            if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) {
                Authentication authentication = jwtTokenProvider.getAuthentication(jwt);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (RedisConnectionFailureException e) {
            SecurityContextHolder.clearContext();
            throw new CommonServerException("redisError");
        } catch (Exception e) {
            SecurityContextHolder.clearContext();
            throw new CommonServerException("invalidToken");
        }

        filterChain.doFilter(request, response);
    }

    // Request Header ์—์„œ ํ† ํฐ ์ •๋ณด๋ฅผ ๊บผ๋‚ด์˜ค๊ธฐ
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.split(" ")[1].trim();
        }
        return null;
    }
}

 

OncePerRequestFilter

  • ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ๊ตฌํ˜„ํ•˜๊ธฐ ๋•Œ๋ฌธ์— ์š”์ฒญ๋ฐ›์„ ๋•Œ ๋‹จ ํ•œ ๋ฒˆ๋งŒ ์‹คํ–‰๋œ๋‹ค.

doFilterInternal

  • ์‹ค์ œ ํ•„ํ„ฐ๋ง ๋กœ์ง์„ ์ˆ˜ํ–‰ํ•œ๋‹ค.
  • Request Header์—์„œ Access Token์„ ๊บผ๋‚ด๊ณ  ์œ ์ € ์ •๋ณด๋ฅผ ๊บผ๋‚ด SecurityContext์— ์ €์žฅํ•œ๋‹ค.
  • SecurityConfig์—์„œ ๊ถŒํ•œ ์ฒดํฌ๊ฐ€ ํ•„์š”ํ•˜์ง€ ์•Š๋‹ค๊ณ  ์ •์˜ํ•œ ๊ฒƒ ์ด์™ธ์˜ ๋ชจ๋“  ์š”์ฒญ์€ ์ด ํ•„ํ„ฐ๋ฅผ ๊ฑฐ์น˜๊ธฐ ๋•Œ๋ฌธ์— ํ† ํฐ ์ •๋ณด๊ฐ€ ์—†๊ฑฐ๋‚˜ ์œ ํšจํ•˜์ง€ ์•Š๋‹ค๋ฉด ์ •์ƒ์ ์œผ๋กœ ์ˆ˜ํ–‰๋˜์ง€ ์•Š๋Š”๋‹ค.

resolveToken

  • Request Header์—์„œ ํ† ํฐ ์ •๋ณด๋ฅผ ๊บผ๋‚ด์˜ค๊ธฐ ์œ„ํ•œ ๋ฉ”์„œ๋“œ์ด๋‹ค.

 

JwtSecurityConfig

import com.study.footprint.config.auth.filter.JwtFilter;
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

// ์ง์ ‘ ๋งŒ๋“  TokenProvider ์™€ JwtFilter ๋ฅผ SecurityConfig ์— ์ ์šฉํ•  ๋•Œ ์‚ฌ์šฉ
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    private final JwtTokenProvider jwtTokenProvider;

    public JwtSecurityConfig(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    // TokenProvider ๋ฅผ ์ฃผ์ž…๋ฐ›์•„์„œ JwtFilter ๋ฅผ ํ†ตํ•ด Security ๋กœ์ง์— ํ•„ํ„ฐ๋ฅผ ๋“ฑ๋ก
    @Override
    public void configure(HttpSecurity http) {
        JwtFilter customFilter = new JwtFilter(jwtTokenProvider);
        http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
    }
}
  • SecurityConfigurerAdapter <DefaultSecurityFilterChain, HttpSecurity> ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ๊ตฌํ˜„ํ•˜๋Š” ๊ตฌํ˜„์ฒด์ด๋‹ค.
  • ์ง์ ‘ ๋งŒ๋“  JwtFilter๋ฅผ Security Filter ์•ž์— ์ถ”๊ฐ€ํ•œ๋‹ค.

 

JwtAuthenticationEntryPoint

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence (HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {
        // ์œ ํšจํ•œ ์ž๊ฒฉ์ฆ๋ช…์„ ์ œ๊ณตํ•˜์ง€ ์•Š๊ณ  ์ ‘๊ทผํ•˜๋ ค ํ• ๋•Œ 401
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}
  • ์‚ฌ์šฉ์ž ์ •๋ณด๊ฐ€ ์ž˜๋ชป๋˜๊ฑฐ๋‚˜, ํ† ํฐ์ด ์œ ํšจํ•˜์ง€ ์•Š์€ ๊ฒฝ์šฐ 401 ์—๋Ÿฌ๋ฅผ ๋ฐœ์ƒ์‹œํ‚จ๋‹ค.

 

JwtAccessDeniedHandler

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle (HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException, ServletException {
        // ํ•„์š”ํ•œ ๊ถŒํ•œ์ด ์—†์ด ์ ‘๊ทผํ•˜๋ ค ํ• ๋•Œ 403
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
}
  • ํ•„์š”ํ•œ ๊ถŒํ•œ์ด ์กด์žฌํ•˜์ง€ ์•Š์€ ๊ฒฝ์šฐ 403 ์—๋Ÿฌ๋ฅผ ๋ฐœ์ƒ์‹œํ‚จ๋‹ค.

 

CorsConfig

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

@Configuration
public class CorsConfig {
    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true); //๋‚ด ์„œ๋ฒ„๊ฐ€ ์‘๋‹ตํ•  ๋•Œ json ์ž๋ฐ” ์Šคํฌ๋ฆฝํŠธ ํ—ˆ์šฉ
        config.addAllowedOrigin("*"); //ํฌํŠธ๋ฒˆํ˜ธ ์‘๋‹ต ๋‹ค๋ฆ„ ํ—ˆ์šฉ
        config.addAllowedHeader("*"); //ํ—ค๋” ๊ฐ’ ์‘๋‹ต ํ—ˆ์šฉ
        config.addAllowedMethod("*"); //๋ฉ”์„œ๋“œ ์‘๋‹ต ํ—ˆ์šฉ

        source.registerCorsConfiguration("/**", config); //๋ชจ๋“  url์— ๋Œ€ํ•˜์—ฌ ์œ„ ๋“ค์„ ์ ์šฉ์‹œํ‚ค๊ฒ ๋‹ค.
        return new CorsFilter(source);
    }
}
  • ํ”„๋ก ํŠธ์—”๋“œ, ๋ฐฑ์—”๋“œ๋ฅผ ๊ตฌ๋ถ„์ง€์–ด์„œ ๊ฐœ๋ฐœํ•˜๊ฑฐ๋‚˜ ์„œ๋กœ ๋‹ค๋ฅธ ์„œ๋ฒ„ ํ™˜๊ฒฝ์—์„œ ์ž์›์„ ๊ณต์œ ํ•  ๋•Œ ์„ค์ •ํ•œ๋‹ค. 
  • Cors ์„ค์ •์ด ์•ˆ ๋˜์–ด์žˆ์œผ๋ฉด ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. 

 

SecurityConfig

import com.study.footprint.config.auth.JwtSecurityConfig;
import com.study.footprint.config.auth.JwtTokenProvider;
import com.study.footprint.config.auth.handler.JwtAccessDeniedHandler;
import com.study.footprint.config.auth.handler.JwtAuthenticationEntryPoint;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.filter.CorsFilter;

@EnableWebSecurity
@EnableMethodSecurity
@Configuration
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
    private final CorsFilter corsFilter;

    public SecurityConfig(JwtTokenProvider jwtTokenProvider, CorsFilter corsFilter,
                          JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
                          JwtAccessDeniedHandler jwtAccessDeniedHandler) {
        this.jwtTokenProvider = jwtTokenProvider;
        this.corsFilter = corsFilter;
        this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
        this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .httpBasic(AbstractHttpConfigurer::disable)
                .csrf(AbstractHttpConfigurer::disable) // token์„ ์‚ฌ์šฉํ•˜๋ฏ€๋กœ csrf ๋ณด์•ˆ์ด ํ•„์š”์—†์œผ๋ฏ€๋กœ disable ์ฒ˜๋ฆฌ
                .formLogin(AbstractHttpConfigurer::disable)

                .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
                .exceptionHandling(config -> config
                        .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                        .accessDeniedHandler(jwtAccessDeniedHandler)
                )

                .headers(header -> header.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable))
                .authorizeHttpRequests(
                        registry -> registry.requestMatchers("/", "v1/join/**", "v1/login/**", "v1/reissue", "/profile", "/css/**","/images/**","/js/**","/h2-console/**","/favicon.ico")
                                .permitAll()
                                .anyRequest()
                                .authenticated()
                )

                // ์„ธ์…˜์„ ์‚ฌ์šฉํ•˜์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์— STATELESS๋กœ ์„ค์ •
                .sessionManagement(sessionManagement ->
                        sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

                // JwtFilter๋ฅผ addFilterBefore๋กœ ๋“ฑ๋กํ–ˆ๋˜ JwtSecurityConfig ํด๋ž˜์Šค๋ฅผ ์ ์šฉ
                .with(new JwtSecurityConfig(jwtTokenProvider), customizer -> {});
    return http.build();
    }
}

 

 

 

SecurityUtil

import com.study.footprint.common.exception.CommonBadRequestException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;

@Slf4j
public class SecurityUtil {

    private SecurityUtil() { }

    // SecurityContext ์— ์œ ์ € ์ •๋ณด๊ฐ€ ์ €์žฅ๋˜๋Š” ์‹œ์ 
    // Request ๊ฐ€ ๋“ค์–ด์˜ฌ ๋•Œ JwtFilter ์˜ doFilter ์—์„œ ์ €์žฅ
    public static Long getCurrentMemberId() {
        final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication == null || authentication.getName() == null) {
            throw new CommonBadRequestException("authenticationInfoNotFound");
        }

        return Long.parseLong(authentication.getName());
    }
}
  • JwtFilter์—์„œ ์„ธํŒ…ํ•œ ์œ ์ € ์ •๋ณด๋ฅผ ๊บผ๋‚ธ๋‹ค.
  • MemberId๋ฅผ ์ €์žฅํ–ˆ๊ธฐ ๋•Œ๋ฌธ์— Long ํƒ€์ž…์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.
  • SecurityContext๋Š” ์‚ฌ์šฉ์ž์˜ ์ •๋ณด๋ฅผ ์ €์žฅํ•œ๋‹ค.

 

CustomUserDetailsService

import com.study.footprint.common.exception.CommonNotFoundException;
import com.study.footprint.domain.member.Member;
import com.study.footprint.domain.member.MemberRepository;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Collections;

@Transactional(readOnly = true)
@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    public CustomUserDetailsService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Override
    @Transactional
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        return memberRepository.findByEmail(username)
                .map(this::createUserDetails)
                .orElseThrow(() -> new CommonNotFoundException("userNotFound"));
    }

    // DB ์— User ๊ฐ’์ด ์กด์žฌํ•œ๋‹ค๋ฉด UserDetails ๊ฐ์ฒด๋กœ ๋งŒ๋“ค์–ด์„œ ๋ฆฌํ„ด
    private UserDetails createUserDetails(Member member) {
        GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(member.getRole().toString());

        return new User(
                String.valueOf(member.getId()),
                member.getPassword(),
                Collections.singleton(grantedAuthority)
        );
    }
}
  • loadUserByUsername ๋ฉ”์„œ๋“œ๋ฅผ ์˜ค๋ฒ„๋ผ์ด๋“œํ•˜๋Š”๋ฐ ์—ฌ๊ธฐ์„œ ๋„˜๊ฒจ๋ฐ›์€ UserDetails๋ž‘ Authentication์˜ ํŒจ์Šค์›Œ๋“œ๋ฅผ ๋น„๊ตํ•˜๊ณ  ๊ฒ€์ฆํ•œ๋‹ค.
  • DB์— User๊ฐ’์ด ์กด์žฌํ•˜๋ฉด UserDetails ๊ฐ์ฒด๋ฅผ ๋งŒ๋“ค์–ด์„œ ๋ฆฌํ„ดํ•œ๋‹ค.

 

AuthService

import com.study.footprint.common.exception.CommonBadRequestException;
import com.study.footprint.common.response.SingleResult;
import com.study.footprint.config.auth.JwtTokenProvider;
import com.study.footprint.dto.member.request.LoginReqDto;
import com.study.footprint.dto.member.request.TokenReqDto;
import com.study.footprint.dto.member.response.LoginResDto;
import com.study.footprint.dto.member.response.TokenResDto;
import com.study.footprint.service.common.ResponseService;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Transactional(readOnly = true)
@Service
public class AuthService {

    private final AuthenticationManagerBuilder authenticationManagerBuilder;
    private final ResponseService responseService;
    private final JwtTokenProvider jwtTokenProvider;
    private final RedisTemplate<String, String> redisTemplate;

    public AuthService(AuthenticationManagerBuilder authenticationManagerBuilder, ResponseService responseService,
                       JwtTokenProvider jwtTokenProvider, RedisTemplate<String, String> redisTemplate) {
        this.authenticationManagerBuilder = authenticationManagerBuilder;
        this.responseService = responseService;
        this.jwtTokenProvider = jwtTokenProvider;
        this.redisTemplate = redisTemplate;
    }


    @Transactional
    public SingleResult<LoginResDto> login(LoginReqDto loginReqDto) {

        // 1. Login ID/PW ๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ AuthenticationToken ์ƒ์„ฑ
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginReqDto.getEmail(), loginReqDto.getPassword());

        // 2. ์‹ค์ œ๋กœ ๊ฒ€์ฆ (์‚ฌ์šฉ์ž ๋น„๋ฐ€๋ฒˆํ˜ธ ์ฒดํฌ) ์ด ์ด๋ฃจ์–ด์ง€๋Š” ๋ถ€๋ถ„
        //    authenticate ๋ฉ”์„œ๋“œ๊ฐ€ ์‹คํ–‰์ด ๋  ๋•Œ CustomUserDetailsService ์—์„œ ๋งŒ๋“ค์—ˆ๋˜ loadUserByUsername ๋ฉ”์„œ๋“œ๊ฐ€ ์‹คํ–‰๋จ
        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

        // 3. ์ธ์ฆ ์ •๋ณด๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ JWT ํ† ํฐ ์ƒ์„ฑ

        LoginResDto loginResDto = LoginResDto.builder()
                .accessToken(jwtTokenProvider.createAccessToken(authentication))
                .refreshToken(jwtTokenProvider.createRefreshToken(authentication))
                .build();

        return responseService.getSingleResult(loginResDto);
    }


    @Transactional
    public SingleResult<TokenResDto> reissue(TokenReqDto tokenReqDto) {

        // Refresh Token ๊ฒ€์ฆ
        jwtTokenProvider.validateToken(tokenReqDto.getRefreshToken());

        // Access Token ์—์„œ Member ID ๊ฐ€์ ธ์˜ค๊ธฐ
        Authentication authentication = jwtTokenProvider.getAuthentication(tokenReqDto.getAccessToken());

        // ์ €์žฅ์†Œ์—์„œ Member ID ๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ Refresh Token ๊ฐ’ ๊ฐ€์ ธ์˜ด
        String refreshToken = redisTemplate.opsForValue().get(authentication.getName());

        // Refresh Token ์ผ์น˜ํ•˜๋Š”์ง€ ๊ฒ€์‚ฌ
        if (!refreshToken.equals(tokenReqDto.getRefreshToken())) {
            throw new CommonBadRequestException("invalidRefreshToken");
        }

        // ์ƒˆ๋กœ์šด ํ† ํฐ ์ƒ์„ฑ
        TokenResDto tokenResDto = TokenResDto.builder()
                .accessToken(jwtTokenProvider.createAccessToken(authentication))
                .refreshToken(jwtTokenProvider.createRefreshToken(authentication))
                .build();

        return responseService.getSingleResult(tokenResDto);
    }
}

 

 

 

 

 

๊ฒฐ๊ณผํ™”๋ฉด

๋กœ๊ทธ์ธ
ํ† ํฐ ์žฌ๋ฐœ๊ธ‰
redis์—์„œ ํ™•์ธํ•œ ํ† ํฐ ์žฌ๋ฐœ๊ธ‰ ๋ฐ ๋ฆฌํ”„๋ ˆ์Šคํ† ํฐ ๊ฐฑ์‹ 

 

 

 

 

reference

https://9keyyyy.tistory.com/48

https://kimtaesoo99.tistory.com/118