JWT Token 다시 연습하자

김원기·2024년 6월 27일

JWT

목록 보기
1/2
post-thumbnail

이게 몇번째 JWT 다루는건지 모르겠네...

항상 프로젝트 하면서 JWT, Redis, AWS 이런게 조금 부족하다 생각이 들어서 JWT부터 순서대로 필수 기능만 구현해보면서 다시 알아볼 계획이다.

의존성 추가

일단 JWT 생성을 하기 전에 Build.gradle에

implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
runtimeOnly 'com.h2database:h2'

의존성 먼저 추가하고 난 뒤 yml파일에 Database설정을 연결하도록 해보자

spring:
  application:
    name: Jwt_Practice

  # H2 Database 설정
  datasource:
    driver-class-name: org.h2.Driver
    #url: 
    url: 'jdbc:h2:~/jwt'    # H2 DB 연결 주소 (Embedded Mode)
    username: sa        # H2 DB 접속 ID (사용자 지정)
    password: password       # H2 DB 접속 PW (사용자 지정)

  # H2 Console 설정
  h2:
    console: # H2 DB를 웹에서 관리할 수 있는 기능
      enabled: true           # H2 Console 사용 여부
      path: /h2-console       # H2 Console 접속 주소

User 엔티티 생성

의존성을 다 추가 했다면 이제 로그인 할 유저의 엔티티를 만들어 보자

package com.wongi.jwt_practice.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

@Entity
@Getter
@Setter
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String password;

    @Column(length = 1000)
    private String refreshToken;

}

일단 연습용이기 때문에 Getter와 Setter를 추가해 주었지만 무분별한 Setter는 권장하지 않는 사항이기 때문에 나중에는 뺴주도록 하자

Controller, Service, Repository, DTO

JWT 발급으 시험할 수 있도록 위의 제목과 관련된 파일을 생성해주도록 하자

Controller

컨트롤러 클래스는 다음과 같이 작성했다.

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class UserController {
    private final AuthService authService;

    @PostMapping("/signup")
    public ResponseEntity<?> signup(@RequestBody UserloginReqeustDto userloginReqeustDto) {
        return ResponseEntity.ok(authService.signup(userloginReqeustDto));
    }

    @PostMapping("/login")
    public ResponseEntity<LoginResponseDto> login(@RequestBody UserloginReqeustDto reqeustDto) {
        return authService.authenticate(reqeustDto.getUsername(), reqeustDto.getPassword());
    }

    @PostMapping("/refresh")
    public ResponseEntity<?> refresh(@RequestBody TokenRefreshRequestDto requestDto) {
        return ResponseEntity.ok(authService.refreshToken(requestDto.getRefreshToken()));
    }
}

Service

Service 클래스 작성은 다음과 같다.

@Service
@RequiredArgsConstructor
@Slf4j(topic = "service 로그 확인")
public class AuthService {

    private final PasswordEncoder passwordEncoder;
    private final UserRepository userRepository;
    private final JwtUtil jwtUtil;

    public ResponseEntity<LoginResponseDto> authenticate(String username, String password) {
        User user = userRepository.findByUsername(username).orElseThrow(() -> new IllegalArgumentException("유저 없음"));
        if (!passwordEncoder.matches(password, user.getPassword())) {
            throw new InputMismatchException("비번 다름");
        }

        String accessToken = jwtUtil.createAccessToken(user.getUsername());
        String refreshToken = jwtUtil.createRefreshToken(user.getUsername());
        user.updateRefreshToken(refreshToken);
        userRepository.save(user);

        HttpHeaders headers = new HttpHeaders();
        headers.set("Authorization", "Bearer " + accessToken);
        headers.set("Refresh-Token", refreshToken);

        LoginResponseDto responseDto = new LoginResponseDto(user.getId(), user.getUsername());

        log.info("Returning response with headers: {}", headers);

        return new ResponseEntity<>(responseDto,headers, HttpStatus.OK);

        //return responseDto;

    }

    public String refreshToken(String refreshToken) {

        if (!jwtUtil.validateToken(refreshToken)) {
            throw new IllegalArgumentException("INVALID_TOKEN");
        }


        String username = jwtUtil.getUsernameFromToken(refreshToken);
        User user = userRepository.findByUsername(username).orElse(null);

        if (user == null) {
            throw new IllegalArgumentException("USER_NOT_FOUND");
        }

        // AccessToken 재발급
        String newAccessToken = jwtUtil.createAccessToken(username);
        HttpHeaders headers = new HttpHeaders();
        headers.set("Authorization", "Bearer " + newAccessToken);

        return refreshToken;
    }

    public LoginResponseDto signup(UserloginReqeustDto userloginReqeustDto) {
        String username = userloginReqeustDto.getUsername();
        String password = userloginReqeustDto.getPassword();

        String encodePassword = passwordEncoder.encode(password);

        User user = new User(username,encodePassword, UserRoleEnum.ADMIN);

        User savedUser = userRepository.save(user);

        return new  LoginResponseDto(savedUser.getId(), savedUser.getUsername());
    }
}

