본문 바로가기
Java/spring

JWT를 가장 쉽게 적용해보자.

by 티코딩 2024. 6. 28.

1. 나는 gradle기준이다. 고로 build.gradle에 의존성 추가를 해주자.

    implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
    implementation 'io.jsonwebtoken:jjwt-impl:0.11.2'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.11.2'

 

첫줄부터 역할을 간략히 보자.

implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
JWT를 생성, 서명, 인코딩, 파싱하는 기능을 제공함.

 

implementation 'io.jsonwebtoken:jjwt-impl:0.11.2'

위의 기능들의 실제 구현을 제공함.(서명 알고리즘, JWT토큰의 인코딩/디코딩, 클레임 처리 로직)

 

implementation 'io.jsonwebtoken:jjwt-jackson:0.11.2'

jjwt라이브러리가 JSON처리를 위해 Jackson라이브러리를 사용할수 있게해줌.

JWT토큰의 클레임을 JSON 형식으로 직렬화/역직렬화 하는 기능을 함.

 

**클레임은 JWT의 구조(헤더, 페이로드, 서명)에서 페이로드에 포함된 정보로, 토큰에 담을 실제 데이터를 의미한다!

 

 

2. JWT 유틸리티 클래스 작성

@Component	//Spring 이 빈으로 관리하게함.
public class JwtTokenProvider {

    @Value("${jwt.secret}")
    private String secretKey;
	//secretKey는 JWT에 사용할 비밀키.
    
    @Value("${jwt.expiration}")
    private long validityInMilliseconds;
	//JWT토큰의 유효기간
    
    @PostConstruct
    public void init() {
    	//HMAC-256알고리즘에 사용할 키를 생성함
        this.key = Keys.hmacShaKeyFor(secretKey.getBytes());
    }
    
    // JWT 토큰 생성 메서드(이메일(아이디)로)
    public String createToken(String email) {
        Claims claims = Jwts.claims().setSubject(email);//클레임을 설정함.
        Date now = new Date();//현재시간
        Date validity = new Date(now.getTime() + validityInMilliseconds);//토큰 만료시간 설정
		
        return Jwts.builder()
                .setClaims(claims)//클레임설정
                .setIssuedAt(now)//발행시간을 현재시간으로
                .setExpiration(validity)//만료 시간을 현재시간+1시간
                .signWith(key, SignatureAlgorithm.HS256)//위에서 설정한 비밀키로 서명
                .compact();//JWT 문자열 생성!
    }

    // JWT 토큰에서 이메일 추출하는 로직
    public String getEmail(String token) {
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getSubject();
    }

    // JWT 토큰 유효성 검증 로직
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            //파라미터로 받은 token을 파싱하고 서명을 검증함.
            return true;
        } catch (JwtException | IllegalArgumentException e) {
        	//유효하지않거나, 만료되면 바로 예외처리. 참고로 | 하나만쓴건 다중 예외처리하기 위한 구분자. OR이 아님.
            throw new RuntimeException("토큰이 만료되었거나 유효하지 않습니다.");
        }
    }
}

주석으로 친절히 설명을 달아놨으니 읽어보시길..

 

3. SecurityConfig

@Configuration
@EnableWebSecurity // 스프링 시큐리티의 웹 보안 활성화
@RequiredArgsConstructor // 생성자필요한거 생성해줌
public class SecurityConfig{
	
    private final JwtTokenProvider jwtTokenProvider;//위에서 만든클래스 주입
    private final UserDetailsService userDetailsService;//사용자 정보 로드하는 서비스 주입

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf(csrf -> csrf.disable()) //일반적으로 REST API에서는 csrf 비활성화 함.
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/v1/users/signup", "/api/v1/users/login").permitAll()//회원가입api, login api는 token이 필요없음.
                        .anyRequest().authenticated()//위에 두개api외에는 인증 요구함.
                )
                .sessionManagement(sessionManagement -> sessionManagement
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)//세션을 사용하지 않음.
                );

        return http.build();
    }
	/**
    * 사용자 인증을 처리하는 데 사용됨.
    * authenticationManager를 소환함.
    **/
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }
    //사용자의 비밀번호를 암호화,해싱하는 빈
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}

 

4. JWT 기반 인증을 처리하는 클래스

//AbstractAuthenticationToken를 상속해 JWT기반 인증 토큰 클래스 정의함.
public class JwtAuthenticationToken extends AbstractAuthenticationToken {
	
    private final UserDetails principal;//인증된사용자의 정보를 담는 UserDetails

    public JwtAuthenticationToken(UserDetails principal, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);//생성자로 사용자의 권한 목록 받아와서 초기화.
        this.principal = principal;
        super.setAuthenticated(true);//인증된 상태로 설정함.
    }

    @Override
    public Object getCredentials() {
        return null;	//자격증명은 필요없다!
    }

    @Override
    public UserDetails getPrincipal() {
        return this.principal;	//인증된 사용자의 정보를 반환함.
    }
}

 

5. 필터 작성

