diff --git a/.coderabbit.yaml b/.coderabbit.yaml similarity index 100% rename from .coderabbit.yaml rename to .coderabbit.yaml diff --git a/build.gradle b/build.gradle index 055cacb..acfb9e7 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,17 @@ dependencies { // Security implementation 'org.springframework.boot:spring-boot-starter-security' + // OAuth + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // Jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + // Validation implementation 'org.springframework.boot:spring-boot-starter-validation' diff --git a/src/main/java/com/Timo/Timo/domain/user/entity/User.java b/src/main/java/com/Timo/Timo/domain/user/entity/User.java new file mode 100644 index 0000000..9f204eb --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/entity/User.java @@ -0,0 +1,50 @@ +package com.Timo.Timo.domain.user.entity; + +import com.Timo.Timo.domain.user.enums.Provider; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String email; + + private String name; + private String imageUrl; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Provider provider; + + @Builder + private User(String email, String name, String imageUrl, Provider provider) { + this.email = email; + this.name = name; + this.imageUrl = imageUrl; + this.provider = provider; + } + + public void update(String name, String imageUrl){ + this.name = name; + this.imageUrl = imageUrl; + } + +} diff --git a/src/main/java/com/Timo/Timo/domain/user/enums/Provider.java b/src/main/java/com/Timo/Timo/domain/user/enums/Provider.java new file mode 100644 index 0000000..b9e1a3a --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/enums/Provider.java @@ -0,0 +1,5 @@ +package com.Timo.Timo.domain.user.enums; + +public enum Provider { + GOOGLE +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/domain/user/repository/UserRepository.java b/src/main/java/com/Timo/Timo/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..c5c9696 --- /dev/null +++ b/src/main/java/com/Timo/Timo/domain/user/repository/UserRepository.java @@ -0,0 +1,9 @@ +package com.Timo.Timo.domain.user.repository; + +import com.Timo.Timo.domain.user.entity.User; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); +} diff --git a/src/main/java/com/Timo/Timo/global/auth/controller/AuthController.java b/src/main/java/com/Timo/Timo/global/auth/controller/AuthController.java new file mode 100644 index 0000000..aac9342 --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/controller/AuthController.java @@ -0,0 +1,51 @@ +package com.Timo.Timo.global.auth.controller; + +import com.Timo.Timo.global.auth.handler.AuthErrorResponseWriter; +import com.Timo.Timo.global.auth.service.AuthCodeService; +import com.Timo.Timo.global.exception.code.ErrorCode; +import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthCodeService authCodeService; + private final JwtTokenProvider jwtTokenProvider; + private final AuthErrorResponseWriter authErrorResponseWriter; + private final ObjectMapper objectMapper; + + @GetMapping("/token") + public void token( + @RequestParam String code, + HttpServletRequest request, + HttpServletResponse response + ) throws IOException { + + String userId = authCodeService.getAndDelete(code); + + if (userId == null) { + authErrorResponseWriter.write(response, ErrorCode.INVALID_AUTH_CODE, request.getRequestURI()); + return; + } + + String accessToken = jwtTokenProvider.generateAccessToken(Long.parseLong(userId)); + + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write( + objectMapper.writeValueAsString(Map.of("accessToken", accessToken)) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/global/auth/dto/CustomUserDetails.java b/src/main/java/com/Timo/Timo/global/auth/dto/CustomUserDetails.java new file mode 100644 index 0000000..dc87799 --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/dto/CustomUserDetails.java @@ -0,0 +1,49 @@ +package com.Timo.Timo.global.auth.dto; + +import com.Timo.Timo.domain.user.entity.User; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; + +@Getter +public class CustomUserDetails implements OAuth2User, UserDetails { + + private final User user; + private final Map attributes; + + public CustomUserDetails(User user, Map attributes){ + this.user = user; + this.attributes = attributes; + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public String getName() { + return user.getEmail(); + } + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority("ROLE_USER")); + } + + @Override + public String getPassword(){ + return null; + } + + @Override + public String getUsername(){ + return user.getEmail(); + } + +} diff --git a/src/main/java/com/Timo/Timo/global/auth/handler/AuthErrorResponseWriter.java b/src/main/java/com/Timo/Timo/global/auth/handler/AuthErrorResponseWriter.java new file mode 100644 index 0000000..67b445f --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/handler/AuthErrorResponseWriter.java @@ -0,0 +1,36 @@ +package com.Timo.Timo.global.auth.handler; + +import com.Timo.Timo.global.exception.code.ErrorCode; +import com.Timo.Timo.global.exception.dto.ErrorDto; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AuthErrorResponseWriter { + + private final ObjectMapper objectMapper; + + public void write(HttpServletResponse response, ErrorCode errorCode, String path) + throws IOException { + + ErrorDto errorDto = new ErrorDto( + LocalDateTime.now(), + errorCode.getHttpStatus().value(), + errorCode.getCode(), + errorCode.getMessage(), + path + ); + + response.setStatus(errorCode.getHttpStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(errorDto)); + } + +} diff --git a/src/main/java/com/Timo/Timo/global/auth/handler/JwtAuthenticationEntryPoint.java b/src/main/java/com/Timo/Timo/global/auth/handler/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..8a9f2ce --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/handler/JwtAuthenticationEntryPoint.java @@ -0,0 +1,26 @@ +package com.Timo.Timo.global.auth.handler; + +import com.Timo.Timo.global.exception.code.ErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final AuthErrorResponseWriter authErrorResponseWriter; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + authErrorResponseWriter.write(response, ErrorCode.UNAUTHORIZED, request.getRequestURI()); + } +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/global/auth/handler/OAuthFailureHandler.java b/src/main/java/com/Timo/Timo/global/auth/handler/OAuthFailureHandler.java new file mode 100644 index 0000000..8558df7 --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/handler/OAuthFailureHandler.java @@ -0,0 +1,26 @@ +package com.Timo.Timo.global.auth.handler; + +import com.Timo.Timo.global.exception.code.ErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class OAuthFailureHandler extends SimpleUrlAuthenticationFailureHandler { + + private final AuthErrorResponseWriter authErrorResponseWriter; + + @Override + public void onAuthenticationFailure( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception + ) throws IOException { + authErrorResponseWriter.write(response, ErrorCode.OAUTH_LOGIN_FAILED, request.getRequestURI()); + } +} diff --git a/src/main/java/com/Timo/Timo/global/auth/handler/OAuthSuccessHandler.java b/src/main/java/com/Timo/Timo/global/auth/handler/OAuthSuccessHandler.java new file mode 100644 index 0000000..58b72ae --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/handler/OAuthSuccessHandler.java @@ -0,0 +1,64 @@ +package com.Timo.Timo.global.auth.handler; + +import com.Timo.Timo.global.auth.dto.CustomUserDetails; +import com.Timo.Timo.global.auth.service.AuthCodeService; +import com.Timo.Timo.global.auth.service.RefreshTokenService; +import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.time.Duration; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +@Component +@RequiredArgsConstructor +public class OAuthSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenService refreshTokenService; + private final AuthCodeService authCodeService; + + @Value("${app.oauth2.redirect-uri}") + private String redirectUri; + + @Value("${app.auth.cookie-secure:false}") + private boolean cookieSecure; + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication + ) throws IOException { + + CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal(); + Long userId = userDetails.getUser().getId(); + + String refreshToken = jwtTokenProvider.generateRefreshToken(userId); + refreshTokenService.save(String.valueOf(userId), refreshToken); + + ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", refreshToken) + .httpOnly(true) + .secure(cookieSecure) + .path("/") + .maxAge(Duration.ofSeconds(jwtTokenProvider.getRefreshTokenExpiry())) + .sameSite("Strict") + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString()); + + String code = authCodeService.generateAndSave(String.valueOf(userId)); + + String redirectUrl = UriComponentsBuilder.fromUriString(redirectUri) + .queryParam("code", code) + .build().toUriString(); + + getRedirectStrategy().sendRedirect(request, response, redirectUrl); + } +} diff --git a/src/main/java/com/Timo/Timo/global/auth/service/AuthCodeService.java b/src/main/java/com/Timo/Timo/global/auth/service/AuthCodeService.java new file mode 100644 index 0000000..e9810c0 --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/service/AuthCodeService.java @@ -0,0 +1,32 @@ +package com.Timo.Timo.global.auth.service; + +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AuthCodeService { + + private final RedisTemplate redisTemplate; + + private static final String KEY_PREFIX = "auth:code:"; + private static final long CODE_EXPIRY_SECONDS = 30L; + + public String generateAndSave(String userId) { + String code = UUID.randomUUID().toString(); + redisTemplate.opsForValue().set( + KEY_PREFIX + code, + userId, + CODE_EXPIRY_SECONDS, + TimeUnit.SECONDS + ); + return code; + } + + public String getAndDelete(String code) { + return redisTemplate.opsForValue().getAndDelete(KEY_PREFIX + code); + } +} \ No newline at end of file diff --git a/src/main/java/com/Timo/Timo/global/auth/service/CustomOAuth2UserService.java b/src/main/java/com/Timo/Timo/global/auth/service/CustomOAuth2UserService.java new file mode 100644 index 0000000..572f21c --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/service/CustomOAuth2UserService.java @@ -0,0 +1,63 @@ +package com.Timo.Timo.global.auth.service; + +import com.Timo.Timo.domain.user.entity.User; +import com.Timo.Timo.domain.user.enums.Provider; +import com.Timo.Timo.domain.user.repository.UserRepository; +import com.Timo.Timo.global.auth.dto.CustomUserDetails; +import com.Timo.Timo.global.exception.code.ErrorCode; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +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 org.springframework.util.StringUtils; + +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService implements OAuth2UserService { + + private final UserRepository userRepository; + + @Override + @Transactional + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2UserService delegate = new DefaultOAuth2UserService(); + OAuth2User oAuth2User = delegate.loadUser(userRequest); + + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + Provider provider = Provider.valueOf(registrationId.toUpperCase()); + + Map attributes = oAuth2User.getAttributes(); + String email = (String) attributes.get("email"); + String name = (String) attributes.get("name"); + String imageUrl = (String) attributes.get("picture"); + + if (!StringUtils.hasText(email)) { + throw new OAuth2AuthenticationException( + new OAuth2Error(ErrorCode.OAUTH2_INVALID_USER_INFO.getCode()), + ErrorCode.OAUTH2_INVALID_USER_INFO.getMessage() + ); + } + + User user = userRepository.findByEmail(email) + .map(existing -> { + existing.update(name, imageUrl); + return existing; + }) + .orElseGet(() -> userRepository.save( + User.builder() + .email(email) + .name(name) + .imageUrl(imageUrl) + .provider(provider) + .build() + )); + + return new CustomUserDetails(user, attributes); + } +} diff --git a/src/main/java/com/Timo/Timo/global/auth/service/RefreshTokenService.java b/src/main/java/com/Timo/Timo/global/auth/service/RefreshTokenService.java new file mode 100644 index 0000000..ec3b598 --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/service/RefreshTokenService.java @@ -0,0 +1,39 @@ +package com.Timo.Timo.global.auth.service; + +import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class RefreshTokenService { + + private final RedisTemplate redisTemplate; + private final JwtTokenProvider jwtTokenProvider; + + private static final String KEY_PREFIX = "refresh:"; + + public void save(String userId, String refreshToken){ + redisTemplate.opsForValue().set( + KEY_PREFIX + userId, + refreshToken, + jwtTokenProvider.getRefreshTokenExpiry(), + TimeUnit.SECONDS + ); + } + + public String get(String email){ + return redisTemplate.opsForValue().get(KEY_PREFIX + email); + } + + public void delete(String userId){ + redisTemplate.delete(KEY_PREFIX + userId); + } + + public boolean isValid(String userId, String refreshToken){ + return Objects.equals(refreshToken, get(userId)); + } +} diff --git a/src/main/java/com/Timo/Timo/global/config/RedisConfig.java b/src/main/java/com/Timo/Timo/global/config/RedisConfig.java new file mode 100644 index 0000000..2846c60 --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/config/RedisConfig.java @@ -0,0 +1,23 @@ +package com.Timo.Timo.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory factory) { + + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(factory); + + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new StringRedisSerializer()); + + return template; + } +} diff --git a/src/main/java/com/Timo/Timo/global/config/SecurityConfig.java b/src/main/java/com/Timo/Timo/global/config/SecurityConfig.java index 0d03134..1fdad75 100644 --- a/src/main/java/com/Timo/Timo/global/config/SecurityConfig.java +++ b/src/main/java/com/Timo/Timo/global/config/SecurityConfig.java @@ -1,32 +1,82 @@ package com.Timo.Timo.global.config; +import com.Timo.Timo.global.auth.handler.JwtAuthenticationEntryPoint; +import com.Timo.Timo.global.auth.handler.OAuthFailureHandler; +import com.Timo.Timo.global.auth.handler.OAuthSuccessHandler; +import com.Timo.Timo.global.auth.service.CustomOAuth2UserService; +import com.Timo.Timo.global.jwt.filter.JwtAuthenticationFilter; +import java.util.List; +import lombok.RequiredArgsConstructor; 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.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; 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; @Configuration +@EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuthSuccessHandler oAuthSuccessHandler; + private final OAuthFailureHandler oAuthFailureHandler; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(authorize -> authorize .requestMatchers( + "/", "/swagger-ui/**", "/swagger-ui.html", - "/v3/api-docs/**" + "/v3/api-docs/**", + "/login/**", + "/oauth2/**", + "/api/auth/reissue", + "/api/auth/token" ).permitAll() - // TODO: JWT 인증 적용 시 보호가 필요한 API는 authenticated()로 변경 - .anyRequest().permitAll()); + .anyRequest().authenticated()) + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> + userInfo.userService(customOAuth2UserService) + ) + .successHandler(oAuthSuccessHandler) + .failureHandler(oAuthFailureHandler) + ) + .exceptionHandling(exception -> + exception.authenticationEntryPoint(jwtAuthenticationEntryPoint) + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); - return http.build(); + return http.build(); } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(List.of("http://localhost:3000", "http://localhost:5173")); // TODO: 프론트 배포 시 도메인으로 변경 + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + config.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } } diff --git a/src/main/java/com/Timo/Timo/global/exception/code/ErrorCode.java b/src/main/java/com/Timo/Timo/global/exception/code/ErrorCode.java index a8a8d62..948d834 100644 --- a/src/main/java/com/Timo/Timo/global/exception/code/ErrorCode.java +++ b/src/main/java/com/Timo/Timo/global/exception/code/ErrorCode.java @@ -12,9 +12,15 @@ public enum ErrorCode implements BaseErrorCode { BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_400", "잘못된 요청입니다."), UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON_401", "인증이 필요합니다."), FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON_403", "접근 권한이 없습니다."), - NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON_404", "요청한 리소스를 찾을 수 없습니다."), - METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "COMMON_405", "지원하지 않는 HTTP 메서드입니다."), - INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_500", "서버 내부 오류가 발생했습니다."); + NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON_404", "요청한 리소스를 찾을 수 없습니다."), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "COMMON_405", "지원하지 않는 HTTP 메서드입니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_500", "서버 내부 오류가 발생했습니다."), + + // Auth + OAUTH2_INVALID_USER_INFO(HttpStatus.UNAUTHORIZED, "AUTH_401", "OAuth2 유저 정보가 유효하지 않습니다."), + OAUTH_LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "AUTH_402", "소셜 로그인에 실패했습니다."), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_403", "유효하지 않거나 만료된 리프레시 토큰입니다."), + INVALID_AUTH_CODE(HttpStatus.UNAUTHORIZED, "AUTH_404", "유효하지 않거나 만료된 인증 코드입니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/Timo/Timo/global/jwt/filter/JwtAuthenticationFilter.java b/src/main/java/com/Timo/Timo/global/jwt/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..6dc8942 --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/jwt/filter/JwtAuthenticationFilter.java @@ -0,0 +1,60 @@ +package com.Timo.Timo.global.jwt.filter; + +import com.Timo.Timo.domain.user.repository.UserRepository; +import com.Timo.Timo.global.auth.dto.CustomUserDetails; +import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + private final UserRepository userRepository; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + String token = resolveToken(request); + + if(token!=null && jwtTokenProvider.validateAccessToken(token)){ + Long userId = jwtTokenProvider.getUserId(token); + userRepository.findById(userId).ifPresent(user -> { + CustomUserDetails userDetails = new CustomUserDetails(user, Map.of()); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities() + ); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + }); + } + + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + + String bearer = request.getHeader("Authorization"); + if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) { + return bearer.substring(7); + } + return null; + } +} diff --git a/src/main/java/com/Timo/Timo/global/jwt/provider/JwtTokenProvider.java b/src/main/java/com/Timo/Timo/global/jwt/provider/JwtTokenProvider.java new file mode 100644 index 0000000..432dee2 --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/jwt/provider/JwtTokenProvider.java @@ -0,0 +1,85 @@ +package com.Timo.Timo.global.jwt.provider; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import javax.crypto.SecretKey; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class JwtTokenProvider { + + @Value("${jwt.secret}") + private String secretKey; + + @Value("${jwt.access-token-expires-in-seconds}") + private long accessTokenExpirySeconds; + + @Value("${jwt.refresh-token-expires-in-seconds}") + private long refreshTokenExpirySeconds; + + private SecretKey getSigningKey(){ + return Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + } + + public String generateAccessToken(Long userId){ + return Jwts.builder() + .subject(String.valueOf(userId)) + .claim("type", "access") + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + accessTokenExpirySeconds * 1000)) + .signWith(getSigningKey()) + .compact(); + } + + public String generateRefreshToken(Long userId){ + return Jwts.builder() + .subject(String.valueOf(userId)) + .claim("type", "refresh") + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + refreshTokenExpirySeconds * 1000)) + .signWith(getSigningKey()) + .compact(); + } + + public boolean validateAccessToken(String token){ + return validateToken(token, "access"); + } + + public boolean validateRefreshToken(String token){ + return validateToken(token, "refresh"); + } + + private boolean validateToken(String token, String expectedType){ + try { + Claims claims = getClaims(token); + return expectedType.equals(claims.get("type", String.class)); + } catch (JwtException | IllegalArgumentException e){ + return false; + } + } + + public Long getUserId(String token){ + return Long.parseLong(getClaims(token).getSubject()); + } + + private Claims getClaims(String token){ + return Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + public long getAccessTokenExpiry() { + return accessTokenExpirySeconds; + } + + public long getRefreshTokenExpiry() { + return refreshTokenExpirySeconds; + } +}