스프링부트/Security

[Spring]JWT 로그인 - 1. 구현 절차

삼록이 2025. 7. 9. 16:24

로그인 방식에는 쿠키인증, 세션인증, 토큰인증이 있다.

여기서는 토큰인증 중에 JWT 로그인 방식을 구현하는 법에 대해 다루고자 한다.

*각각의 인증방식에 대한 정확한 개념과 원리에 대해서는 아래 블로그 추천

https://inpa.tistory.com/entry/WEB-%F0%9F%93%9A-JWTjson-web-token-%EB%9E%80-%F0%9F%92%AF-%EC%A0%95%EB%A6%AC

 

🌐 JWT 토큰 인증 이란? (쿠키 vs 세션 vs 토큰)

Cookie / Session / Token 인증 방식 종류 보통 서버가 클라이언트 인증을 확인하는 방식은 대표적으로 쿠키, 세션, 토큰 3가지 방식이 있다. JWT를 배우기 앞서 우선 쿠키와 세션의 통신 방식을 복습해

inpa.tistory.com

 

JWT 토큰 로그인의 진행방식에 대해 간단하게 말하자면 아래와 같다.

1.사용자가 아이디와 패스워드를 입력해 로그인에 성공하면 서버는 JWT토큰을 발급한다.
이때, 서버는 AccessToken과 RefreshToken이라는 두 가지 종류의 토큰을 만들어 사용자에게 발급하고 이 중 RefreshToken은 따로 서버쪽 DB에 저장시킨다.

2.이후 사용자는 모든 HTTP요청을 보낼때 헤더에  AccessToken을 담아 서버로 보낸다.

3.서버가 해당 요청을 받으면 JWT 필터가 먼저 AccessToken을 내 서버가 발행했던 토큰인지 아니면 위조된건 아닌지 검증한다.

4.검증에 통과하면 해당 요청을 통과하여 처리한다.

+만약 AccessToken의 유효시간이 만료되었다면, 사용자는 RefreshToken을 서버로 보낸다.

+서버는 DB에서 해당 RefreshToken을 찾아 내가 발행했던 정상적인 RefreshToken인 걸 확인하면 AccessToken을 새로 발행해준다

 

그렇다면 위와 같은 절차를 진행하기 위해서 백엔드에서는 어떤 작업을 하면 될까?

1.JWT토큰을 만들고 인증절차에 필요한 라이브러리를 추가한다.

2.JWT 토큰을 발급하는 클래스(JwtTokenProvider)를 만든다.  JWT토큰은 헤더 / 페이로드 / 시그니처 세 구조로 이루어진다고 했다. 따라서,여기 클래스에서 이 세 구조를 정의하고 AccessToken과 RefreshToken을 만드는 메서드를 구현한다.

3.우리는 RefreshToken을 발급할 때 레디스에 저장할 것 이다. 그래서 레디스 라이브러리를 추가한다.

*레디스를 사용해면 RDB보다 훨씬 빠른 속도로 토큰이 조회가 되고 토큰 만료 시간과 함께 자동 삭제가 가능하다

4.레디스의 연결객체인 RedisTemplate 객체를 만든다.

5.로그인 API를 구현한다.

6.이제 사용자가 로그인을 하면 JWT토큰을 발행하는 로직을 구현했으므로 이제 사용자의 토큰을 검증하는 JWT필터를 만들어야한다.  그 전에 우선 Spring Security 의존성을 추가한다. 그러면 자동으로 UsernamePasswordAuthentication 필터가 생긴다.

*이 Spring Security가 있어야  로그인 인증(Authentication)과 권한에 대한 인가 구조가 제공된다. 또한 추가적인 보안설정이 가능함

7.JWT필터를 만든다.

8.그 구조의 UsernamePasswordAuthentication필터 앞에 우리가 직접 만들 JWT 필터를 추가한다.


1.JWT토큰을 만들고 인증절차에 필요한 라이브러리를 추가한다.

// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
// JWT(1-인터페이스 밎 API정의 //2-실제구현체 //3-JSON serializer(claim을 위해 필요)
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
//Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

 

