안녕하세요? 이번 시간에는 지난 번에 SecurityConfig 파일 생성까지만 했었던 프로젝트를 이어서 만들어보겠습니다.
지난 시간에 설명한 내용으로는, SecurityConfig 파일은 보안과 관련된 설정을 해주는 파일이라고 설명했습니다.
그런데 일단 우리는 JWT라는 Json 형식의 Web Token을 사용하여 로그인 기능을 구현할 예정이라, 이 토큰과 관련된 로직을 구현해주어야합니다.
일단은 해당 로직을 구현해주기 전에, gradle로 들어가서 의존 관계를 설정해줍니다.
의존 관계 하단에 보시면, io.jsonwebtoken 이라는 코드를 보실 수 있으실텐데, 이것이 바로 JWT와 관련된 의존 관계를 맺어주는 설정이라고 보실 수 있으십니다.
그 다음으로는 application.yml 파일로 들어가서 다음과 같이 설정을 해줍니다.
일단은 이렇게 설정해두고, 추후에 사용하게 될 경우에 어떠한 기능을 수행하는지 설명해드리겠습니다.
일단 대강 설정을 마치고나면, 이제 본격적으로 JWT와 관련된 로직을 수행하는 파일을 만들어보도록 하겠습니다.
일단 jwt라는 패키지를 생성하여 하단에 TokenProvider라는 클래스를 생성해주었습니다.
해당 클래스는 JWT와 관련된 기능을 수행한다고 하였는데, 토큰의 유효성 검증, 토큰의 생성 및 복호화 기능을 수행할 것입니다.
package chrkb1569.LoginAPI.jwt;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.stereotype.Component;
import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;
@Component
@Slf4j
public class TokenProvider implements InitializingBean {
private String secret; // 비밀키에 해당하는 문자열 secret
private long tokenValidationTime; // 토큰의 유효 시간에 해당하는 tokenValidationTime
private final String AUTHORITIES_KEY = "auth"; // 추후에 권한을 얻기 위한 키로 사용될 문자열 AUTHORITIES_KEY
private Key key; // 토큰을 암호화하는데에 사용됨
public TokenProvider( // application.yml 파일에 선언해주었던 값들을 가져와서 해당 변수에 대입
@Value("${jwt.secret}") String secret,
@Value("${jwt.valid_time}") long tokenValidationTime) {
this.secret = secret;
this.tokenValidationTime = tokenValidationTime * 1000;
}
@Override
public void afterPropertiesSet() throws Exception // Bean이 생성된 뒤에 해당 메소드가 실행됨됨
{
byte[] keyBytes = Decoders.BASE64.decode(secret); // 비밀키를 BASE64 방식을 통하여 암호화 진행함
this.key = Keys.hmacShaKeyFor(keyBytes); // 비밀키 만들어서 key에 대입
}
public String createToken(Authentication authentication) {
// Authentication 객체가 가지고 있는 권한 정보를 문자열로 변환
String authorities = authentication.getAuthorities()
.stream().map(GrantedAuthority::getAuthority).collect(Collectors.joining(","));
Date date = new Date();
long now = date.getTime(); // 현재 시간을 얻어옴
Date tokenExpiration = new Date(now + this.tokenValidationTime);
return Jwts.builder()
.setSubject(authentication.getName()) // 토큰의 주제를 나타내는 부분같은데, 이 토큰은 로그인을 위하여 사용되는 토큰이므로,
// 사용자의 이름을 통하여 구별
.claim(AUTHORITIES_KEY, authorities)
.signWith(this.key, SignatureAlgorithm.HS512)
.setExpiration(tokenExpiration)
.compact();
}
public Authentication getAuthentication(String token) {
Claims claims = Jwts.parserBuilder() // 토큰으로부터 Claim 정보를 얻어옴
.setSigningKey(this.key)
.build()
.parseClaimsJws(token)
.getBody();
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
User user = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(user, token, authorities);
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(this.key).build().parseClaimsJws(token);
return true;
}
catch(SecurityException | MalformedJwtException e) {
log.info("잘못된 형식의 token입니다.");
}
catch(ExpiredJwtException e) {
log.info("만료된 token입니다.");
}
catch(UnsupportedJwtException e) {
log.info("지원하지 않는 JWT token입니다.");
}
catch(IllegalArgumentException e) {
log.info("잘못된 형식의 JWT token입니다.");
}
return false;
}
}
일단 다음과 같은 코드를 통하여 토큰의 생성 및 유효성 검사 등의 기능을 수행하는 파일을 만들었습니다.
먼저, TokenProvider 파일의 생성자부터 설명하자면, @Value 어노테이션이 활용된 것을 볼 수 있습니다.
이는 우리가 이전에 application.yml 파일에 선언해두었던 값들을 가져와서 대입시켜주는데, secret의 경우에는 우리가 암호화하는데에 필요한 비밀키의 값을 의미하며, valid_time의 경우에는 토큰의 유효 시간을 정해주기 위하여 선언해주었습니다.
@Override
public void afterPropertiesSet() throws Exception {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
그리고 다음과 같이 Override된 메소드가 존재하는데, 이는 InitializingBean 인터페이스를 구현함에 있어서 Override 해주는 메소드로, Bean이 생성된 뒤에 실행되는 메소드입니다.
비밀키를 BASE64 방식을 통하여 byte형 배열의 키로 변화시킨 뒤에, 해당 비밀키를 hmacShaKeyFor() 메소드를 통하여 비밀키를 만들어 key에 대입시켜줍니다.
이제 우리는 이 key라는 객체를 통하여 토큰을 암호화하는데에 사용할 것입니다.
그럼 이제부터 TokenProvider에 선언된 메소드들을 설명해보겠습니다.
public String createToken(Authentication authentication) {
String authorities = authentication.getAuthorities()
.stream().map(GrantedAuthority::getAuthority).collect(Collectors.joining(","));
Date date = new Date();
long now = date.getTime();
Date tokenExpiration = new Date(now + this.tokenValidationTime);
return Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.signWith(this.key, SignatureAlgorithm.HS512)
.setExpiration(tokenExpiration)
.compact();
}
먼저, 해당 메소드는 토큰을 생성하는 메소드입니다.
우리가 토큰을 생성할 때, Authentication 객체를 받아와 해당 객체를 토대로 토큰을 생성하게됩니다.
Authentication 객체에는 사용자와 관련된 정보가 담겨져있는데, 그 중, 우리가 활용할 정보가 바로 사용자의 이름과 사용자에게 부여된 권한입니다.
먼저, getAuthorities() 메소드를 통하여 사용자가 가지고 있는 권한들을 뽑아낸 뒤에, 이를 collect()로 String 형으로 바꿔줍니다.
그 다음으로는 Date 객체를 통하여 현재 시간에서 아까 생성자 메소드가 실행되면서 대입해주었던 tokenValidationTime을 통해 토큰의 유효 기간을 설정해줍니다.
Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.signWith(this.key, SignatureAlgorithm.HS512)
.setExpiration(tokenExpiration)
.compact();
그 후로는 다음의 메소드를 통하여 토큰을 String형으로 반환시켜주면 되는데,
토큰의 목적을 설정하는 부분이라고하는데, 솔직히 이 부분에 대한 설명이 애매해서 이해하는데에 좀 오래 걸렸습니다.
여러 자료를 찾아보니, 다른 토큰과 구별되는 식별자 느낌으로 생각하면 될 것 같습니다.
어차피 우리는 로그인 기능을 구현해야하므로, Token에는 사용자에 관한 정보를 담기 때문에, 토큰끼리 서로 구별하는데에 가장 좋은 대상이 사용자의 이름이므로, authentication 객체에서 이름을 받아와 이를 토큰의 subject로 설정한게 아닌가 생각합니다.
JWT의 데이터 부분은 다음과 같이 "key" : "value"의 형태로 되어 있습니다.
ex)
"JWT 데이터 1" : "데이터 1",
"JWT 데이터 2" : "데이터 2"
따라서, AUTHORITIES_KEY를 key값으로, authorities를 value 값으로 설정해두는 것입니다.
이를 통하여 Map처럼 키값을 통하여 value값을 얻을 수 있습니다.
afterPropertiesSet() 메소드에서 설정해두었던 key값을 사용하여 토큰의 정보들을 암호화한다고 생각하시면됩니다.
추후에 동일한 키를 사용하여 암호화된 데이터들을 복호화 할 수 있습니다.
JWT의 유효 기간을 설정하는 부분입니다.
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(this.key).build().parseClaimsJws(token);
return true;
}
catch(SecurityException | MalformedJwtException e) {
log.info("잘못된 형식의 token입니다.");
}
catch(ExpiredJwtException e) {
log.info("만료된 token입니다.");
}
catch(UnsupportedJwtException e) {
log.info("지원하지 않는 JWT token입니다.");
}
catch(IllegalArgumentException e) {
log.info("잘못된 형식의 JWT token입니다.");
}
return false;
}
마지막으로 해당 토큰의 유효성을 검사하는 로직입니다.
try문 내부에 설정된 로직을 구현하면서 발생하는 오류들에 따라서 log를 통하여 오류를 출력하고, 오류가 발생할 경우 false를 반환함으로써 해당 토큰이 유효하지 않음을 알려주며, 오류가 발생하지 않을 경우에는 true를 반환함으로써 해당 토큰이 유효함을 알려줍니다.
일단 오늘은 TokenProvider 파일을 생성하는 단계까지 해보았습니다.
모르는 부분이 많아, 구글링을 통해 조사할 수 있는 부분들은 모두 조사해보았으나, 아직 모호한 부분들이 많았습니다ㅠ
그래도 모르는 내용들이 이해되는 순간은 엄청 뿌듯하더라구요ㅋㅋ
이 다음으로는 보안 설정에 필요한 추가적으로 필요한 정보나 설정들을 위한 파일들을 만들어보도록 하겠습니다.