
Filter에도 종류가 많지만(Rememberme, Logout 등) 필요할 때 찾아보면 됨
Json Web Token이란 뜻으로, ‘JSON 객체에 인증 객체(토큰)을 담아 보낸다’ 라고 이해하면 된다
쿠키와 세션을 이용한 로그인의 문제점을 보완한 형태
쉽게 말하면 쿠키는 사용자 정보를 기록하는 작은 저장소이고(아이디 비번 저장, 오늘 다시 보지 않기), 세션은 쿠키를 이용해 사용자 정보에 이름을 붙여 저장하는 것

Payload부분에 회원의 정보가 담기고, 정보 속 하나의 조각을 Claim이라 한다
로그인에 성공하면 서버는 회원의 정보가 담긴 JWT를 반환하고 (Response)
이후 클라이언트는 권한이 필요한(로그인이 필요한) 요청이 있을 때마다 요청 헤더에 JWT를 담아서 보내야 한다
JWT가 뭔지는 대충 알 것 같은데 이건 또 뭐지?
개발 단계에서는 주로 토큰의 유효기간에 제한을 안 두지만, 실제 서비스에선 절!대! 그렇게 하면 안된다.
위에서 언급했듯, JWT는 세션 기반이 아니기 때문에 한도 무제한의 토큰을 주인이 아닌 누군가에게 털린다면, 서버 입장에선 그 사실을 알 수가 없다… 그래서 등장한 것이 Access와 Refresh 토큰이다
물론 이 방식도 완벽하진 않다(둘 다 털리면;;)
HMAC-SHA-512 (Hash-based Message Authentication Code with Secure Hash Algorithm 512) 알고리즘의 한 종류
주로 데이터의 무결성과 인증을 보장하기 위해 사용
한마디로 비밀번호 인코딩(해싱)에 사용된다고 이해하면 됨
이제 천 천 히 실제 코드로 작성해보자
이전에 만든 게시판 프로젝트에 로그인을 주입해볼 예정이다
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'// 스프링 시큐리티
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test' // 스프링 시큐리티 테스트
runtimeOnly 'com.h2database:h2'
// JWT
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
}
# JWT 시크릿 키 설정
jwt.secret.key = 1bHO0iXdu2GfRE1Lj0GMSTrcv5U2Xqy0+VDViviWOh4QS9t1q69NOWD20nZO5/o6UyKUKS7w8pkpygfN9XF1vg==
위에 거 그냥 복사해도 됨(원래는 자기가 정한 64비트 길이 키)
랜덤 키 생성기 이용하면
이메일, 비번 그리고 권한을 뜻하는 Authority 추가
public enum Authority {
ROLE_USER, ROLE_ADMIN
}
enum 타입으로 생성한 후,
@Column(name = "EMAIL")
private String email;
@Column(name = "PASSWORD")
private String password;
@Enumerated(EnumType.STRING)
private Authority authority;
이정도만 추가
꿀팁) 처음에 Member라고 이름 지은 이유가 쿼리 예약어 때문도 있지만 시큐리티에서의 UserDetails 관련 코딩이 수월해짐!
@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByEmail(String email); //이메일로 Member 찾기
boolean existsByEmail(String email); //이메일로 Member 존재 여부 확인
}
CorsConfig 클래스는 Cross-Origin Resource Sharing (CORS)를 관리하고 구성하는 데 사용되는 클래스CorsConfig 클래스의 역할:서로 다른 도메인 또는 서버로부터의 HTTP 요청을 수락 또는 거부할 CORS 규칙을 구성
특정 경로 또는 URL에 대한 CORS 정책을 설정하여, 해당 경로로의 요청에 대한 제어
CORS 관련 HTTP 헤더를 응답에 포함하여 브라우저가 해당 요청을 허용
예를 들어, 다른 도메인의 웹 애플리케이션이 스프링 기반의 API를 호출하려고 할 때, 스프링 애플리케이션에서는 CORS 설정을 사용하여 이 요청을 허용하거나 거부할 수 있다.
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class TokenDto {
private String grantType;
private String accessToken;
private Long accessTokenExpiresIn;
private String refreshToken;
}
@Builder
@Entity
@NoArgsConstructor
@AllArgsConstructor
public class RefreshToken {
@Id
@Column(name = "RT_KEY")
private String key; //member id 들어감
@Column(name = "RT_VALUE")
private String value;// token값 들어감
public RefreshToken updateValue(String token) {
this.value = token;
return this;
}
}
@Slf4j
@Component
public class TokenProvider {
private static final String AUTHORITIES_KEY = "auth";
private static final String BEARER_TYPE = "Bearer";
private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30; // 유효기간 30분
private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7; // 유효기간 7일
private final Key key;
public TokenProvider(@Value("${jwt.secret.key}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public TokenDto generateTokenDto(Authentication authentication) {
// 권한들 가져오기
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
// Access Token 생성
Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
String accessToken = Jwts.builder()
.setSubject(authentication.getName()) // payload "sub": "name"
.claim(AUTHORITIES_KEY, authorities) // payload "auth": "ROLE_USER"
.setExpiration(accessTokenExpiresIn) // payload "exp": 151621022 (ex)
.signWith(key, SignatureAlgorithm.HS512) // header "alg": "HS512"
.compact();
// Refresh Token 생성
String refreshToken = Jwts.builder()
.setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
.signWith(key, SignatureAlgorithm.HS512)
.compact();
return TokenDto.builder()
.grantType(BEARER_TYPE)
.accessToken(accessToken)
.accessTokenExpiresIn(accessTokenExpiresIn.getTime())
.refreshToken(refreshToken)
.build();
}
public Authentication getAuthentication(String accessToken) {
// 토큰 복호화
Claims claims = parseClaims(accessToken);
if (claims.get(AUTHORITIES_KEY) == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
// 클레임에서 권한 정보 가져오기
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// UserDetails 객체를 만들어서 Authentication 리턴
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
public boolean validateToken(String token) { //토큰 검증
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
log.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
SimpleGrantedAuthority 있는 부분 빨간줄이 해결이 안된다면 위에서import org.springframework.security.core.authority.SimpleGrantedAuthority;TokenProvider 생성자:@Value("${jwt.secret.key}")를 통해 설정에서 읽어와서 Keys.hmacShaKeyFor()를 사용하여 jwt를 사용할 키 생성generateTokenDto(Authentication authentication) 메서드:Authentication 객체를 받아서 JWT 토큰을 생성하고, 이를 TokenDto 객체로 래핑하여 반환Authentication 객체는 현재 사용자의 인증 및 권한 정보를 포함getAuthentication(String accessToken) 메서드:Authentication 객체를 생성+반환UserDetails 객체를 생성UserDetails 객체를 사용하여 UsernamePasswordAuthenticationToken을 생성+반환validateToken(String token) 메서드:true, 그렇지 않으면 falseparseClaims(String accessToken) 메서드:@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String BEARER_PREFIX = "Bearer ";
private final TokenProvider tokenProvider;
// 실제 필터링 로직은 doFilterInternal 에 들어감
// JWT 토큰의 인증 정보를 현재 쓰레드의 SecurityContext 에 저장하는 역할
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 1. Request Header 에서 토큰을 꺼낸다
String jwt = resolveToken(request);
// 2. validateToken 으로 토큰 유효성 검사
// 정상 토큰이면 해당 토큰으로 Authentication 을 가져와서 SecurityContext 에 저장
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
Authentication authentication = tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
// Request Header 에서 토큰 정보를 꺼내오는 메서드
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.split(" ")[1].trim();
}
return null;
}
}
doFilterInternal 메서드:resolveToken 메서드:Authorization 헤더에서 "Bearer " 접두사를 가진 토큰을 추출nulltokenProvider.validateToken(jwt) 메서드로 토큰의 유효성 검사tokenProvider.getAuthentication(jwt)를 호출하여 해당 토큰에서 Authentication 객체(사용자 인증 및 권한 정보)를 가져옴SecurityContextHolder에 인증 정보 저장:Authentication 객체를 현재 쓰레드의 SecurityContext에 저장filterChain.doFilter(request, response) 호출:filterChain.doFilter(request, response)를 호출다음 시간엔 본격적으로 Spring Security와 관련된 Config 코드와 실제 동작까지 해보자!