Skip to content

Commit

Permalink
feat: refreshToken 기능 추가 (#119)
Browse files Browse the repository at this point in the history
* feat: refreshToken 기능 추가

- 최초 로그인 시 refreshToken redis에 저장
- 요청 시 받은 accessToken 만료되면 refreshToken 여부 확인해서 재발급
- newAccessToken 클라이언트에서 받을 수 있도록 CORS config 수정
- 특정 메서드는 token 검사 하지 않도록 수정
- 특정 API는 인증 됐는지 확인

* fix: corsConfig

* fix: redis 연결 해제 예외 처리 추가
  • Loading branch information
ah9mon authored Aug 16, 2023
1 parent 93a9ca0 commit 3981cc8
Show file tree
Hide file tree
Showing 8 changed files with 172 additions and 55 deletions.
6 changes: 5 additions & 1 deletion src/main/java/com/anywayclear/config/CorsConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,16 @@ public CorsFilter corsFilter() {

// 요청과 응답에 인증 정보를 사용할 수 있도록 설정
config.setAllowCredentials(true);

// 모든 도메인에서 요청을 허용 (필요에 따라 특정 도메인만 허용 가능)
config.addAllowedOriginPattern("*");


// 모든 헤더를 허용 (필요한 경우 특정 헤더만 허용 가능)
config.addAllowedHeader("*");

// 특정 exposed 헤더 허용
config.addExposedHeader("newAccessToken");

// 모든 HTTP 메서드를 허용 (필요한 경우 특정 메서드만 허용 가능)
config.addAllowedMethod("*");

Expand Down
12 changes: 6 additions & 6 deletions src/main/java/com/anywayclear/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -56,21 +56,21 @@ public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws
.exceptionHandling()
.accessDeniedHandler(jwtAccessDeniedHandler());

// 요청 설정
// 인가 설정
httpSecurity
.authorizeHttpRequests(authorize -> authorize
.antMatchers(HttpMethod.OPTIONS).permitAll() // OPTIONS 메서드는 모두 허용
// .antMatchers("/api/members/**").authenticated() // jwt없이 요청한건지 재확인 가능
.antMatchers("/api/subscribes/**").authenticated() // jwt없이 요청한건지 재확인 가능
.antMatchers("/api/dibs/**").authenticated() // jwt없이 요청한건지 재확인 가능
.antMatchers("/api/members/**").authenticated()
.antMatchers("/api/reviews/**").authenticated()
.antMatchers("/api/points/**").authenticated()
.antMatchers("/api/subscribes/**").authenticated()
.antMatchers("/api/dibs/**").authenticated()
.anyRequest().permitAll()
);

// OAuth2 로그인 설정
httpSecurity
.oauth2Login(oauth2Login -> oauth2Login
// 로그인 페이지 지정
// .loginPage("https://mokumoku-git-develop-mokumoku.vercel.app")
.userInfoEndpoint()
.userService(custumOAuth2UserService)
.and()
Expand Down
118 changes: 91 additions & 27 deletions src/main/java/com/anywayclear/config/jwt/JwtAuthorizationFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.redis.RedisConnectionFailureException;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
Expand All @@ -26,8 +27,10 @@
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;

public class JwtAuthorizationFilter extends BasicAuthenticationFilter {

Expand All @@ -44,56 +47,117 @@ public JwtAuthorizationFilter(AuthenticationManager authenticationManager, Membe

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
System.out.println("JwtAuthorizationFilter : 인증이나 권한이 필요한 주소 요청이 됨");
System.out.println("JwtAuthorizationFilter : JWT 유효성 검사");

// // 특정 경로에 대한 요청이라면 JWT 검사를 하지 않음
// if (!request.getRequestURI().startsWith("/api/auctions")) {
// chain.doFilter(request, response);
// return;
// }
// 특정 경로에 대한 요청이라면 JWT 검사를 하지 않음
if (request.getMethod().equals("GET") && request.getRequestURI().startsWith("/api/produces")) {
if (!request.getRequestURI().startsWith("/api/produces/")) {
chain.doFilter(request, response);
return;
}
}

String jwtHeader = request.getHeader(jwtConfig.getHeader());
System.out.println("jwtHeader = " + jwtHeader);

// JWT 토큰을 검증을 해서 정상적인 사용자인지 확인 (Header, Prefix 확인)
// JWT Header, Prefix 확인
if (jwtHeader == null || !jwtHeader.startsWith(jwtConfig.getPrefix())) {
sendJsonResponse(response, ExceptionCode.INVALID_TOKEN);
return;
}

// JWT 토큰을 검증해서 정상적인 사용자인지 확인 (토큰 검증)
// JWT 유효성 검사
String accessToken = request.getHeader(jwtConfig.getHeader()).replace(jwtConfig.getPrefix() + " ", "");
try {
String accessToken = request.getHeader(jwtConfig.getHeader()).replace(jwtConfig.getPrefix() + " ", "");
String userId = JWT.require(Algorithm.HMAC512(jwtConfig.getKey())).build().verify(accessToken).getClaim("userId").asString();
if (userId != null) {
Member member = memberRepository.findByUserId(userId).orElseThrow(() -> new EntityNotFoundException("해당 JWT의 member가 없습니다. userId: " + userId));
if (!member.isDeleted()) {
if (checkDuplicatedLogin(userId, accessToken, response)) return; // 중복로그인 시 예외처리
processValidJwt(member);
chain.doFilter(request, response);

} else { // 탈퇴한 회원일 때
sendJsonResponse(response, ExceptionCode.INVALID_DELETED_MEMBER);
try {
Member member = memberRepository.findByUserId(userId).orElseThrow(() -> new EntityNotFoundException("해당 JWT의 member가 없습니다. userId: " + userId));
if (!member.isDeleted()) {
if (checkDuplicatedLogin(userId, accessToken, response)) return; // 중복로그인 시 예외 처리
saveMemberInSecurityContextHolder(member);
chain.doFilter(request, response);
} else {
sendJsonResponse(response, ExceptionCode.INVALID_DELETED_MEMBER); // 탈퇴한 회원 예외 처리
}
} catch (EntityNotFoundException ex) {
sendJsonResponse(response, ExceptionCode.INVALID_USER_ID); // 해당 userId의 회원이 없을 때 예외 처리
}
} else { // 해당 토큰에 담긴 userId를 가진 사용자가 없을 때
sendJsonResponse(response, ExceptionCode.INVALID_TOKEN);
} else {
sendJsonResponse(response, ExceptionCode.INVALID_TOKEN); // 복호화 했지만 userId 정보가 없는 잘못된 토큰 예외 처리
}
} catch (TokenExpiredException ex) {
// 만료된 토큰 처리
sendJsonResponse(response, ExceptionCode.INVALID_EXPIRED_TOKEN);
//refreshToken 확인
String refreshToken = getRefreshToken(accessToken);
if (refreshToken != null) {
// accessToken 재발급
String userId = JWT.require(Algorithm.HMAC512(jwtConfig.getKey())).build().verify(refreshToken).getClaim("userId").asString();
String newAccessToken = createAccessToken(userId, accessToken,refreshToken,response);
if (newAccessToken != null) {
System.out.println("accessToken 재발급");
response.setHeader("newAccessToken", newAccessToken);
chain.doFilter(request, response);
}
} else {
// redis에 refreshToken없으면 만료된 토큰 처리
sendJsonResponse(response, ExceptionCode.INVALID_EXPIRED_TOKEN);
}
} catch (JWTDecodeException ex) {
// JWT 디코딩 예외 처리
sendJsonResponse(response, ExceptionCode.INVALID_TOKEN);
}
}

private boolean checkDuplicatedLogin(String userId, String token, HttpServletResponse response) throws IOException {
String tokenInRedis = redisAuthenticatedUserTemplate.opsForValue().get(userId);
if (tokenInRedis != null && !tokenInRedis.equals(token)) { // redis에 저장된 토큰과 다른 값이면
sendJsonResponse(response, ExceptionCode.INVALID_DUPLICATED_AUTHENTICATION); // 중복로그인 토큰 만료 처리
try {
String tokenInRedis = redisAuthenticatedUserTemplate.opsForValue().get(userId);
if (tokenInRedis != null && !tokenInRedis.equals(token)) { // redis에 저장된 토큰과 다른 값이면
sendJsonResponse(response, ExceptionCode.INVALID_DUPLICATED_AUTHENTICATION); // 중복로그인 토큰 만료 처리
return true;
}
return false;
} catch (RedisConnectionFailureException ex) {
sendJsonResponse(response, ExceptionCode.UNCONNECTED_REDIS);
return true;
}
return false;
}

private String getRefreshToken(String accessToken) {
String refreshToken = redisAuthenticatedUserTemplate.opsForValue().get(accessToken);
return refreshToken;
}

private String createAccessToken(String userId, String accessToken, String refreshToken,HttpServletResponse response) throws IOException {
int second = Integer.parseInt(jwtConfig.getSecond());
int minute = Integer.parseInt(jwtConfig.getMinute());
int hour = Integer.parseInt(jwtConfig.getHour());
try {
Member member = memberRepository.findByUserId(userId).orElseThrow(() -> new EntityNotFoundException("해당 JWT의 member가 없습니다. userId: " + userId));
saveMemberInSecurityContextHolder(member);

String newAccessToken = JWT.create()
.withSubject("mokumokuAccess")
.withExpiresAt(new Date(System.currentTimeMillis() + (second * minute)))
.withClaim("userId", userId)
.withClaim("role", member.getRole())
.sign(Algorithm.HMAC512(jwtConfig.getKey()));

// 기존 user 및 jwt 데이터 삭제
redisAuthenticatedUserTemplate.delete(userId);
redisAuthenticatedUserTemplate.delete(accessToken);

// redis에 newAccessToken : refreshToken 저장
redisAuthenticatedUserTemplate.opsForValue().set(userId, newAccessToken);
redisAuthenticatedUserTemplate.opsForValue().set(newAccessToken, refreshToken);
redisAuthenticatedUserTemplate.expire(newAccessToken, second * minute * hour, TimeUnit.MILLISECONDS);

return newAccessToken;
} catch (EntityNotFoundException ex) {
sendJsonResponse(response, ExceptionCode.INVALID_USER_ID);
return null;
} catch (RedisConnectionFailureException ex) {
sendJsonResponse(response, ExceptionCode.UNCONNECTED_REDIS);
return null;
}
}

private void sendJsonResponse(HttpServletResponse response, ExceptionCode exceptionCode) throws IOException {
Expand All @@ -104,7 +168,7 @@ private void sendJsonResponse(HttpServletResponse response, ExceptionCode except
new ObjectMapper().writeValue(response.getOutputStream(), errorResponse);
}

private void processValidJwt(Member member) {
private void saveMemberInSecurityContextHolder(Member member) {
Map<String, Object> userAttributes = createNewAttribute(member);
DefaultOAuth2User oAuth2User = new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(member.getRole())),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic

Map<String, Object> userAttributes = createNewAttribute(member);

// Spring Security의 세션에 OAuth2User객체 저장됨
// Spring Security의 세션에 OAuth2User객체 저장
return new DefaultOAuth2User(Collections.singleton(new SimpleGrantedAuthority(member.getRole())), userAttributes, "id");
}

Expand All @@ -62,7 +62,7 @@ private Member getMember(Optional<Member> memberOptional) {
if (member.isDeleted()) {
member.setDeleted(false);
memberRepository.save(member);
System.out.println("CustumOAuth2UserService : 재가입합니다");
System.out.println("CustumOAuth2UserService : 재가입합니다"); // 탈퇴회원이 다시 소셜 로그인하면 재가입
} else {
System.out.println("CustumOAuth2UserService : 이미 회원입니다");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.auth0.jwt.algorithms.Algorithm;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.data.redis.RedisConnectionFailureException;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.user.OAuth2User;
Expand All @@ -28,9 +29,9 @@ public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationS

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {

System.out.println("OAuth2AuthenticationSuccessHandler : 로그인 성공");

// 이미 응답이 커밋됐는데 response하면 예외 발생하므로 return
if (response.isCommitted()) {
log.debug("Response has already been committed");
return;
Expand All @@ -39,21 +40,50 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
String userId = (String) oAuth2User.getAttributes().get("userId");
String role = (String) oAuth2User.getAttributes().get("role");
int expirationTime = setExpirationTime();
System.out.println("expirationTime = " + expirationTime);

String accessToken = createToken(userId, role, expirationTime);
setTokenInRedis(userId, accessToken, expirationTime);
System.out.println("accessToken = " + accessToken);
int[] expirationTimes = setExpirationTime();
int accessExpirationTime = expirationTimes[0];
int refreshExpirationTime = expirationTimes[1];

// token 생성
String[] tokens = createToken(userId, role, accessExpirationTime, refreshExpirationTime);
String accessToken = tokens[0];
String refreshToken = tokens[1];
if (!setTokenInRedis(userId, accessToken, accessExpirationTime, refreshToken, refreshExpirationTime, request, response)) {
return;
}

String redirectUrl = createRedirectUrl(accessToken, userId);

// token 발급 및 리턴
getRedirectStrategy().sendRedirect(request,response,redirectUrl);
}

private void setTokenInRedis(String userId, String token, int expirationTime) {
redisAuthenticatedUserTemplate.opsForValue().set(userId, token);
redisAuthenticatedUserTemplate.expire(userId, expirationTime, TimeUnit.MILLISECONDS);
private boolean setTokenInRedis(String userId, String accessToken, int accessExpirationTime, String refreshToken, int refreshExpirationTime, HttpServletRequest request, HttpServletResponse response) throws IOException {

try {
redisAuthenticatedUserTemplate.opsForValue().set(userId, accessToken);
redisAuthenticatedUserTemplate.expire(userId, accessExpirationTime, TimeUnit.MILLISECONDS);

redisAuthenticatedUserTemplate.opsForValue().set(accessToken, refreshToken);
redisAuthenticatedUserTemplate.expire(accessToken, refreshExpirationTime, TimeUnit.MILLISECONDS);
return true;
} catch (RedisConnectionFailureException ex) {
sendRedisConnectionFailureException(request, response, ex);
return false;
}
}

private void sendRedisConnectionFailureException(HttpServletRequest request,HttpServletResponse response,RedisConnectionFailureException ex) throws IOException {
String redirectUrl = jwtConfig.getFail();

String errorMessage = ex.getLocalizedMessage();
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

redirectUrl = UriComponentsBuilder.fromUriString(redirectUrl)
.queryParam("error", errorMessage)
.build().toUriString();

getRedirectStrategy().sendRedirect(request, response, redirectUrl);
}

private String createRedirectUrl(String accessToken, String userId) {
Expand All @@ -65,21 +95,29 @@ private String createRedirectUrl(String accessToken, String userId) {
return redirectUrl;
}

private int setExpirationTime() {
private int[] setExpirationTime() {
int second = Integer.parseInt(jwtConfig.getSecond());
int minute = Integer.parseInt(jwtConfig.getMinute());
int hour = Integer.parseInt(jwtConfig.getHour());

return 1000 * second * minute * hour;
return new int[] {1000 * second * minute, 1000 * second * minute * hour};
}

private String createToken(String userId, String role, int expirationTime) {
private String[] createToken(String userId, String role, int accessExpirationTime, int refreshExpirationTime) {
String accessToken = JWT.create()
.withSubject("mokumoku")
.withExpiresAt(new Date(System.currentTimeMillis() + (expirationTime)))
.withSubject("mokumokuAccess")
.withExpiresAt(new Date(System.currentTimeMillis() + (accessExpirationTime)))
.withClaim("userId", userId)
.withClaim("role", role)
.sign(Algorithm.HMAC512(jwtConfig.getKey()));
return accessToken;

String refreshToken = JWT.create()
.withSubject("mokumokuRefresh")
.withExpiresAt(new Date(System.currentTimeMillis() + (refreshExpirationTime)))
.withClaim("userId", userId)
.withClaim("role", role)
.sign(Algorithm.HMAC512(jwtConfig.getKey()));

return new String[] {accessToken, refreshToken};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.web.bind.annotation.*;
Expand All @@ -36,16 +37,14 @@ public ProduceController(ProduceService produceService, AuctionService auctionSe
}

@PostMapping
// @PreAuthorize("hasRole('ROLE_CONSUMER')")
// @Secured({"ROLE_CONSUMER", "ROLE_SELLER"})
@PreAuthorize("hasRole('ROLE_SELLER')")
public ResponseEntity<Void> createProduce(@AuthenticationPrincipal OAuth2User oAuth2User, @Valid @RequestBody ProduceCreateRequest request) {
String sellerId = (String) oAuth2User.getAttributes().get("userId");
final Long id = produceService.createProduce(request, sellerId);
return ResponseEntity.created(URI.create("/api/produces/" + id)).build();
}

@GetMapping("/{id}")
// @Secured({"ROLE_CONSUMER", "ROLE_SELLER"})
public ResponseEntity<ProduceResponse> getProduce(@Positive @PathVariable("id") long id) {
produceService.updateProduceStatus();
return ResponseEntity.ok(produceService.getProduce(id));
Expand Down
Loading

0 comments on commit 3981cc8

Please sign in to comment.