개인 프로젝트 진행을 위해 세팅한 back-end 환경 일부를 기록
JWT
Json 객체를 사용해서 토큰 자체에 정보를 저장하는 Web Token 이다.
Header, Payload, Signature 3 개의 부분으로 구성되어 있으며, 쿠키나 세션을 이용한 인증보다 안전하고 효율적이다.
다만, 서버에서 관리하지 않다보니 탈취당한 경우 대처가 어려운 단점이 존재함. 이를 위해 유효시간을 짧게 가져가고 refresh token 을 발급하는 정책을 사용하는 것이 올바름.
Refresh Token 정책
서버는 Access Token 이 만료된 사용자가 재발급을 원할 경우 사용자의 정보를 확인하고 Refresh Token 이 만료되지 않았다면 새로운 토큰을 발급해준다.
Token 재발급 시나리오
1. 클라이언트가 Access Token 을 통해 API 요청
2. Access Token 이 만료된 경우 서버에서 Access Token 만료 응답 반환
3. 클라이언트는 만료 응답을 받고 재발급을 위해 Access Token + Refresh Token 을 함께 보냄
4. 서버는 Refresh Token 의 만료 여부 확인
5. 서버에서 Access Token 으로 유저 정보를 획득하고 별도 저장소에서 해당 유저 정보를 가져와 Refresh Token 과 일치하는 지 확인
6. 검증이 끝나면 새로운 토큰 발급 (Access Token + Refresh Token)
7. 서버에서 Refresh Token 저장소 정보 업데이트
서버 환경(build.gradle)
buildscript {
ext {
queryDslVersion = "5.0.0"
}
}
plugins {
id 'java'
id 'org.springframework.boot' version '3.0.2'
id 'io.spring.dependency-management' version '1.1.0'
id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}
group = 'laegel'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
repositories {
mavenCentral()
}
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'
// Querydsl 추가
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
implementation "net.coobird:thumbnailator:0.4.8"
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'
implementation 'com.google.code.gson:gson:2.8.9'
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.8.0'
compileOnly 'org.projectlombok:lombok'
//runtimeOnly 'com.h2database:h2'
runtimeOnly 'mysql:mysql-connector-java'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
///querydsl 추가 시작
def querydslDir = "$buildDir/generated/querydsl"
querydsl {
jpa = true
querydslSourcesDir = querydslDir
}
sourceSets {
main.java.srcDir querydslDir
}
compileQuerydsl{
options.annotationProcessorPath = configurations.querydsl
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
querydsl.extendsFrom compileClasspath
}
//querydsl 추가 끝
compileQuerydsl.doFirst {
if (file(querydslDir).exists()) {
delete(file(querydslDir))
}
}
JWT 부분 코드
TokenProvider
path: /common/jwt/TokenProvider
package laegel.nailart.common.jwt;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import laegel.nailart.common.dto.TokenDto;
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.stereotype.Component;
import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;
@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;
private final Key key;
public TokenProvider(@Value("${jwt.secret}") 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();
Date tokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
System.out.println(tokenExpiresIn);
String accessToken = Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.setExpiration(tokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS512)
.compact();
return TokenDto.builder()
.grantType(BEARER_TYPE)
.accessToken(accessToken)
.tokenExpiresIn(tokenExpiresIn.getTime())
.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).toList();
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) {
// TODO 후에 로그로 변경할 것
System.out.println("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
System.out.println("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
System.out.println("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
System.out.println("JWT 토큰이 잘못되었습니다.");
}
return false;
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
토큰 발급 및 인증 확인, 토큰 validation 을 처리하는 Class 이다. 각 메서드에 대한 자세한 설명은 다음과 같다.
generateTokenDto()
인증에 성공한 Authentication 객체를 인자로 받아 권한에 맞는 Token 을 발급해주는 메서드이다.
getAuthentication()
AccessToken 을 인자로 받아 토큰의 인증을 꺼내는 메서드이다. 뒤이어 설명할 parseClaims() 메서드를 통해 AccessToken 을 jwt 정보 단위인 claim 으로 변환하여 사용한다. 이 후 claim 에서 발췌한 인가 정보를 포함하여 인증을 반환한다.
validationToken()
token 정보를 인자로 받아 잘못된 토큰인지 확인하는 메서드이다.
parseClaims()
jwt 로 전달되는 정보의 한 조각을 claim 이라고 하는데 이러한 형태로 AccessToken 을 반환해준다.
반환되는 Claims 객체를 뜯어보면 다음과 같다.
public interface Claims extends Map<String, Object>, ClaimsMutator<Claims> {
public static final String ISSUER = "iss";
public static final String SUBJECT = "sub";
public static final String AUDIENCE = "aud";
public static final String EXPIRATION = "exp";
public static final String NOT_BEFORE = "nbf";
public static final String ISSUED_AT = "iat";
public static final String ID = "jti";
String getIssuer();
@Override //only for better/targeted JavaDoc
Claims setIssuer(String iss);
String getSubject();
@Override //only for better/targeted JavaDoc
Claims setSubject(String sub);
String getAudience();
@Override //only for better/targeted JavaDoc
Claims setAudience(String aud);
Date getExpiration();
@Override //only for better/targeted JavaDoc
Claims setExpiration(Date exp);
Date getNotBefore();
@Override //only for better/targeted JavaDoc
Claims setNotBefore(Date nbf);
Date getIssuedAt();
@Override //only for better/targeted JavaDoc
Claims setIssuedAt(Date iat);
String getId();
@Override //only for better/targeted JavaDoc
Claims setId(String jti);
<T> T get(String claimName, Class<T> requiredType);
}
static final 로 선언된 값들이 jwt claim 과 대응되는 개념이다.
JwtFilter
path: /common/jwt/JwtFilter
package laegel.nailart.common.jwt;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
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 org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer";
private final TokenProvider tokenProvider;
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(7);
}
return null;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String jwt = resolveToken(request);
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
Authentication authentication = tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
OncePerRequestFilter 를 상속받아 한 요청에 한 번만 수행되게끔 구성하였다.
http 요청이 들어올 시 토큰 정보를 꺼내와서 앞에서 설명했던 TokenProvider 를 통해 토큰을 검증한다. 검증 완료 시 SecurityContext 에 Authentication 객체를 저장한다.
resolveToken()
HttpServletRequest 로 받은 request header 에서 토큰 정보를 꺼내오는 메서드이다.
doFilterInternal()
resolveToken() 메서드를 통해 꺼내온 토큰 정보를 가지고 검증을 하는 메서드이다. 토큰 정보가 유효하다면 Authentication 객체를 가져와 SecurityContext 에 저장한다.
SecurityContext 에서 허가된 uri 이외의 모든 request 요청은 전부 이 필터를 거치게 되며, 토큰 정보가 없거나 유효하지 않으면 정상적으로 수행되지 않는다.
JwtAuthenticationEntryPoint
path: /common/jwt/JwtAuthenticationEntryPoint
package laegel.nailart.common.jwt;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
// 유효한 자격증명을 제공하지 않고 접근하려 할 때 401
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
JwtAccessDeniedHandler
path: /common/jwt/JwtAccessDeniedHandler
package laegel.nailart.common.jwt;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
// 필요한 권한 없이 접근하려 할 때 403
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
JwtAuthenticationEntryPoint, JwtAccessDeniedHandler 두 클래스 모두 유효하지 않은 접근을 할 때 error 를 뿜어내는 컴포넌트이다.
JwtSecurityConfig
path: /common/config/JwtSecurityConfig
package laegel.nailart.config;
import laegel.nailart.common.jwt.JwtFilter;
import laegel.nailart.common.jwt.TokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
private final TokenProvider tokenProvider;
@Override
public void configure(HttpSecurity http) {
JwtFilter customFilter = new JwtFilter(tokenProvider);
http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
}
}
Spring Security 설정을 커스터마이징 하는 부분이다.
TokenProvider 를 생성하고 우리가 만든 JwtFilter에 주입한 후 SecurityConfig 에 JwtFilter 를 등록하는 과정이다.
configure() 를 오버라이드하여 jwt 관련 filter 들을 세팅해준다.
WebSecurityConfigurerAdapter 가 deprecated 되어 SecurityConfigurerAdapter 를 사용하였다.
WebSecurityConfig
path: /common/config/WebSecurityConfig
package laegel.nailart.config;
import laegel.nailart.common.jwt.JwtAccessDeniedHandler;
import laegel.nailart.common.jwt.JwtAuthenticationEntryPoint;
import laegel.nailart.common.jwt.TokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.stereotype.Component;
@Component
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class WebSecurityConfig {
private final TokenProvider tokenProvider;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic().disable()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
.and()
.apply(new JwtSecurityConfig(tokenProvider))
.and()
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/admin/auth/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
}
Spring Security 핵심 객체 HttpSecurity의 filterChain 을 구성하는 클래스이다.
예외 핸들링을 위해 이전에 작성했던 JwtAuthenticationEntryPoint, JwtAccessDeniedHanlder 를 적용하고 '/admin/auth/**' 를 제외한 모든 uri 에 토큰이 필요하다는 것을 적용시켰다.
(세세한 filter 커스터마이징은 입맛에 맞게 적용하면 된다.)
SecurityUtil
path: /common/config/SecurityUtil
package laegel.nailart.config;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
public class SecurityUtil {
private SecurityUtil() {}
public static Long getCurrentMemberId() {
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || authentication.getName() == null) {
throw new RuntimeException("Security Context에 인증 정보가 없습니다.");
}
return Long.parseLong(authentication.getName());
}
}
인증이 완료된 Authentication 을 담고있는 SecurityContext 유저 정보를 가져오는 클래스이다.
TokenDto
path: /common/jwt/TokenDto
package laegel.nailart.common.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TokenDto {
private String grantType;
private String accessToken;
private Long tokenExpiresIn;
}
토큰 정보를 담는 Dto
이후로 로그인 및 회원가입 로직을 구미에 맞게 만들면 된다.
주의해야 할 점은 비밀번호의 경우 PasswordEncoder를 사용해 검증해야 한다.
'스프링' 카테고리의 다른 글
[Spring Security] Spring Security 란 ? (0) | 2023.07.25 |
---|