📝 개요
앞에서 JWT에 대해 대략적으로 알아봤으니, 이제는 JWT를 사용한 Spring Security 로그인을 구현하려고 한다. 이때, Refresh Token을 저장하기 위해 Redis를 사용하려고 한다.
[Spring] JWT(JSON Web Token)란?
스프링 시큐리티를 사용한 로그인(with JWT)을 구현하기 전, JWT에 대해 간단하게 알아보려고 한다! 🔎 JWT 개념JWT는 사용자 인증 정보를 JSON 형태로 안전하게 전달하는 Claim 기반 웹 토큰으로, Base6
guswls28.tistory.com
👤 로그인 과정 알아보기
AccessToken과 RefreshToken을 사용한 로그인 과정
- 클라이언트가 ID, PW로 서버에게 인증을 요청한다.
- 서버는 이를 확인하고 AccessToken과 RefreshToken을 발급한 뒤, RefreshToken을 데이터베이스에 저장한다. 이후 서버는 AccessToken과 RefreshToken을 응답한다.
- 클라이언트는 AccessToken을 들고 서버에 자유롭게 요청할 수 있다. 만약 AccessToken이 만료된다면 더 이상 사용할 수 없다는 오류를 서버로부터 전달받는다.
- 클라이언트는 본인이 사용한 AccessToken이 만료되었다는 사실을 인지하면 가지고 있던 RefreshToken을 서버로 전달한다.
- 서버는 RefreshToken을 받아 해당 토큰이 유효한지 확인한다. 만약 유효한 토큰이라면 AccessToken을 재발급하여 응답한다.
- 이후 2번 과정으로 돌아가서 동일한 작업을 수행한다.

❓ RefreshToken이란
AccessToken을 탈취당했을 경우에 대한 최소한의 대비책이다.(해결책은 X)
AccessToken의 유효기간을 짧게 설정해서 탈취당해도 사용 기간을 줄이는 효과를 볼 수 있다.
RefreshToken은 오로지 AccessToken 재발급 용도로만 사용하므로, 인증 정보를 담고있지 않다.
🛢️ Refresh Token 저장에 Redis를 사용하는 이유
Refresh Token은 Access Token 재발급 용도로만 사용되고, 만료 시간이 지나면 유효하지 않다. Redis는 TTL (Time To Live) 기능을 제공하여 만료된 토큰을 자동으로 삭제한다. 또한, In-Memory 방식이므로 MySQL에 저장하는 것보다 훨씬 빠르게 조회할 수 있다. 따라서 Refresh Token을 Redis에 저장하여 관리한다.
⚙️ 설정 파일
build.gradle
우선 JWT 라이브러리들과 Redis 라이브러리를 추가한다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
// jwt
// JWT 생성 및 검증을 위한 라이브러리
implementation 'io.jsonwebtoken:jjwt-api:0.11.5' // 핵심 JWT 인터페이스 & 빌더 API
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' // 실제 JWT 구현체
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' // JWT를 JSON으로 직렬화/역직렬화 하는데 사용
// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}
application.yml
JWT 설정
토큰의 무결성 보장을 위해 사용하는 JWT 암호키(secret key)를 추가한다. 이 암호키는 토큰에 서명할 때 사용되며, 해당 키를 이용하여 토큰이 서버에서 발급된 것이 맞는지 검증할 수 있다.
누군가 토큰을 위조하거나 변조하려고 해도 secret key가 없으면 유효한 서명을 만들 수 없으므로 secret key를 절대 외부에 노출하면 안된다!
이때, HS256 알고리즘을 사용하기 위해 암호키의 길이는 약 32글자 이상으로 설정해야 한다. (32비트 이상이여야 하므로)
🔑 OpenSSL을 이용한 랜덤 키 생성 방법
32바이트:openssl rand -base64 32
64바이트:openssl rand -base64 64
Redis 설정
Redis에서 사용하는 host와 port 정보를 저장한다. Redis는 기본적으로 6379 포트를 사용한다.
예전에는 spring.redis.port로 설정했지만 현재는 spring.data.redis.port로 설정해야 한다. (Deprecated 되었다고 나온다!)