2.JWT 토큰을 발급하는 클래스(JwtTokenProvider)를 만든다. 

2-1.JWT토큰을 만들기 위해서는 먼저 본인만의 SecretKey가 필요하다.

아래 사이트에서 아무렇게나 긴~ 문자열을 입력하고 인코딩된 값을 만든다.

https://www.base64encode.org/ko/

 

Base64 인코딩 및 디코딩 - 온라인

Base64 형식으로 인코딩해보세요. 아니면 다양한 고급 옵션으로 디코딩해보세요. 저희 사이트에는 데이터 변환하기에 사용하기 쉬운 온라인 도구가 있습니다.

www.base64encode.org

그리고 .yml에 아래와 같이 추가한다. 편의상 accessToken에 쓰일 secretKey와 refreshToken에 쓰일 secretKeyRt를 같게 하였으나 반드시 달라야한다!

jwt:
# thisissecretkeyforlettinyouknowhowtowritejwtlogincodeihopethiswouldbehelfful
  secretKey: dGhpc2lzc2VjcmV0a2V5Zm9ybGV0dGlueW91a25vd2hvd3Rvd3JpdGVqd3Rsb2dpbmNvZGVpaG9wZXRoaXN3b3VsZGJlaGVsZmZ1bA==
  expiration: 3000
  secretKeyRt: dGhpc2lzc2VjcmV0a2V5Zm9ybGV0dGlueW91a25vd2hvd3Rvd3JpdGVqd3Rsb2dpbmNvZGVpaG9wZXRoaXN3b3VsZGJlaGVsZmZ1bA==
#14일(2주)*24시간*60분
  expirationRt: 20160

 

2-2.JwtTokenProvider 클래스를 만든다. 우리가 .yml에 설정해놓은 secretKey, secretKeyRT값을 Value어노테이션으로 가지고 온다. 그리고 JwtTokenProvider를 빈객체로 설정하기 위해 Component어노테이션을 클래스에다 붙였다. 그러면 스프링 실행시 스프링 컨테이너가 자동으로 JwtTokenProvider 객체를 싱글톤으로 생성할 것이고. 이때 우리가 정의해놓은 생성자를 통해 객체를 생성한다. 따라서 아래와 같이 생성자의 매개변수로 해놓으면 해당 값을 가진 JwtTokenProvider객체가 싱글톤으로 생성된다.

@Component
public class JwtTokenProvider {
    private final String secretKey;
    private final int expiration;
    private final Key SECRET_KEY;
    private final String secretKeyRt;
    private final int expirationRt;
    private final Key SECRET_KEY_RT;

    public JwtTokenProvider(@Value("${jwt.secretKey}") String secretKey, @Value("${jwt.expiration}") int expiration,
                            @Value("${jwt.expirationRt}")int expirationRt, @Value("${jwt.secretKeyRt}")String secretKeyRt){
        this.secretKey=secretKey;
        this.expiration=expiration;
        this.SECRET_KEY = new SecretKeySpec(Base64.getDecoder().decode(secretKey), SignatureAlgorithm.HS512.getJcaName());
        //SecretKeySpec은 SecretKey객체를 생성하는 클래스.
        this.secretKeyRt =secretKeyRt;
        this.expirationRt =expirationRt;
        this.SECRET_KEY_RT = new SecretKeySpec(Base64.getDecoder().decode(secretKeyRt), SignatureAlgorithm.HS512.getJcaName());
    }
//매개변수에 List<String>은 나의 프로그램 로직에 따라 추가한 것으로 일반적으론 String role을 사용한다
    public String createToken(String loginId, List<String> roles){
        Claims claims = Jwts.claims().setSubject(loginId);
        claims.put("roles",roles);
        Date now = new Date();
        String token = Jwts.builder()
                .setClaims(claims) //페이로드에 담기는 부분
                .setIssuedAt(now) //페이로드에 담기는 부분
                .setExpiration(new Date(now.getTime()+expiration*60*1000L)) //약 50시간 ,페이로드에 담기는 부분
                .signWith(SECRET_KEY) //시그니처에 담기는 부분
                .compact();
        return token;
       //헤더는 SecretKeySpec에 쓰인 알고리즘명으로 자동 구성된다 
    }
//매개변수에 List<String>은 나의 프로그램 로직에 따라 추가한 것으로 일반적으론 String role을 사용한다
    public String createRefreshToken(String loginId,List<String> roles){
        Claims claims = Jwts.claims().setSubject(loginId);
        claims.put("roles",roles);
        Date now = new Date();
        String refreshToken = Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime()+expirationRt*60*1000L)) //20일
                .signWith(SECRET_KEY_RT)
                .compact();
        return refreshToken;
    }
}

 

