JWT란?

토큰 기반 인증 시스템의 대표적인 구현체입니다. Java를 포함한 많은 프로그래밍 언어에서 이를 지원하며, Session 대신에 사용해 사용자를 판별할 수 있습니다.

 

JWT는. 을 기준으로 헤더(header) - 내용(payload) - 서명(signature)으로 이루어져 있습니다. 각각 무슨 역할을 하는지 간단하게 알아보도록 하겠습니다.

1. 헤더(header)

Header는 토큰의 타입과 해싱 알고리즘을 지정하는 정보를 포함합니다.

  • typ : JWT라는 문자열 
  • alg: 해상 알고리즘을 지정

2. 정보(payload)

토큰에 담을 정보가 들어갑니다. 총 3가지 종류의 클레임이 있습니다. 

  • 등록된(registered) 클레임
    • 토큰에 대한 정보를 담기 위한 클레임들이며, 이미 이름이 등록되어있는 클레임
    • iss : 토큰 발급자(issuer)
    • sub : 토큰 제목(subject)
    • aud : 토큰 대상자(audience)
    • exp : 토큰의 만료시간(expiraton). 시간은 NumericDate 형식으로 되어있어야 하며,(예: 1480849147370) 항상 현재 시간보다 이후로 설정되어있어야 한다.
    • nbf : Not Before를 의미하며, 토큰의 활성 날짜와 비슷한 개념. NumericDate 형식으로 날짜를 지정하며, 이 날짜가 지나기 전까지는 토큰이 처리되지 않는다.
    • iat : 토큰이 발급된 시간 (issued at)
    • jti : JWT의 고유 식별자로서, 주로 일회용 토큰에 사용한다.
  • 공개(public) 클레임
    • 말 그대로 공개된 클레임, 충돌을 방지할 수 있는 이름을 가져야 하며, 보통 클레임 이름을 URI로 짓는다.
  • 비공개(private) 클레임
    • 클라이언트 - 서버 간에 통신을 위해 사용되는 클레임

3. 서명(signature)

해당 토큰이 조작되었거나 변경되지 않았음을 확인하는 용도로 사용하며, 헤더(header)의 인코딩 값과 정보(payload)의 인코딩 값을 합친 후에 주어진 비밀키를 통해 해쉬값을 생성합니다.

 

JWT가 무엇인지 알아보았습니다. 저희가 만든 프로젝트에 적용해보겠습니다.


1. User

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "email")
    private String email;

    @Column(name = "password")
    private String password;


    @ElementCollection(fetch = FetchType.EAGER)
    @Column(name = "role")
    private List<String> roles = new ArrayList<>();

    @Builder
    public User(String email, String password, List<String> roles){
        this.email=email;
        this.password=password;
        this.roles=roles;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return roles.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

    @Override
    public String getUsername() {
        return null;
    }

    @Override
    public boolean isAccountNonExpired() {
        return false;
    }

    @Override
    public boolean isAccountNonLocked() {
        return false;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return false;
    }

    @Override
    public boolean isEnabled() {
        return false;
    }
}
  • Spring Security에서는 UserDetails를 사용하여 인증을 진행합니다. 그래서 상속받아 User Entity를 구성합니다.

2. UserRepository

public interface UserRepository extends JpaRepository<User, Long> {


    Optional<User> findByEmail(@Param("email") String email);
}

3. UserService, UserServiceImpl

public interface UserService extends UserDetailsService{

    User join(String email, String password) throws UserDuplicateException;


    User login(String email, String password) throws UsernameNotFoundException, BadCredentialsException, Throwable;

}
  • UserDetails와 같은 맥락으로 Security가 UserDetailsService를 사용하므로 상속받습니다. 
@Service
@RequiredArgsConstructor
@Transactional
public class UserServiceImpl implements UserService{

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    public User join(String email, String password) throws UserDuplicateException {
        userRepository.findByEmail(email)
                .ifPresent(m->{
                    throw new UserDuplicateException("이미 존재하는 아이디 입니다.");
                });
        return  userRepository.save(User.builder()
                .email(email)
                .password(passwordEncoder.encode(password))
                .roles(Collections.singletonList("ROLE_USER"))
                .build());
    }

    @Override
    public User login(String email, String password) throws UsernameNotFoundException, BadCredentialsException, Throwable {
        User user = userRepository.findByEmail(email)
                .orElseThrow(() -> {
                    throw new UsernameNotFoundException("등록되지 않은 아이디입니다.");
                });
        if(!passwordEncoder.matches(password,user.getPassword())){
            throw new BadCredentialsException("잘못된 비밀번호입니다.");
        }
        return user;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return userRepository.findByEmail(username)
                .orElseThrow(()-> new UsernameNotFoundException("등록되지 않은 아이디입니다. "));
    }
}

4. JwtProvider

@Component
@RequiredArgsConstructor
public class JwtProvider {
    private String secretKey = "hiJwt";

    private Long validTokenTime = 30 *60* 60 *1000L;