Repository랑 Dto는 생략하도록 하겠다...

여튼 다음으론 JWT 발급 받는 과정들을 볼건데 내가 이해한 순서대로 할 예정이다.

Spring Security의 순서

일단 그 전에 Spring Security의 순서에 대해서 볼 예정인데 순서라고 하기에는 약간 확신이 없다...


(화이트모드로 봐야하려나...)
https://wildeveloperetrain.tistory.com/50

자주 보이는 Spring 필터의 사진인데 솔직히 어렵기도 해서 내가 코드를 구현하고 순서에따라 매우 간단하게 순서를 잡아봤다.

매우 간단하지만 내가 이해한 순서대로 적어보니 위의 사진처럼 나왔다.

틀릴 수 있음 (주의!)

사진에는 설명 생략이 되어있긴 한데 설명을 풀어 보자면

  1. 일단 클라이언트에 로그인을 한다.
  2. 로그인이 성공적으로 진행 된다면 서버는 JWT토큰을 발급하여 ResponseHeader에 넣어 보내준다.
  3. 사용자는 각 서블릿에 접근하기 전에 Header에 있는 토큰을 통해 필터에서 인증을 받는다.
  4. 필터를 통해 인증과 인가의 과정이 수행되면 각 서블릿을 통해 컨트롤러에 접근하여 원하는 과정을 실행할 수 있다.


이 사진 두 개를 참고하긴 했는데.... 여기서 필터를 공부하는게 나을 것 같다.

참고 : https://velog.io/@seongwon97/Spring-Security-Filter란

JWT 토큰 발급

이제 Jwt토큰을 발급 해보겠다. 이것도 나름 혼자 이해해보려고 노력하면서 했기 때문에 내가 이해한 흐름대로 코드를 올리면서 작성하겠다.

yml에 jwt 값 추가

  jwt:
    secret:
      key: 7JWI64WV7ZWY7IS47JqULuuwmOqwkeyKteuLiOuLpC4gSldUIO2GoO2BsCDrsJzquIkg7Jew7Iq17J2EIOychO2VnCBTZWNyZXQgS2V5IOyeheuLiOuLpC4=
    token:
      expiration: 1800000
    refresh:
      token:
        expiration: 1209600000

JwtConfig

@Configuration
@Getter
public class JwtConfig {
    @Value("${spring.jwt.secret.key}")
    private String secretKey;

    @Value("${spring.jwt.token.expiration}")
    private long tokenExpiration;

    @Value("${spring.jwt.refresh.token.expiration}")
    private long refreshTokenExpiration;
}
  • JwtConfig 클래스는 JWT 토큰 설정에 필요한 값을 관리하는 역할을 한다.
  • 각각 시크릿 키와 만료시간을 설정하는 값이다.

Jwt 토큰의 설정을 완료했다면 생성과 검증을 위한 클래스를 작성하자.

JwtUtil

@Component
@RequiredArgsConstructor
public class JwtUtil {
    private final long tokenExpiration;
    private final long refreshTokenExpiration;
    private final SecretKey secretKey;

    public JwtUtil(JwtConfig jwtConfig) {
        this.tokenExpiration = jwtConfig.getTokenExpiration();
        this.refreshTokenExpiration = jwtConfig.getRefreshTokenExpiration();
        this.secretKey = Keys.hmacShaKeyFor(jwtConfig.getSecretKey().getBytes());
    }

    public String createAccessToken(String username) {
        return generateToken(username, tokenExpiration);
    }

    public String createRefreshToken(String username) {
        return generateToken(username, refreshTokenExpiration);
    }

    public String createAccessTokenFromRefresh(String refreshToken) {
        if (validateToken(refreshToken)) {
            String username = getUsernameFromToken(refreshToken);
            return createAccessToken(username);
        }
        throw new IllegalArgumentException("Refresh 토큰이 유효하지 않음");
    }

    public String generateToken(String username, long expiration) {
        return Jwts.builder()
                .setSubject(username)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(secretKey, SignatureAlgorithm.HS256)
                .compact();
    }

    public boolean validateToken(String token, UserDetailsImpl userDetails) {
        try {
            String username = getUsernameFromToken(token);
            return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
        } catch (Exception e) {
            return false;
        }
    }

    public boolean validateToken(String token) {
        try {
            return !isTokenExpired(token);
        } catch (Exception e) {
            return false;
        }
    }

    private Claims extractClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(secretKey)
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    public String getUsernameFromToken(String token) {
        return extractClaims(token).getSubject();
    }