전체 설정
spring:
application:
name: securityTest
# 데이터베이스 설정
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/security
username: {username}
password: {password}
# JPA 설정
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
format_sql: true
show-sql: true
# Redis 설정
data:
redis:
host: localhost
port: 6379 # redis 데이터베이스는 기본적으로 6379 포트 사용
# JWT 설정
jwt:
secret: ${JWT_SECRET_KEY}
# 로그 설정
logging:
level:
org:
springframework: info
zerock: debug
🗄️ Redis 관련 파일
RedisConfig
@EnableRedisRepositories는 Redis 저장소 기능을 활성화하는 어노테이션이다. Redis에는 Lettuce 라이브러리와 Jedis 라이브러리가 있다. 둘 중 Lettuce가 성능이 더 좋고, spring-boot-starter-data-redis를 사용하면 의존성 설정 없이 Lettuce 라이브러리를 사용할 수 있으므로 Lettuce 라이브러리를 선택했다.
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
@EnableRedisRepositories // Redis 저장소 기능 활성화
public class RedisConfig {
// application.yml에서 host, port 값을 주입하기
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
// Redis 연결 팩토리 설정
@Bean
public RedisConnectionFactory redisConnectionFactory() {
// Redis 설정 - host와 port가 필요함
RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
redisStandaloneConfiguration.setHostName(host);
redisStandaloneConfiguration.setPort(port);
// Lettuce vs Jedis ⇒ Lettuce 선택, Lettuce 라이브러리를 사용해서 Redis에 연결
// Lettuce는 Jedis보다 성능이 좋고 비동기 처리가 가능함
return new LettuceConnectionFactory(redisStandaloneConfiguration);
}
// RedisTemplate 설정
// RedisTemplate은 DB 서버에 Set, Get, Delete 등을 사용할 수 있음
@Bean
public RedisTemplate<String, Object> redisTemplate() {
// RedisTemplate는 트랜잭션을 지원함
// 트랜잭션 안에서 오류가 발생하면 그 작업을 모두 취소함
// Redis와 통신할 때 사용할 템플릿 설정
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
// key, value에 대한 직렬화 방법 설정
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
// hash key, hash value에 대한 직렬화 방법 설정
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
RedisDao
Redis 데이터 접근을 위한 클래스로, key-value 형식으로 데이터를 저장하기 때문에 값을 쉽게 저장하고 꺼낼 수 있다.
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;
import java.time.Duration;
// Redis 데이터 접근을 위한 클래스
@Component
public class RedisDao {
private final RedisTemplate<String, Object> redisTemplate;
private final ValueOperations<String, Object> values;
public RedisDao(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
this.values = redisTemplate.opsForValue(); // String 타입을 쉽게 처리하는 메서드
}
// 기본 데이터 저장
public void setValues(String key, String data) {
values.set(key, data);
}
// 만료 시간이 있는 데이터 저장
// 주로 RefreshToken 저장할 때 주로 사용함
public void setValues(String key, String data, Duration duration) {
values.set(key, data, duration);
}
// 데이터 조회
// RefreshToken 검증 시 사용됨
public Object getValues(String key) {
return values.get(key);
}
// 데이터 삭제
// 로그아웃 시 RefreshToken을 삭제할 때 사용함
public void deleteValues(String key) {
redisTemplate.delete(key);
}
}
🔐 Spring Security 관련 파일
JwtToken
AccessToken과 RefreshToken을 담을 클래스이다.
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@Builder
@AllArgsConstructor
@Data
public class JwtToken {
private String grantType; // JWT에 대한 인증 타입, Bearer 인증 방식 사용할 예정
private String accessToken;
private String refreshToken;
}
JwtTokenProvider
JWT를 생성하고 검증하는 등의 핵심 기능을 제공하는 클래스이다.
AccessToken 만료 시간은 24시간, RefreshToken 만료 시간은 3일로 설정했다. Key에는 생성자를 이용해서 설정 파일( application.yml)에 저장된 key를 가져오도록 한다.
private final Key key;
private final UserDetailsService userDetailsService;
private final RedisDao redisDao; // RefreshToken 저장을 위해 Redis 사용
private static final String GRANT_TYPE = "Bearer";
@Value("${jwt.access-token.expire-time}") // 1000 * 60 * 60 * 24 = 1일
private long ACCESS_TOKEN_EXPIRE_TIME;
@Value("${jwt.refresh-token.expire-time}") // 1000 * 60 * 60 * 24 * 3 = 3일
private long REFRESH_TOKEN_EXPIRE_TIME;
// application.properties에서 secret 값 가져와서 secretKey 사용하기
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey,
UserDetailsService userDetailsService,
RedisDao redisDao) {
byte[] keyBytes = Base64.getEncoder().encode(secretKey.getBytes());
this.key = Keys.hmacShaKeyFor(keyBytes);
this.userDetailsService = userDetailsService;
this.redisDao = redisDao;
}
처음에 Lombok의 @Value를 사용해서 계속 빨간줄이 떴었다. Lombok이 아닌 Spring의 @Value를 사용해야 하므로 주의하기!
토큰 생성 메서드
인증 객체(Authentication)를 가지고 AccessToken과 RefreshToken을 생성하는 메서드이다.
AccessToken에는 인증된 사용자 정보와 권한 정보, 토큰 만료 시간을 포함하고 있다.
RefreshToken은 AccessToken 재발급 용도로만 사용하므로, 토큰 만료 시간만 포함하고 있다.
생성된 RefreshToken은 Redis에서 관리하므로 key - value 형태로 Redis에 저장한다. 만료 시간이 지나면 자동으로 삭제되도록 토큰의 만료 시간도 함께 넣어준다.
redisDao.setValues(username, refreshToken, Duration.ofMillis(REFRESH_TOKEN_EXPIRE_TIME))
이때, 두 토큰 모두 지정된 키와 알고리즘으로 서명해야 한다. (다른 키, 다른 알고리즘 X)
signWith(key, SignatureAlgorithm.HS256)
// Member 정보를 가지고 AccessToken, RefreshToken을 생성하기
public JwtToken generateToken(Authentication authentication) {
// 권한 가져오기
// JWT 토큰의 claims에 포함되어 사용자의 권한 정보를 저장하는데 사용됨
String authorities = authentication.getAuthorities().stream() // Authentication 객체에서 사용자 권한 목록 가져오기
.map(GrantedAuthority::getAuthority) // 각 GrantedAuthority 객체에서 권한 문자열만 추출하기
.collect(Collectors.joining(",")); // 추출된 권한 문자열들을 쉼표로 구분하여 하나의 문자열로 결합하기
long now = (new Date()).getTime();
String username = authentication.getName();
// AccessToken 생성
Date accessTokenExpire = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
String accessToken = generateAccessToken(username, authorities, accessTokenExpire);
// RefreshToken 생성
Date refreshTokenExpire = new Date(now + REFRESH_TOKEN_EXPIRE_TIME);
String refreshToken = generateRefreshToken(username, refreshTokenExpire);
// Redis에 RefreshToken 넣기
// "REFRESH_TOKEN_EXPIRE_TIME"만큼 시간이 지나면 삭제됨
redisDao.setValues(username, refreshToken, Duration.ofMillis(REFRESH_TOKEN_EXPIRE_TIME));
return JwtToken.builder()
.grantType(GRANT_TYPE) // "Bearer"
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
private String generateAccessToken(String username, String authorities, Date expireDate) {
return Jwts.builder()
.setSubject(username) // 토큰 제목 (사용자 이름)
.claim("auth", authorities) // 권한 정보 (커스텀 클레임)
.setExpiration(expireDate) // 토큰 만료 시간
.signWith(key, SignatureAlgorithm.HS256) // 지정된 키와 알고리즘으로 서명
.compact(); // 최종 JWT 문자열 생성 (header.payload.signature 구조);
}
private String generateRefreshToken(String username, Date expireDate) {
return Jwts.builder()
.setSubject(username)
.setExpiration(expireDate)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
getAuthentication 메서드
JWT 토큰을 복호화하여 토큰에 들어있는 인증 정보를 꺼내는 메서드이다.
클레임에서 권한 정보를 가져올 때, 기존 리턴 타입은 List<SimpleGrantedAuthority>이지만 Collection<? extends GrantedAuthority>으로 해당 값을 받았다. 그 이유는 다음과 같다.
- SimpleGrantedAuthority 클래스가 GrantedAuthority 인터페이스를 구현한다
- 따라서 상위 타입인 GrantedAuthority로 받음으로써 권한 정보를 다양한 타입의 객체로 처리하고 더 큰 유연성과 확장성을 가질 수 있다
- +) Collection도 List보다 상위 인터페이스
- Collection을 사용하면 Set, List, Queue 등 여러 컬렉션 타입을 받을 수 있음
- List를 사용하면 ArrayList, LinkedList 등 List 인터페이스의 구현체만 받을 수 있음
UserDetails는 Spring Security에서 제공하는 인터페이스로, 애플리케이션에서 사용되는 사용자의 정보를 표현하는 역할을 한다. User도 Spring Security에서 제공하는 클래스로, UserDetails 인터페이스를 구현한 것이다.
UsernamePasswordAuthenticationToken은 Spring Security의 Authentication을 구현한 것으로, 인증이 완료된 사용자의 인증 정보를 안전하게 담아 전달하는 토큰 객체이다.
// JWT 토큰을 복호화하여 토큰에 들어있는 정보 꺼내기
public Authentication getAuthentication(String accessToken) {
// JWT 토큰 복호화
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) // SimpleGrantedAuthority 객체들의 컬렉션으로 변환
.toList();
// UserDetails 객체를 만들어서 Authentication return
// UserDetails: interface, User: UserDetails를 구현한 클래스
UserDetails principal = new User(claims.getSubject(), "", authorities); // 파라미터: 사용자 식별자, credentials, 권한 목록
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
parseClaims 메서드
JWT 토큰을 복호화하는 메서드로, JWT 토큰 검증과 파싱을 모두 수행한다.
setSignKey(key)- 토큰을 생성할 때 사용한 key로 검증하도록 key 설정
parseClaimsJws(accessToken)- 토큰 검증: 토큰 형식 검증 + 서명 검증 + 만료시간 검증
- 검증 후 파싱: JWT 토큰을 3부분(Header, Body, Signature)으로 분리
- 실패하면 예외가 발생
// JWT 토큰 복호화
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(accessToken) // JWT 토큰 검증과 파싱을 모두 수행함
.getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
토큰 정보 검증 메서드
토큰 정보를 검증하는 메서드로, 유효한 토큰인지 검사하는 역할을 한다.
RefreshToken의 경우 토큰 검증을 수행한 후, Redis에 저장된 RefreshToken과도 일치하는지 확인하면 된다.
// 토큰 정보 검증
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
return true;
} catch (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;
}
// RefreshToken 검증
public boolean validateRefreshToken(String token) {
// 기본적인 JWT 검증
if (!validateToken(token)) return false;
try {
// token에서 username 추출하기
String username = getUserNameFromToken(token);
// Redis에 저장된 RefreshToken과 비교하기
String redisToken = (String) redisDao.getValues(username);
return token.equals(redisToken);
} catch (Exception e) {
log.info("RefreshToken Validation Failed", e);
return false;
}
}
getUserNameFromToken 메서드
토큰에서 username을 추출하는 메서드이다.
// 토큰에서 username 추출
public String getUserNameFromToken(String token) {
try {
// 토큰 파싱해서 클레임 얻기
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
// 사용자 이름(subject) 반환
return claims.getSubject();
} catch (ExpiredJwtException e) {
// 토큰이 만료되어도 클레임 내용을 가져올 수 있음
return e.getClaims().getSubject();
}
}
deleteRefreshToken 메서드
로그아웃 시 RefreshToken이 남아있으면 안되므로, Redis에서 RefreshToken을 삭제하는 메서드이다.
// RefreshToken 삭제
public void deleteRefreshToken(String username) {
if (username == null || username.trim().isEmpty()) {
throw new IllegalArgumentException("Username cannot be null or empty");
}
// 로그아웃 시 Redis에서 RefreshToken 삭제
redisDao.deleteValues(username);
}
전체 코드
package practice.securitytest.security;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
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.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;
import practice.securitytest.redis.RedisDao;
import java.security.Key;
import java.time.Duration;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;
@Slf4j
@Component
public class JwtTokenProvider {
private final Key key;
private final UserDetailsService userDetailsService;
private final RedisDao redisDao; // RefreshToken 저장을 위해 Redis 사용
private static final String GRANT_TYPE = "Bearer";
private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60; // 1분
// private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24; // 24시간
private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 3; // 3일
// application.properties에서 secret 값 가져와서 secretKey 사용하기
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey,
UserDetailsService userDetailsService,
RedisDao redisDao) {
byte[] keyBytes = Base64.getEncoder().encode(secretKey.getBytes());
this.key = Keys.hmacShaKeyFor(keyBytes);
this.userDetailsService = userDetailsService;
this.redisDao = redisDao;
}
// Member 정보를 가지고 AccessToken, RefreshToken을 생성하기
public JwtToken generateToken(Authentication authentication) {
// 권한 가져오기
// JWT 토큰의 claims에 포함되어 사용자의 권한 정보를 저장하는데 사용됨
String authorities = authentication.getAuthorities().stream() // Authentication 객체에서 사용자 권한 목록 가져오기
.map(GrantedAuthority::getAuthority) // 각 GrantedAuthority 객체에서 권한 문자열만 추출하기
.collect(Collectors.joining(",")); // 추출된 권한 문자열들을 쉼표로 구분하여 하나의 문자열로 결합하기
long now = (new Date()).getTime();
String username = authentication.getName();
// AccessToken 생성
Date accessTokenExpire = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
String accessToken = generateAccessToken(username, authorities, accessTokenExpire);
// RefreshToken 생성
Date refreshTokenExpire = new Date(now + REFRESH_TOKEN_EXPIRE_TIME);
String refreshToken = generateRefreshToken(username, refreshTokenExpire);
// Redis에 RefreshToken 넣기
// "REFRESH_TOKEN_EXPIRE_TIME"만큼 시간이 지나면 삭제됨
redisDao.setValues(username, refreshToken, Duration.ofMillis(REFRESH_TOKEN_EXPIRE_TIME));
return JwtToken.builder()
.grantType(GRANT_TYPE) // "Bearer"
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
// AccessToken & RefreshToken 재발급 할 때는 비밀번호가 필요 없음
// 이미 유효한 RefreshToken을 가지고 있다는 것이 인증된 사용자이므로
private String generateAccessToken(String username, String authorities, Date expireDate) {
return Jwts.builder()
.setSubject(username) // 토큰 제목 (사용자 이름)
.claim("auth", authorities) // 권한 정보 (커스텀 클레임)
.setExpiration(expireDate) // 토큰 만료 시간
.signWith(key, SignatureAlgorithm.HS256) // 지정된 키와 알고리즘으로 서명
.compact(); // 최종 JWT 문자열 생성 (header.payload.signature 구조);
}
private String generateRefreshToken(String username, Date expireDate) {
return Jwts.builder()
.setSubject(username)
.setExpiration(expireDate)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
public JwtToken generateTokenWithRefreshToken(String username) {
long now = (new Date()).getTime();
// AccessToken 생성
Date accessTokenExpire = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
// UserDetailsService로 사용자 권한 정보 가져오기
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
String authorities = userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
String accessToken = generateAccessToken(username, authorities, accessTokenExpire);
// RefreshToken 생성
Date refreshTokenExpire = new Date(now + REFRESH_TOKEN_EXPIRE_TIME);
String refreshToken = generateRefreshToken(username, refreshTokenExpire);
// 다시 발급한 RefreshToken Redis에 저장하기
redisDao.setValues(username, refreshToken, Duration.ofMillis(REFRESH_TOKEN_EXPIRE_TIME));
return JwtToken.builder()
.grantType(GRANT_TYPE)
.accessToken(accessToken)
.refreshToken(refreshToken).build();
}
// JWT 토큰을 복호화하여 토큰에 들어있는 정보 꺼내기
public Authentication getAuthentication(String accessToken) {
// JWT 토큰 복호화
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) // SimpleGrantedAuthority 객체들의 컬렉션으로 변환
.toList();
// UserDetails 객체를 만들어서 Authentication return
// UserDetails: interface, User: UserDetails를 구현한 클래스
UserDetails principal = new User(claims.getSubject(), "", authorities); // 파라미터: 사용자 식별자, credentials, 권한 목록
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
// JWT 토큰 복호화
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(accessToken) // JWT 토큰 검증과 파싱을 모두 수행함
.getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
// 토큰 정보 검증
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
return true;
} catch (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;
}
// RefreshToken 검증
public boolean validateRefreshToken(String token) {
// 기본적인 JWT 검증
if (!validateToken(token)) return false;
try {
// token에서 username 추출하기
String username = getUserNameFromToken(token);
// Redis에 저장된 RefreshToken과 비교하기
String redisToken = (String) redisDao.getValues(username);
return token.equals(redisToken);
} catch (Exception e) {
log.info("RefreshToken Validation Failed", e);
return false;
}
}
// 토큰에서 username 추출
public String getUserNameFromToken(String token) {
try {
// 토큰 파싱해서 클레임 얻기
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
// 사용자 이름(subject) 반환
return claims.getSubject();
} catch (ExpiredJwtException e) {
// 토큰이 만료되어도 클레임 내용을 가져올 수 있음
return e.getClaims().getSubject();
}
}
// RefreshToken 삭제
public void deleteRefreshToken(String username) {
if (username == null || username.trim().isEmpty()) {
throw new IllegalArgumentException("Username cannot be null or empty");
}
// 로그아웃 시 Redis에서 RefreshToken 삭제
redisDao.deleteValues(username);
}
}
JwtAuthenticationFilter
인증 필터 체인에서 UsernamePasswordAuthenticationFilter 이전에 동작하는 커스텀 필터이다. 클라이언트 요청에서 JWT를 검증하고, 유효한 토큰이면 해당 토큰에서 추출한 사용자의 인증 정보를 SecurityContext에 저장하여 인증 상태를 유지한다.
doFilter
Request Header에서 JWT를 추출하고 검증하여, 유효한 토큰이라면 인증 정보를 SecurityContext에 저장한 후 다음 필터로 요청을 전달하는 메서드이다.
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// Request Header에서 JWT 토큰 추출
String accessToken = resolveToken((HttpServletRequest) request);
// accessToken 유효성 검사하기
if (accessToken != null) {
if (jwtTokenProvider.validateToken(accessToken)) {
// 토큰이 유효할 경우, 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장함
Authentication authentication = jwtTokenProvider.getAuthentication(accessToken); // 토큰에 있는 정보 꺼내기
SecurityContextHolder.getContext().setAuthentication(authentication); // 현재 실행 중인 스레드에 인증 정보를 저장
} else {
// 토큰이 유효하지 않은 경우, 더 이상의 필터 처리 하지 않음
HttpServletResponse httpResponse = (HttpServletResponse) response;
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 인증 정보 부족
return;
}
}
chain.doFilter(request, response); // 다음 필터로 요청 전달
}
resolveToken
Request Header에서 JWT 토큰을 추출하는 메서드이다. Authorization 헤더에 Bearer {AccessToken} 형식으로 넘어오므로 Bearer 이후만 넘기면 된다.
// Request Header에서 JWT 토큰 추출
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
return bearerToken.substring(7); // "Bearer " 이후만 넘기기
}
return null;
}
전체 코드
package practice.securitytest.security;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import java.io.IOException;
// 클라이언트 요청시 JWT 인증을 하기 위해 설치하는 커스텀 필터
// UsernamePasswordAuthenticationFilter 이전에 실행
// 클라이언트에서 들어오는 요청에서 JWT 토큰 처리
// => 유효한 토큰이면 토큰의 인증 정보를 SecurityContext에 저장하여 인증된 요청을 처리할 수 있도록
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// Request Header에서 JWT 토큰 추출
String accessToken = resolveToken((HttpServletRequest) request);
// accessToken 유효성 검사하기
if (accessToken != null) {
if (jwtTokenProvider.validateToken(accessToken)) {
// 토큰이 유효할 경우, 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장함
Authentication authentication = jwtTokenProvider.getAuthentication(accessToken); // 토큰에 있는 정보 꺼내기
SecurityContextHolder.getContext().setAuthentication(authentication); // 현재 실행 중인 스레드에 인증 정보를 저장
} else {
// 토큰이 유효하지 않은 경우, 더 이상의 필터 처리 하지 않음
HttpServletResponse httpResponse = (HttpServletResponse) response;
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 인증 정보 부족
return;
}
}
chain.doFilter(request, response); // 다음 필터로 요청 전달
}
// Request Header에서 JWT 토큰 추출
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
return bearerToken.substring(7); // "Bearer " 이후만 넘기기
}
return null;
}
}
SecurityConfig
Spring Security의 설정을 담당하는 클래스이다.
@EnableWebSecurity는 Spring Security를 활성화하는 어노테이션이다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
...
}
filterChain
HttpSecurity를 통해 인증/인가에 대한 전반적인 보안 설정을 구성하는 메서드이다.
- REST API
- Basic auth와 csrf 보안을 사용하지 않음
- JWT 사용
- 세션이 필요하지 않음
- HTML 폼 로그인, 로그아웃을 사용하지 않음
- HTTP Request 인증 설정
- 모든 권한 허락하기:
permitAll() - 특정 권한이 필요함:
hasRole("권한") - 인증된 사용자만 가능함:
authenticated()
- 모든 권한 허락하기:
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity, HandlerMappingIntrospector introspector) throws Exception {
// Spring Security 체크 제외 목록
MvcRequestMatcher.Builder mvc = new MvcRequestMatcher.Builder(introspector);
MvcRequestMatcher[] permitAllList = {
mvc.pattern("/members/sign-in")
};
// REST API -> basic auth 및 csrf 보안을 사용하지 않음
httpSecurity.httpBasic(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable);
// JWT를 사용하므로 세션을 사용하지 않음
// 세션 생성 정책: ALWAYS, NEVER, IF_REQUIRED, STATELESS
httpSecurity.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// JWT 방식을 사용하므로 폼 로그인, 로그아웃을 사용하지 않음
httpSecurity.formLogin(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable);
// http request 인증 설정
httpSecurity.authorizeHttpRequests(authorize ->
authorize.requestMatchers(permitAllList).permitAll()
// 사용자 삭제는 관리자 권한만 가능
.requestMatchers(HttpMethod.DELETE, "/user").hasRole("ADMIN")
.requestMatchers("/members/role").hasRole("USER")
// 이 밖의 모든 요청에 대해서 인증을 필요로 함
.anyRequest().authenticated()
);
// JWT 인증을 위하여 직접 구현한 필터를 UsernamePasswordAuthnticationFilter 전에 실행
httpSecurity.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class);
return httpSecurity.build();
}
passwordEncoder 메소드
PasswordEncoder를 Bean으로 등록하는 메서드이다. 없으면 아래와 같은 에러가 발생하므로 꼭 Bean으로 등록해야 한다. 또한, 처음에는 SpringConfig 클래스에서 Bean을 등록했는데 RedisDao를 추가하니 순환 참조가 발생해서 다른 클래스로 설정을 분리했다.
BCryptPasswordEncoder는 BCrypt Encoder를 기본 인코딩 방식으로 사용한다. (BCrypt: 단방향 해시 함수)
@Bean
public PasswordEncoder passwordEncoder() {
// BCrypt Encoder 사용
// BCrypt 알고리즘만 사용해서 접두어 없이 순수한 해시값만 저장됨
return new BCryptPasswordEncoder();
}
처음에는 PasswordEncoderFactories를 빈으로 등록했는데, 해당 빈을 사용하면 {bcrypt}암호화된 값처럼 {bcrypt}와 같은 접두어가 붙었다. 암호화된 값만 DB에 저장하고 싶었기 때문에 접두어가 붙지 않는 BCryptPasswordEncoder를 빈으로 등록했다. 만약 접두어가 붙는게 상관없다면 PasswordEncoderFactories.createDelegatingPasswordEncoder()를 사용하면 된다.
- 접두어 O: {bcrypt}$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
- 접두어 X: $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
이렇게 비밀번호를 암호화해서 DB에 저장하면 로그인 시 아래와 같은 로직이 실행된다고 한다!
- 비밀번호를 암호화해서 DB에 저장한다
- 로그인 할 때 전송한 비밀번호를 스프링 시큐리티가 암호화 한다
- 스프링 시큐리티가 암호화 한 비밀번호와 DB에 저장된 비밀번호를 비교한다
- 이때, 같은 비밀번호라도 매번 다른 해시값이 생성됨
- 따라서
matches()메서드를 통해 두 값이 같은 원본 비밀번호에서 생성되었는지 검증함
- 이 값이 일치하면 로그인 성공 (물론 아이디도 일치해야 함!)
전체 코드
package practice.securitytest.security;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher;
import org.springframework.web.servlet.handler.HandlerMappingIntrospector;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity, HandlerMappingIntrospector introspector) throws Exception {
// Spring Security 체크 제외 목록
MvcRequestMatcher.Builder mvc = new MvcRequestMatcher.Builder(introspector);
MvcRequestMatcher[] permitAllList = {
mvc.pattern("/members/sign-up"),
mvc.pattern("/members/sign-in"),
mvc.pattern("/members/refresh")
};
// REST API -> basic auth 및 csrf 보안을 사용하지 않음
httpSecurity.httpBasic(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable);
// JWT를 사용하므로 세션을 사용하지 않음
// 세션 생성 정책: ALWAYS, NEVER, IF_REQUIRED, STATELESS
httpSecurity.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// JWT 방식을 사용하므로 폼 로그인, 로그아웃을 사용하지 않음
httpSecurity.formLogin(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable);
// http request 인증 설정
httpSecurity.authorizeHttpRequests(authorize ->
authorize.requestMatchers(permitAllList).permitAll()
// 사용자 삭제는 관리자 권한만 가능
// .requestMatchers(HttpMethod.DELETE, "/user").hasRole("ADMIN")
// .requestMatchers("/members/role").hasRole("USER")
.requestMatchers(HttpMethod.DELETE, "/user").hasRole("ADMIN")
.requestMatchers("/members/role").hasRole("USER")
// 이 밖의 모든 요청에 대해서 인증을 필요로 함
.anyRequest().authenticated()
);
// JWT 인증을 위하여 직접 구현한 필터를 UsernamePasswordAuthnticationFilter 전에 실행
httpSecurity.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class);
return httpSecurity.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
// BCrypt Encoder 사용
// BCrypt 알고리즘만 사용해서 접두어 없이 순수한 해시값만 저장됨
return new BCryptPasswordEncoder();
}
}
SecurityUtil
현재 인증된 사용자의 정보를 조회하는 클래스로, 사용자의 이름(username)을 얻을 수 있다.
JwtAuthenticationFilter 클래스에서 SecurityContext에 저장한 인증 정보를 가져오면 된다.
SecurityContextHolder.getContext().getAuthentication()
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
public class SecurityUtil {
// 어떤 회원이 API를 호출했는지 조회하는 메서드
public static String getCurrentUsername() {
// 현재 실행 중인 스레드에 저장했던 인증 정보 가져오기
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || authentication.getName() == null) {
throw new RuntimeException("No authentication information");
}
return authentication.getName();
}
}
🎉 마치며
여기까지 Spring Security 관련 클래스들을 작성해 봤다. 예전에 스프링 시큐리티 로그인을 만져본 적은 있지만 구현에만 집중하다 보니 기억이 하나도 나지 않았다. 이번에는 메소드 하나하나 무슨 의미인지 알아가며 로그인을 구현하다보니 시간은 오래 걸렸지만 새롭게 알게된 내용이 많아 나름 재미있었다. 또한 Refresh Token 저장을 위해 Redis도 처음 사용해봤는데 key-value 형태라 익히는데 큰 어려움이 없어 다행이었다. (물론 깊게 파고들면 또 어렵겠지만..ㅎㅎ)
+) 실제 로그인 서비스를 구현하다보니(본 계정, 서브 계정 구조 ex. 넷플) 위의 코드보다 복잡해졌는데, 그래도 잘 정리해놓은 덕에 수월하게 로그인을 개발했던 것 같다!
참고
https://jangjjolkit.tistory.com/72
https://byungil.tistory.com/309
'Backend' 카테고리의 다른 글
| [Spring] Access Token 블랙리스트 관리하기 (0) | 2025.02.25 |
|---|---|
| [Web] 웹 소켓(Web Socket)이란? (1) | 2025.01.29 |
| [Spring] JWT(JSON Web Token)란? (0) | 2025.01.11 |
| [Spring] 객체 지향 설계와 스프링 (0) | 2024.10.22 |
| [Error] 셧다운 포트가 설정되지 않았습니다. (0) | 2024.04.16 |