diff --git a/build.gradle b/build.gradle index 8a8f4c5..be36740 100644 --- a/build.gradle +++ b/build.gradle @@ -33,14 +33,21 @@ dependencies { compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' -// implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' -// implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-security' runtimeOnly 'com.mysql:mysql-connector-j' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // test testRuntimeOnly 'com.h2database:h2' testCompileOnly 'org.projectlombok:lombok' diff --git a/src/main/java/com/cake/pop/domain/user/repository/UserRepository.java b/src/main/java/com/cake/pop/domain/user/repository/UserRepository.java index 29aeab3..a6fbb13 100644 --- a/src/main/java/com/cake/pop/domain/user/repository/UserRepository.java +++ b/src/main/java/com/cake/pop/domain/user/repository/UserRepository.java @@ -1,5 +1,7 @@ package com.cake.pop.domain.user.repository; +import java.util.Optional; + import com.cake.pop.domain.user.exception.UserErrorCode; import com.cake.pop.entity.User; import com.cake.pop.global.exception.RestApiException; @@ -12,4 +14,6 @@ public interface UserRepository extends JpaRepository { default User getById(Long id){ return findById(id).orElseThrow(()->new RestApiException(UserErrorCode.USER_NOT_FOUND)); } + + Optional findByEmail(String email); } diff --git a/src/main/java/com/cake/pop/domain/user/service/UserService.java b/src/main/java/com/cake/pop/domain/user/service/UserService.java index f6d2615..27fbc3e 100644 --- a/src/main/java/com/cake/pop/domain/user/service/UserService.java +++ b/src/main/java/com/cake/pop/domain/user/service/UserService.java @@ -1,6 +1,9 @@ package com.cake.pop.domain.user.service; import com.cake.pop.domain.user.repository.UserRepository; +import com.cake.pop.entity.User; +import com.cake.pop.global.auth.oauth.Oauth2Response; + import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -8,4 +11,24 @@ @RequiredArgsConstructor public class UserService { private final UserRepository userRepository; + + public User saveOrUpdate(Oauth2Response oauth2Response) { + User user = userRepository.findByEmail(oauth2Response.getEmail()) + .map(u -> { + u.updateEmail(oauth2Response.createSocialEmail()); + // deleteRefreshTokenIfExists(m); + // deleteOauthAccessTokenIfExists(m); + // saveOauth2AccessToken(oauth2Response, m); + return u; + }) + .orElseGet(() -> createMemberFromOauth2Response(oauth2Response)); + + return userRepository.save(user); + } + + private User createMemberFromOauth2Response(Oauth2Response oauth2Response) { + User user = User.of(oauth2Response.getEmail()); + // saveOauth2AccessToken(oauth2Response, member); + return user; + } } diff --git a/src/main/java/com/cake/pop/entity/User.java b/src/main/java/com/cake/pop/entity/User.java index df50a5c..5981e74 100644 --- a/src/main/java/com/cake/pop/entity/User.java +++ b/src/main/java/com/cake/pop/entity/User.java @@ -36,4 +36,8 @@ public static User of(String email){ .email(email) .build(); } + + public void updateEmail(String email) { + this.email = email; + } } diff --git a/src/main/java/com/cake/pop/global/auth/handler/CustomAccessDeniedHandler.java b/src/main/java/com/cake/pop/global/auth/handler/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..683f887 --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/handler/CustomAccessDeniedHandler.java @@ -0,0 +1,81 @@ +package com.cake.pop.global.auth.handler; + +import java.io.IOException; +import java.util.Objects; +import java.util.stream.Collectors; + +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import com.cake.pop.global.exception.ErrorResponse; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + private static final String ROLE_GUEST = "ROLE_GUEST"; + private static final String ROLE_USER = "ROLE_USER"; + + private static boolean matchAuthenticationFromRole(Authentication authentication, String role) { + String authRole = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining()); + + return Objects.equals(authRole, role); + } + + private static void setUpResponse( + HttpServletResponse response, + SecurityErrorCode securityErrorCode + ) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 Unauthorized + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + + ErrorResponse errorResponse = new ErrorResponse( + securityErrorCode.getMessage(), + securityErrorCode.getHttpStatus().name() + ); + + ObjectMapper mapper = new ObjectMapper(); + String jsonResponse = mapper.writeValueAsString(errorResponse); + + response.getWriter().write(jsonResponse); + } + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + // 현재 인증된 사용자 정보 가져오기 + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + log.error(authentication.getName()); + // 사용자 권한에 따라 다른 응답 제공 + if (!Objects.isNull(accessDeniedException)) { + if (!matchAuthenticationFromRole(authentication, ROLE_USER)) { + // ROLE_USER 권한이 없는 경우 + log.info(SecurityErrorCode.FORBIDDEN_USER.getMessage()); + setUpResponse(response, SecurityErrorCode.FORBIDDEN_USER); + } else if (!matchAuthenticationFromRole(authentication, ROLE_GUEST)) { + // ROLE_GUEST 권한이 없는 경우 + log.info(SecurityErrorCode.FORBIDDEN_GUEST.getMessage()); + setUpResponse(response, SecurityErrorCode.FORBIDDEN_GUEST); + } else { + // 기타 권한이 없는 경우 + log.info(SecurityErrorCode.FORBIDDEN_MISMATCH.getMessage()); + setUpResponse(response, SecurityErrorCode.FORBIDDEN_MISMATCH); + } + } else { + log.info(SecurityErrorCode.FORBIDDEN_MISMATCH.getMessage()); + setUpResponse(response, SecurityErrorCode.FORBIDDEN_MISMATCH); + } + } +} diff --git a/src/main/java/com/cake/pop/global/auth/handler/CustomAuthenticationEntryPoint.java b/src/main/java/com/cake/pop/global/auth/handler/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..82c02d3 --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/handler/CustomAuthenticationEntryPoint.java @@ -0,0 +1,35 @@ +package com.cake.pop.global.auth.handler; + +import java.io.IOException; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; + +import com.cake.pop.global.exception.ErrorResponse; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + + log.error("비인가 사용자 요청 -> 예외 발생 : {}", authException.getMessage()); + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 Unauthorized + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + + ErrorResponse errorResponse = new ErrorResponse(SecurityErrorCode.UNAUTHORIZED_USER.getMessage(), + SecurityErrorCode.UNAUTHORIZED_USER.getMessage()); + + ObjectMapper mapper = new ObjectMapper(); + String jsonResponse = mapper.writeValueAsString(errorResponse); + + response.getWriter().write(jsonResponse); + } +} diff --git a/src/main/java/com/cake/pop/global/auth/handler/CustomOauth2FailureHandler.java b/src/main/java/com/cake/pop/global/auth/handler/CustomOauth2FailureHandler.java new file mode 100644 index 0000000..5568edd --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/handler/CustomOauth2FailureHandler.java @@ -0,0 +1,23 @@ +package com.cake.pop.global.auth.handler; + +import java.io.IOException; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class CustomOauth2FailureHandler implements AuthenticationFailureHandler { + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws IOException { + log.error("소셜 로그인 실패", exception); + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "소셜 로그인에 실패하였습니다."); + } +} diff --git a/src/main/java/com/cake/pop/global/auth/handler/CustomOauth2SuccessHandler.java b/src/main/java/com/cake/pop/global/auth/handler/CustomOauth2SuccessHandler.java new file mode 100644 index 0000000..0a59b5a --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/handler/CustomOauth2SuccessHandler.java @@ -0,0 +1,54 @@ +package com.cake.pop.global.auth.handler; + +import java.io.IOException; +import java.util.Date; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import com.cake.pop.domain.user.exception.UserErrorCode; +import com.cake.pop.domain.user.repository.UserRepository; +import com.cake.pop.entity.User; +import com.cake.pop.global.auth.jwt.CookieUtil; +import com.cake.pop.global.auth.jwt.TokenProvider; +import com.cake.pop.global.auth.oauth.CustomOauth2User; +import com.cake.pop.global.exception.RestApiException; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class CustomOauth2SuccessHandler implements AuthenticationSuccessHandler { + + private final UserRepository userRepository; + private final TokenProvider tokenProvider; + private final CookieUtil cookieUtil; + @Value("${direct.home}") + private String REDIRECTION_HOME; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException { + + CustomOauth2User customOauth2User = (CustomOauth2User)authentication.getPrincipal(); + + String email = customOauth2User.getEmail(); + User findUser = userRepository.findByEmail(email) + .orElseThrow(() -> new RestApiException(UserErrorCode.USER_NOT_FOUND)); + + String token = tokenProvider.generateAccessToken(findUser, customOauth2User, new Date()); + tokenProvider.generateRefreshToken(findUser, customOauth2User, new Date()); + + response.addCookie(cookieUtil.createCookie(token)); + + response.sendRedirect(REDIRECTION_HOME); + } + + private boolean isRoleGuest(String role) { + return "ROLE_GUEST".equals(role); + } +} diff --git a/src/main/java/com/cake/pop/global/auth/handler/SecurityErrorCode.java b/src/main/java/com/cake/pop/global/auth/handler/SecurityErrorCode.java new file mode 100644 index 0000000..c31c9ce --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/handler/SecurityErrorCode.java @@ -0,0 +1,20 @@ +package com.cake.pop.global.auth.handler; + + +import org.springframework.http.HttpStatus; + +import com.cake.pop.global.exception.ErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SecurityErrorCode implements ErrorCode { + UNAUTHORIZED_USER(HttpStatus.NOT_FOUND, "비인가 사용자 요청입니다."), + FORBIDDEN_USER(HttpStatus.NOT_FOUND, "ROLE_USER 권한이 필요합니다."), + FORBIDDEN_GUEST(HttpStatus.NOT_FOUND, "ROLE_GUEST 권한이 필요합니다."), + FORBIDDEN_MISMATCH(HttpStatus.NOT_FOUND, "어떤 권한도 매치되지 않습니다."); + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/cake/pop/global/auth/jwt/CookieUtil.java b/src/main/java/com/cake/pop/global/auth/jwt/CookieUtil.java new file mode 100644 index 0000000..a7670e0 --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/jwt/CookieUtil.java @@ -0,0 +1,49 @@ +package com.cake.pop.global.auth.jwt; + +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CookieUtil { + + public Cookie createCookie(String token) { + Cookie cookie = new Cookie("Authorization", token); + cookie.setPath("/"); + cookie.setMaxAge(60 * 180); + cookie.setHttpOnly(true); + cookie.setSecure(true); + cookie.setAttribute("SameSite", "None"); + return cookie; + } + + public void deleteCookie(HttpServletResponse response) { + Cookie cookie = new Cookie("Authorization", null); + cookie.setPath("/"); + cookie.setMaxAge(0); + cookie.setHttpOnly(true); + cookie.setSecure(true); + cookie.setAttribute("SameSite", "None"); + + response.addCookie(cookie); + } + + + public String getCookieValue(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if ("Authorization".equals(cookie.getName())) { + return cookie.getValue(); + } + } + } + return null; + } +} diff --git a/src/main/java/com/cake/pop/global/auth/jwt/CustomJwtException.java b/src/main/java/com/cake/pop/global/auth/jwt/CustomJwtException.java new file mode 100644 index 0000000..a46e21b --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/jwt/CustomJwtException.java @@ -0,0 +1,17 @@ +package com.cake.pop.global.auth.jwt; + + +import com.cake.pop.global.exception.ErrorCode; + +import lombok.Getter; + +@Getter +public class CustomJwtException extends RuntimeException { + + private final String code; + + public CustomJwtException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.code = errorCode.getHttpStatus().name(); + } +} diff --git a/src/main/java/com/cake/pop/global/auth/jwt/JwtErrorCode.java b/src/main/java/com/cake/pop/global/auth/jwt/JwtErrorCode.java new file mode 100644 index 0000000..a394719 --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/jwt/JwtErrorCode.java @@ -0,0 +1,21 @@ +package com.cake.pop.global.auth.jwt; + +import org.springframework.http.HttpStatus; + +import com.cake.pop.global.exception.ErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum JwtErrorCode implements ErrorCode { + + MALFORMED_TOKEN(HttpStatus.NOT_FOUND, "알맞지 않은 형식의 토큰입니다."), + INVALID_TOKEN(HttpStatus.NOT_FOUND, "유효하지 않은 토큰입니다."), + EXPIRED_TOKEN(HttpStatus.NOT_FOUND, "만료된 토큰입니다."); + + private final HttpStatus httpStatus; + private final String message; + +} \ No newline at end of file diff --git a/src/main/java/com/cake/pop/global/auth/jwt/TokenAuthenticationFilter.java b/src/main/java/com/cake/pop/global/auth/jwt/TokenAuthenticationFilter.java new file mode 100644 index 0000000..98cd49b --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/jwt/TokenAuthenticationFilter.java @@ -0,0 +1,74 @@ +package com.cake.pop.global.auth.jwt; + +import static org.springframework.http.HttpHeaders.*; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Date; +import java.util.List; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.ObjectUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Component +@Slf4j +public class TokenAuthenticationFilter extends OncePerRequestFilter { + + private static final String TOKEN_PREFIX = "Bearer "; + private static final List SKIP_URLS = Arrays.asList( + "/error", "/favicon.ico", "/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html", "/ws/**", + "/api/auth/temp-signup", "/api/auth/temp-signin", "/api/auth/reissue/token" + ); + private static final AntPathMatcher pathMatcher = new AntPathMatcher(); + private final TokenProvider tokenProvider; + private final CookieUtil cookieUtil; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + for (String pattern : SKIP_URLS) { + if (pathMatcher.match(pattern, request.getRequestURI())) { + filterChain.doFilter(request, response); + return; + } + } + + String accessToken = cookieUtil.getCookieValue(request); + + if (tokenProvider.validateToken(accessToken, new Date())) { + // accessToken logout 여부 확인 + if (tokenProvider.verifyBlackList(accessToken)) { + saveAuthentication(accessToken); + } + } + + filterChain.doFilter(request, response); + } + + private void saveAuthentication(String accessToken) { + Authentication authentication = tokenProvider.getAuthentication(accessToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + private String resolveToken(HttpServletRequest request) { + String token = request.getHeader(AUTHORIZATION); + if (ObjectUtils.isEmpty(token) || !token.startsWith(TOKEN_PREFIX)) { + return null; + } + return token.substring(TOKEN_PREFIX.length()); + } + +} diff --git a/src/main/java/com/cake/pop/global/auth/jwt/TokenExceptionFilter.java b/src/main/java/com/cake/pop/global/auth/jwt/TokenExceptionFilter.java new file mode 100644 index 0000000..2f93e2f --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/jwt/TokenExceptionFilter.java @@ -0,0 +1,40 @@ +package com.cake.pop.global.auth.jwt; + +import java.io.IOException; + +import org.springframework.web.filter.OncePerRequestFilter; + +import com.cake.pop.global.exception.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.extern.slf4j.Slf4j; + +@Slf4j +public class TokenExceptionFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (CustomJwtException e) { + + log.error("JWT 검증 실패로 인한 예외 발생 : {}", e.getMessage()); + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 Unauthorized + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + + ErrorResponse errorResponse = new ErrorResponse(e.getMessage(), e.getCode()); + + ObjectMapper mapper = new ObjectMapper(); + String jsonResponse = mapper.writeValueAsString(errorResponse); + + response.getWriter().write(jsonResponse); + } + } +} diff --git a/src/main/java/com/cake/pop/global/auth/jwt/TokenProvider.java b/src/main/java/com/cake/pop/global/auth/jwt/TokenProvider.java new file mode 100644 index 0000000..ff26660 --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/jwt/TokenProvider.java @@ -0,0 +1,154 @@ +package com.cake.pop.global.auth.jwt; + +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.stream.Collectors; + +import javax.crypto.SecretKey; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import com.cake.pop.domain.user.exception.UserErrorCode; +import com.cake.pop.domain.user.repository.UserRepository; +import com.cake.pop.entity.User; +import com.cake.pop.global.auth.oauth.CustomOauth2User; +import com.cake.pop.global.exception.RestApiException; +import com.cake.pop.global.redis.util.RedisUtil; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class TokenProvider { + + private static final String ROLE_KEY = "ROLE"; + private static final String[] BLACKLIST = new String[] {"false", "delete"}; + private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 90L; + private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24L; + private final UserRepository userRepository; + private final RedisUtil redisUtil; + @Value("${spring.jwt.key}") + private String key; + private SecretKey secretKey; + + @PostConstruct + private void initSecretKey() { + this.secretKey = Keys.hmacShaKeyFor(key.getBytes()); + } + + public String generateAccessToken(User findUser, CustomOauth2User authentication, Date now) { + return generateToken(findUser, authentication, ACCESS_TOKEN_EXPIRE_TIME, now); + } + + public void generateRefreshToken(User findUser, CustomOauth2User authentication, Date now) { + String refreshToken = generateToken(findUser, authentication, REFRESH_TOKEN_EXPIRE_TIME, now); + + // redis Refresh 저장 + redisUtil.setValues("RT:" + authentication.getEmail(), refreshToken, + Duration.ofMillis(REFRESH_TOKEN_EXPIRE_TIME)); + } + + private String generateToken(User findUser, CustomOauth2User authentication, long tokenExpireTime, Date now) { + Date expiredTime = createExpiredDateWithTokenType(now, tokenExpireTime); + String authorities = getAuthorities(authentication); + + return Jwts.builder() + .subject(String.valueOf(findUser.getId())) + .claim(ROLE_KEY, authorities) + .issuedAt(now) + .expiration(expiredTime) + .signWith(secretKey, Jwts.SIG.HS512) + .compact(); + } + + private String getAuthorities(CustomOauth2User authentication) { + return authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining()); + } + + private Date createExpiredDateWithTokenType(Date date, long tokenExpireTime) { + return new Date(date.getTime() + tokenExpireTime); + } + + public Authentication getAuthentication(String token) { + Claims claims = parseToken(token); + List authorities = getAuthorities(claims); + + String subject = claims.getSubject(); + User principal = userRepository.findById(Long.valueOf(subject)) + .orElseThrow(() -> new RestApiException(UserErrorCode.USER_NOT_FOUND)); + + return new UsernamePasswordAuthenticationToken(principal.getId(), token, authorities); + } + + public boolean validateToken(String token, Date date) { + if (!StringUtils.hasText(token)) { + return false; + } + + Claims claims = parseToken(token); + if (!claims.getExpiration().after(date)) { + throw new CustomJwtException(JwtErrorCode.EXPIRED_TOKEN); + } + + return true; + } + + private Claims parseToken(String token) { + try { + return Jwts.parser().verifyWith(secretKey).build() + .parseSignedClaims(token).getPayload(); + } catch (ExpiredJwtException e) { + return e.getClaims(); + } catch (MalformedJwtException e) { + throw new CustomJwtException(JwtErrorCode.MALFORMED_TOKEN); + } catch (JwtException e) { + throw new CustomJwtException(JwtErrorCode.INVALID_TOKEN); + } catch (Exception e) { + throw new CustomJwtException(JwtErrorCode.INVALID_TOKEN); + } + } + + private List getAuthorities(Claims claims) { + return Collections.singletonList(new SimpleGrantedAuthority( + claims.get(ROLE_KEY).toString() + )); + } + + public Long getExpiration(String token, Date date) { + Claims claims = parseToken(token); + Date expiration = claims.getExpiration(); + return (expiration.getTime() - date.getTime()); + } + + public boolean verifyBlackList(String accessToken) { + String value = redisUtil.getValues(accessToken); + return Arrays.asList(BLACKLIST).contains(value); + } + + public User getMemberAllowExpired(String token) { + Claims claims = parseToken(token); + + String subject = claims.getSubject(); + return userRepository.findById(Long.valueOf(subject)) + .orElseThrow(() -> new RestApiException(UserErrorCode.USER_NOT_FOUND)); + } + +} diff --git a/src/main/java/com/cake/pop/global/auth/oauth/AuthErrorCode.java b/src/main/java/com/cake/pop/global/auth/oauth/AuthErrorCode.java new file mode 100644 index 0000000..240e06f --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/oauth/AuthErrorCode.java @@ -0,0 +1,23 @@ +package com.cake.pop.global.auth.oauth; + +import org.springframework.http.HttpStatus; + +import com.cake.pop.global.exception.ErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum AuthErrorCode implements ErrorCode { + + UNSUPPORTED_SOCIAL_LOGIN(HttpStatus.NOT_FOUND, "해당 소셜 로그인은 지원되지 않습니다."), + NOT_FOUND_PROVIDER(HttpStatus.NOT_FOUND, "알맞은 Provider를 찾을 수 없습니다."), + NOT_FOUND_AUTH(HttpStatus.NOT_FOUND, "회원의 AUTH를 찾을 수 없습니다."), + UNAUTHORIZED_TOKEN(HttpStatus.NOT_FOUND, "잘못된 토큰입니다."), + FAIL_REISSUE_TOKEN(HttpStatus.NOT_FOUND, "토큰 재발급에 실패했습니다."), + MISSING_ACCESS_TOKEN(HttpStatus.NOT_FOUND, "AT가 존재하지 않습니다."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/cake/pop/global/auth/oauth/AuthInfo.java b/src/main/java/com/cake/pop/global/auth/oauth/AuthInfo.java new file mode 100644 index 0000000..848a6d4 --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/oauth/AuthInfo.java @@ -0,0 +1,23 @@ +package com.cake.pop.global.auth.oauth; + +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class AuthInfo { + + private String email; + + @Builder + private AuthInfo(String email) { + this.email = email; + } + + public static AuthInfo of(String email) { + return AuthInfo.builder() + .email(email) + .build(); + } +} diff --git a/src/main/java/com/cake/pop/global/auth/oauth/CustomOauth2User.java b/src/main/java/com/cake/pop/global/auth/oauth/CustomOauth2User.java new file mode 100644 index 0000000..477b95b --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/oauth/CustomOauth2User.java @@ -0,0 +1,38 @@ +package com.cake.pop.global.auth.oauth; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +public class CustomOauth2User implements OAuth2User { + + private final AuthInfo authInfo; + private Map attributes; + + public CustomOauth2User(AuthInfo authInfo) { + this.authInfo = authInfo; + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public Collection getAuthorities() { + return Collections.singletonList(new SimpleGrantedAuthority("USER")); + } + + @Override + public String getName() { + return authInfo.getEmail(); + } + + public String getEmail() { + return authInfo.getEmail(); + } +} diff --git a/src/main/java/com/cake/pop/global/auth/oauth/CustomOauth2UserService.java b/src/main/java/com/cake/pop/global/auth/oauth/CustomOauth2UserService.java new file mode 100644 index 0000000..d4bfc0b --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/oauth/CustomOauth2UserService.java @@ -0,0 +1,57 @@ +package com.cake.pop.global.auth.oauth; + + + +import static com.cake.pop.global.auth.oauth.AuthErrorCode.*; +import static com.cake.pop.global.auth.oauth.Provider.*; + +import java.util.Objects; + +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.cake.pop.domain.user.service.UserService; +import com.cake.pop.entity.User; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CustomOauth2UserService extends DefaultOAuth2UserService { + + private final UserService userService; + + @Transactional + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(userRequest); + + String oauth2AccessToken = userRequest.getAccessToken().getTokenValue(); + + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + Oauth2Response oauth2Response = null; + + if (Objects.equals(registrationId, KAKAO.getLabel())) { + oauth2Response = new KakaoResponse(oAuth2User.getAttributes(), oauth2AccessToken); + } else { + throw new OAuth2AuthenticationException( + new OAuth2Error(AuthErrorCode.UNSUPPORTED_SOCIAL_LOGIN.getMessage()), + AuthErrorCode.UNSUPPORTED_SOCIAL_LOGIN.getMessage() + ); + } + + User savedUser = userService.saveOrUpdate(oauth2Response); + + AuthInfo authInfo = AuthInfo.of( + savedUser.getEmail() + ); + return new CustomOauth2User(authInfo); + } + +} + diff --git a/src/main/java/com/cake/pop/global/auth/oauth/KakaoResponse.java b/src/main/java/com/cake/pop/global/auth/oauth/KakaoResponse.java new file mode 100644 index 0000000..e5d720d --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/oauth/KakaoResponse.java @@ -0,0 +1,51 @@ +package com.cake.pop.global.auth.oauth; + +import java.util.Map; + +public class KakaoResponse implements Oauth2Response { + + private final Map attribute; + private final Long id; + private final String oauth2AccessToken; + + public KakaoResponse(Map attribute, String oauth2AccessToken) { + this.attribute = (Map)attribute.get("kakao_account"); + this.id = (Long)attribute.get("id"); + this.oauth2AccessToken = oauth2AccessToken; + } + + @Override + public String getProvider() { + return Provider.KAKAO.getLabel(); + } + + @Override + public String getProviderId() { + return this.id.toString(); + } + + @Override + public String getEmail() { + return attribute.get("email").toString(); + } + + @Override + public String getName() { + return ((Map)attribute.get("profile")).get("nickname").toString(); + } + + @Override + public String createSocialEmail() { + return String.format("%s%s/%s", + this.getProvider(), + this.getProviderId(), + this.getEmail() + ); + } + + @Override + public String getOauth2AccessToken() { + return this.oauth2AccessToken; + } + +} diff --git a/src/main/java/com/cake/pop/global/auth/oauth/Oauth2Response.java b/src/main/java/com/cake/pop/global/auth/oauth/Oauth2Response.java new file mode 100644 index 0000000..952e398 --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/oauth/Oauth2Response.java @@ -0,0 +1,15 @@ +package com.cake.pop.global.auth.oauth; + +public interface Oauth2Response { + String getProvider(); + + String getProviderId(); + + String getEmail(); + + String getName(); + + String createSocialEmail(); + + String getOauth2AccessToken(); +} diff --git a/src/main/java/com/cake/pop/global/auth/oauth/Provider.java b/src/main/java/com/cake/pop/global/auth/oauth/Provider.java new file mode 100644 index 0000000..cebc13b --- /dev/null +++ b/src/main/java/com/cake/pop/global/auth/oauth/Provider.java @@ -0,0 +1,32 @@ +package com.cake.pop.global.auth.oauth; + +import java.util.Arrays; + +import com.cake.pop.global.exception.RestApiException; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Provider { + + KAKAO("kakao"), + NAVER("naver"); + + private final String label; + + public static Provider fromProviderName(String providerName) { + return Arrays.stream(values()) + .filter(provider -> provider.getLabel().equalsIgnoreCase(providerName)) + .findFirst() + .orElseThrow(() -> new RestApiException(AuthErrorCode.NOT_FOUND_PROVIDER)); + } + + public static Provider fromSocialEmail(String socialEmail) { + return Arrays.stream(values()) + .filter(provider -> socialEmail.contains(provider.getLabel())) + .findFirst() + .orElseThrow(() -> new RestApiException(AuthErrorCode.NOT_FOUND_PROVIDER)); + } +} diff --git a/src/main/java/com/cake/pop/global/config/RedisConfig.java b/src/main/java/com/cake/pop/global/config/RedisConfig.java new file mode 100644 index 0000000..0ca9245 --- /dev/null +++ b/src/main/java/com/cake/pop/global/config/RedisConfig.java @@ -0,0 +1,44 @@ +package com.cake.pop.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Value("${spring.data.redis.password:}") + private String password; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(); + config.setHostName(host); + config.setPort(port); + if (!password.isEmpty()) { + config.setPassword(password); + } + return new LettuceConnectionFactory(config); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + + return redisTemplate; + } +} \ No newline at end of file diff --git a/src/main/java/com/cake/pop/global/config/SecurityConfig.java b/src/main/java/com/cake/pop/global/config/SecurityConfig.java new file mode 100644 index 0000000..108e0ed --- /dev/null +++ b/src/main/java/com/cake/pop/global/config/SecurityConfig.java @@ -0,0 +1,102 @@ +package com.cake.pop.global.config; + +import java.util.Arrays; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import com.cake.pop.global.auth.handler.CustomAccessDeniedHandler; +import com.cake.pop.global.auth.handler.CustomAuthenticationEntryPoint; +import com.cake.pop.global.auth.handler.CustomOauth2FailureHandler; +import com.cake.pop.global.auth.handler.CustomOauth2SuccessHandler; +import com.cake.pop.global.auth.jwt.TokenAuthenticationFilter; +import com.cake.pop.global.auth.jwt.TokenExceptionFilter; +import com.cake.pop.global.auth.oauth.CustomOauth2UserService; + +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + + private final CustomOauth2UserService customOauth2UserService; + private final CustomOauth2SuccessHandler customOauth2SuccessHandler; + private final CustomOauth2FailureHandler customOauth2FailureHandler; + private final TokenAuthenticationFilter tokenAuthenticationFilter; + private final CustomAccessDeniedHandler customAccessDeniedHandler; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf((auth) -> auth.disable()) + .formLogin((auth) -> auth.disable()) + .httpBasic((auth) -> auth.disable()) + .sessionManagement( + (session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ); + + http + .authorizeHttpRequests( + (auth) -> auth + .requestMatchers("/").permitAll() + .requestMatchers("/api/auth/reissue/token").permitAll() + ) + .oauth2Login((oauth2) -> oauth2 + .userInfoEndpoint( + (userInfoEndpointConfig -> userInfoEndpointConfig.userService(customOauth2UserService)) + ) + .successHandler(customOauth2SuccessHandler) + .failureHandler(customOauth2FailureHandler) + ) + + .addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(new TokenExceptionFilter(), tokenAuthenticationFilter.getClass()) + + .exceptionHandling((exception) -> exception + .authenticationEntryPoint(new CustomAuthenticationEntryPoint()) + .accessDeniedHandler(customAccessDeniedHandler) + ); + + return http.build(); + } + + @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + return web -> web.ignoring() + .requestMatchers( + "/error", "/favicon.ico", "/api/auth/temp-signup", "/api/auth/temp-signin", + "/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html", "/ws/**" + ); + } + + // Spring Security cors Bean 등록 + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + configuration.setAllowedOrigins( + Arrays.asList( + "http://localhost:3000", "https://cake-pop.vercel.app", + "https://www.cake-pop.shop", "https://cake-pop.shop", "http://localhost:8080", + "https://dev.cake-pop.shop" + )); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(Arrays.asList("*")); + configuration.setExposedHeaders(Arrays.asList("Set-Cookie", "Authorization")); + configuration.setAllowCredentials(true); + configuration.setMaxAge(3000L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/src/main/java/com/cake/pop/global/exception/ErrorResponse.java b/src/main/java/com/cake/pop/global/exception/ErrorResponse.java new file mode 100644 index 0000000..3720e71 --- /dev/null +++ b/src/main/java/com/cake/pop/global/exception/ErrorResponse.java @@ -0,0 +1,8 @@ +package com.cake.pop.global.exception; + +public record ErrorResponse( + + String message, + String code +) { +} diff --git a/src/main/java/com/cake/pop/global/redis/exception/RedisErrorCode.java b/src/main/java/com/cake/pop/global/redis/exception/RedisErrorCode.java new file mode 100644 index 0000000..965851e --- /dev/null +++ b/src/main/java/com/cake/pop/global/redis/exception/RedisErrorCode.java @@ -0,0 +1,22 @@ +package com.cake.pop.global.redis.exception; + +import org.springframework.http.HttpStatus; + +import com.cake.pop.global.exception.ErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum RedisErrorCode implements ErrorCode { + + REDIS_SAVE_ERROR(HttpStatus.NOT_FOUND, "저장하지 못했습니다."), + REDIS_FIND_ERROR(HttpStatus.NOT_FOUND, "값을 찾는 도중 오류가 발생했습니다."), + REDIS_DELETE_ERROR(HttpStatus.NOT_FOUND, "삭제하지 못했습니다."), + REDIS_EXPIRE_ERROR(HttpStatus.NOT_FOUND, "만료시키지하지 못했습니다."), + REDIS_EXPIRED_ERROR(HttpStatus.NOT_FOUND, "만료된 키 입니다."); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/cake/pop/global/redis/util/RedisUtil.java b/src/main/java/com/cake/pop/global/redis/util/RedisUtil.java new file mode 100644 index 0000000..5f45f99 --- /dev/null +++ b/src/main/java/com/cake/pop/global/redis/util/RedisUtil.java @@ -0,0 +1,88 @@ +package com.cake.pop.global.redis.util; + +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.stereotype.Component; + +import com.cake.pop.global.exception.RestApiException; +import com.cake.pop.global.redis.exception.RedisErrorCode; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RedisUtil { + + private final RedisTemplate redisTemplate; + + public void setValues(String key, String data) { + try { + ValueOperations values = redisTemplate.opsForValue(); + values.set(key, data); + } catch (Exception e) { + throw new RestApiException(RedisErrorCode.REDIS_SAVE_ERROR); + } + + } + + public void setValues(String key, String data, Duration duration) { + try { + ValueOperations values = redisTemplate.opsForValue(); + values.set(key, data, duration); + } catch (Exception e) { + throw new RestApiException(RedisErrorCode.REDIS_SAVE_ERROR); + } + } + + public String getValues(String key) { + try { + ValueOperations values = redisTemplate.opsForValue(); + if (values.get(key) == null) { + return "false"; + } + return (String)values.get(key); + } catch (Exception e) { + throw new RestApiException(RedisErrorCode.REDIS_FIND_ERROR); + } + } + + public void deleteValues(String key) { + try { + redisTemplate.delete(key); + } catch (Exception e) { + throw new RestApiException(RedisErrorCode.REDIS_DELETE_ERROR); + } + } + + public void expireValues(String key, int timeout) { + try { + redisTemplate.expire(key, timeout, TimeUnit.MILLISECONDS); + + } catch (Exception e) { + throw new RestApiException(RedisErrorCode.REDIS_EXPIRE_ERROR); + } + } + + public boolean validateData(String key, String data) { + String findValues = this.getValues(key); + if (Objects.equals(findValues, "false")) { + throw new RestApiException(RedisErrorCode.REDIS_FIND_ERROR); + } + + return Objects.equals(findValues, data); + } + + public void validateExpiredFromKey(String key) { + Long ttl = redisTemplate.getExpire(key, TimeUnit.MILLISECONDS); + if (ttl == null || ttl <= 0) { + throw new RestApiException(RedisErrorCode.REDIS_EXPIRED_ERROR); + } + } + +}