@Component //Spring 빈으로 등록
@RequiredArgsConstructor //필요한 생성자 자동생성
//OncePerRequestFilter는 요청당 한번씩 실행되는 필터를 구현할때 쓴다!
public class JwtTokenFilter extends OncePerRequestFilter {
	
    private final JwtTokenProvider jwtTokenProvider;
    private final UserDetailsService userDetailsService;

    @Override //doFilterInternal 메서드는 이 필터의 주요 로직정의하는 메서드. 각 HTTP 요청마다 실행됨!
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
        throws IOException, ServletException {

            String token = resolveToken(request);//요청에서 토큰 추출
			
            //토큰이 널이 아니고, validateToken메서드에 넣어봤을때 true를 반환하면,
            if(token != null && jwtTokenProvider.validateToken(token)) {
                String email = jwtTokenProvider.getEmail(token); //email을 불러와서 초기화.
                UserDetails userDetails = userDetailsService.loadUserByUsername(email);//사용자 정보도 불러와서 초기화.
				
                //만약 사용자 정보를 불러왔는데 널이 아니면?
                if(userDetails != null) {
                	//위의 JwtAuthenticationToken객체를 생성해서 SecurityContext에 설정함.
                    //이러면 Spring Security가 인증된 사용자로 인식하게됨.
                    JwtAuthenticationToken authentication = new JwtAuthenticationToken(userDetails, userDetails.getAuthorities());
                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            }
            //이 필터 다음에 실행되는 필터로 요청,응답을 넘긴다.
            filterChain.doFilter(request, response);
        }
	//요청에서 토큰을 추출하는 메서드
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");//헤더를 추출함.(Bearer빼고)
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

 

 

6. UserDetailsService구현

@Service //서비스 계층 컴포넌트로 등록.
public class CustomUserDetailsService implements UserDetailsService {
	
    //사용자 정보를 DB에서 조회하기위한 리포지토리 의존성 선언.
    private final UserRepository userRepository;
	
    //생성자
    public CustomUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
	
    //UserDetailsService의 loadUserByUsername메서드를 오버라이딩함.
    // email로 유저정보를 불러온다.
    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        User user = userRepository.findByEmail(email)
                .orElseThrow(() -> new UsernameNotFoundException("email로 유저를 찾을 수 없습니다. : " + email));
		//내가 구현한 User객체말고 Userdetails.User 객체를 생성해 반환한다.
        return new org.springframework.security.core.userdetails.User(user.getEmail(), user.getPassword(), Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")));
    }
}

 

7. Controller & Service

@PostMapping("/signup")
    public ResponseEntity<?> SignUp(@RequestBody UserDto.Signup signup){
            User newUser = userService.Signup(signup);
            return ResponseEntity.status(HttpStatus.CREATED).body(newUser);
    }
    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody UserDto.Login login) {
        String token = userService.login(login);
        return ResponseEntity.ok(token);
    }

 

@Transactional
    public User Signup(UserDto.Signup signup){
        if (userRepository.findByEmail(signup.getEmail()).isPresent()) {
            throw new RuntimeException("이미 회원가입되어있는 회원입니다.");
        }
        User newUser = User.builder()
                .username(signup.getUsername())
                .email(signup.getEmail())
                .password(passwordEncoder.encode(signup.getPassword()))
                .nickname(signup.getNickname())
                .build();
        return userRepository.save(newUser);
    }
    @Transactional
    public String login(UserDto.Login login) {
        User user = userRepository.findByEmail(login.getEmail())
                .orElseThrow(() -> new RuntimeException("가입되지 않은 이메일입니다."));

        if (!passwordEncoder.matches(login.getPassword(), user.getPassword())) {
            throw new RuntimeException("잘못된 비밀번호입니다.");
        }

        return jwtTokenProvider.createToken(user.getEmail());
    }

쉬우니깐 별다른 설명은 생략함.

 

9. 환경변수로 SecretKey, expiration설정

application.yml에 아래와 같이 적고 환경변수로 엄청길게 아무렇게 나 secretKey설정해주면된다.

jwt:
  secret: ${JWT_SECRET_KEY}
  expiration: 3600000

10. 과정

JWT가 언제 생성되고 인증되는지 일련의 과정을 위의 코드와 함께 설명해보겠다.

1. 7번의 signup api를 호출한다.

2. 7번의 login api를 호출해서 로그인이 성공하면 jwt 토큰을 생성한다.

3. 2번의JwtTokenProvider의 createToken 메서드가 호출됨.

4. 5번의 Filter가 HTTP 요청을 가로채 JWT 토큰을 검사함.

5. 2번의 JwtTokenProvider의 getEmail메서드를 호출해 token으로부터 사용자의 email얻어옴.

6. 6번의 CustomUserDetailsService의 loadUserByUsername메서드에 email을 넣어서 사용자 정보를 얻어오고 UserDetails 객체로 반환함.

7. 클라이언트는 로그인 시 받은 토큰을 이후 요청의 'Authorization' 헤더에 포함시켜 리소스에 접근할 수 있게된다!