Spring 3점대 버전 및 gradle 8점대 버전을 기준으로 한다
// build.gradle
dependencies {
// ... dependencies
// spring security
implementation("org.springframework.boot:spring-boot-starter-security")
// jwt
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")
// ... dependencies
}
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain filterChain(
HttpSecurity http,
HandlerMappingIntrospector introspector
) throws Exception {
return http.csrf(AbstractHttpConfigurer::disable)
.sessionManagement((sessionManagement) -> sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.authorizeHttpRequests((authorizeRequests) -> authorizeRequests
.requestMatchers(new AntPathRequestMatcher("/login", "POST")).permitAll()
.anyRequest().authenticated()
)
.build();
}
}
위 파일은 Spring Security의 환경 설정을 작성하는 파일이다.
우선 메서드 및 필드 명에서 알 수 있듯이 Spring Security는 Filter로써 작동 한다.
![]()
간단하게 말해서
Filter와Interceptor그리고AOP는 비슷한 역할을 하지만 각각 다른 단계에서 작동한다. 그중Spring Security가 작동하는Filter는Dispatcher Servlet에 요청이 전해지기 전에 작동한다.즉,
Spring Context에 도달하기 전Web Context에서 작동하며Spring Context에 요청을 전달할지 말지를 결정하는 부분이라고 볼 수 있다.
filterChain 메서드를 뜯어보면 제일 먼저 csrf를 disable한다.
csrf는 사용자의 인증된 세션을 악용하는 공격 방식이기 때문에 세션 대신 토큰을 사용하는 인증방식인 JWT를 사용할 때는 꺼둔다.
마찬가지로 SessionManagament 객체 또한 STATELESS(무상태성)으로 설정한다.
다음으로, formLogin과 httpBasic은 Spring Security가 제공하는 기능인데 BackEnd와 FrontEnd 애플리케이션을 분리하여 JWT로 인증기능을 사용한다면 비활성화 하면 된다.
마지막으로, authorizeHttpRequests는 예외 URL을 설정하는 부분인데, 로그인 요청을 받는 URL을 예외시켜두었다.
// JwtProvider.java
@Component
public class JwtProvider {
private static final String AUTHORIZATION_HEADER = "Authorization";
private final String REFRESH_TOKEN_COOKIE_NAME = "refresh_token";
private static final String BEARER_TYPE = "Bearer";
private final String ID_CLAIM = "id";
private final String AUTH_CLAIM = "auth";
private static final Long REFRESH_TOKEN_EXPIRES_IN = 180 * 24 * 60 * 60 * 1000L;
private static final Long ACCESS_TOKEN_EXPIRES_IN = 30 * 60 * 1000L;
private final Key refreshkey;
private final Key accesskey;
public JwtProvider(
@Value("${jwt.secret.refresh}") String refreshSecretKey,
@Value("${jwt.secret.access}") String accessSecretKey
) {
byte[] refreshKeyBytes = Decoders.BASE64.decode(refreshSecretKey);
this.refreshkey = Keys.hmacShaKeyFor(refreshKeyBytes);
byte[] accessKeyBytes = Decoders.BASE64.decode(accessSecretKey);
this.accesskey = Keys.hmacShaKeyFor(accessKeyBytes);
}
/**
* RefreshToken 발급
*/
public String generateRefreshToken(String id) {
Date now = new Date();
return Jwts.builder()
.setSubject(id)
.claim(ID_CLAIM, id)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + REFRESH_TOKEN_EXPIRES_IN))
.signWith(refreshKey, SignatureAlgorithm.HS256)
.compact();
}
/**
* AccessToken 발급
*/
public String generateAccessToken(String id, List<Authority> roles) {
Date now = new Date();
return Jwts.builder()
.setSubject(id)
.claim(ID_CLAIM, id)
.claim(AUTH_CLAIM, roles)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + ACCESS_TOKEN_EXPIRES_IN))
.signWith(accessKey, SignatureAlgorithm.HS256)
.compact();
}
/**
* 권한정보 획득
*/
public Authentication getAuthentication(String token) {
UserDetails userDetails = userDetailsService.loadUserByUsername(this.parseClaims(token).getSubject());
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
/**
* 토큰에서 body에 실린 데이터 추출
*/
private Claims parseClaims(String token) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
/**
* 헤더에서 액세스 토큰 추출
*/
public String extractAccessToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_TYPE)) {
return bearerToken.substring(7);
}
return null;
}
/**
* 쿠키에서 Refresh Token 을 추출한다.
* @param request HttpServletRequest 객체
* @return 쿠키에서 추출한 Refresh Token
*/
public String extractRefreshToken(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName() != null && cookie.getName().equals(REFRESH_TOKEN_COOKIE_NAME))
return cookie.getValue();
}
}
return null;
}
/**
* 토큰 정보를 검증하는 메서드
*/
public boolean validateAccessToken(String accessToken) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken);
return true;
} catch (SecurityException | MalformedJwtException e) {
log.error("Invalid JWT Token", e);
} catch (ExpiredJwtException e) {
log.error("Expired JWT Token", e);
} catch (UnsupportedJwtException e) {
log.error("Unsupported JWT Token", e);
} catch (IllegalArgumentException e) {
log.error("JWT claims string is empty.", e);
}
return false;
}
/**
* 토큰 정보를 검증하는 메서드
*/
public boolean validateRefreshToken(String refreshToken) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(refreshToken);
return true;
} catch (SecurityException | MalformedJwtException e) {
log.error("Invalid JWT Token", e);
} catch (ExpiredJwtException e) {
log.error("Expired JWT Token", e);
} catch (UnsupportedJwtException e) {
log.error("Unsupported JWT Token", e);
} catch (IllegalArgumentException e) {
log.error("JWT claims string is empty.", e);
}
return false;
}
}
이 클래스에서는 JWT의 발급, 추출, 검증 등 모든 관련 기능을 수행한다.
일반적으로 refresh token은 response에 httpOnly 쿠키로 싣어서 사용자가 임의로 수정하거나
Javascript로 접근하지 못하게하고access token은 응답의 body에 싣어주어 사용자가 api 요청을 보낼 떄 Authorization에 으로 request header에 싣을 수 있게 한다.
extractAccessToken메서드와extractRefreshToken메서드는 이에 따라 추출 방식이 다르다.
// JwtAuthenticationFilter.java
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final CustomUserDetailsService userDetailsService;
private final JwtProvider jwtProvider;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String token = jwtProvider.extractAccessToken(request);
if (token != null && jwtProvider.validateAccessToken(token)) {
Authentication auth = jwtProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
}
이 단계에서는 2단계에서 환경설정 해줬던 Filter를 구현하는데 OncePerRequestFilter를 상속(implements)받는다.
OncePerRequestFilter는 한번의 요청당 한번 동작하는 필터이다.
이 클래스는 사용자의 요청을 필터링해 access token을 추출하고 유효성을 검증하여 유효하다면 Context에 토큰에 담긴 정보를 싣어 이후 처리과정에서 사용할 수 있게 한다.
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String memberId) throws UsernameNotFoundException {
MemberEntity member = memberRepository.findById(memberId).orElseThrow(() ->
new UsernameNotFoundException("사용자가 존재하지 않습니다."));
return new User(memberId, "", member.getRoles().stream().map(o -> new SimpleGrantedAuthority(
o.getName()
)).toList());
}
}
CustomUserDetailsService는 UserDetailsService를 상속(implements)받아 앞서 3번에서 작성한 클래스의 getAuthentication 메서드에서 사용한 loadUserByUsername 메서드를 구현만 해준다.
회원을 조회하는 repository 의존성을 주입하여 사용자 정보를 조회하고 UserDetails 객체를 생성하여 반환한다.
// LoginController.java
@RestController
@RequiredArgsConstructor
public class LoginController {
private final SignService signService;
private final JwtProvider jwtProvider;
@PostMapping("/login")
public ResponseEntity login(
HttpServletResponse response,
@RequestBody MemberDto member
) {
if (signService.checkMember(member.getId(), member.getPassword())) {
String refreshToken = jwtProvider.generateRefreshToken(member.getId());
String accessToken = jwtProvider.generateAccessToken(member.getId());
Cookie cookie = new Cookie("refresh_token", refreshToken);
cookie.setHttpOnly(true);
cookie.setSecure(true);
cookie.setMaxAge((180 * 24 * 60 * 60) - (60 * 60));
cookie.setPath("/");
cookie.setDomain("localhost");
cookie.setAttribute("SameSite", "none");
response.addCookie(cookie);
Map<String, String> result = new HashMap<>();
result.put("access_token", accessToken);
return ResponseEntity.ok(result);
} else {
return ResponseEntity.badRequest();
}
}
@ResponseStatus(HttpStatus.OK)
@GetMapping("/test)
public ResponseEntity test() {
return ResponseEntity.ok("success")
}
}
로그인 없이 /test 엔드포인트에 요청을 보내면 403응답을 받는다.
authentication filter에서 걸리기 때문이다.
하지만 /login 엔드포인트에 요청을 보내 로그인에 성공하면 서버에서 발급하는 access_token과 refresh_token을 발급받을 수 있다.
이 때 서버에서 반환한 httpOnly 쿠키가 Set-Cookie로 헤더에 실려 와서 저장이 되고,
응답 body에서 access_token을 받아 저장해두고 api 요청을 보낼 때 header에 Authorization으로 함께 실어서 보내주면 된다.
이후 다시 /test로 요청을 보내면 200응답을 받을 수 있다.
// LoginController.java
@RestController
@RequiredArgsConstructor
public class LoginController {
// ... Controller
// 방법 1
@ResponseStatus(HttpStatus.OK)
@GetMapping("/test)
public ResponseEntity test(
Authentication authentication
) {
return ResponseEntity.ok("success")
}
// 방법 2
@ResponseStatus(HttpStatus.OK)
@GetMapping("/test)
public ResponseEntity test() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return ResponseEntity.ok("success")
}
}
만약 /test 엔드포인트 컨트롤러에서 인증정보를 사용하려면 4번에서 context에 저장해두었던 Authentication 객체를 사용할 수 있다.