3.레디스 라이브러리를 추가한다.

1번에서 레디스 라이브러리를 추가했다. 추가한 후 .yml설정에 spring 밑에 redis 정보를 추가한다.

redis:
  host: localhost
  port: 6379

4.레디스의 연결객체인 RedisTemplate 객체를 만든다.

@Configuration
public class RedisConfig {
    @Value("${spring.redis.host}")
    private String host;
    @Value("${spring.redis.port}")
    private int port;

    @Bean(name = "redisConnectionFactory")
    @Qualifier("refreshToken")
    public RedisConnectionFactory redisConnectionFactory(){
        RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration();
        configuration.setHostName(host);
        configuration.setPort(port);
        //레디스는 데이터베이스를 0번~15번까지 가진다
        configuration.setDatabase(0);
        return new LettuceConnectionFactory(configuration);
    }

    @Bean
    @Qualifier("refreshToken")
    public RedisTemplate<String,Object> redisTemplate(@Qualifier("refreshToken") RedisConnectionFactory redisConnectionFactory){
        RedisTemplate<String,Object> redisTemplate = new RedisTemplate<>();
        //레디스에 저장될 때 문자열로 직렬화하여 설정
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        return redisTemplate;
    }


}

 

5.로그인 API를 구현한다.

controller

@RestController
@RequestMapping("/user")
public class UserController {
    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

//    로그인
    @PostMapping("/login")
    public ResponseEntity<?> userLogin(@RequestBody UserLoginReqDto dto){
        Map<String,Object>loginInfo = userService.userLogin(dto);
        return new ResponseEntity<>(new CommonDto(HttpStatus.OK.value(),"login success",loginInfo),HttpStatus.OK);
    }

//   리프레시 토큰 재발급
   @PostMapping("/refreshToken")
   public ResponseEntity<?> userReLogin(@RequestBody UserReLoginDto dto){
        Map<String, Object> loginInfo = userService.userReLogin(dto);
        if(loginInfo.get("token").equals("fail")){
            return new ResponseEntity<>(new CommonDto(HttpStatus.BAD_REQUEST.value(), "no exist refreshToken",null),HttpStatus.BAD_REQUEST);
        }
        return new ResponseEntity<>(new CommonDto(HttpStatus.OK.value(), "accessToken is recreated", loginInfo),HttpStatus.CREATED);
   }


}

 

Service

@Slf4j
@Service
@Transactional
public class UserService {
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtTokenProvider jwtTokenProvider;
    @Qualifier("refreshToken")
    private final RedisTemplate<String,Object> redisTemplate;

    public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtTokenProvider jwtTokenProvider, @Qualifier("refreshToken") RedisTemplate<String, Object> redisTemplate) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
        this.jwtTokenProvider = jwtTokenProvider;
        this.redisTemplate = redisTemplate;
    }
    @Value("${jwt.secretKeyRt}")
    private String secretKeyRt;

