JWT 토큰은 웹에서 사용되는 JSON 형식의 토큰에 대한 표준 규격 입니다.
주로 사용자의 인증(authentication) 또는 인가(authorization) 정보를 서버와 클라이언트 간에 안전하게 주고 받기 위해서 사용됩니다.
JWT 토큰을 Decode 해보면 아래와 같이 3부분의 정보를 확인 할 수 있습니다.
JWT 토큰 Decode 사이트 : https://jwt.io/
header 부분에는 토큰의 종류와 해싱 알고리즘을 가지고 있습니다.
{
"alg": "HS256",
"typ": JWT
}
alg : 해싱 알고리즘
typ : 토큰의 종류
payload 부분에는 토큰에 담을 정보가 들어있습니다.
이 정보들을 클레임(Claim) 이라고 부르고, 이는 Json(Key/Value) 형태의 한 쌍으로 이루어져 있습니다.
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
클레임에는 registered(등록), public(공개), private(비공개) 세가지 유형이 있습니다.
iss(issuer) : 토큰 발급자
exp(expiration time) : 토큰 만료 시간
sub(subject) : 토큰 제목
aud(audience) : 토큰 수신자/대상자
nbf(Not Before) : 토큰의 활성 날짜
iat(issued at): 토큰이 발급된 시간, 토큰의 age가 얼마나 되었는지 판단 가능
jti : JWT의 고유 식별자, 주로 중복 처리 방지를 위해 사용, 일회용 토큰에 사용하면 유용
서명 부분을 생성하려면 인코딩된 Header, 인코딩된 Payload, 암호, Header에 지정된 알고리즘을 가져와서 서명해야한다.
Header의 인코딩 값과 Data의 인코딩 값을 합친 후 주어진 private key로 해시하여 생성한다.
서명은 메시지가 도중에 변경되지 않았는지 확인하는데 사용되며, private key로 서명된 토큰의 경우 JWT의 송신자가 누구인지 확인할 수도 있다.
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
클라이언트에서 ID/PW 로 로그인 요청
서버에서 해당 ID/PW 를 가진 사용자가 있을 경우 Access Token과 Refresh Token 발급
클라이언트에서 API 요청 시 헤더에 Access Token을 담아서 요청
Refresh Token 은 새로운 Access Token을 발급하기 위한 토큰입니다.
Access Token은 외부 유출 문제로 인해 유효기간을 짧게 설정하고
Refresh Token의 유효기간은 Access Token의 유효기간보다 길게 설정 합니다.
클라이언트에서 API 요청 시 Access Token이 만료되었을 경우 만료되었다고 응답
클라이언트에서 Access Token 재발급 요청
서버에서 요청을 받아 Refresh Token 검증 후 Access Token + Refresh Token 재발급
아래 라이브러리 추가
dependencies {
//spring security
implementation 'org.springframework.boot:spring-boot-starter-security'
// jwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}
클라이언트에 전달할 토큰 DTO 생성
@Builder
@Data
@AllArgsConstructor
public class TokenInfo {
private String grantType;
private String accessToken;
private String refreshToken;
}
JWT토큰 생성, 복호화, 정보추출, 검증을 구현할 클래스
jwt.secret=VlwEyVBsYt9V7zq57TejMnVUyzblYcfPQye08f7MGVA9XkHa
토큰 암호화 복호화 하기 위한 secret key 를 application.properties 에 추가합니다.
@Slf4j
@Component
public class JwtTokenProvider {
private final Key key;
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
// AccessToken, RefreshToken 을 생성하는 메서드
public TokenInfo generateToken(Authentication authentication) {
// 권한 가져오기
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
// Access Token 생성
// Date 생성자에 삽입하는 숫자 86480000
// -> 30분: 30(m) * 60(s) * 1000(ms) = 1800000
// -> 1일: 24(h) * 60(m) * 60(s) * 1000(ms) = 86400000
Date accessTokenExpiresIn = new Date(now + 1800000);
String accessToken = Jwts.builder()
.setSubject(authentication.getName())
.claim("auth", authorities)
.claim("member_code", ((UserCustom)authentication.getPrincipal()).getMemberCode())
.setExpiration(accessTokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
// Refresh Token 생성
String refreshToken = Jwts.builder()
.claim("member_code", ((UserCustom)authentication.getPrincipal()).getMemberCode())
.setExpiration(new Date(now + 86400000))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return TokenInfo.builder()
.grantType("Bearer")
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
// JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
public Authentication getAuthentication(String accessToken) {
// 토큰 복호화
Claims claims = parseClaims(accessToken);
if (claims.get("auth") == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
// 클레임에서 권한 정보 가져오기
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get("auth").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// UserDetails 객체를 만들어서 Authentication 리턴
UserCustom principal = new UserCustom(claims.getSubject(), "", authorities, (Integer)claims.get("member_code"));
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
public Integer getMemberCodeByRefreshToken(String refreshToken){
Claims claims = parseClaims(refreshToken);
return (Integer)claims.get("member_code");
}
// 토큰 정보를 검증하는 메서드
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("Invalid JWT Token", e);
} catch (ExpiredJwtException e) {
log.info("Expired JWT Token", e);
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT Token", e);
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty.", e);
}
return false;
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
클라이언트 요청 시 JWT 인증을 하기 위해 설치하는 커스텀 필터
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 1. Request Header 에서 JWT 토큰 추출
String token = resolveToken((HttpServletRequest) request);
// 2. validateToken 으로 토큰 유효성 검사
if (token != null && jwtTokenProvider.validateToken(token)) {
// 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
// Request Header 에서 토큰 정보 추출
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
return bearerToken.substring(7);
}
return null;
}
}
Spring Security 설정을 위한 클래스이다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// .httpBasic().disable().csrf().disable(): rest api이므로 basic auth 및 csrf 보안을 사용하지 않는다는 설정
.httpBasic().disable().csrf().disable()
// .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS): JWT를 사용하기 때문에 세션을 사용하지 않는다는 설정
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 정적자원에 대해서 요청을 허가한다는 설정
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
.antMatchers("/api/**").authenticated()
/*
.antMatchers("호출 URL").permitAll(): 해당 API에 대해서는 모든 요청을 허가한다는 설정
.antMatchers("호출 URL").authenticated(): 해당 API 에 대해 인증을 필요로 한다는 설정
.antMatchers("호출 URL").hasRole("권한명"): 특정 권한이 있어야 요청할 수 있다는 설정
.antMatchers("호출 URL").hasAnyRole("권한명1", "권한명2"): 권한1 또는 권한2 이 있어야 요청할 수 있다는 설정
.anyRequest().authenticated(): 이 밖에 모든 요청에 대해서 인증을 필요로 한다는 설정
*/
.and()
/*
addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
: JWT 인증을 위하여 직접 구현한 필터를 UsernamePasswordAuthenticationFilter 전에 실행하겠다는 설정
*/
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
http.headers().frameOptions().sameOrigin();
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
인증을 위한 사용자 도메인
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "member")
public class Member implements UserDetails {
@Id
@Column(name = "member_code", nullable = false)
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer memberCode;
@Column(name = "member_id", nullable = false)
private String memberId;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String email;
@Column(nullable = false)
private String salt;
@Column(name = "refresh_token")
private String refreshToken;
@ElementCollection(fetch = FetchType.EAGER)
@Builder.Default
private List<String> roles = new ArrayList<>();
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@Override
public String getUsername() {
return memberId;
}
@Override
public String getPassword() {
return password;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
public interface MemberRepository extends JpaRepository<Member, Integer> {
Optional<Member> findByMemberId(String id);
@Query(value = "select salt from member where member_id = :id", nativeQuery = true)
String getSalt(@Param("id") String id);
}
토큰 발급 메서드 구현
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final JwtTokenProvider jwtTokenProvider;
@Transactional
public TokenInfo getToken(String memberId, String password) throws Exception{
String salt = memberRepository.getSalt(memberId);
if(salt == null || salt == "") {
throw new Exception("해당하는 유저를 찾을 수 없습니다.");
}
// 1. Login ID/PW 를 기반으로 Authentication 객체 생성
// 이때 authentication 는 인증 여부를 확인하는 authenticated 값이 false
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(memberId, getSecurePassword(password, salt));
// 2. 실제 검증 (사용자 비밀번호 체크)이 이루어지는 부분
// authenticate 매서드가 실행될 때 CustomUserDetailsService 에서 만든 loadUserByUsername 메서드가 실행
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
// 3. 인증 정보를 기반으로 JWT 토큰 생성
TokenInfo tokenInfo = jwtTokenProvider.generateToken(authentication);
// 4. refreshToken DB에 저장
Member member = memberRepository.findByMemberId(memberId).get();
member.setRefreshToken(tokenInfo.getRefreshToken());
memberRepository.save(member);
return tokenInfo;
}
public String getSecurePassword(String password, String salt) throws NoSuchAlgorithmException {
String passwordAndSalt = password + salt;
MessageDigest md = MessageDigest.getInstance("SHA-256");
// 암호화
md.update(passwordAndSalt.getBytes());
return String.format("%064x", new BigInteger(1, md.digest()));
}
}
토큰 발급 과정은
1. 로그인 요청으로 들어온 memberId로 해당 계정의 salt를 조회
2. 해당계정이 없으면 Exception 반환
3. memberId와 비밀번호 + salt로 암호화 한 비밀번호로 Authentication 객체를 생성
4. authenticate() 메서드를 통해 요청된 Member에 대해 검증
5. 검증 완료 시 새로운 토큰 생성 후 Refresh Token DB에 저장
현재 비밀번호 임의의 문자열인 salt와 조합하여 암호화 한 비밀번호를 사용하고 있으나
테스트를 위해 제외하고 진행하여도 무방합니다.
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String id) throws UsernameNotFoundException {
return memberRepository.findByMemberId(id)
.map(this::createUserDetails)
.orElseThrow(() -> new UsernameNotFoundException("해당하는 유저를 찾을 수 없습니다."));
}
// 해당하는 User 의 데이터가 존재한다면 UserDetails 객체로 만들어서 리턴
private UserDetails createUserDetails(Member member) {
UserCustom userCustom = new UserCustom(member.getUsername()
, passwordEncoder.encode(member.getPassword()), authorities(member.getRoles())
, member.getMemberCode()
);
return userCustom;
}
private static Collection authorities(List<String> roles){
Collection authorities = new ArrayList<>();
for (String role : roles) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
}
return authorities;
}
}
사용자 정보를 가져올 객체
@Getter
@Setter
@ToString
public class UserCustom extends User {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
// 유저의 정보를 더 추가하고 싶다면 이곳과, 아래의 생성자 파라미터를 조절해야 한다.
private Integer memberCode;
public UserCustom(String username, String password, Collection authorities, Integer member_code) {
super(username, password, authorities);
this.memberCode = member_code;
}
}
로그인 요청을 받을 객체
@Data
public class MemberLoginRequestDto {
private String memberId;
private String password;
}
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/member")
public class MemberController {
@Autowired
private final MemberService memberService;
@PostMapping("/login")
public ResponseEntity login(@RequestBody MemberLoginRequestDto memberLoginRequestDto, HttpServletResponse response) {
JSONObject resJobj = new JSONObject();
try {
String memberId = memberLoginRequestDto.getMemberId();
String password = memberLoginRequestDto.getPassword();
TokenInfo tokenInfo = memberService.getToken(memberId, password);
createCookie("refreshToken", tokenInfo.getRefreshToken(), response);
resJobj.put("status", "ERROR");
resJobj.put("grantType", tokenInfo.getGrantType());
resJobj.put("accessToken", tokenInfo.getAccessToken());
return new ResponseEntity(resJobj, HttpStatus.OK);
}
catch (Exception e){
resJobj.put("status", "ERROR");
resJobj.put("message", e.getMessage());
return new ResponseEntity(resJobj, HttpStatus.BAD_REQUEST);
}
}
테스트를 위해 각 컬럼에 맞는 데이터 생성
이후 postman 으로 로그인 테스트를 해보면
정상적으로 Access Token과 Refresh Token 이 발급되는 것을 볼 수 있습니다.
또한 발급 받은 Access Token을 헤더에 담아 API 호출 시 정상적으로 작동하는 것을 확인 할 수 있습니다.