Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ public enum TokenType {

ACCESS("ACCESS:", 1000 * 60 * 60), // 1hour
REFRESH("REFRESH:", 1000 * 60 * 60 * 24 * 7), // 7days
KAKAO_OAUTH("KAKAO:", 1000 * 60 * 60); // 1hour
KAKAO_OAUTH("KAKAO:", 1000 * 60 * 60), // 1hour
BLACKLIST("BLACKLIST:", ACCESS.expireTime)
;

private final String prefix;
private final int expireTime;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import java.util.concurrent.TimeUnit;

import static com.example.solidconnection.auth.domain.TokenType.ACCESS;
import static com.example.solidconnection.auth.domain.TokenType.BLACKLIST;
import static com.example.solidconnection.auth.domain.TokenType.REFRESH;
import static com.example.solidconnection.custom.exception.ErrorCode.REFRESH_TOKEN_EXPIRED;

Expand All @@ -30,16 +31,13 @@ public class AuthService {

/*
* 로그아웃 한다.
* - 리프레시 토큰을 무효화하기 위해 리프레시 토큰의 value 를 변경한다.
* - 어떤 사용자가 엑세스 토큰으로 인증이 필요한 기능을 사용하려 할 때, 로그아웃 검증이 진행되는데,
* - 이때 리프레시 토큰의 value 가 SIGN_OUT_VALUE 이면 예외 응답이 반환된다.
* - (TokenValidator.validateNotSignOut() 참고)
* - 엑세스 토큰을 블랙리스트에 추가한다.
* */
public void signOut(String email) {
public void signOut(String accessToken) {
redisTemplate.opsForValue().set(
REFRESH.addPrefixToSubject(email),
SIGN_OUT_VALUE,
REFRESH.getExpireTime(),
BLACKLIST.addPrefixToSubject(accessToken),
accessToken,
BLACKLIST.getExpireTime(),
TimeUnit.MILLISECONDS
);
}
Expand All @@ -61,15 +59,15 @@ public void quit(String email) {
* - 리프레시 토큰이 만료되었거나, 존재하지 않는다면 예외 응답을 반환한다.
* - 리프레시 토큰이 존재한다면, 액세스 토큰을 재발급한다.
* */
public ReissueResponse reissue(String email) {
public ReissueResponse reissue(String subject) {
// 리프레시 토큰 만료 확인
String refreshTokenKey = REFRESH.addPrefixToSubject(email);
String refreshTokenKey = REFRESH.addPrefixToSubject(subject);
String refreshToken = redisTemplate.opsForValue().get(refreshTokenKey);
if (ObjectUtils.isEmpty(refreshToken)) {
throw new CustomException(REFRESH_TOKEN_EXPIRED);
}
// 액세스 토큰 재발급
String newAccessToken = tokenProvider.generateToken(email, ACCESS);
String newAccessToken = tokenProvider.generateToken(subject, ACCESS);
tokenProvider.saveToken(newAccessToken, ACCESS);
return new ReissueResponse(newAccessToken);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
import java.util.Date;
import java.util.concurrent.TimeUnit;

import static com.example.solidconnection.util.JwtUtils.parseSubjectIgnoringExpiration;
import static com.example.solidconnection.util.JwtUtils.parseSubject;
import static com.example.solidconnection.util.JwtUtils.parseSubjectOrElseThrow;

@RequiredArgsConstructor
@Component
Expand All @@ -35,7 +35,7 @@ public String generateToken(String email, TokenType tokenType) {
}

public String saveToken(String token, TokenType tokenType) {
String subject = parseSubjectOrElseThrow(token, jwtProperties.secret());
String subject = parseSubject(token, jwtProperties.secret());
redisTemplate.opsForValue().set(
tokenType.addPrefixToSubject(subject),
token,
Expand All @@ -46,6 +46,6 @@ public String saveToken(String token, TokenType tokenType) {
}

public String getEmail(String token) {
return parseSubject(token, jwtProperties.secret());
return parseSubjectIgnoringExpiration(token, jwtProperties.secret());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,48 @@
import com.example.solidconnection.custom.exception.CustomException;
import com.example.solidconnection.custom.response.ErrorResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

import static com.example.solidconnection.custom.exception.ErrorCode.AUTHENTICATION_FAILED;

@Component
@RequiredArgsConstructor
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
public class ExceptionHandlerFilter extends OncePerRequestFilter {

private final ObjectMapper objectMapper;

@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
ErrorResponse errorResponse = new ErrorResponse(AUTHENTICATION_FAILED, authException.getMessage());
writeResponse(response, errorResponse);
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (CustomException e) {
customCommence(response, e);
} catch (Exception e) {
generalCommence(response, e);
}
}

public void generalCommence(HttpServletResponse response, Exception exception) throws IOException {
ErrorResponse errorResponse = new ErrorResponse(AUTHENTICATION_FAILED, exception.getMessage());
public void customCommence(HttpServletResponse response, CustomException customException) throws IOException {
SecurityContextHolder.clearContext();
ErrorResponse errorResponse = new ErrorResponse(customException);
writeResponse(response, errorResponse);
}

public void customCommence(HttpServletResponse response, CustomException customException) throws IOException {
ErrorResponse errorResponse = new ErrorResponse(customException);
public void generalCommence(HttpServletResponse response, Exception exception) throws IOException {
SecurityContextHolder.clearContext();
ErrorResponse errorResponse = new ErrorResponse(AUTHENTICATION_FAILED, exception.getMessage());
Comment on lines +39 to +47
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이렇게 CustomException예외와 일반 예외를 한 필터에서 관리하니 훨씬 좋은 거 같습니다! 한 가지 궁금한 점이 있는데 예외 발생시마다SecurityContextHolder.clearContext()를 호출해야하는 이유가 혹시 무엇인지 알 수 있을까요? 저는 보통 토큰 만료나 로그아웃 등의 인증 관련 상황에서만 호출했어서 궁금해서 여쭤봅니다!

Copy link
Collaborator Author

@nayonsoso nayonsoso Jan 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Authentication 이 불완전한 상태로 저장되는 것을 막기 위해 그렇게 했습니다.
예를 들어 [A 필터에서 정상 저장됨 -> B 필터에서 예외 발생] 의 경우, 이전에 저장된 것을 지워주는게 안전하다 생각해서요 😊

writeResponse(response, errorResponse);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
package com.example.solidconnection.config.security;

import com.example.solidconnection.custom.exception.CustomException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

import static com.example.solidconnection.util.JwtUtils.parseSubjectOrElseThrow;
import static com.example.solidconnection.util.JwtUtils.parseSubject;
import static com.example.solidconnection.util.JwtUtils.parseTokenFromRequest;

@Component
Expand All @@ -27,7 +25,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String REISSUE_METHOD = "post";

private final JwtProperties jwtProperties;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;

@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
Expand All @@ -39,19 +36,11 @@ protected void doFilterInternal(@NonNull HttpServletRequest request,
return;
}

try {
String subject = parseSubjectOrElseThrow(token, jwtProperties.secret());
UserDetails userDetails = new JwtUserDetails(subject);
Authentication auth = new JwtAuthentication(userDetails, token, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth);
filterChain.doFilter(request, response);
} catch (AuthenticationException e) {
jwtAuthenticationEntryPoint.commence(request, response, e);
} catch (CustomException e) {
jwtAuthenticationEntryPoint.customCommence(response, e);
} catch (Exception e) {
jwtAuthenticationEntryPoint.generalCommence(response, e);
}
String subject = parseSubject(token, jwtProperties.secret());
UserDetails userDetails = new JwtUserDetails(subject);
Authentication auth = new JwtAuthentication(userDetails, token, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth);
filterChain.doFilter(request, response);
}

private boolean isReissueRequest(HttpServletRequest request) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
public class SecurityConfiguration {

private final CorsProperties corsProperties;
private final ExceptionHandlerFilter exceptionHandlerFilter;
private final SignOutCheckFilter signOutCheckFilter;
private final JwtAuthenticationFilter jwtAuthenticationFilter;

Expand All @@ -45,8 +46,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.cors(corsConfigurer -> corsConfigurer.configurationSource(corsConfigurationSource()))
.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
.addFilterBefore(this.jwtAuthenticationFilter, BasicAuthenticationFilter.class)
.addFilterBefore(this.signOutCheckFilter, JwtAuthenticationFilter.class)
.addFilterBefore(jwtAuthenticationFilter, BasicAuthenticationFilter.class)
.addFilterBefore(signOutCheckFilter, JwtAuthenticationFilter.class)
.addFilterBefore(exceptionHandlerFilter, SignOutCheckFilter.class)
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,8 @@

import java.io.IOException;

import static com.example.solidconnection.auth.domain.TokenType.REFRESH;
import static com.example.solidconnection.auth.service.AuthService.SIGN_OUT_VALUE;
import static com.example.solidconnection.auth.domain.TokenType.BLACKLIST;
import static com.example.solidconnection.custom.exception.ErrorCode.USER_ALREADY_SIGN_OUT;
import static com.example.solidconnection.util.JwtUtils.parseSubject;
import static com.example.solidconnection.util.JwtUtils.parseTokenFromRequest;

@Component
Expand All @@ -25,24 +23,20 @@ public class SignOutCheckFilter extends OncePerRequestFilter {

private final RedisTemplate<String, String> redisTemplate;
private final JwtProperties jwtProperties;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;

@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
String token = parseTokenFromRequest(request);
if (token == null || !isSignOut(token)) {
filterChain.doFilter(request, response);
return;
if (token != null && hasSignedOut(token)) {
throw new CustomException(USER_ALREADY_SIGN_OUT);
}

jwtAuthenticationEntryPoint.customCommence(response, new CustomException(USER_ALREADY_SIGN_OUT));
filterChain.doFilter(request, response);
}

private boolean isSignOut(String accessToken) {
String subject = parseSubject(accessToken, jwtProperties.secret());
String refreshToken = REFRESH.addPrefixToSubject(subject);
return SIGN_OUT_VALUE.equals(redisTemplate.opsForValue().get(refreshToken));
private boolean hasSignedOut(String accessToken) {
String blacklistKey = BLACKLIST.addPrefixToSubject(accessToken);
return redisTemplate.opsForValue().get(blacklistKey) != null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.example.solidconnection.siteuser.domain;

public enum AuthType {

KAKAO,
APPLE,
EMAIL,
;
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
Expand All @@ -32,15 +34,25 @@
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@AllArgsConstructor
@Table(uniqueConstraints = {
@UniqueConstraint(
name = "uk_site_user_email_auth_type",
columnNames = {"email", "auth_type"}
)
})
public class SiteUser {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false, length = 100)
@Column(name = "email", nullable = false, length = 100)
private String email;

@Column(name = "auth_type", nullable = false, length = 100)
@Enumerated(EnumType.STRING)
private AuthType authType;

@Setter
@Column(nullable = false, length = 100)
private String nickname;
Expand Down Expand Up @@ -100,5 +112,25 @@ public SiteUser(
this.preparationStage = preparationStage;
this.role = role;
this.gender = gender;
this.authType = AuthType.KAKAO;
}

public SiteUser(
String email,
String nickname,
String profileImageUrl,
String birth,
PreparationStatus preparationStage,
Role role,
Gender gender,
AuthType authType) {
this.email = email;
this.nickname = nickname;
this.profileImageUrl = profileImageUrl;
this.birth = birth;
this.preparationStage = preparationStage;
this.role = role;
this.gender = gender;
this.authType = authType;
}
}
23 changes: 20 additions & 3 deletions src/main/java/com/example/solidconnection/util/JwtUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Component;

import java.util.Date;

import static com.example.solidconnection.custom.exception.ErrorCode.INVALID_TOKEN;

@Component
Expand All @@ -25,22 +27,37 @@ public static String parseTokenFromRequest(HttpServletRequest request) {
return token.substring(TOKEN_PREFIX.length());
}

public static String parseSubject(String token, String secretKey) {
public static String parseSubjectIgnoringExpiration(String token, String secretKey) {
try {
return extractSubject(token, secretKey);
} catch (ExpiredJwtException e) {
return e.getClaims().getSubject();
} catch (Exception e) {
throw new CustomException(INVALID_TOKEN);
}
}

public static String parseSubjectOrElseThrow(String token, String secretKey) {
public static String parseSubject(String token, String secretKey) {
try {
return extractSubject(token, secretKey);
} catch (ExpiredJwtException e) {
} catch (Exception e) {
throw new CustomException(INVALID_TOKEN);
}
}

public static boolean isExpired(String token, String secretKey) {
try {
Date expiration = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody()
.getExpiration();
return expiration.before(new Date());
} catch (Exception e) {
return true;
}
}

private static String extractSubject(String token, String secretKey) throws ExpiredJwtException {
return Jwts.parser()
.setSigningKey(secretKey)
Expand Down
Loading