CodeStates/Project
[Main-Project] JWT 이용하여 로그인 기능 구현
EUNAH.J
2023. 3. 14. 09:15
이번 프로젝트에서 로그인 기능 구현을 맡게 되어 JWT를 이용하여 기능을 구현해보았다.
왜 JWT를 사용해야 하는가?
로그인 파트를 맡아 처음 구현하려고 했을 때, 세션 방식과 토큰 방식 중 어떤 방식을 사용해야 하는지 많이 고민했다.
둘 다 장단점이 있었지만 서버의 확장성 부분을 고려하여 토큰 기반 인증 방식을 사용하게 되었고, 그 중 가장 범용적으로 사용되는 JWT 를 사용했다.
JWT를 간단하게 소개하자면 JSON 포맷의 토큰 정보를 인코딩 후, 인코딩된 토큰 정보를 secret key 로 서명한 메세지를 web token 으로 이용하는 방식이며 따로 블로깅했다.
JWT(JSON Web Token)
eunaya.tistory.com
구현기
토큰 생성
@Component
public class JwtTokenizer {
@Getter
@Value("${jwt.key}")
private String secretKey;
@Getter
@Value("${jwt.access-token-expiration-minutes}")
private int accessTokenExpirationMinutes;
@Getter
@Value("${jwt.refresh-token-expiration-minutes}")
private int refreshTokenExpirationMinutes;
public String encodeBase64SecretKey(String secretKey) {
return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
}
public String generateAccessToken(Map<String, Object> claims,
String subject,
Date expiration,
String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(Calendar.getInstance().getTime())
.setExpiration(expiration)
.signWith(key)
.compact();
}
public String generateRefreshToken(String subject, Date expiration, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
return Jwts.builder()
.setSubject(subject)
.setIssuedAt(Calendar.getInstance().getTime())
.setExpiration(expiration)
.signWith(key)
.compact();
}
public Jws<Claims> getClaims(String jws, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
Jws<Claims> claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jws);
return claims;
}
// 토큰 검증 메서드
public void verifySignature(String jws, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jws);
}
public Date getTokenExpiration(int expirationMinutes) {
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MINUTE, expirationMinutes);
Date expiration = calendar.getTime();
return expiration;
}
private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) {
byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey);
Key key = Keys.hmacShaKeyFor(keyBytes);
return key;
}
}
토큰 생성 테스트
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class JwtTokenizerTest {
private static JwtTokenizer jwtTokenizer;
private String secretKey;
private String base64EncodedSecretKey;
@BeforeAll
public void init() {
jwtTokenizer = new JwtTokenizer();
secretKey = "testsecretkey12345612346483213854352132789";
base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(secretKey);
}
@Test
@DisplayName("secret key base64 인코딩 테스트")
public void encodeBase64SecretKeyTest() {
System.out.println(base64EncodedSecretKey);
assertThat(secretKey, is(new String(Decoders.BASE64.decode(base64EncodedSecretKey))));
}
@Test
@DisplayName("access token 생성 테스트")
public void generateAccessTokenTest() {
Map<String, Object> claims = new HashMap<>();
claims.put("id", 1);
claims.put("roles", List.of("USER"));
String subject = "test access token";
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MINUTE, 10);
Date expiration = calendar.getTime();
String accessToken
= jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);
System.out.println(accessToken);
assertThat(accessToken, notNullValue());
}
@Test
@DisplayName("refresh token 생성 테스트")
public void generateRefreshTokenTest() {
String subject = "test refresh token";
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.HOUR, 24);
Date expiration = calendar.getTime();
String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);
System.out.println(refreshToken);
assertThat(refreshToken, notNullValue());
}
@Test
@DisplayName("Signature 검증 테스트")
public void verifySignatureTest() {
String accessToken = getAccessToken(Calendar.MINUTE, 10);
assertDoesNotThrow(() -> jwtTokenizer.verifySignature(accessToken, base64EncodedSecretKey));
}
@Test
@DisplayName("JWT 만료 테스트")
public void verifyExpirationTest() throws InterruptedException {
String accessToken = getAccessToken(Calendar.SECOND, 1);
assertDoesNotThrow(() -> jwtTokenizer.verifySignature(accessToken, base64EncodedSecretKey));
TimeUnit.MILLISECONDS.sleep(1500);
assertThrows(ExpiredJwtException.class,
() -> jwtTokenizer.verifySignature(accessToken, base64EncodedSecretKey));
}
private String getAccessToken(int timeUnit, int timeAmount) {
Map<String, Object> claims = new HashMap<>();
claims.put("id", 1);
claims.put("roles", List.of("USER"));
String subject = "test access token";
Calendar calendar = Calendar.getInstance();
calendar.add(timeUnit, timeAmount);
Date expiration = calendar.getTime();
String accessToken
= jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);
return accessToken;
}
}
LoginDto.java
@Getter
public class LoginDto {
private String username;
private String password;
}
JwtAuthenticationFilter.java
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JwtTokenizer jwtTokenizer;
public JwtAuthenticationFilter(AuthenticationManager authenticationManager, JwtTokenizer jwtTokenizer) {
this.authenticationManager = authenticationManager;
this.jwtTokenizer = jwtTokenizer;
}
@SneakyThrows
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) {
ObjectMapper objectMapper = new ObjectMapper();
LoginDto loginDto = objectMapper.readValue(request.getInputStream(), LoginDto.class);
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());
return authenticationManager.authenticate(authenticationToken);
}
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult) {
Member member = (Member) authResult.getPrincipal();
String accessToken = delegateAccessToken(member);
String refreshToken = delegateRefreshToken(member);
response.setHeader("Authorization", "Bearer " + accessToken);
response.setHeader("Refresh", refreshToken);
}
private String delegateAccessToken(Member member) {
Map<String, Object> claims = new HashMap<>();
claims.put("username", member.getEmail());
claims.put("roles", member.getRoles());
String subject = member.getEmail();
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());
String base64EncodedSecretkey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretkey);
return accessToken;
}
private String delegateRefreshToken(Member member) {
String subject = member.getEmail();
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);
return refreshToken;
}
}
SecurityConfiguration에 JwtAuthenticationFilter 추가
@Configuration
public class SecurityConfiguration {
private final JwtTokenizer jwtTokenizer;
public SecurityConfiguration(JwtTokenizer jwtTokenizer) {
this.jwtTokenizer = jwtTokenizer;
}
...
public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {
@Override
public void configure(HttpSecurity builder) throws Exception {
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
JwtAuthenticationFilter jwtAuthenticationFilter =
new JwtAuthenticationFilter(authenticationManager, jwtTokenizer);
jwtAuthenticationFilter.setFilterProcessesUrl("/members/login");
builder.addFilter(jwtAuthenticationFilter);
}
}
}
Spring Security에 구현되어 있는 기능들을 이용해 수월하게 로그인 기능을 구현했다.
구현하고 나름 뿌듯해했는데 멘토링 받으면서 이 코드가 어떻게 실행되고 있는지에 대한 이해가 많이 부족하다고 느껴졌다.
단순히 있는 기능을 이용하는 것이 아니라 어떻게 코드가 돌아가고 있는지 더 공부할 예정이다.