    private final UserService userService;


    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    public String createToken(String UserPk, List<String> roles) {
        Claims claims = Jwts.claims().setSubject(UserPk);
        claims.put("roles", roles);
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + validTokenTime))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
    }

    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userService.loadUserByUsername(this.getUserPk(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    public String getUserPk(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    }

    public String resolveToken(HttpServletRequest request) {
        return request.getHeader("X-AUTH-TOKEN");
    }

    public boolean validateToken(String jwtToken) {
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
            return !claims.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
            return false;
        }
    }
}
  • validTokenTime: 토큰의 유효 기간을 설정하는 변수입니다.
  • secretKey: 토큰을 판별할 키값입니다. 외부에 노출하는 것을 권장하지 않아 외부에서 주입하는 방법을 사용해야 하지만 예제의 단순화를 위해 그냥 사용하겠습니다.
  • init(): secreyKey를 인코딩합니다.
  • create(): JWT Token을 만듭니다. User의 email과 role(권한)을 받아 생성하며, 인코딩한 secretKey와 파기 시간을 설정합니다.
  • getUserPK(): secretyKey를 이용하여 JWT 안에 있는 User의 email을 찾습니다.
  • getAuthentication(): User의 정보를 찾은 뒤 다음 filter에서 사용될 UsernamePasswordAuthenticationToken을 생성하여 넘깁니다.
  • resolveToken(): 토큰의 정보는 "X-AUTH-TOKEN"의 Header에 담을 예정이므로 Header에 담긴 내용을 가져오는 역할을 합니다.
  • validateToken(): 토큰의 파기 시간과 현재 시간을 비교하여 토큰의 유효성을 판별합니다.

5. JwtAuthenticationFilter

@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends GenericFilter {

    private final JwtProvider jwtTokenProvider;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

        String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);
        if(token!=null && jwtTokenProvider.validateToken(token)){
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request,response);
    }
}
  • JwtProvider를 이용하여 토큰들을 판별하며, 유효하면 권한을 부여하며 다음 Filter를 호출합니다.

6. SecurityConfig

@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final JwtProvider jwtProvider;
    private final CustomAccessDeniedHandler customAccessDeniedHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .httpBasic()
                .disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .exceptionHandling()
                .accessDeniedHandler(customAccessDeniedHandler)
                .and()
                .authorizeRequests()
                .antMatchers("/signup","/login","/all").permitAll()
                .antMatchers("/user").hasRole("USER")
                .anyRequest().authenticated()
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);
    }
}
  • Session이 아닌 JWT Token을 이용하므로 sessionManagement의 정책을 STATELESS로 설정합니다.
  • exceptionHandling(). accessDeniedHandler(): 만약 권한이 없는 User가 리소스에 접근했을 때 핸들링을 설정합니다.
  • authorizeRequests(). antMatchers():. permitAll()은 누구나 접근 가능한 URL이며, hasRole을 통하여 권한을 세부적으로 설정이 가능합니다. authenticated()는 어떠한 권한 하나라도 가지고 있다면 통과시킵니다.
  • addFilterBefore(): JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter앞에 배치합니다.

7. CustomAccessDeniedHandler

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.sendRedirect("/error");
    }
}
  • 권한이 아닌 User가 접근했을 때 "/error"로 보내주는 역할을 합니다.

8. UserDto

@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserDto {

    public String email;
    public String password;
}

9. HomeController

@RestController
@RequiredArgsConstructor
public class HomeController {

    private final UserService userService;
    private final JwtProvider jwtProvider;

    @PostMapping("/signup")
    public ResponseEntity<String> signup(@RequestBody UserDto userDto) {
        User joinUser = userService.join(userDto.getEmail(), userDto.getPassword());
        return new ResponseEntity("OK", HttpStatus.OK);
    }

    @PostMapping("/login")
    public ResponseEntity<String> login(@RequestBody UserDto userDto, HttpServletResponse response) throws Throwable {
        User loginUser = userService.login(userDto.getEmail(), userDto.getPassword());
        String token = jwtProvider.createToken(userDto.getEmail(), loginUser.getRoles());
        response.addHeader("X-AUTH-TOKEN", token);
        return new ResponseEntity<String>(token, HttpStatus.OK);
    }

    @GetMapping("/user")
    public String user() {
        return "user";
    }

    @GetMapping("/all")
    public String all() {
        return "all";
    }

    @GetMapping("/guest")
    public String guest() {
        return "guest";
    }

    @GetMapping("/error")
    @ResponseStatus(HttpStatus.FORBIDDEN)
    public String error(){
        return "error";
    }
}
  • signup(): dto를 받아 회원가입을 진행합니다.
  • login(): dto를 받아 login에 성공하면 토큰을 만들고 response.addHeader()를 통하여 토큰을 넣어줍니다.
  • "/user": User의 권한을 가진 User만 접근 가능합니다.
  • "/all": 로그인을 하지 않아도 모두 접근 가능합니다.
  • "/guest": 로그인만 한다면 어떠한 권한을 가져도 접근이 가능합니다.
  • "/error": 권한이 거부됐을 때 이동하는 URL입니다.

10. UserDuplicateException

public class UserDuplicateException extends RuntimeException{

    public UserDuplicateException(String message) {
        super(message);
    }
}

11. PostmanTest 

  • all은 모두 접근 가능하므로 성공하는 모습을 보입니다.

 

  • guest는 어떠한 권한이든, user는 USER 권한을 가진 사람만 접근이 가능합니다. 따라서 오류를 보냅니다.

 

  • 회원가입을 진행하겠습니다.

  • 로그인을 완료하면,  Header에 Token이 온 것을 볼 수 있습니다. 

 

  • 해당 토큰을 Header에 넣고 guest와 user에 접근했을 때 성공한 것을 볼 수 있습니다. 

 

 

 

지금까지 JWT Token을 이용하여 로그인을 구현해보았습니다. 감사합니다.

 

모든 코드는 아래 링크에서 확인 가능합니다.

https://github.com/rlaehdals/JWTExample

 

GitHub - rlaehdals/JWTExample

Contribute to rlaehdals/JWTExample development by creating an account on GitHub.

github.com