안녕하세요! delay100입니다.
오늘은 로그인 및 인가를 도입한 부분에 대한 기록을 남겨보려합니다.
서론
Spring 프로젝트를 해본 사람이면 누구나 한번쯤 들어봤을 방식인 SpringSecurity와 JWT를 혼합해서 사용했습니다.
SpringSecurity는 기본으로 제공해주는 BcryptPasswordEncoder, http authorizeHttpRequests 등이 있어, 인증 및 인가에 대해 복잡하게 직접 구현하지 않고도 손쉽게 사용할 수 있다는 점이 매력적입니다.
JWT는 무상태성(stateless)을 가지므로 매 요청에 대해 DB 부하를 없앨 수 있다는 장점이 있습니다. 기본적으로 Session방식을 사용하게 되면 요청마다 sessionId를 검사해야하므로 DB 부하가 존재합니다. 결국, JWT를 이용하면 매 요청마다 DB접근을 하지 않아 사용자 인가 속도가 빨라지지만 보안은 세션보다는 낮다는 점이 특징을 가집니다.
사실 SpringSecurity는 기본적으로 세션(Session) 및 Form Login을 기반으로 동작하기 때문에 JWT와 그렇게 어울리는 조합은 아닙니다. 하지만 위에서 언급한 2가지 각각의 매력을 놓치고 싶지 않아 두 방식을 같이 이용했습니다.
따라서 인증(로그인)은 SpringSecurity에서 제공하는 인증 필터(AuthenticationFilter)를 이용하지 않고 Login API + JWT를 직접 구현해서 사용했습니다. 그리고 인가(Authorization)에 대해서는 SpringSecurity가 처리하도록 했습니다.
1. SpringSecurity
Github 주소
1. 동작 방식
Spring에서 모든 호출은 DispatcherServlet을 통과하게 되고 이후에 각 요청을 담당하는 Controller로 분배됩니다. 이 때, 각 요청에 대해 공통적으로 처리해야 할 필요가 있을 때 DispatcherServlet 이전 단계가 필요하며, 이것이 Filter입니다.
서론에서 언급했듯이, 현재 제 로그인은 SpringSecurity를 사용하지 않고 API로 따로 빼두도록 설계했으므로, 아래의 JwtAuthenticatioFilter 코드는 실행되지 않고 넘어갑니다.
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
그러나, Filter의 순서를 명시할 때 UsernamePasswordAuthenticationFilter보다 먼저 인가하도록 해야합니다.
http.addFilterBefore(jwtAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class);
- 필터 순서의 의미: JwtAuthorizationFilter가 먼저 실행됨으로써, JWT 토큰을 사용한 인증이 다른 인증 메커니즘보다 우선하여 처리됩니다.
- 실제 동작:
- JwtAuthorizationFilter가 먼저 실행됩니다.
- 이 필터에서 JWT 토큰을 검증하고, 유효한 경우 인증 정보를 설정합니다.
- 이후 Spring Security의 다른 인증 메커니즘(예: UsernamePasswordAuthenticationFilter)은 실행되지 않습니다.
- UsernamePasswordAuthenticationFilter의 역할:
- 실제로 이 필터는 사용되지 않지만, Spring Security 필터 체인에서 중요한 위치를 차지합니다.
- 이 위치는 "여기서 인증이 완료되어야 한다"는 의미를 가집니다.
- 결과적인 흐름:
- 요청이 들어옴
- JwtAuthorizationFilter에서 JWT 토큰 검증 (인증)
- 인증 정보 설정
- 다른 Spring Security 필터들이 이 인증 정보를 바탕으로 인가 처리
- 컨트롤러/서비스 로직 실행
따라서, 이 설정으로 인해 JWT 기반의 인증이 다른 인증 메커니즘보다 우선하여 처리되고, 그 결과를 바탕으로 인가 과정이 진행됩니다. 전통적인 폼 로그인 방식의 인증 과정(UsernamePasswordAuthenticationFilter)은 거치지 않고, JWT 토큰 검증만으로 인증을 완료하는 것입니다.
이 접근 방식은 JWT를 사용하는 RESTful API 서버에 매우 적합하며, 상태를 유지하지 않는(stateless) 인증 방식을 효과적으로 구현할 수 있게 해줍니다.
* 폼 로그인 기반 인증?
2. SpringSecurity 도입
// WebSecurityConfig
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
private final AuthenticationConfiguration authenticationConfiguration;
public WebSecurityConfig(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService, AuthenticationConfiguration authenticationConfiguration) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
this.authenticationConfiguration = authenticationConfiguration;
}
// authenticationManager는 bean으로 직접 등록할거임
// authenticationConfiguration 처럼 바로 가져올 수가 없어서 authenticationConfiguration으로 가져와서 수동등록함
// 파라미터로 받아온 authenticationConfiguration을 넣어줘서 getAuthencationManager()을 가져옴
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
// 인증 필터 객체 생성 후 bean으로 등록
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtil);
filter.setAuthenticationManager(authenticationManager(authenticationConfiguration));
return filter;
}
@Bean
public JwtAuthorizationFilter jwtAuthorizationFilter() {
return new JwtAuthorizationFilter(jwtUtil, userDetailsService);
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// CSRF 설정
http.csrf(AbstractHttpConfigurer::disable);
// 기본 설정인 Session 방식은 사용하지 않고 JWT 방식을 사용하기 위한 설정
http.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
.requestMatchers("/refreshtoken/**").permitAll()
.requestMatchers("/auth/**").permitAll()
.requestMatchers("/products/**").permitAll()
.anyRequest().authenticated()
);
// Exception Handling 설정 (access denied 처리)
http.exceptionHandling((exceptionHandling) ->
exceptionHandling
.authenticationEntryPoint(new FailedAuthenticationEntryPoint()) // 인증 실패 시 처리
);
// http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
// http.addFilterBefore(jwtAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class);
http.addFilterBefore(jwtAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
// 인가 실패인 경우
class FailedAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getWriter().write("{\"error\": \"Unauthorized\", \"message\": \"Authentication is required to access this resource.\"}");
}
}
}
// UserDetailsServiceImpl
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final MemberRepository memberRepository;
public UserDetailsServiceImpl(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Override
public UserDetails loadUserByUsername(String memberId) throws UsernameNotFoundException {
Member member = memberRepository.findByMemberId(memberId)
.orElseThrow(() -> new UsernameNotFoundException("Not Found " + memberId));
return new UserDetailsImpl(member);
}
}
// UserDetailsImpl
@Builder
@AllArgsConstructor
public class UserDetailsImpl implements UserDetails {
private final Member member;
public Member getMember() { return member; }
@Override
public String getPassword() {
return member.getPassword();
}
@Override
public String getUsername() {
return member.getMemberId();
}
public Long getId() { return member.getId(); }
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
MemberRoleEnum role = member.getRole();
String authority = role.getAuthority();
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(authority);
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(simpleGrantedAuthority);
return authorities;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
// JwtAuthorizationFilter
@Slf4j(topic = "JWT 검증 및 인가")
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
public JwtAuthorizationFilter(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain filterChain) throws ServletException, IOException {
String accessToken = jwtUtil.getTokenFromRequest(req);
log.info("AccessToken: {}", accessToken);
if (StringUtils.hasText(accessToken)) {
accessToken = jwtUtil.substringToken(accessToken);
log.info("SubStringToken: {}", accessToken);
if (!jwtUtil.validateToken(accessToken)) {
log.error("Token Error");
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid or expired token");
return;
}
Claims info = jwtUtil.getMemberInfoFromToken(accessToken);
try {
setAuthentication(info.get("id", Long.class), info.getSubject(), MemberRoleEnum.valueOf(info.get("ROLE", String.class)));
} catch (Exception e) {
log.error(e.getMessage());
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authentication failed");
return;
}
}
filterChain.doFilter(req, res);
}
private void setAuthentication(Long id, String memberId, MemberRoleEnum memberRoleEnum) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication = createAuthentication(id, memberId, memberRoleEnum);
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}
private Authentication createAuthentication(Long id, String memberId, MemberRoleEnum memberRoleEnum) {
// UserDetails userDetails = userDetailsService.loadUserByUsername(memberId); // 인증에는 들어오고 인가에는 안들어오게
Member member = Member.builder()
.id(id)
.memberId(memberId)
.role(memberRoleEnum)
.build();
UserDetails userDetails = UserDetailsImpl.builder()
.member(member)
.build();
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
}
2. 로그인(인증)
Github 주소
- WebSecurityConfig
- AuthController
- AuthService
- JwtAuthenticationFilter -> 로그인(인증)처리. 구현만 해두고 직접 사용X (FormLogin을 사용하지 않기 위해)
1. 동작 방식
1. 사용자가 로그인 요청을 보냅니다.(/auth/login)
2. Security에 명시된 requestMatchers에 의해 /auth로 들어오는 모든 요청 허용되어 구현한 API로 이동합니다.
3, 4, 5. /auth/login API가 실행되어 memberId, pw를 검증합니다.
6. 검증에 성공하면 Redis에 key(memberId) - value(RefreshToken)을 저장합니다. 유효기간은 3일로 설정했습니다.
7. 로그인 여부에 따라 프론트에 True 또는 False를 반환합니다.
2. 로그인(인증) 구현
// AuthController
/**
* POST
* 로그인
* @param loginRequestDto 로그인 요청 DTO
* @param res 서블릿 응답 객체
* @return 로그인 응답 DTO
*/
@PostMapping(BASE_AUTH + "/login")
public ApiResponse<LoginResponseDto> login(
@RequestBody LoginRequestDto loginRequestDto,
HttpServletResponse res
) {
return ApiResponse.createSuccess(authService.login(loginRequestDto, res));
}
// AuthService
public LoginResponseDto login(LoginRequestDto loginRequestDto, HttpServletResponse res) {
Member member = memberRepository.findByMemberId(loginRequestDto.getMemberId())
.orElseThrow(() -> new IllegalArgumentException("잘못된 사용자 아이디 또는 비밀번호입니다."));
if(!passwordEncoder.matches(loginRequestDto.getPassword(), member.getPassword())) {
throw new IllegalArgumentException("잘못된 사용자 아이디 또는 비밀번호입니다.");
}
try {
// accessToken 발급
String accessToken = jwtUtil.createAccessToken(member.getId(), member.getMemberId(), member.getRole());
jwtUtil.addJwtToCookie(accessToken, res);
// refreshToken 발급
String refreshToken = jwtUtil.createRefreshToken();
redisTemplate.opsForValue().set(member.getMemberId(), refreshToken, REFRESH_TOKEN_TIME, TimeUnit.MINUTES);
return LoginResponseDto.from(member.getMemberId(), refreshToken);
} catch (TokenCreationException e) {
throw new TokenCreationException("토큰 발급에 실패했습니다.", e);
}
}
3. JWT(인가)
Github 주소
1. 동작 방식
3-1-1. JWT Util
그래서 결국 JWT 동작을 어떻게 구현했냐면요!
JWT 관련 기능들을 가진 JwtUtil이라는 클래스를 만들어 JWT 관련 기능을 수행시키도록 모아두었습니다.
1, 2. JWT 생성 -> 생성한 토큰을 반환하는 방법 2가지(1.그냥 헤더에 담아 보냄(Response객체의 header에 그냥 token넣어 보내기) 2. Cookie객체에 Response에 담는 방법(cookie.setToken해서 넣고 Response객체에 넣어 보내기))
여기서 주의할 점은 Cookie안에는 스페이스바( )가 들어가지 않기 때문에 아래와 같이 %20으로 작성해주었습니다.
token = URLEncoder.encode(token, "UTF-8").replaceAll("\\+", "%20");
// URLEncoder.encode: 이 메서드는 URL 인코딩을 수행합니다. 이 과정에서 공백 문자는 +로 인코딩됩니다.
// replaceAll("\+", "%20"): URL 인코딩된 문자열에서 +를 %20으로 대체합니다.
// 쿠키 값에서는 공백 문자를 +로 인코딩하는 대신 %20으로 인코딩해야 하는데, 이는 HTTP 쿠키의 규격을 따르기 위함입니다. %20은 공백 문자를 URL 인코딩 방식으로 표현한 것입니다.
2. 생성된 JWT를 Cookie에 저장
3. Cookie에 들어있던 JWT 토큰을 Substring하는 메소드
위에서 Token의 가장 앞에있는 Bearer을 떼주는 작업을 수행합니다.
4. JWT 검증
Token이 유효한지 검증하는 작업이 필요합니다.
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); // 토큰 검증, key: secretKey, token: 가져온 토큰
5. JWT에서 사용자 정보 가져오기
3-1-2. JwtAuthorizationFilter
- 역할: 요청을 처리하기 전에 JWT를 검증하고, 인증된 사용자를 컨텍스트에 설정하는 필터입니다.
- 주요 기능:
- OncePerRequestFilter를 상속받아 요청마다 한 번씩 실행됩니다.
- 요청에서 JWT를 추출하고, 유효성을 검증합니다.
- 유효한 JWT인 경우, 토큰에서 사용자 정보를 추출하여 인증 컨텍스트에 설정합니다.
- 인증 실패 시, 에러를 응답합니다
2. JWT(인가) 구현
// Jwtutil
package com.whitedelay.productshop.security.jwt;
import com.whitedelay.productshop.exception.TokenCreationException;
import com.whitedelay.productshop.member.entity.MemberRoleEnum;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.security.Key;
import java.util.Base64;
import java.util.Date;
import java.util.UUID;
// JWT 관련 기능들을 가진 JwtUtil이라는 클래스를 만들어 JWT 관련 기능을 수행시킴
//<JWT 관련 기능>
//1. JWT 생성 -> 생성한 토큰을 반환하는 방법 2가지(1.그냥 헤더에 담아 보냄(Response객체의 header에 그냥 token넣어 보내기) 2. Cookie객체에 Response에 담는 방법(cookie.setToken해서 넣고 Response객체에 넣어 보내기))
//2. 생성된 JWT를 Cookie에 저장
//3. Cookie에 들어있던 JWT 토큰을 Substring
//4. JWT 검증
//5. JWT에서 사용자 정보 가져오기
@Component
public class JwtUtil { // util 클래스: 다른 객체에 의존하지 않고 하나의 모듈로서 동작하는 클래스
@Value("${AUTHORIZATION_HEADER}")
public String AUTHORIZATION_HEADER;
@Value("${REFRESHTOKEN_HEADER}")
public String REFRESHTOKEN_HEADER;
public static final String BEARER_PREFIX = "Bearer ";
@Value("${ACCESS_TOKEN_TIME}")
private Long ACCESS_TOKEN_TIME;
@Value("${REFRESH_TOKEN_TIME}")
private Long REFRESH_TOKEN_TIME;
// @Value는 Beansfactory에서 가져옴(위에 import확인)
@Value("${JWT_SECRET_KEY}") // Base64 Encode 한 SecretKey
private String secretKey; // Encode된 Secret Key를 Decode 해서 사용
private Key key; // Decode된 Secret Key를 담는 객체
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
// 로그 설정, "로깅", 애플리케이션이 동작될 때 시간순으로 기록하는 것임_기본적으로 가지고 있어서 사용할 수 있음
public static final Logger logger = LoggerFactory.getLogger("JWT 관련 로그"); // Logback 로깅 프레임워크
@PostConstruct // 딱 한번만 받아와야하는 값을 받아올때 요청을 새로 호출하는 실수를 방지하기 위한 어노테이션
public void init() {
byte[] bytes = Base64.getDecoder().decode(secretKey); // 디코딩하여 byte배열로 받아옴
key = Keys.hmacShaKeyFor(bytes); // hmacShaKeyFor메소드에서 변환이 일어나고, key를 뱉어줌
}
// 1. JWT 토큰 생성 -> 생성한 토큰을 반환하는 방법 2가지(1.그냥 헤더에 담아 보냄(Response객체의 header에 그냥 token넣어 보내기) 2. Cookie객체에 Response에 담는 방법(cookie.setToken해서 넣고 Response객체에 넣어 보내기))
// Access/Refresh 토큰 생성
public String createAccessToken(Long id, String memberId, MemberRoleEnum role) throws TokenCreationException {
try {
Date date = new Date();
return BEARER_PREFIX + Jwts.builder() // jwt사용자의 권한 정보를 넣음, UserRole의 enum정보를 넣음, claim은 key, value로 데이터를 넣는 것
.setExpiration(new Date(date.getTime() + ACCESS_TOKEN_TIME)) // 만료 시간
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm)// 암호화 알고리즘(시크릿 키, 시크릿 알고리즘)을 넣어주면
.setSubject(memberId) // 사용자 식별자값(ID)
.claim("ROLE", role)
.claim("id", id)
.compact();
} catch (Exception e) {
throw new TokenCreationException("AccessToken 생성에 실패했습니다.", e);
}
}
public String createRefreshToken() throws TokenCreationException {
try {
Date date = new Date();
return BEARER_PREFIX + Jwts.builder() // jwt사용자의 권한 정보를 넣음, UserRole의 enum정보를 넣음, claim은 key, value로 데이터를 넣는 것
.setExpiration(new Date(date.getTime() + REFRESH_TOKEN_TIME)) // 만료 시간
.setIssuedAt(date) // 발급일
.signWith(key, signatureAlgorithm)// 암호화 알고리즘(시크릿 키, 시크릿 알고리즘)을 넣어주면
.claim("UUID", UUID.randomUUID().toString())
.compact();
} catch (Exception e) {
throw new TokenCreationException("RefreshToken 생성에 실패했습니다.", e);
}
}
// 3. 생성된 JWT를 Cookie에 저장
// JWT Cookie 에 저장
public void addJwtToCookie(String token, HttpServletResponse res) {
try {
token = URLEncoder.encode(token, "UTF-8").replaceAll("\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행
Cookie cookie = new Cookie(AUTHORIZATION_HEADER, token); // Name-Value(encoding한 토큰 값을 넣음)
cookie.setPath("/");
cookie.setHttpOnly(true); // HttpOnly 속성 설정
// Response 객체에 Cookie 추가
res.addCookie(cookie);
} catch (UnsupportedEncodingException e) {
logger.error(e.getMessage());
}
}
//4. Cookie에 들어있던 JWT 토큰을 Substring(BEARER 떼기
// JWT 토큰 substring
public String substringToken(String tokenValue) {
if (StringUtils.hasText(tokenValue)) {
if (tokenValue.startsWith(BEARER_PREFIX)) {
return tokenValue.substring(BEARER_PREFIX.length());
} else {
logger.error("Token does not start with 'Bearer ' prefix: {}", tokenValue);
throw new IllegalArgumentException("Invalid token format");
}
}
logger.error("Token is null or empty: {}", tokenValue);
throw new IllegalArgumentException("Token is null or empty");
}
//5. JWT 검증
// 토큰 검증
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); // 토큰 검증, key: secretKey, token: 가져온 토큰
return true;
} catch (SecurityException | MalformedJwtException e) {
logger.info("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
} catch (ExpiredJwtException e) {
logger.info("Expired JWT token, 만료된 JWT token 입니다.");
} catch (UnsupportedJwtException e) {
logger.info("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
} catch (IllegalArgumentException e) {
logger.info("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
}
return false;
}
// 검증해서 문제가 없음이 확인됨
//6. JWT에서 사용자 정보 가져오기
// 토큰에서 사용자 정보 가져오기
public Claims getMemberInfoFromToken(String token) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
} catch (ExpiredJwtException e) {
logger.info("Expired JWT token, 만료된 JWT token 입니다.");
return e.getClaims(); // 만료된 토큰에서 Claims를 추출
} catch (JwtException e) {
logger.error("Invalid JWT token");
throw new IllegalArgumentException("Invalid JWT token", e);
}
}
// @CookieValue를 사용할 수 없는 경우에
// HttpServletRequest 에서 Cookie Value : JWT 가져오기
public String getTokenFromRequest(HttpServletRequest req) {
Cookie[] cookies = req.getCookies();
if(cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(AUTHORIZATION_HEADER)) {
try {
return URLDecoder.decode(cookie.getValue(), "UTF-8"); // Encode 되어 넘어간 Value 다시 Decode
} catch (UnsupportedEncodingException e) {
return null;
}
}
}
}
return null;
}
}
@Slf4j(topic = "JWT 검증 및 인가")
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
public JwtAuthorizationFilter(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain filterChain) throws ServletException, IOException {
String accessToken = jwtUtil.getTokenFromRequest(req);
log.info("AccessToken: {}", accessToken);
if (StringUtils.hasText(accessToken)) {
accessToken = jwtUtil.substringToken(accessToken);
log.info("SubStringToken: {}", accessToken);
if (!jwtUtil.validateToken(accessToken)) {
log.error("Token Error");
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid or expired token");
return;
}
Claims info = jwtUtil.getMemberInfoFromToken(accessToken);
try {
setAuthentication(info.get("id", Long.class), info.getSubject(), MemberRoleEnum.valueOf(info.get("ROLE", String.class)));
} catch (Exception e) {
log.error(e.getMessage());
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authentication failed");
return;
}
}
filterChain.doFilter(req, res);
}
private void setAuthentication(Long id, String memberId, MemberRoleEnum memberRoleEnum) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication = createAuthentication(id, memberId, memberRoleEnum);
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}
private Authentication createAuthentication(Long id, String memberId, MemberRoleEnum memberRoleEnum) {
// UserDetails userDetails = userDetailsService.loadUserByUsername(memberId); // 인증에는 들어오고 인가에는 안들어오게
Member member = Member.builder()
.id(id)
.memberId(memberId)
.role(memberRoleEnum)
.build();
UserDetails userDetails = UserDetailsImpl.builder()
.member(member)
.build();
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
}
3. 주의! (*삽질 내역)
Token과 Security를 혼합해서 사용하는 방식이 대해 제대로 알지 못하고 동작을 이상하게 구현했었습니다.
현재는 모두 개선되었지만, 삽질로 인해 꽤 오랜 시간이 소요되었습니다.. 아래는 크게 3가지의 삽질 내역입니다.
1. AccessToken이 만료됐을 때 Filter에서 바로 RefreshToken을 재발급 하도록 구현했었습니다.
"Filter에서 매번 인가 처리를 하니까 그때 AccessToken이 만료되면 바로 RefreshToken을 재발급 한 다음에 요청 로직을 수행하면 되지 않나?"라는 생각을 했었습니다.
그런데 이러면 만료된 AccessToken에서 유저 정보를 가져오는 아래와 같은 로직을 이용하게 됩니다. (만료된 경우에 RefreshToken을 재발급하게 되니까)
// jwtutil
public Claims getMemberInfoFromToken(String token) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
} catch (ExpiredJwtException e) {
logger.info("Expired JWT token, 만료된 JWT token 입니다.");
return e.getClaims(); // 만료된 토큰에서 Claims를 추출
} catch (JwtException e) {
logger.error("Invalid JWT token");
throw new IllegalArgumentException("Invalid JWT token", e);
}
}
*Exception을 걸어줘야 하는 이유는 JWT 라이브러리가 토큰을 파싱하는 과정에서 만료된 토큰에 대해 ExpiredJwtException을 던지기 때문입니다.
그런데 만료된 토큰에서 RefreshToken을 재발급 하면 사실상 AccessToken에 설정된 만료기한이 의미가 없어집니다.
왜냐하면 만료된 토큰에서 유저 정보를 뽑아오기 때문이죠... 어떠한 만료된 토큰이 있어도 사실상 AccessToken의 역할을 할 수 있습니다.
2. 매 요청마다 AccessToken, RefreshToken이 모두 이동하도록 했었습니다.
1번을 위해서 모든 요청에 대해서 Cookie안에서 AccessToken, RefreshToken이 이동하도록 했습니다.
그러나, 토큰 탈취 위험이 크기 때문에 RefreshToken을 매번 보내는 건 말이 안되고,,
AccessToken만 매 요청마다 이동하도록 변경해야함을 깨달았습니다.
따라서 AccessToken은 HTTP-Only Cookie에 담아서 이동하도록 변경했고, RefreshToken은 사용자의 LocalStorage에 담아두었다가 AccessToken이 만료되었다는 에러를 받으면 RefreshToken을 바로 서버로 던져주도록 했습니다.
이때 RefreshToken도 만료되었다면 로그인이 풀리게 되는 것이죠!
3. RefreshToken에도 유저 정보를 직접 저장했었습니다.
처음에는 RefreshToken에도 AccessToken과 동일하게 userId를 넣어두었습니다.
그러나, 이는 보안상 취약점을 일으킬 수 있음을 깨달았습니다. 탈취된 토큰을 통해 사용자 정보가 노출될 위험이 있으며, 악의적인 사용자가 이를 이용해 불법적인 접근을 시도할 수 있는 가능성이 있기 때문입니다.
이러한 문제를 개선하기 위해, RefreshToken에는 사용자 정보를 포함하지 않고, 고유 식별자로 UUID를 사용하도록 변경했습니다. 이렇게 함으로써 RefreshToken이 탈취되더라도 사용자 정보가 직접 노출되지 않도록 보안을 강화했습니다.
4. 로그아웃
Github 주소
1. 동작 방식
로그아웃은 간단하게 구현했습니다!
1. 로그아웃 요청을 보냅니다.(/logout)
2. 사용자의 Cookie에 있는 AccessToken을 지웁니다.
3. Redis에 저장되어있는 사용자의 RefreshToken을 삭제합니다.
4. 빈(Empty) Cookie를 반환합니다.
(프론트에서는 LocalStorage에 있는 RefreshToken을 지웁니다.)
2. 로그아웃 구현
// MemberController
/**
* DELETE
* 로그아웃
* @param userDetails security의 회원 정보
* @param res 서블릿 응답 객체
* @return 로그아웃 성공 여부(T/F)
*/
@DeleteMapping(BASE_MEMBER + "/logout")
public ApiResponse<Boolean> logout(
@AuthenticationPrincipal UserDetailsImpl userDetails,
HttpServletResponse res
) {
return ApiResponse.createSuccess(memberService.logout(userDetails.getMember(), res));
}
// MemberService
// redis의 토큰 삭제 & UserCookie비워주기
public boolean logout(Member member, HttpServletResponse res) {
// redis에서 memberId 찾아서 삭제
String refreshToken = redisTemplate.opsForValue().get(member.getMemberId());
if (refreshToken != null) {
redisTemplate.delete(member.getMemberId());
}
deleteCookie(res);
return true;
}
public void deleteCookie(HttpServletResponse res) {
// 응답헤더 Cookie 비우기
Cookie accessTokenCookie = new Cookie(AUTHORIZATION_HEADER, null);
accessTokenCookie.setPath("/");
accessTokenCookie.setHttpOnly(true);
accessTokenCookie.setMaxAge(0);
res.addCookie(accessTokenCookie);
}
4. 결과 및 예제(API)
아래의 링크에서 더 자세히 보실 수 있습니다.
API(POSTMAN) : https://documenter.getpostman.com/view/23481846/2sA3kSo3ZJ
긴 글 읽어주셔서 감사합니다! 코드나 동작 방식에 대해 궁금한 점이 있으시면 언제든지 질문해 주세요. 피드백도 환영합니다.
'Study > SpringBoot' 카테고리의 다른 글
[e-commerce 프로젝트] 5. 상품(AWS S3, DB Scheduling, Redis) (0) | 2024.07.26 |
---|---|
[e-commerce 프로젝트] 4. 마이페이지 정보 업데이트 (4) | 2024.07.25 |
[e-commerce 프로젝트] 2. 회원가입(네이버 이메일 인증, 개인정보·비밀번호 암호화) (6) | 2024.07.24 |
[e-commerce 프로젝트] 1. 프로젝트 간단 소개 및 ERD 설계 (1) | 2024.07.17 |
[SpringBoot] 따라하면 만들어지는 메모장 + AWS 배포까지 (1) (0) | 2024.05.22 |