    private boolean isTokenExpired(String token) {
        Date expiration = extractClaims(token).getExpiration();
        return expiration.before(new Date());
    }
}
  • JwtUtil은 JWT 토큰을 생성하고 검증하는 유틸리티 클래스이다.
  • 생성자를 통해 설정값을 초기화 한다.
  • createAccessToken, createRefreshToken: 사용자 이름을 받아 해당 만료 시간으로 JWT 토큰을 생성한다.
  • createAccessTokenFromRefresh: Refresh 토큰을 받아서 유효성을 검사하고, 유효할 경우 Access 토큰을 생성한다.
  • generateToken: 주어진 사용자 이름과 만료 시간을 사용하여 JWT 토큰을 생성한다.
  • validateToken: 주어진 토큰의 유효성을 검사한다.
  • validateToken(String token, UserDetailsImpl userDetails)는 특정 사용자의 토큰 유효성을 검사한다.
  • extractClaims: JWT 토큰에서 클레임을 추출한다.
  • getUsernameFromToken, isTokenExpired: JWT 토큰에서 사용자 이름을 추출하고, 토큰의 만료 여부를 확인한다.

토큰을 생성하고 검증하는 클래스를 작성 했다면 토큰을 가지고 필터로 가보자

JwtAuthenticationFilter

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final UserDetailsServiceImpl userDetailsService;

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

        String authorizationHeader = request.getHeader("Authorization");

        String username = null;
        String token = null;

        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            token = authorizationHeader.substring(7);
            username = jwtUtil.getUsernameFromToken(token);
        }

        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetailsImpl userDetails = this.userDetailsService.loadUserByUsername(username);

            if (jwtUtil.validateToken(token, userDetails)) {
                UsernamePasswordAuthenticationToken authenticationToken =
                        new UsernamePasswordAuthenticationToken(
                                userDetails, null, userDetails.getAuthorities()
                        );
                authenticationToken
                        .setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authenticationToken);
            }
        }

        filterChain.doFilter(request, response);
    }
}
  • JwtAuthenticationFilter는 Spring Security의 OncePerRequestFilter를 확장한 클래스로, HTTP 요청에서 JWT 토큰을 추출하고 인증을 처리한다.
  • doFilterInternal 메서드는 모든 HTTP 요청에 대해 호출된다.
    HTTP 헤더에서 Authorization을 추출하고, "Bearer "로 시작하는 토큰을 분리하여 유효성을 검사한다.
  • jwtUtil을 사용하여 토큰의 유효성을 검사하고, 검증이 성공하면 SecurityContextHolder에 인증 정보를 설정한다.

SecurityContextHolder?

https://wildeveloperetrain.tistory.com/163
위의 주소가 내가 본 글 중에 가장 정리가 잘 되어있는 것 같다.

일단 여기서 사용할 것 만 보자면

Authentication 구조

principal : 접근 주체의 아이디 혹은 User 객체를 저장합니다.
credentials : 접근 주체의 비밀번호를 저장합니다.
authorities : 인증된 접근 주체자의 권한 목록을 저장합니다.
details : 인증에 대한 부가 정보를 저장합니다.
authenticated : boolean 타입의 인증 여부를 저장합니다.

Authentication 이 존재한다는 것은 인증이 되어있다고 간단하게 생각하면 되고, 여기에 존재하는 값을 가져다 사용하는 것이다.

이 부분도 다다음 포스팅에서 다뤄봐야 겠다.

SecurityConfig

public class SecurityConfig {

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

    @Bean
    @ConditionalOnProperty(name = "spring.h2.console.enabled", havingValue = "true")
    public WebSecurityCustomizer corsCustomizer() {
        return web -> web.ignoring()
                .requestMatchers(PathRequest.toH2Console());
    }

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http,
                                            JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception {

        http.csrf((csrf) -> csrf.disable())
                .sessionManagement((sessionManagement) -> sessionManagement.sessionCreationPolicy(
                        SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests((authorizeHttpRequest) ->
                        authorizeHttpRequest
                                .requestMatchers(PathRequest.toStaticResources().atCommonLocations())
                                .permitAll()
                                .requestMatchers("/", "api/auth/**").permitAll()
                                .requestMatchers(HttpMethod.GET, "/**").permitAll()
                                .requestMatchers("/h2-console/**").permitAll()
                                .anyRequest().permitAll()
                );

        http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}
  • SecurityConfig 클래스는 Spring Security 구성을 정의한다.
  • passwordEncoder 빈을 설정하여 비밀번호 인코딩을 처리한다.
  • corsCustomizer 빈을 설정하여 H2 Console 접근을 허용한다.
  • securityFilterChain 메서드에서 HttpSecurity를 설정하고, CSRF 보호를 비활성화하며 세션을 stateless로 설정한다.
  • authorizeHttpRequests 메서드에서 특정 URL 패턴에 대한 접근 권한을 설정하고, JwtAuthenticationFilter를 등록하여 JWT 토큰을 기반으로 인증을 처리한다.

끝!

일단 JWT는 발급 받는 과정은 다 다루어 봤으니 다음 포스팅은 위의 내용을 이용한 Redis를 다룰 것 같다.

코드 : https://github.com/WonGi-Kim/jwt-practice

profile
혼자 공부하는 블로그라 부족함이 많아요 https://www.notion.so/18067a27ac7e4f4790dde645fb3bf3d3?pvs=4

0개의 댓글