//    로그인
    public Map<String,Object> userLogin(UserLoginReqDto dto){
        User user = userRepository.findByLoginIdAndDelYN(dto.getLoginId(), DelYN.N).orElseThrow(()->new EntityNotFoundException("No exist"));

        if(!passwordEncoder.matches(dto.getPassword(),user.getPassword())){
            throw new IllegalArgumentException("password is wrong");
        }
        //내 프로그램 로직에 의해 List<String>으로 구성하였으나 일반적으로 String role
        List<String> roles = new ArrayList<>();
        for(UserRoleMap u: user.getRoles()){
            roles.add(u.getUserRole().getName());
        }
        String jwtToken = jwtTokenProvider.createToken(user.getLoginId(), roles);
        String refreshToken = jwtTokenProvider.createRefreshToken(user.getLoginId(), roles);
        //리프레시 토큰은 레디스에 저장. 2주후 자동 삭제하도록 설정
        redisTemplate.opsForValue().set(user.getLoginId(),refreshToken,14, TimeUnit.DAYS);

        Map<String,Object> loginInfo = new HashMap<>();
        loginInfo.put("id",user.getLoginId());
        loginInfo.put("token",jwtToken);
        loginInfo.put("refreshToken",refreshToken);
        return loginInfo;
    }
//     토큰 재발급
    public Map<String,Object> userReLogin(UserReLoginDto dto){
       Map<String,Object> loginInfo = new HashMap<>();

        Claims claims = Jwts.parserBuilder()
                .setSigningKey(secretKeyRt)
                .build()
                .parseClaimsJws(dto.getRefreshToken())
                .getBody();
        Object refreshToken = redisTemplate.opsForValue().get(claims.getSubject());
        if(refreshToken == null || !refreshToken.toString().equals(dto.getRefreshToken())){
           loginInfo.put("token","fail");
        }
        ObjectMapper mapper = new ObjectMapper();
        List<String> roles = mapper.convertValue(claims.get("roles"), new TypeReference<List<String>>() {
        });
        String token = jwtTokenProvider.createToken(claims.getSubject(),roles);
        loginInfo.put("token",token);
        return loginInfo;
    }

}

 

 

6.Spring Security 의존성을 추가한다. 그러면 자동으로UsernamePasswordAuthentication 필터가 생긴다.

아까 1번에서 미리 Spring Securoty 의존성을 추가했다.

7.이제 사용자가 로그인을 하면 JWT토큰을 발행하는 로직을 구현했으므로 이제 사용자의 토큰을 검증하는 JWT필터를 만들어야한다.

이제 사용자가 모든 요청에 JWT 토큰을 헤더에 넣어서 우리에게 요청할 것이니 그 JWT토큰을 받아 꺼내 우리가 만든 토큰인지 검증이 필요하다. 이때 사용자가 우리에게 전달하는 토큰은 JWT토큰이자 Bearer토큰이기도 하다.

JWT는 "토큰의 형식" 이라면 Bearer는 토큰을 전달하는 방식이다. 다시 말해, 헤더에 우리가 발급했던 AccessToken앞에 Bearer라는 문자가 붙어서온다. 그래서 'Bearer '를 분리해줘야 우리가 발급했던 토큰을 꺼낼 수 있다.

@Component
public class JwtAuthFilter extends GenericFilter {

    @Value("${jwt.secretKey}")
    private String secretKey;

    @Override //doFilter는 클라이언트의 요청/응답 처리시 실행되는 메서드 다음에 체이닝할 필터가 있으면 다음 필터로 넘겨주고 없으면 DispatcherServlet을 호출함
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        //ServletRequest와 ServletResponse는 Http요청이 아닌 경우까지 고려해서 만든 인터페이스로. HTTP를 사용한다면 Http~로 다운 캐스팅
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        HttpServletResponse httpServletResponse = (HttpServletResponse) servletResponse;
        String token = httpServletRequest.getHeader("Authorization");

        try {
            if (token != null) {
                if (!token.substring(0, 7).equals("Bearer ")) {
                    throw new AuthenticationServiceException("Bearer 형식이 아닙니다");
                }
                String jwtToken = token.substring(7);
//               토큰 검증 및 claims 추출
                Claims claims = Jwts.parserBuilder()// Jwts.parserBuilder 는 JWT문자열을 파싱하고 검증하기 위한 빌더 객체 만드는 것
                        .setSigningKey(secretKey) // JWT를 Signature로 검증할 때 사용할 비밀 키
                        .build() //JwtParser객체가 생성되었음
                        .parseClaimsJws(jwtToken) //jwt토큰을 파싱하고 서명 검증
                        .getBody(); //페이로드 추출

//              Authentication 객체 생성
                List<GrantedAuthority> authorities = new ArrayList<>();
                Object rolesObj = claims.get("roles");
                if(rolesObj instanceof List<?>){
                    for(Object role : (List<?>)rolesObj){
                        authorities.add(new SimpleGrantedAuthority("ROLE_" + role.toString()));
                    }
                }


                UserDetails userDetails = new User(claims.getSubject(), "", authorities);
                Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception e) {
            e.printStackTrace();
            httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
            httpServletResponse.setContentType("application/json");
            httpServletResponse.getWriter().write("invalid token");

        }
        //현재 필터가 요청과 응갑을 처리한 후 다음 필터 혹은 DispatcherServlet으로 넘긴다
        filterChain.doFilter(servletRequest, servletResponse);


    }
}

 

 

8. UsernamePasswordAuthentication필터 앞에 우리가 직접 만들 JWT 필터를 추가한다.

UsernamePasswordAuthenticaion필터 앞에 JWT필터를 추가하러면 아래와 같이 SecurityFilterChain을 구성해야한다.

SecurityFilterChain은 여러 보안 필터들을 체인처럼 연결하는 망이고. HttpSecurity는 이 체인의 동작 방식을 설정하는 도구다.

이 체인을 설정하는데에 있어 cors. csrf설정도 해야한다.

https://gotopm.tistory.com/59

 

[Spring]JWT 로그인 - 2.CORS , CSRF란?

이전 포스트 마지막 부분에 SecurityFilterChain 설정을 구성하면서 cors 설정과 csrf설정을 disable을 해놓았는데,cors와 csrf가 뭔지 궁금증이 생겼다. 설정 코드를 다시보며 cors와 csrf 에 대해 알아보고자

gotopm.tistory.com

 

 

그리고 기본적으로 토큰이 발급되기전 회원가입이나, 로그인 요청에 대해서는 필터가 적용되지 않도록 해야할 것이다.

그래서 a->a.requestMatchers()이 괄호부분에 해당 api의 요청url을 입력하면 해당 요청은 필터를 통과한다.

 

@Configuration
@EnableMethodSecurity
public class SecurityConfigs {
    private final JwtAuthFilter jwtAuthFilter;

    public SecurityConfigs(JwtAuthFilter jwtAuthFilter) {
        this.jwtAuthFilter = jwtAuthFilter;
    }


    @Bean
    public SecurityFilterChain myFilter(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .cors(cors->cors.configurationSource(corsConfigurationSource()))
                .csrf(AbstractHttpConfigurer::disable) //스프링 시큐어리티는 기본적으로 세션기반인증에서 csrf 보호를 켜둔다 따라서 disable사용하지 않도록 설정
                .httpBasic(AbstractHttpConfigurer::disable) //스프링시큐어리티를 적용하면 로그인페이지를 만들지 않더라도 자동으로 로그인 페이지가 뜨는데 그걸 비활성화
                .authorizeHttpRequests(a->a.requestMatchers("login").permitAll().anyRequest().authenticated())//"login"대신에 본인 회원가입이나 로그인 url을 입력해야 함
                .sessionManagement(s->s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) //JWT는 서버에 인증정보를 저장하지 않는 stateless 인증 방식이므로 Spring Security가 세션을 사용하지 않도록 지정한다
                .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) // 커스텀한 JWT 필터를 스프링 시큐어리티 필터 앞단에 작동되도록한다. 이를 통해 JWT통해 먼저 인증을 처리한뒤 Spring Securoty필터인 UsernamePasswordAuthenticationFilter가 실행되도록한다
                .build();
    }

    @Bean
    CorsConfigurationSource corsConfigurationSource(){
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000"));
        configuration.setAllowedMethods(Arrays.asList("*"));
        configuration.setAllowedHeaders(Arrays.asList("*"));
        configuration.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

    @Bean
    public PasswordEncoder makePassword(){
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }



}