최근 토비의 스프링 3.1 책을 배우기 시작했다.
김영한 선생님의 강의를 이미 다 들었지만, 뭔가 더 깊게 배우고싶다는 생각이 들었다.
1장 챕터의 내용은 오브젝트와 의존관계로
내가 이해한 바로는 책임, 관심사 분리이다.
해당 부분을 공부하면서 임시로 미리 만든 프로젝트의 필터를 보니 정말 책에서 표현한 대로 난감했다.
필터의 역할은 토큰 검증인데 비지니스 로직과, 시큐리티컨텍스트 홀더 저장 로직, 헤더와 토큰 저장 로직등이 들어있었다.
이 기회에 배운 내용을 적용해보려한다.
기존 코드
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtils jwtUtils;
private final UserDetailsService userDetailsService;
private final MemberRepository memberRepository;
private final RedisRepository tokenRepository;
private static final String SOCIAL_TYPE_HEADER = "SocialType";
private static final String SOCIAL_TYPE_KAKAO = "kakao";
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String accessToken = resolveToken(request);
if (accessToken == null) {
log.error("Missing access token");
throw new MissingTokenException("AccessToken is required");
}
// 소셜 토큰일 경우
if (isSocialAccessToken(request)) {
KakaoUserInfo kakaoUserInfo = KakaoService.getKakaoUserInfo(accessToken);
if (kakaoUserInfo == null) {
throw new InvalidTokenException("social AccessToken is invalid or malformed");
}
// 회원가입 하지 않았다면 가입
Member member = memberRepository.findBySocialId(kakaoUserInfo.getId())
.orElse(null);
if (member == null) {
member = Member.builder()
.id(String.valueOf(UUID.randomUUID()))
.name(kakaoUserInfo.getNickname())
.socialId(kakaoUserInfo.getId())
.build();
// 회원 db에 저장 및 토큰 생성
memberRepository.save(member);
}
String newAccessToken = jwtUtils.generateAccessToken(member.getId());
String newRefreshToken = jwtUtils.generateRefreshToken(member.getId());
tokenRepository.saveRefreshToken(member.getId(), newRefreshToken); // redis에 리프레시 토큰 저장
authenticateUser(member.getId());
// 응답에 토큰 삽입
CookieUtils.addCookie(response, newRefreshToken);
response.addHeader(AUTHORIZATION_HEADER, BEARER_PREFIX + newAccessToken);
// 자체 JWT 토큰일 경우
} else {
// 검증 성공 시 Authentication 생성 및 인가
if (jwtUtils.validationToken(accessToken)) {
String memberId = jwtUtils.extractUserId(accessToken);
authenticateUser(memberId);
// 액세스 토큰 만료 시 claims를 반환
} else {
String memberId = jwtUtils.getSubjectEvenIfExpired(accessToken);
String refreshToken = tokenRepository.findRefreshToken(memberId);
log.info("액세스 토큰 만료 리프레시 발급 = {}", refreshToken);
// 리프레시 토큰 검증
if (jwtUtils.validationToken(refreshToken)) {
String newAccessToken = jwtUtils.generateAccessToken(memberId);
response.addHeader(AUTHORIZATION_HEADER, BEARER_PREFIX + newAccessToken); // 새로운 액세스 토큰 발급
authenticateUser(memberId);
// 액세스, 리프레시 둘다 만료되었다면 에러를 던지고, 프론트에서 로그인 페이지로 이동
} else {
log.warn("Both access and refresh tokens are expired");
throw new MissingTokenException("Access/RefreshToken is expired");
}
}
}
filterChain.doFilter(request, response);
}
// Authentication 등록
private void authenticateUser(String memberId) {
UserDetails userDetails = new CustomUserDetails(memberId, "ROLE_USER");
UsernamePasswordAuthenticationToken authenticationToken
= new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
//Authentication 저장
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
// 소셜 토큰 여부 체크
private boolean isSocialAccessToken(HttpServletRequest request) {
String socialTypeHeader = request.getHeader(SOCIAL_TYPE_HEADER);
return socialTypeHeader != null && socialTypeHeader.equals("kakao");
}
// jwt 토큰 추출
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (bearerToken != null && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(BEARER_PREFIX.length());
}
return null;
}
}
분리해야할 관심사
1. 비지니스 로직 (멤버 생성 및 저장, 레디스를 통한 리프레시 토큰 접근)
2. 토큰 생성
3. Authentication 등록
정도 인 것 같다.
또한 userinfo도 kakoUserInfo 클래스를 사용하여 확장성이 없다. 추후에 네이버, 구글등 소셜 서비스를 이용하도록 UserInfo 인터페이스를 만들어 사용한다.
kakaoService도 확장성을 위해 SocialService 인터페이스를 만들어 구현하게 하고, SocialServiceFactory라는 팩토리 메서드를 만들어 클라이언트가 로그인한 소셜 타입에 따라 해당 SocialService를 반환하였다.
AuthService 클래스를 만들어서 비지니스 로직, 토큰 생성, authentication 등록 로직을 위임했다.
AuthService
@Slf4j
@Service
public class AuthService {
private final RedisRepository tokenRepository;
private final MemberRepository memberRepository;
private final SocialServiceFactory socialServiceFactory;
private final JwtUtils jwtUtils;
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";
public AuthService(RedisRepository tokenRepository, MemberRepository memberRepository, SocialServiceFactory socialServiceFactory, JwtUtils jwtUtils) {
this.tokenRepository = tokenRepository;
this.memberRepository = memberRepository;
this.socialServiceFactory = socialServiceFactory;
this.jwtUtils = jwtUtils;
}
public void handleAuthenticationFromSocialToken(HttpServletRequest request, HttpServletResponse response, String accessToken) {
// 팩토리에서 요청에 있는 소셜 타입에 따라 소셜 서비스를 반환
SocialService socialService = socialServiceFactory.getService(request);
UserInfo userInfo = socialService.getUserInfo(accessToken);
if (userInfo == null) {
throw new InvalidTokenException("social AccessToken is invalid or malformed");
}
// 회원가입 하지 않았다면 가입
Member member = memberRepository.findBySocialId(userInfo.getId())
.orElseGet(() -> {
Member newMember = Member.builder()
.id(String.valueOf(UUID.randomUUID()))
.name(userInfo.getNickName())
.socialId(userInfo.getId())
.build();
// 회원 db에 저장 및 토큰 생성
memberRepository.save(newMember);
return newMember;
});
String newAccessToken = jwtUtils.generateAccessToken(member.getId());
String newRefreshToken = findMemoryRefreshToken(member);
registryAuthenticatedUser(member.getId());
// 응답에 토큰 삽입
CookieUtils.addCookie(response, newRefreshToken);
response.addHeader(AUTHORIZATION_HEADER, BEARER_PREFIX + newAccessToken); // 헤더에 액세스 토큰 삽입
}
//
public void authenticateUserFromToken(HttpServletResponse response, String accessToken) {
String memberId = jwtUtils.getSubjectEvenIfExpired(accessToken); // 만료된 accessToken의 userId값을 추출
String refreshToken = tokenRepository.findRefreshToken(memberId); ;// redis에 저장된 리프레시 토큰을 찾음
log.info("액세스 토큰 만료 리프레시 발급 = {}", refreshToken);
// 리프레시 토큰 검증 (검증 성공시)
if (jwtUtils.validationToken(refreshToken)) {
String newAccessToken = jwtUtils.generateAccessToken(memberId);
response.addHeader(AUTHORIZATION_HEADER, BEARER_PREFIX + newAccessToken); // 새로운 액세스 토큰 발급
// Authentication 등록
registryAuthenticatedUser(memberId);
// 액세스, 리프레시 둘다 만료되었다면 에러를 던지고, 프론트에서 로그인 페이지로 이동
} else {
log.warn("Both access and refresh tokens are expired");
throw new MissingTokenException("Access/RefreshToken is expired");
}
}
// redis 메모리에서 리프레시토큰 찾고 없다면 생성해서 반환
private String findMemoryRefreshToken(Member member) {
String refreshToken = tokenRepository.findRefreshToken(member.getId());
// 메모리에 리프레시 토큰이 없다면 생성하고 저장
if (refreshToken == null) {
String newRefreshToken = jwtUtils.generateRefreshToken(member.getId());
tokenRepository.saveRefreshToken(member.getId(), newRefreshToken); // redis에 리프레시 토큰 저장
}
return refreshToken;
}
// Authentication 등록
public void registryAuthenticatedUser(String memberId) {
UserDetails userDetails = new CustomUserDetails(memberId, "ROLE_USER");
UsernamePasswordAuthenticationToken authenticationToken
= new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
//Authentication 저장
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
SocialServiceFactory
public class SocialServiceFactory {
private static final String SOCIAL_TYPE_HEADER = "SocialType";
private final Map<SocialType, SocialService> serviceMap;
public SocialServiceFactory(Map<SocialType, SocialService> serviceMap) {
this.serviceMap = serviceMap;
}
public SocialService getService(HttpServletRequest request) {
String type = request.getHeader(SOCIAL_TYPE_HEADER);
SocialType socialType = SocialType.from(type);
SocialService service = serviceMap.get(socialType);
// 소셜 타입에 맞는 서비스가 없다면 null을 반환
if (service == null) {
throw new IllegalArgumentException("지원하지 않는 소셜 타입 " + type);
}
return service;
}
}
변경된 Filter
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtils jwtUtils;
private final AuthService authService;
private static final String SOCIAL_TYPE_HEADER = "SocialType";
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String accessToken = resolveToken(request);
if (accessToken == null) {
log.warn("Missing access token");
throw new MissingTokenException("AccessToken is required");
}
// 소셜 토큰일 경우
if (isSocialAccessToken(request)) {
authService.handleAuthenticationFromSocialToken(request, response, accessToken);
// 자체 JWT 토큰일 경우
} else {
// 검증 성공 시 Authentication 생성 및 인가
if (jwtUtils.validationToken(accessToken)) {
authService.registryAuthenticatedUser(jwtUtils.extractUserId(accessToken));
// 액세스 토큰 만료시
} else {
authService.authenticateUserFromToken(response, accessToken);
}
}
// 다음 필터로 이동
filterChain.doFilter(request, response);
}
// 소셜 토큰 여부 체크
private boolean isSocialAccessToken(HttpServletRequest request) {
String socialTypeHeader = request.getHeader(SOCIAL_TYPE_HEADER);
return socialTypeHeader != null && SocialType.checkSocialType(socialTypeHeader);
}
// jwt 토큰 추출
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (bearerToken != null && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(BEARER_PREFIX.length());
}
return null;
}
}
훨씬 보기 좋고 검증 책임만 가지는 필터가 되었다.
'Back-End > Spring' 카테고리의 다른 글
JWT(Json Web Token) (0) | 2025.03.15 |
---|---|
JDBCTemplate (0) | 2025.03.13 |