diff --git a/build.gradle b/build.gradle index 5fc3b04..c003cc0 100644 --- a/build.gradle +++ b/build.gradle @@ -40,11 +40,14 @@ dependencies { implementation group: 'org.springframework.boot', name: 'spring-boot-starter-mail', version: '3.0.5' - // Swagger 3.0.0 + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation group: 'org.springframework.boot', name: 'spring-boot-starter-data-redis' + } tasks.named('test') { diff --git a/src/main/java/com/example/reddiserver/common/ApiResponse.java b/src/main/java/com/example/reddiserver/common/ApiResponse.java index d994f78..be02ccf 100644 --- a/src/main/java/com/example/reddiserver/common/ApiResponse.java +++ b/src/main/java/com/example/reddiserver/common/ApiResponse.java @@ -18,6 +18,10 @@ public static ApiResponse successResponse(T data) { return new ApiResponse<>(SUCCESS_STATUS, data, null); } + public static ApiResponse successResponse(T data, String message) { + return new ApiResponse<>(SUCCESS_STATUS, data, message); + } + public static ApiResponse successWithNoContent() { return new ApiResponse<>(SUCCESS_STATUS, null, null); } diff --git a/src/main/java/com/example/reddiserver/config/RedisConfig.java b/src/main/java/com/example/reddiserver/config/RedisConfig.java new file mode 100644 index 0000000..fb03ad6 --- /dev/null +++ b/src/main/java/com/example/reddiserver/config/RedisConfig.java @@ -0,0 +1,34 @@ +package com.example.reddiserver.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.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 redisHost; + + @Value("${spring.data.redis.port}") + private int redisPort; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(redisHost, redisPort); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + + return redisTemplate; + } +} diff --git a/src/main/java/com/example/reddiserver/controller/auth/AuthController.java b/src/main/java/com/example/reddiserver/controller/auth/AuthController.java new file mode 100644 index 0000000..68848cc --- /dev/null +++ b/src/main/java/com/example/reddiserver/controller/auth/AuthController.java @@ -0,0 +1,23 @@ +package com.example.reddiserver.controller.auth; + +import com.example.reddiserver.common.ApiResponse; +import com.example.reddiserver.dto.auth.request.ReissueRequestDto; +import com.example.reddiserver.dto.security.response.TokenDto; +import com.example.reddiserver.service.auth.AuthService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/auth") +public class AuthController { + private final AuthService authService; + + @PostMapping("/reissue") + public ApiResponse reissue(@RequestBody ReissueRequestDto reissueRequestDto) { + return ApiResponse.successResponse(authService.reissue(reissueRequestDto), "Access Token 재발급 성공"); + } +} diff --git a/src/main/java/com/example/reddiserver/dto/auth/request/ReissueRequestDto.java b/src/main/java/com/example/reddiserver/dto/auth/request/ReissueRequestDto.java new file mode 100644 index 0000000..202a7b3 --- /dev/null +++ b/src/main/java/com/example/reddiserver/dto/auth/request/ReissueRequestDto.java @@ -0,0 +1,9 @@ +package com.example.reddiserver.dto.auth.request; + +import lombok.Getter; + +@Getter +public class ReissueRequestDto { + private String accessToken; + private String refreshToken; +} diff --git a/src/main/java/com/example/reddiserver/dto/security/response/JwtErrorResponse.java b/src/main/java/com/example/reddiserver/dto/security/response/JwtErrorResponse.java new file mode 100644 index 0000000..89b7570 --- /dev/null +++ b/src/main/java/com/example/reddiserver/dto/security/response/JwtErrorResponse.java @@ -0,0 +1,30 @@ +package com.example.reddiserver.dto.security.response; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class JwtErrorResponse { + private static final ObjectMapper objectMapper = new ObjectMapper(); + + private int status; + private String message; + + @Builder + public JwtErrorResponse(int status, String message) { + this.status = status; + this.message = message; + } + public static JwtErrorResponse of(int status, String message) { + return JwtErrorResponse.builder() + .status(status) + .message(message) + .build(); + } + + public String convertToJson() throws JsonProcessingException { + return objectMapper.writeValueAsString(this); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/reddiserver/dto/security/response/TokenDto.java b/src/main/java/com/example/reddiserver/dto/security/response/TokenDto.java new file mode 100644 index 0000000..10038b5 --- /dev/null +++ b/src/main/java/com/example/reddiserver/dto/security/response/TokenDto.java @@ -0,0 +1,11 @@ +package com.example.reddiserver.dto.security.response; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class TokenDto { + private String accessToken; + private String refreshToken; +} diff --git a/src/main/java/com/example/reddiserver/entity/Member.java b/src/main/java/com/example/reddiserver/entity/Member.java index da0a598..b67d55f 100644 --- a/src/main/java/com/example/reddiserver/entity/Member.java +++ b/src/main/java/com/example/reddiserver/entity/Member.java @@ -25,7 +25,7 @@ public class Member extends BaseTimeEntity { private List bookmarks = new ArrayList<>(); @Column(nullable = false, unique = true) - private String email; + private String providerId; @Column(nullable = false) private String name; @@ -42,8 +42,8 @@ public class Member extends BaseTimeEntity { private Authority authority; @Builder - public Member(String email, String name, String imageUrl, ProviderType providerType, Authority authority) { - this.email = email; + public Member(String providerId, String name, String imageUrl, ProviderType providerType, Authority authority) { + this.providerId = providerId; this.name = name; this.imageUrl = imageUrl; this.providerType = providerType; diff --git a/src/main/java/com/example/reddiserver/entity/RefreshToken.java b/src/main/java/com/example/reddiserver/entity/RefreshToken.java new file mode 100644 index 0000000..24da08c --- /dev/null +++ b/src/main/java/com/example/reddiserver/entity/RefreshToken.java @@ -0,0 +1,30 @@ +package com.example.reddiserver.entity; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.index.Indexed; + +@Getter +@RedisHash(value = "refreshToken", timeToLive = 60*60*24*3) +public class RefreshToken { + + @Id + private String refreshToken; + + @Indexed + private String providerId; + + + @Builder + public RefreshToken(String providerId, String refreshToken) { + this.providerId = providerId; + this.refreshToken = refreshToken; + } + + public RefreshToken updateToken(String token) { + this.refreshToken = token; + return this; + } +} diff --git a/src/main/java/com/example/reddiserver/repository/MemberRepository.java b/src/main/java/com/example/reddiserver/repository/MemberRepository.java new file mode 100644 index 0000000..21abeda --- /dev/null +++ b/src/main/java/com/example/reddiserver/repository/MemberRepository.java @@ -0,0 +1,10 @@ +package com.example.reddiserver.repository; + +import com.example.reddiserver.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + Optional findByProviderId(String providerId); +} diff --git a/src/main/java/com/example/reddiserver/repository/RefreshTokenRepository.java b/src/main/java/com/example/reddiserver/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..b77330f --- /dev/null +++ b/src/main/java/com/example/reddiserver/repository/RefreshTokenRepository.java @@ -0,0 +1,12 @@ +package com.example.reddiserver.repository; + +import com.example.reddiserver.entity.RefreshToken; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface RefreshTokenRepository extends CrudRepository { + Optional findByProviderId(String providerId); +} diff --git a/src/main/java/com/example/reddiserver/security/config/CorsConfig.java b/src/main/java/com/example/reddiserver/security/config/CorsConfig.java new file mode 100644 index 0000000..7083be4 --- /dev/null +++ b/src/main/java/com/example/reddiserver/security/config/CorsConfig.java @@ -0,0 +1,25 @@ +package com.example.reddiserver.security.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +public class CorsConfig { + @Bean + public CorsConfigurationSource corsConfigurationSource() { + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + CorsConfiguration config = new CorsConfiguration(); + + config.setAllowCredentials(true); + config.addAllowedOriginPattern("*"); + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + + source.registerCorsConfiguration("/api/**", config); + + return source; + } +} diff --git a/src/main/java/com/example/reddiserver/security/config/WebSecurityConfig.java b/src/main/java/com/example/reddiserver/security/config/WebSecurityConfig.java new file mode 100644 index 0000000..974e09c --- /dev/null +++ b/src/main/java/com/example/reddiserver/security/config/WebSecurityConfig.java @@ -0,0 +1,63 @@ +package com.example.reddiserver.security.config; + +import com.example.reddiserver.security.jwt.JwtAuthenticationFilter; +import com.example.reddiserver.security.jwt.JwtExceptionHandlerFilter; +import com.example.reddiserver.security.jwt.exception.JwtAccessDeniedHandler; +import com.example.reddiserver.security.jwt.exception.JwtAuthenticationEntryPoint; +import com.example.reddiserver.security.oauth.handler.OAuthFailureHandler; +import com.example.reddiserver.security.oauth.handler.OAuthSuccessHandler; +import com.example.reddiserver.security.oauth.PrincipalOAuth2DetailsService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +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.CorsConfigurationSource; + +@EnableWebSecurity +@Configuration +@RequiredArgsConstructor +public class WebSecurityConfig { + private final JwtExceptionHandlerFilter jwtExceptionHandlerFilter; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CorsConfigurationSource corsConfigurationSource; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final PrincipalOAuth2DetailsService customOAuth2UserService; + private final OAuthSuccessHandler oAuthSuccessHandler; + private final OAuthFailureHandler oAuthFailureHandler; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .cors((cors) -> cors.configurationSource(corsConfigurationSource)) + .csrf(csrf -> csrf.disable()) + .sessionManagement(sessionManagement -> + sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .httpBasic(httpBasic -> httpBasic.disable()) + .formLogin(formLogin -> formLogin.disable()) + .exceptionHandling(authenticationManager -> authenticationManager + .accessDeniedHandler(jwtAccessDeniedHandler) + .authenticationEntryPoint(jwtAuthenticationEntryPoint)) + .oauth2Login(oauth2LoginConfigurer -> oauth2LoginConfigurer + .failureHandler(oAuthFailureHandler) + .successHandler(oAuthSuccessHandler) + .userInfoEndpoint() + .userService(customOAuth2UserService)) + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers("/**").permitAll() + .requestMatchers(HttpMethod.GET, "/swagger-ui/**").permitAll() + .requestMatchers(HttpMethod.GET, "/v3/api-docs/**").permitAll() + .anyRequest().authenticated() + ); + + http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + http.addFilterBefore(jwtExceptionHandlerFilter, JwtAuthenticationFilter.class); + + return http.build(); + } +} diff --git a/src/main/java/com/example/reddiserver/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/example/reddiserver/security/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..f9d6faf --- /dev/null +++ b/src/main/java/com/example/reddiserver/security/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,44 @@ +package com.example.reddiserver.security.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer"; + private final TokenProvider tokenProvider; + + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { + return bearerToken.substring(7); + } + + return null; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String jwt = resolveToken(request); + + if (StringUtils.hasText(jwt) && tokenProvider.validateAccessToken(jwt)) { + Authentication authentication = tokenProvider.getAuthentication(jwt); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/example/reddiserver/security/jwt/JwtExceptionHandlerFilter.java b/src/main/java/com/example/reddiserver/security/jwt/JwtExceptionHandlerFilter.java new file mode 100644 index 0000000..ba11a09 --- /dev/null +++ b/src/main/java/com/example/reddiserver/security/jwt/JwtExceptionHandlerFilter.java @@ -0,0 +1,47 @@ +package com.example.reddiserver.security.jwt; + +import com.example.reddiserver.dto.security.response.JwtErrorResponse; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class JwtExceptionHandlerFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (io.jsonwebtoken.security.SignatureException | MalformedJwtException e) { + throw new JwtException("잘못된 JWT 서명입니다.", e); + } catch (ExpiredJwtException e) { + throw new JwtException("만료된 JWT 토큰입니다.", e); + } catch (UnsupportedJwtException e) { + throw new JwtException("지원되지 않는 JWT 토큰입니다.", e); + } catch (IllegalArgumentException e) { + throw new JwtException("Jwt 토큰이 잘못되었습니다.", e); + } + } + + public void setErrorResponse(HttpStatus status, HttpServletResponse response, Throwable e) throws IOException { + response.setStatus(status.value()); + response.setContentType("application/json; charset=UTF-8"); + + response.getWriter().write( + JwtErrorResponse.of( + HttpServletResponse.SC_UNAUTHORIZED, + e.getMessage() + ).convertToJson() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/reddiserver/security/jwt/TokenProvider.java b/src/main/java/com/example/reddiserver/security/jwt/TokenProvider.java new file mode 100644 index 0000000..91b0c5c --- /dev/null +++ b/src/main/java/com/example/reddiserver/security/jwt/TokenProvider.java @@ -0,0 +1,141 @@ +package com.example.reddiserver.security.jwt; + +import com.example.reddiserver.dto.security.response.TokenDto; +import com.example.reddiserver.entity.RefreshToken; +import com.example.reddiserver.repository.RefreshTokenRepository; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.InitializingBean; +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.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Component +@RequiredArgsConstructor +public class TokenProvider implements InitializingBean { + private static final String AUTHORITIES_KEY = "auth"; + private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30L; + private static final long REFRESH_TOKEN_EXPIRE_TIME = 60 * 60 * 24 * 3L; + + @Value("${spring.jwt.secret}") + private String secret; + private Key key; + + private final RefreshTokenRepository refreshTokenRepository; + + @Override + public void afterPropertiesSet() { + Base64.Decoder decoders = Base64.getDecoder(); + byte[] keyBytes = decoders.decode(secret); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + private String createToken(String name, String authorities, String type) { + long now = (new Date()).getTime(); + long time = type.equals("access") ? ACCESS_TOKEN_EXPIRE_TIME : REFRESH_TOKEN_EXPIRE_TIME; + Date tokenExpiredIn = new Date(now + time); + + String token = Jwts.builder() + .setSubject(name) + .claim(AUTHORITIES_KEY, authorities) + .setExpiration(tokenExpiredIn) + .signWith(key, SignatureAlgorithm.HS512) + .compact(); + + return token; + } + + public TokenDto createAccessToken(Authentication authentication) { + String authorities = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + String accessToken = createToken(authentication.getName(), authorities, "access"); + String newRefreshToken = createToken(authentication.getName(), authorities, "refresh"); + + Optional oldRefreshToken = refreshTokenRepository.findByProviderId(authentication.getName()); + + if (oldRefreshToken.isPresent()) { + refreshTokenRepository.save(oldRefreshToken.get().updateToken(newRefreshToken)); + } else { + RefreshToken refreshToken = RefreshToken.builder() + .providerId(authentication.getName()) + .refreshToken(newRefreshToken) + .build(); + + refreshTokenRepository.save(refreshToken); + System.out.println("test"); + } + + return TokenDto.builder() + .accessToken(accessToken) + .refreshToken(newRefreshToken) + .build(); + } + + private Claims parseClaims(String accessToken) { + try { + return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody(); + } catch (ExpiredJwtException e) { + log.error("Expried JWT Token"); + return e.getClaims(); + } + } + + public Authentication getAuthentication(String accessToken) { + Claims claims = parseClaims(accessToken); + + if (claims.get(AUTHORITIES_KEY) == null){ + throw new RuntimeException("권한 정보가 없는 토큰"); + } + + Collection authorities = + Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(",")) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + + UserDetails userDetails = new User(claims.getSubject(), "", authorities); + + return new UsernamePasswordAuthenticationToken(userDetails, "", authorities); + } + + public boolean validateAccessToken(String accessToken) { + try { + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken); + return true; + } catch (io.jsonwebtoken.security.SignatureException | MalformedJwtException e) { + log.error("잘못된 JWT 서명입니다"); + } catch (ExpiredJwtException e) { + log.error("만료된 JWT 토큰입니다"); + } catch (UnsupportedJwtException e) { + log.error("지원되지 않는 JWT 토큰입니다"); + } catch (IllegalArgumentException e) { + log.error("Jwt 토큰이 잘못되었습니다"); + } + return false; + } + + public boolean refreshTokenValidation(String token) { + if (!validateAccessToken(token)) return false; + + Optional refreshToken = refreshTokenRepository.findByProviderId(getEmailFromToken(token)); + + return refreshToken.isPresent() && token.equals(refreshToken.get().getRefreshToken()); + } + + public String getEmailFromToken(String token) { + return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getSubject(); + } +} diff --git a/src/main/java/com/example/reddiserver/security/jwt/exception/JwtAccessDeniedHandler.java b/src/main/java/com/example/reddiserver/security/jwt/exception/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..18f9581 --- /dev/null +++ b/src/main/java/com/example/reddiserver/security/jwt/exception/JwtAccessDeniedHandler.java @@ -0,0 +1,21 @@ +package com.example.reddiserver.security.jwt.exception; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@Slf4j +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { + // 필요한 권한이 없는 경우 403 + log.error("필요한 권한이 없는 경우 : JwtAccessDeniedHandler"); + response.sendError(HttpServletResponse.SC_FORBIDDEN); + } +} diff --git a/src/main/java/com/example/reddiserver/security/jwt/exception/JwtAuthenticationEntryPoint.java b/src/main/java/com/example/reddiserver/security/jwt/exception/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..ca6bcd0 --- /dev/null +++ b/src/main/java/com/example/reddiserver/security/jwt/exception/JwtAuthenticationEntryPoint.java @@ -0,0 +1,22 @@ +package com.example.reddiserver.security.jwt.exception; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@Slf4j +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + // 유효한 자격증명을 제공하지 않고 접근하려 할 때, 401 + log.error("유효하지 않은 자격증명 : JwtAuthenticationEntryPoint", authException.getMessage()); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage()); + } +} diff --git a/src/main/java/com/example/reddiserver/security/oauth/OAuth2UserInfoFactory.java b/src/main/java/com/example/reddiserver/security/oauth/OAuth2UserInfoFactory.java new file mode 100644 index 0000000..da135c4 --- /dev/null +++ b/src/main/java/com/example/reddiserver/security/oauth/OAuth2UserInfoFactory.java @@ -0,0 +1,16 @@ +package com.example.reddiserver.security.oauth; + +import com.example.reddiserver.security.oauth.provider.GoogleOAuth2User; +import com.example.reddiserver.security.oauth.provider.OAuth2UserInfo; + +import java.util.Map; + +public class OAuth2UserInfoFactory { + public static OAuth2UserInfo getOAuth2UserInfo(String registrationId, Map attributes) { + switch (registrationId) { + case "GOOGLE": return new GoogleOAuth2User(attributes); + + default: throw new IllegalArgumentException("지원하지 않는 OAuth 플랫폼 입니다. \n지원하는 플랫폼 : Google"); + } + } +} diff --git a/src/main/java/com/example/reddiserver/security/oauth/PrincipalOAuth2Details.java b/src/main/java/com/example/reddiserver/security/oauth/PrincipalOAuth2Details.java new file mode 100644 index 0000000..a781132 --- /dev/null +++ b/src/main/java/com/example/reddiserver/security/oauth/PrincipalOAuth2Details.java @@ -0,0 +1,49 @@ +package com.example.reddiserver.security.oauth; + +import com.example.reddiserver.entity.Member; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; + +@Getter +public class PrincipalOAuth2Details implements OAuth2User { + + private Member member; + private Map attributes; + + public PrincipalOAuth2Details(Member member) { + this.member = member; + } + + public PrincipalOAuth2Details(Member member, Map attributes) { + this.member = member; + this.attributes = attributes; + } + + @Override + public Map getAttributes() { + return null; + } + + @Override + public Collection getAuthorities() { + Collection collect = new ArrayList<>(); + collect.add(new GrantedAuthority() { + @Override + public String getAuthority() { + return member.getAuthority().toString(); + } + }); + + return collect; + } + + @Override + public String getName() { + return member.getProviderId(); + } +} diff --git a/src/main/java/com/example/reddiserver/security/oauth/PrincipalOAuth2DetailsService.java b/src/main/java/com/example/reddiserver/security/oauth/PrincipalOAuth2DetailsService.java new file mode 100644 index 0000000..abf9f76 --- /dev/null +++ b/src/main/java/com/example/reddiserver/security/oauth/PrincipalOAuth2DetailsService.java @@ -0,0 +1,48 @@ +package com.example.reddiserver.security.oauth; + +import com.example.reddiserver.entity.Member; +import com.example.reddiserver.entity.enums.Authority; +import com.example.reddiserver.repository.MemberRepository; +import com.example.reddiserver.security.oauth.provider.OAuth2UserInfo; +import lombok.RequiredArgsConstructor; +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.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class PrincipalOAuth2DetailsService extends DefaultOAuth2UserService { + private final MemberRepository memberRepository; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + return processOAuth2User(userRequest, super.loadUser(userRequest)); + } + + protected OAuth2User processOAuth2User(OAuth2UserRequest userRequest, OAuth2User oAuth2User) { + String registrationId = userRequest.getClientRegistration().getRegistrationId().toUpperCase(); + OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, oAuth2User.getAttributes()); + + Member member = memberRepository.findByProviderId(oAuth2UserInfo.getProviderId()).orElse(null); + if (member == null) { + member = signup(oAuth2UserInfo); + } + + return new PrincipalOAuth2Details(member, oAuth2UserInfo.getAttributes()); + } + + private Member signup(OAuth2UserInfo oAuth2UserInfo) { + Member member = Member.builder() + .providerId(oAuth2UserInfo.getProviderId()) + .name(oAuth2UserInfo.getName()) + .providerType(oAuth2UserInfo.getProviderType()) + .authority(Authority.ROLE_USER) + .build(); + + return memberRepository.save(member); + } +} diff --git a/src/main/java/com/example/reddiserver/security/oauth/handler/OAuthFailureHandler.java b/src/main/java/com/example/reddiserver/security/oauth/handler/OAuthFailureHandler.java new file mode 100644 index 0000000..c9fb8c4 --- /dev/null +++ b/src/main/java/com/example/reddiserver/security/oauth/handler/OAuthFailureHandler.java @@ -0,0 +1,22 @@ +package com.example.reddiserver.security.oauth.handler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class OAuthFailureHandler extends SimpleUrlAuthenticationFailureHandler { + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { + System.out.println("test"); + super.onAuthenticationFailure(request, response, exception); + } +} diff --git a/src/main/java/com/example/reddiserver/security/oauth/handler/OAuthSuccessHandler.java b/src/main/java/com/example/reddiserver/security/oauth/handler/OAuthSuccessHandler.java new file mode 100644 index 0000000..f3bf65d --- /dev/null +++ b/src/main/java/com/example/reddiserver/security/oauth/handler/OAuthSuccessHandler.java @@ -0,0 +1,33 @@ +package com.example.reddiserver.security.oauth.handler; + +import com.example.reddiserver.security.jwt.TokenProvider; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@RequiredArgsConstructor +@Component +public class OAuthSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final TokenProvider tokenProvider; + private static final ObjectMapper mapper = new ObjectMapper(); + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { + // Access, Refresh Token Body 저장 + response.setContentType("application/json"); + response.setCharacterEncoding("utf-8"); + + String tokenDto = mapper.writeValueAsString(tokenProvider.createAccessToken(authentication)); + response.getWriter().write(tokenDto); + + response.getWriter().flush(); + } +} diff --git a/src/main/java/com/example/reddiserver/security/oauth/provider/GoogleOAuth2User.java b/src/main/java/com/example/reddiserver/security/oauth/provider/GoogleOAuth2User.java new file mode 100644 index 0000000..7caee51 --- /dev/null +++ b/src/main/java/com/example/reddiserver/security/oauth/provider/GoogleOAuth2User.java @@ -0,0 +1,28 @@ +package com.example.reddiserver.security.oauth.provider; + +import com.example.reddiserver.entity.enums.ProviderType; + +import java.util.Map; + +public class GoogleOAuth2User extends OAuth2UserInfo{ + + public GoogleOAuth2User(Map attributes) { + super(attributes); + } + + public String getProviderId() { + return (String) attributes.get("sub"); + } + + public String getName() { + return (String) attributes.get("name"); + } + + public ProviderType getProviderType() { + return ProviderType.GOOGLE; + } + + public String getEmail() { + return (String) attributes.get("email"); + } +} diff --git a/src/main/java/com/example/reddiserver/security/oauth/provider/OAuth2UserInfo.java b/src/main/java/com/example/reddiserver/security/oauth/provider/OAuth2UserInfo.java new file mode 100644 index 0000000..e5a3274 --- /dev/null +++ b/src/main/java/com/example/reddiserver/security/oauth/provider/OAuth2UserInfo.java @@ -0,0 +1,18 @@ +package com.example.reddiserver.security.oauth.provider; + +import com.example.reddiserver.entity.enums.ProviderType; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Map; + +@Getter +@AllArgsConstructor +public abstract class OAuth2UserInfo { + protected Map attributes; + + public abstract String getProviderId(); + public abstract String getName(); + public abstract ProviderType getProviderType(); + public abstract String getEmail(); +} diff --git a/src/main/java/com/example/reddiserver/service/auth/AuthService.java b/src/main/java/com/example/reddiserver/service/auth/AuthService.java new file mode 100644 index 0000000..2efddd2 --- /dev/null +++ b/src/main/java/com/example/reddiserver/service/auth/AuthService.java @@ -0,0 +1,27 @@ +package com.example.reddiserver.service.auth; + +import com.example.reddiserver.dto.auth.request.ReissueRequestDto; +import com.example.reddiserver.dto.security.response.TokenDto; +import com.example.reddiserver.security.jwt.TokenProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuthService { + private final TokenProvider tokenProvider; + + @Transactional + public TokenDto reissue(ReissueRequestDto reissueRequestDto) { + if (!tokenProvider.refreshTokenValidation(reissueRequestDto.getRefreshToken())) { + throw new RuntimeException("유효하지 않은 Refresh Token 입니다."); + } + + Authentication authentication = tokenProvider.getAuthentication(reissueRequestDto.getAccessToken()); + + return tokenProvider.createAccessToken(authentication); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f84c0e7..a71ec2c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -39,6 +39,18 @@ spring: format_sql: true jwt: secret: ${JWT_SECRET} + security: + oauth2: + client: + registration: + google: + client-id: ${CLIENT_ID} + client-secret: ${CLIENT_SECRET} + scope: profile, email + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} springdoc: api-docs: