From 7e7936bb9199d618814e2e6f8f29ed52cd4b64a4 Mon Sep 17 00:00:00 2001 From: jiseon Date: Mon, 2 Sep 2024 22:56:58 +0900 Subject: [PATCH 01/11] =?UTF-8?q?ITDS-38=20feat:=20token=20filtering=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC,=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=20=EC=A0=95=EB=B3=B4=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../itit/common/jwt/filter/JwtAuthFilter.java | 86 +++--- .../itit/common/jwt/util/JwtUtil.java | 271 ++++++++++-------- .../itit/domain/entity/UserDetailsImpl.java | 55 ++++ .../itit/service/UserDetailsServiceImpl.java | 31 ++ 4 files changed, 266 insertions(+), 177 deletions(-) create mode 100644 src/main/java/com/dissonance/itit/domain/entity/UserDetailsImpl.java create mode 100644 src/main/java/com/dissonance/itit/service/UserDetailsServiceImpl.java diff --git a/src/main/java/com/dissonance/itit/common/jwt/filter/JwtAuthFilter.java b/src/main/java/com/dissonance/itit/common/jwt/filter/JwtAuthFilter.java index 0079aa0..8087095 100644 --- a/src/main/java/com/dissonance/itit/common/jwt/filter/JwtAuthFilter.java +++ b/src/main/java/com/dissonance/itit/common/jwt/filter/JwtAuthFilter.java @@ -1,75 +1,55 @@ package com.dissonance.itit.common.jwt.filter; -import com.dissonance.itit.common.exception.ErrorCode; -import com.dissonance.itit.common.exception.CustomException; +import java.io.IOException; + +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 com.dissonance.itit.common.jwt.util.JwtUtil; -import com.dissonance.itit.domain.entity.User; -import com.dissonance.itit.repository.UserRepository; + 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; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -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; -import java.util.List; @RequiredArgsConstructor @Slf4j @Component public class JwtAuthFilter extends OncePerRequestFilter { + private final JwtUtil jwtUtil; - private final JwtUtil jwtUtil; - private final UserRepository userRepository; - - - @Override - protected void doFilterInternal(HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain) throws ServletException, IOException { - String accessToken = resolveToken(request); - - // 토큰 검사 생략 - if (request.getServletPath().equals("/api/v1/reissue") || !StringUtils.hasText(accessToken)) { - filterChain.doFilter(request, response); - return; - } - - if (jwtUtil.verifyToken(accessToken)) { - // AccessToken의 payload에 있는 email로 user를 조회한다. - User findUser = userRepository.findByEmail(jwtUtil.getUid(accessToken)) - .orElseThrow(() -> new CustomException(ErrorCode.NON_EXISTENT_EMAIL)); + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String accessToken = resolveToken(request); - // SecurityContext에 인증 객체를 등록한다. - Authentication auth = getAuthentication(findUser); - SecurityContextHolder.getContext().setAuthentication(auth); - } + // 토큰 검사 생략 + if (request.getServletPath().equals("/api/v1/reissue") || !StringUtils.hasText(accessToken)) { + filterChain.doFilter(request, response); + return; + } - filterChain.doFilter(request, response); - } + if (jwtUtil.verifyToken(accessToken)) { + Authentication auth = jwtUtil.getAuthentication(accessToken); + SecurityContextHolder.getContext().setAuthentication(auth); + } - // request Header에서 토큰 추출 - private String resolveToken(HttpServletRequest httpServletRequest) { - String bearerToken = httpServletRequest.getHeader("Authorization"); + filterChain.doFilter(request, response); + } - if (bearerToken != null && bearerToken.startsWith("Bearer ")) { - return bearerToken.substring(7); - } + private String resolveToken(HttpServletRequest httpServletRequest) { + String bearerToken = httpServletRequest.getHeader("Authorization"); - return null; - } + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } - // Authentication 생성 - private Authentication getAuthentication(User user) { - return new UsernamePasswordAuthenticationToken(user, "", - List.of(new SimpleGrantedAuthority(user.getRole().toString()))); - } + return null; + } } \ No newline at end of file diff --git a/src/main/java/com/dissonance/itit/common/jwt/util/JwtUtil.java b/src/main/java/com/dissonance/itit/common/jwt/util/JwtUtil.java index 1cc3026..562b0a4 100644 --- a/src/main/java/com/dissonance/itit/common/jwt/util/JwtUtil.java +++ b/src/main/java/com/dissonance/itit/common/jwt/util/JwtUtil.java @@ -1,137 +1,160 @@ package com.dissonance.itit.common.jwt.util; -import com.dissonance.itit.dto.response.GeneratedToken; -import io.jsonwebtoken.*; -import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; +import static com.dissonance.itit.domain.enums.JwtTokenExpiration.*; import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.Date; -import static com.dissonance.itit.domain.enums.JwtTokenExpiration.ACCESS_TOKEN_EXPIRED_TIME; -import static com.dissonance.itit.domain.enums.JwtTokenExpiration.REFRESH_TOKEN_EXPIRATION_TIME; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; + +import com.dissonance.itit.domain.entity.UserDetailsImpl; +import com.dissonance.itit.dto.response.GeneratedToken; +import com.dissonance.itit.service.UserDetailsServiceImpl; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.UnsupportedJwtException; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; @Slf4j @Service @RequiredArgsConstructor public class JwtUtil { - @Value("${jwt.token.secret-key}") - private String jwtSecretKey; - private String secretKey; - - // TODO: redis를 이용한 refresh token 재발급 구현 -// private final RedisService redisService; - - @PostConstruct - protected void init() { - secretKey = Base64.getEncoder().encodeToString(jwtSecretKey.getBytes(StandardCharsets.UTF_8)); - } - - public GeneratedToken generateToken(String email, String role) { - String refreshToken = generateRefreshToken(email, role); - String accessToken = generateAccessToken(email, role); - - return GeneratedToken.builder() - .accessToken(accessToken) - .refreshToken(refreshToken) - .build(); - } - - public String generateRefreshToken(String email, String role) { - // Claim에 이메일, 권한 세팅 - Claims claims = Jwts.claims().setSubject(email); - claims.put("role", role); - - Date now = new Date(); - - return Jwts.builder() - .setClaims(claims) - .setIssuedAt(now) // 발행일자 - .setExpiration(new Date(now.getTime() + REFRESH_TOKEN_EXPIRATION_TIME.getValue())) // 만료 일시 - .signWith(SignatureAlgorithm.HS256, secretKey) // HS256 알고리즘과 secretKey로 서명 - .compact(); - } - - public String generateAccessToken(String email, String role) { - Claims claims = Jwts.claims().setSubject(email); - claims.put("role", role); - - Date now = new Date(); - return - Jwts.builder() - .setClaims(claims) - .setIssuedAt(now) - .setExpiration(new Date(now.getTime() + ACCESS_TOKEN_EXPIRED_TIME.getValue())) - .signWith(SignatureAlgorithm.HS256, secretKey) - .compact(); - } - - public boolean verifyToken(String token) { - try { -// if (redisService.getValues(token) != null && redisService.getValues(token).equals("logout")) { -// throw new JwtException("Invalid JWT Token - logout"); -// } - Jws claims = Jwts.parser() - .setSigningKey(secretKey) - .parseClaimsJws(token); - - return claims.getBody() - .getExpiration() - .after(new Date()); - } catch (MalformedJwtException e) { - log.info("Invalid JWT Token", e); - throw new JwtException("Invalid JWT Token", e); - } catch (ExpiredJwtException e) { - log.info("Expired JWT Token", e); - throw new JwtException("Expired JWT Token", e); - } catch (UnsupportedJwtException e) { - log.info("Unsupported JWT Token", e); - throw new JwtException("Unsupported JWT Token", e); - } catch (IllegalArgumentException e) { - log.info("JWT claims string is empty.", e); - throw new JwtException("JWT claims string is empty.", e); - } - } - - // 토큰에서 email 추출 - public String getUid(String token) { - try { - return Jwts.parser() - .setSigningKey(secretKey) - .parseClaimsJws(token) - .getBody() - .getSubject(); - } catch (ExpiredJwtException e) { - return e.getClaims().getSubject(); - } - } - - // 토큰에서 권한 추출 - public String getRole(String token) { - try { - return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().get("role", String.class); - } catch (ExpiredJwtException e) { - return e.getClaims().get("role", String.class); - } - } - - //JWT 토큰의 남은 유효 시간 추출 - public Long getExpiration(String token){ - Date expiration = Jwts.parser().setSigningKey(secretKey) - .parseClaimsJws(token).getBody().getExpiration(); - - return expiration.getTime() - new Date().getTime(); - } - - // request Header에서 토큰 추출 - public String resolveToken(String requestAccessTokenInHeader) { - if (requestAccessTokenInHeader != null && requestAccessTokenInHeader.startsWith("Bearer ")) { - return requestAccessTokenInHeader.substring(7); - } - return null; - } + @Value("${jwt.token.secret-key}") + private String jwtSecretKey; + private String secretKey; + + private final UserDetailsServiceImpl userDetailsService; + + // TODO: redis를 이용한 refresh token 재발급 구현 + // private final RedisService redisService; + + @PostConstruct + protected void init() { + secretKey = Base64.getEncoder().encodeToString(jwtSecretKey.getBytes(StandardCharsets.UTF_8)); + } + + public GeneratedToken generateToken(String email, String role) { + String refreshToken = generateRefreshToken(email, role); + String accessToken = generateAccessToken(email, role); + + return GeneratedToken.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + public String generateRefreshToken(String email, String role) { + // Claim에 이메일, 권한 세팅 + Claims claims = Jwts.claims().setSubject(email); + claims.put("role", role); + + Date now = new Date(); + + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) // 발행일자 + .setExpiration(new Date(now.getTime() + REFRESH_TOKEN_EXPIRATION_TIME.getValue())) // 만료 일시 + .signWith(SignatureAlgorithm.HS256, secretKey) // HS256 알고리즘과 secretKey로 서명 + .compact(); + } + + public String generateAccessToken(String email, String role) { + Claims claims = Jwts.claims().setSubject(email); + claims.put("role", role); + + Date now = new Date(); + return + Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + ACCESS_TOKEN_EXPIRED_TIME.getValue())) + .signWith(SignatureAlgorithm.HS256, secretKey) + .compact(); + } + + public boolean verifyToken(String token) { + try { + // if (redisService.getValues(token) != null && redisService.getValues(token).equals("logout")) { + // throw new JwtException("Invalid JWT Token - logout"); + // } + Jws claims = Jwts.parser() + .setSigningKey(secretKey) + .parseClaimsJws(token); + + return claims.getBody() + .getExpiration() + .after(new Date()); + } catch (MalformedJwtException e) { + log.info("Invalid JWT Token", e); + throw new JwtException("Invalid JWT Token", e); + } catch (ExpiredJwtException e) { + log.info("Expired JWT Token", e); + throw new JwtException("Expired JWT Token", e); + } catch (UnsupportedJwtException e) { + log.info("Unsupported JWT Token", e); + throw new JwtException("Unsupported JWT Token", e); + } catch (IllegalArgumentException e) { + log.info("JWT claims string is empty.", e); + throw new JwtException("JWT claims string is empty.", e); + } + } + + // 토큰에서 email 추출 + public String getUid(String token) { + try { + return Jwts.parser() + .setSigningKey(secretKey) + .parseClaimsJws(token) + .getBody() + .getSubject(); + } catch (ExpiredJwtException e) { + return e.getClaims().getSubject(); + } + } + + // 토큰에서 권한 추출 + public String getRole(String token) { + try { + return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().get("role", String.class); + } catch (ExpiredJwtException e) { + return e.getClaims().get("role", String.class); + } + } + + //JWT 토큰의 남은 유효 시간 추출 + public Long getExpiration(String token) { + Date expiration = Jwts.parser().setSigningKey(secretKey) + .parseClaimsJws(token).getBody().getExpiration(); + + return expiration.getTime() - new Date().getTime(); + } + + // request Header에서 토큰 추출 + public String resolveToken(String requestAccessTokenInHeader) { + if (requestAccessTokenInHeader != null && requestAccessTokenInHeader.startsWith("Bearer ")) { + return requestAccessTokenInHeader.substring(7); + } + return null; + } + + // Authentication 생성 + public Authentication getAuthentication(String accessToken) { + String email = getUid(accessToken); + + UserDetailsImpl userDetailsImpl = userDetailsService.loadUserByUsername(email); + + return new UsernamePasswordAuthenticationToken(userDetailsImpl, "", userDetailsImpl.getAuthorities()); + } } \ No newline at end of file diff --git a/src/main/java/com/dissonance/itit/domain/entity/UserDetailsImpl.java b/src/main/java/com/dissonance/itit/domain/entity/UserDetailsImpl.java new file mode 100644 index 0000000..35f10cb --- /dev/null +++ b/src/main/java/com/dissonance/itit/domain/entity/UserDetailsImpl.java @@ -0,0 +1,55 @@ +package com.dissonance.itit.domain.entity; + +import java.util.ArrayList; +import java.util.Collection; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class UserDetailsImpl implements UserDetails { + private final User user; + + @Override + public Collection getAuthorities() { + Collection authorities = new ArrayList<>(); + + authorities.add(() -> user.getRole().getKey()); + + return authorities; + } + + @Override + public String getUsername() { + return user.getEmail(); + } + + @Override + public String getPassword() { + return user.getPassword(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/com/dissonance/itit/service/UserDetailsServiceImpl.java b/src/main/java/com/dissonance/itit/service/UserDetailsServiceImpl.java new file mode 100644 index 0000000..f8c532c --- /dev/null +++ b/src/main/java/com/dissonance/itit/service/UserDetailsServiceImpl.java @@ -0,0 +1,31 @@ +package com.dissonance.itit.service; + +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import com.dissonance.itit.common.exception.CustomException; +import com.dissonance.itit.common.exception.ErrorCode; +import com.dissonance.itit.domain.entity.User; +import com.dissonance.itit.domain.entity.UserDetailsImpl; +import com.dissonance.itit.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class UserDetailsServiceImpl implements UserDetailsService { + private final UserRepository userRepository; + + @Override + public UserDetailsImpl loadUserByUsername(String email) throws UsernameNotFoundException { + User findUser = userRepository.findByEmail(email) + .orElseThrow(() -> new CustomException(ErrorCode.NON_EXISTENT_EMAIL)); + + if (findUser != null) { + return new UserDetailsImpl(findUser); + } + + return null; + } +} From 3d7901379758aebd06d2a0d2ab45eff329af215d Mon Sep 17 00:00:00 2001 From: jiseon Date: Mon, 2 Sep 2024 22:58:56 +0900 Subject: [PATCH 02/11] =?UTF-8?q?ITDS-38=20feat:=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=9C=A0=EC=A0=80=20=EA=B0=9D=EC=B2=B4=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=9B=EC=95=84=EC=98=A4=EB=8A=94=20custom=20annotation=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../itit/common/annotation/CurrentUser.java | 16 ++++++++++++ .../controller/FeaturedPostContorller.java | 26 ++++++++++--------- .../itit/controller/InfoPostController.java | 6 ++--- 3 files changed, 32 insertions(+), 16 deletions(-) create mode 100644 src/main/java/com/dissonance/itit/common/annotation/CurrentUser.java diff --git a/src/main/java/com/dissonance/itit/common/annotation/CurrentUser.java b/src/main/java/com/dissonance/itit/common/annotation/CurrentUser.java new file mode 100644 index 0000000..5532921 --- /dev/null +++ b/src/main/java/com/dissonance/itit/common/annotation/CurrentUser.java @@ -0,0 +1,16 @@ +package com.dissonance.itit.common.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@AuthenticationPrincipal(expression = "getUser()") +public @interface CurrentUser { +} \ No newline at end of file diff --git a/src/main/java/com/dissonance/itit/controller/FeaturedPostContorller.java b/src/main/java/com/dissonance/itit/controller/FeaturedPostContorller.java index 4c0246f..b56f4ce 100644 --- a/src/main/java/com/dissonance/itit/controller/FeaturedPostContorller.java +++ b/src/main/java/com/dissonance/itit/controller/FeaturedPostContorller.java @@ -1,26 +1,28 @@ package com.dissonance.itit.controller; -import com.dissonance.itit.dto.response.FeaturedPostRes; -import com.dissonance.itit.service.FeaturedPostService; -import io.swagger.v3.oas.annotations.Operation; -import lombok.RequiredArgsConstructor; +import java.util.List; + import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.List; +import com.dissonance.itit.dto.response.FeaturedPostRes; +import com.dissonance.itit.service.FeaturedPostService; + +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; @RestController @RequiredArgsConstructor @RequestMapping("featured-posts") public class FeaturedPostContorller { - private final FeaturedPostService featuredPostService; + private final FeaturedPostService featuredPostService; - @GetMapping - @Operation(summary = "추천 게시글 조회", description = "운영진 추천 게시글 5개를 조회합니다.") - public ResponseEntity> getFeaturedPosts() { - List featuredPostRes = featuredPostService.getFeaturedPost(); - return ResponseEntity.ok(featuredPostRes); - } + @GetMapping + @Operation(summary = "추천 게시글 조회", description = "운영진 추천 게시글 5개를 조회합니다.") + public ResponseEntity> getFeaturedPosts() { + List featuredPostRes = featuredPostService.getFeaturedPost(); + return ResponseEntity.ok(featuredPostRes); + } } diff --git a/src/main/java/com/dissonance/itit/controller/InfoPostController.java b/src/main/java/com/dissonance/itit/controller/InfoPostController.java index e26766a..cd965ae 100644 --- a/src/main/java/com/dissonance/itit/controller/InfoPostController.java +++ b/src/main/java/com/dissonance/itit/controller/InfoPostController.java @@ -13,13 +13,13 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +import com.dissonance.itit.common.annotation.CurrentUser; import com.dissonance.itit.domain.entity.User; import com.dissonance.itit.dto.request.InfoPostReq; import com.dissonance.itit.dto.response.InfoPostCreateRes; import com.dissonance.itit.dto.response.InfoPostDetailRes; import com.dissonance.itit.dto.response.InfoPostRes; import com.dissonance.itit.service.InfoPostService; -import com.dissonance.itit.service.UserService; import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; @@ -30,13 +30,11 @@ @RequestMapping("/info-posts") public class InfoPostController { private final InfoPostService infoPostService; - private final UserService userService; @PostMapping(consumes = {MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE}) @Operation(summary = "공고 게시글 등록", description = "공고 게시글을 등록합니다.") public ResponseEntity createInfoPost(@RequestPart MultipartFile imgFile, - @Valid @RequestPart InfoPostReq infoPostReq) { - User loginUser = userService.findById(1L); // TODO: 로그인 유저 정보 적용 예정 + @Valid @RequestPart InfoPostReq infoPostReq, @CurrentUser User loginUser) { InfoPostCreateRes infoPostCreateRes = infoPostService.createInfoPost(imgFile, infoPostReq, loginUser); return ResponseEntity.ok(infoPostCreateRes); } From a4459d747450f86fa16be891919ab5a4ee442a81 Mon Sep 17 00:00:00 2001 From: jiseon Date: Mon, 2 Sep 2024 23:00:05 +0900 Subject: [PATCH 03/11] =?UTF-8?q?ITDS-38=20feat:=20api=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20security=20filter=20chain=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../itit/config/SecurityConfig.java | 87 ++++++++++--------- 1 file changed, 45 insertions(+), 42 deletions(-) diff --git a/src/main/java/com/dissonance/itit/config/SecurityConfig.java b/src/main/java/com/dissonance/itit/config/SecurityConfig.java index a724aad..a3fe340 100644 --- a/src/main/java/com/dissonance/itit/config/SecurityConfig.java +++ b/src/main/java/com/dissonance/itit/config/SecurityConfig.java @@ -1,11 +1,11 @@ package com.dissonance.itit.config; -import com.dissonance.itit.common.jwt.filter.JwtAuthFilter; -import com.dissonance.itit.common.jwt.filter.JwtExceptionFilter; -import lombok.RequiredArgsConstructor; +import java.util.List; + import org.springframework.beans.factory.annotation.Value; 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.annotation.web.configurers.AbstractHttpConfigurer; @@ -16,54 +16,57 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import java.util.List; +import com.dissonance.itit.common.jwt.filter.JwtAuthFilter; +import com.dissonance.itit.common.jwt.filter.JwtExceptionFilter; + +import lombok.RequiredArgsConstructor; @RequiredArgsConstructor @EnableWebSecurity @Configuration public class SecurityConfig { - @Value("${cors.allowed-origins}") - String[] corsOrigins; + @Value("${cors.allowed-origins}") + String[] corsOrigins; - private final JwtAuthFilter jwtAuthFilter; - private final JwtExceptionFilter jwtExceptionFilter; + private final JwtAuthFilter jwtAuthFilter; + private final JwtExceptionFilter jwtExceptionFilter; - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - return http - .cors(corsConfig -> corsConfig.configurationSource(configurationSource())) - .csrf(AbstractHttpConfigurer::disable) - .formLogin(AbstractHttpConfigurer::disable) - .sessionManagement(sessionManagementConfig -> - sessionManagementConfig.sessionCreationPolicy(SessionCreationPolicy.STATELESS) - ) - .authorizeHttpRequests(authorizeRequests -> - authorizeRequests - .requestMatchers("/**", // TODO: 토큰 관련 작업 후 security 적용 - "/swagger-ui/**", - "/v3/api-docs/**").permitAll() - .anyRequest().authenticated() - ) - .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) - .addFilterBefore(jwtExceptionFilter, JwtAuthFilter.class) - .build(); - } + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .cors(corsConfig -> corsConfig.configurationSource(configurationSource())) + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .sessionManagement(sessionManagementConfig -> + sessionManagementConfig.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .authorizeHttpRequests(authorizeRequests -> + authorizeRequests + .requestMatchers("/oauth/**", // TODO: 토큰 관련 작업 후 security 적용 + "/swagger-ui/**", + "/v3/api-docs/**").permitAll() + .requestMatchers(HttpMethod.POST, "/info-posts").hasRole("ADMIN") + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(jwtExceptionFilter, JwtAuthFilter.class) + .build(); + } - @Bean - public CorsConfigurationSource configurationSource() { - // TODO: 클라이언트 주소만 허용 예정 - CorsConfiguration configuration = new CorsConfiguration(); + @Bean + public CorsConfigurationSource configurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOriginPatterns(List.of(corsOrigins)); - configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE")); - configuration.setAllowedHeaders(List.of("*")); - configuration.setExposedHeaders(List.of("Access-Control-Allow-Credentials", "Authorization")); - configuration.setAllowCredentials(true); - configuration.setMaxAge(3600L); + configuration.setAllowedOriginPatterns(List.of(corsOrigins)); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setExposedHeaders(List.of("Access-Control-Allow-Credentials", "Authorization")); + configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", configuration); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); - return source; - } + return source; + } } \ No newline at end of file From 73a1292b5d24d529fec253ab9bdd587f291f46de Mon Sep 17 00:00:00 2001 From: jiseon Date: Mon, 2 Sep 2024 23:00:55 +0900 Subject: [PATCH 04/11] =?UTF-8?q?ITDS-38=20fix:=20=EC=B9=B4=EC=B9=B4?= =?UTF-8?q?=EC=98=A4=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20json=20exception=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC,=20AT=20=EB=A7=8C=EB=A3=8C=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=20=EC=9E=84=EC=8B=9C=20=EC=97=B0=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../itit/domain/enums/JwtTokenExpiration.java | 8 +- .../dto/response/KakaoUserInformation.java | 131 +++++++++--------- 2 files changed, 71 insertions(+), 68 deletions(-) diff --git a/src/main/java/com/dissonance/itit/domain/enums/JwtTokenExpiration.java b/src/main/java/com/dissonance/itit/domain/enums/JwtTokenExpiration.java index 4d238b2..97884d3 100644 --- a/src/main/java/com/dissonance/itit/domain/enums/JwtTokenExpiration.java +++ b/src/main/java/com/dissonance/itit/domain/enums/JwtTokenExpiration.java @@ -6,9 +6,9 @@ @Getter @AllArgsConstructor public enum JwtTokenExpiration { - ACCESS_TOKEN_EXPIRED_TIME("1시간", 1000L * 60 * 60), - REFRESH_TOKEN_EXPIRATION_TIME("2주", 1000L * 60 * 60 * 24 * 14); + ACCESS_TOKEN_EXPIRED_TIME("1시간", 1000L * 60 * 60 * 100000), // TODO: RT 토큰 도입 후 만료 시간 적용 + REFRESH_TOKEN_EXPIRATION_TIME("2주", 1000L * 60 * 60 * 24 * 14); - private final String description; - private final Long value; + private final String description; + private final Long value; } \ No newline at end of file diff --git a/src/main/java/com/dissonance/itit/dto/response/KakaoUserInformation.java b/src/main/java/com/dissonance/itit/dto/response/KakaoUserInformation.java index 87e1941..813accc 100644 --- a/src/main/java/com/dissonance/itit/dto/response/KakaoUserInformation.java +++ b/src/main/java/com/dissonance/itit/dto/response/KakaoUserInformation.java @@ -1,81 +1,84 @@ package com.dissonance.itit.dto.response; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; + import lombok.Getter; import lombok.ToString; @ToString public class KakaoUserInformation implements OAuthUserInformation { - @JsonProperty("id") - private String providerId; - @JsonProperty("connected_at") - private String connectedAt; - @JsonProperty("properties") - private KakaoOAuthProperties properties; - @JsonProperty("kakao_account") - private KakaoAccount kakaoAccount; + @JsonProperty("id") + private String providerId; + @JsonProperty("connected_at") + private String connectedAt; + @JsonProperty("properties") + private KakaoOAuthProperties properties; + @JsonProperty("kakao_account") + private KakaoAccount kakaoAccount; - @Override - public String getNickname() { - return properties.getNickname(); - } + @Override + public String getNickname() { + return properties.getNickname(); + } - @Override - public String getProfileImgUrl() { - return properties.thumbnailImage != null ? properties.thumbnailImage : null; - } + @Override + public String getProfileImgUrl() { + return properties.thumbnailImage != null ? properties.thumbnailImage : null; + } - @Override - public String getProvider() { - return "kakao"; - } + @Override + public String getProvider() { + return "kakao"; + } - @Override - public String getProviderId() { - return providerId; - } + @Override + public String getProviderId() { + return providerId; + } - @Override - public String getEmail() { - return kakaoAccount.getEmail(); - } + @Override + public String getEmail() { + return kakaoAccount.getEmail(); + } - @ToString - @Getter - static class KakaoOAuthProperties { - @JsonProperty("nickname") - private String nickname; - @JsonProperty("profile_image") - private String profileImage; - @JsonProperty("thumbnail_image") - private String thumbnailImage; - } + @ToString + @Getter + static class KakaoOAuthProperties { + @JsonProperty("nickname") + private String nickname; + @JsonProperty("profile_image") + private String profileImage; + @JsonProperty("thumbnail_image") + private String thumbnailImage; + } - @ToString - @Getter - static class KakaoAccount { - @JsonProperty(value = "email") - private String email; - @JsonProperty(value = "profile_image_needs_agreement") - private Boolean profileImageNeedsAgreement; - @JsonProperty(value = "profile_nickname_needs_agreement") - private Boolean profileNicknameNeedsAgreement; - @JsonProperty(value = "profile") - private Profile profile; - } + @ToString + @Getter + @JsonIgnoreProperties(ignoreUnknown = true) + static class KakaoAccount { + @JsonProperty(value = "email") + private String email; + @JsonProperty(value = "profile_image_needs_agreement") + private Boolean profileImageNeedsAgreement; + @JsonProperty(value = "profile_nickname_needs_agreement") + private Boolean profileNicknameNeedsAgreement; + @JsonProperty(value = "profile") + private Profile profile; + } - @ToString - @Getter - static class Profile { - @JsonProperty("nickname") - private String nickname; - @JsonProperty("profile_image_url") - private String profileImage; - @JsonProperty("thumbnail_image_url") - private String thumbnailImage; - @JsonProperty("is_default_image") - private Boolean isDefaultImage; - @JsonProperty("is_default_nickname") - private Boolean isDefaultNickname; - } + @ToString + @Getter + static class Profile { + @JsonProperty("nickname") + private String nickname; + @JsonProperty("profile_image_url") + private String profileImage; + @JsonProperty("thumbnail_image_url") + private String thumbnailImage; + @JsonProperty("is_default_image") + private Boolean isDefaultImage; + @JsonProperty("is_default_nickname") + private Boolean isDefaultNickname; + } } From 4d0dad4371419114d347e1b100218da72c757d9c Mon Sep 17 00:00:00 2001 From: jiseon Date: Tue, 3 Sep 2024 17:50:37 +0900 Subject: [PATCH 05/11] =?UTF-8?q?ITDS-38=20feat:=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=9C=A0=EC=A0=80=20=EC=A0=95=EB=B3=B4=20api=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../itit/controller/OauthController.java | 27 ++-- .../itit/controller/UserController.java | 29 +++++ .../itit/dto/response/LoginUserInfoRes.java | 6 + .../dissonance/itit/service/UserService.java | 115 +++++++++--------- 4 files changed, 111 insertions(+), 66 deletions(-) create mode 100644 src/main/java/com/dissonance/itit/controller/UserController.java create mode 100644 src/main/java/com/dissonance/itit/dto/response/LoginUserInfoRes.java diff --git a/src/main/java/com/dissonance/itit/controller/OauthController.java b/src/main/java/com/dissonance/itit/controller/OauthController.java index eea848f..0798889 100644 --- a/src/main/java/com/dissonance/itit/controller/OauthController.java +++ b/src/main/java/com/dissonance/itit/controller/OauthController.java @@ -1,27 +1,32 @@ package com.dissonance.itit.controller; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +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; + import com.dissonance.itit.dto.request.OauthTokenReq; import com.dissonance.itit.dto.response.GeneratedToken; import com.dissonance.itit.service.UserService; + import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; @RequiredArgsConstructor @RestController @RequestMapping("oauth") public class OauthController { - private final UserService userService; + private final UserService userService; - // TODO: 커스텀 어노테이션으로 로그인 유저 정보 추출 - @PostMapping("/{provider}") - @Operation(summary = "소셜 로그인", description = "소셜 로그인 (provider - kakao, apple)") - public ResponseEntity getUserInfos(@PathVariable String provider, - @Valid @RequestBody OauthTokenReq oauthTokenReq) { - GeneratedToken token = userService.login(provider, oauthTokenReq.accessToken()); + @PostMapping("/{provider}") + @Operation(summary = "소셜 로그인", description = "소셜 로그인 (provider - kakao, apple)") + public ResponseEntity getUserInfos(@PathVariable String provider, + @Valid @RequestBody OauthTokenReq oauthTokenReq) { + GeneratedToken token = userService.login(provider, oauthTokenReq.accessToken()); - return ResponseEntity.ok(token); - } + return ResponseEntity.ok(token); + } } diff --git a/src/main/java/com/dissonance/itit/controller/UserController.java b/src/main/java/com/dissonance/itit/controller/UserController.java new file mode 100644 index 0000000..ee1097d --- /dev/null +++ b/src/main/java/com/dissonance/itit/controller/UserController.java @@ -0,0 +1,29 @@ +package com.dissonance.itit.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.dissonance.itit.common.annotation.CurrentUser; +import com.dissonance.itit.domain.entity.User; +import com.dissonance.itit.dto.response.LoginUserInfoRes; +import com.dissonance.itit.service.UserService; + +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@RestController +@RequestMapping("users") +public class UserController { + private final UserService userService; + + @GetMapping + @Operation(summary = "로그인 유저 정보", description = "로그인 유저의 관리자 여부와 소셜 로그인 provider를 제공합니다.") + public ResponseEntity getUserInfo(@CurrentUser User loginUser) { + LoginUserInfoRes userInfoRes = userService.getUserInfo(loginUser); + + return ResponseEntity.ok(userInfoRes); + } +} diff --git a/src/main/java/com/dissonance/itit/dto/response/LoginUserInfoRes.java b/src/main/java/com/dissonance/itit/dto/response/LoginUserInfoRes.java new file mode 100644 index 0000000..c6bad2b --- /dev/null +++ b/src/main/java/com/dissonance/itit/dto/response/LoginUserInfoRes.java @@ -0,0 +1,6 @@ +package com.dissonance.itit.dto.response; + +public record LoginUserInfoRes( + boolean isAdmin, + String provider) { +} diff --git a/src/main/java/com/dissonance/itit/service/UserService.java b/src/main/java/com/dissonance/itit/service/UserService.java index c41d3c8..0872e8c 100644 --- a/src/main/java/com/dissonance/itit/service/UserService.java +++ b/src/main/java/com/dissonance/itit/service/UserService.java @@ -1,72 +1,77 @@ package com.dissonance.itit.service; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + import com.dissonance.itit.common.exception.CustomException; import com.dissonance.itit.common.exception.ErrorCode; import com.dissonance.itit.common.jwt.util.JwtUtil; -import com.dissonance.itit.domain.enums.Role; import com.dissonance.itit.domain.entity.User; +import com.dissonance.itit.domain.enums.Role; import com.dissonance.itit.dto.response.GeneratedToken; +import com.dissonance.itit.dto.response.LoginUserInfoRes; import com.dissonance.itit.dto.response.OAuthUserInformation; import com.dissonance.itit.repository.UserRepository; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - @Slf4j @RequiredArgsConstructor @Service public class UserService { - private final OauthService oauthService; - private final UserRepository userRepository; - private final JwtUtil jwtUtil; - - @Transactional - public GeneratedToken login(String provider, String token) { - OAuthUserInformation userInformation; - - if (provider.equals("kakao")) { - userInformation = oauthService.requestUserInformation(token); - } else { - log.info("존재하지 않는 provider: " + provider); - throw new CustomException(ErrorCode.INVALID_PROVIDER); - } - - User user; - - if (userRepository.existsByProviderAndProviderId(provider, userInformation.getProviderId())) { - log.info("[UserService] login, response: {}", userInformation); - - user = findByEmail(userInformation.getEmail()); - - } else { - log.info("[UserService] signUp, response: {}", userInformation); - - user = User.builder() - .email(userInformation.getEmail()) - .password("kakao") - .name(userInformation.getNickname()) - .provider(userInformation.getProvider()) - .providerId(userInformation.getProviderId()) - .profileImgUrl(userInformation.getProfileImgUrl()) - .role(Role.USER) - .build(); - - userRepository.save(user); - } - - return jwtUtil.generateToken(user.getEmail(), user.getRole().getKey()); - } - - private User findByEmail(String email) { - return userRepository.findByEmail(email) - .orElseThrow(() -> new CustomException(ErrorCode.NON_EXISTENT_EMAIL)); - } - - @Transactional(readOnly = true) - public User findById(Long id) { - return userRepository.findById(id) - .orElseThrow(() -> new CustomException(ErrorCode.NON_EXISTENT_USER_ID)); - } + private final OauthService oauthService; + private final UserRepository userRepository; + private final JwtUtil jwtUtil; + + @Transactional + public GeneratedToken login(String provider, String token) { + OAuthUserInformation userInformation; + + if (provider.equals("kakao")) { + userInformation = oauthService.requestUserInformation(token); + } else { + log.info("존재하지 않는 provider: " + provider); + throw new CustomException(ErrorCode.INVALID_PROVIDER); + } + + User user; + + if (userRepository.existsByProviderAndProviderId(provider, userInformation.getProviderId())) { + log.info("[UserService] login, response: {}", userInformation); + + user = findByEmail(userInformation.getEmail()); + + } else { + log.info("[UserService] signUp, response: {}", userInformation); + + user = User.builder() + .email(userInformation.getEmail()) + .password("kakao") + .name(userInformation.getNickname()) + .provider(userInformation.getProvider()) + .providerId(userInformation.getProviderId()) + .profileImgUrl(userInformation.getProfileImgUrl()) + .role(Role.USER) + .build(); + + userRepository.save(user); + } + + return jwtUtil.generateToken(user.getEmail(), user.getRole().getKey()); + } + + private User findByEmail(String email) { + return userRepository.findByEmail(email) + .orElseThrow(() -> new CustomException(ErrorCode.NON_EXISTENT_EMAIL)); + } + + @Transactional(readOnly = true) + public LoginUserInfoRes getUserInfo(User loginUser) { + return new LoginUserInfoRes(isAdmin(loginUser), loginUser.getProvider()); + } + + private boolean isAdmin(User loginUser) { + return loginUser.getRole().equals(Role.ADMIN); + } } \ No newline at end of file From d9d34e9b7a8c3e3c5006ded3ae37970631bd1e49 Mon Sep 17 00:00:00 2001 From: jiseon Date: Mon, 9 Sep 2024 00:00:01 +0900 Subject: [PATCH 06/11] =?UTF-8?q?ITDS-25=20feat:=20password=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EA=B1=B0,=20provider=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=20enum=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dissonance/itit/domain/entity/User.java | 81 ++++++++++--------- .../itit/domain/entity/UserDetailsImpl.java | 2 +- .../domain/enums/SocialLoginProvider.java | 6 ++ .../dto/response/KakaoUserInformation.java | 5 +- .../itit/dto/response/LoginUserInfoRes.java | 4 +- .../dto/response/OAuthUserInformation.java | 16 ++-- .../itit/repository/UserRepository.java | 11 ++- src/main/resources/application-oauth.yml | 3 + 8 files changed, 79 insertions(+), 49 deletions(-) create mode 100644 src/main/java/com/dissonance/itit/domain/enums/SocialLoginProvider.java diff --git a/src/main/java/com/dissonance/itit/domain/entity/User.java b/src/main/java/com/dissonance/itit/domain/entity/User.java index d76d361..9dc0665 100644 --- a/src/main/java/com/dissonance/itit/domain/entity/User.java +++ b/src/main/java/com/dissonance/itit/domain/entity/User.java @@ -1,10 +1,23 @@ package com.dissonance.itit.domain.entity; import com.dissonance.itit.domain.enums.Role; -import jakarta.persistence.*; +import com.dissonance.itit.domain.enums.SocialLoginProvider; + +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 jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; -import lombok.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Getter @Builder @@ -13,38 +26,34 @@ @Entity @Table(name = "user") public class User extends BaseTime { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id") - private Long id; - - @Column(name = "password") - private String password; - - @Size(max = 50) - @NotNull - @Column(name = "name") - private String name; - - @Size(max = 100) - @NotNull - @Column(name = "email") - private String email; - - @Enumerated(EnumType.STRING) - @NotNull - @Column(name = "role") - private Role role; - - @Column(name = "provider_id") - private String providerId; - - @Size(max = 50) - @NotNull - @Column(name = "provider") - private String provider; - - @Size(max = 255) - @Column(name = "profile_img_url") - private String profileImgUrl; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Size(max = 50) + @Column(name = "name") + private String name; + + @Size(max = 100) + @NotNull + @Column(name = "email") + private String email; + + @Enumerated(EnumType.STRING) + @NotNull + @Column(name = "role") + private Role role; + + @Column(name = "provider_id") + private String providerId; + + @Enumerated(EnumType.STRING) + @NotNull + @Column(name = "provider") + private SocialLoginProvider provider; + + @Size(max = 255) + @Column(name = "profile_img_url") + private String profileImgUrl; } \ No newline at end of file diff --git a/src/main/java/com/dissonance/itit/domain/entity/UserDetailsImpl.java b/src/main/java/com/dissonance/itit/domain/entity/UserDetailsImpl.java index 35f10cb..6a64da5 100644 --- a/src/main/java/com/dissonance/itit/domain/entity/UserDetailsImpl.java +++ b/src/main/java/com/dissonance/itit/domain/entity/UserDetailsImpl.java @@ -30,7 +30,7 @@ public String getUsername() { @Override public String getPassword() { - return user.getPassword(); + return user.getProviderId(); } @Override diff --git a/src/main/java/com/dissonance/itit/domain/enums/SocialLoginProvider.java b/src/main/java/com/dissonance/itit/domain/enums/SocialLoginProvider.java new file mode 100644 index 0000000..d9a6c38 --- /dev/null +++ b/src/main/java/com/dissonance/itit/domain/enums/SocialLoginProvider.java @@ -0,0 +1,6 @@ +package com.dissonance.itit.domain.enums; + +public enum SocialLoginProvider { + KAKAO, + APPLE +} diff --git a/src/main/java/com/dissonance/itit/dto/response/KakaoUserInformation.java b/src/main/java/com/dissonance/itit/dto/response/KakaoUserInformation.java index 813accc..ac7ae84 100644 --- a/src/main/java/com/dissonance/itit/dto/response/KakaoUserInformation.java +++ b/src/main/java/com/dissonance/itit/dto/response/KakaoUserInformation.java @@ -1,5 +1,6 @@ package com.dissonance.itit.dto.response; +import com.dissonance.itit.domain.enums.SocialLoginProvider; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; @@ -28,8 +29,8 @@ public String getProfileImgUrl() { } @Override - public String getProvider() { - return "kakao"; + public SocialLoginProvider getProvider() { + return SocialLoginProvider.KAKAO; } @Override diff --git a/src/main/java/com/dissonance/itit/dto/response/LoginUserInfoRes.java b/src/main/java/com/dissonance/itit/dto/response/LoginUserInfoRes.java index c6bad2b..6811237 100644 --- a/src/main/java/com/dissonance/itit/dto/response/LoginUserInfoRes.java +++ b/src/main/java/com/dissonance/itit/dto/response/LoginUserInfoRes.java @@ -1,6 +1,8 @@ package com.dissonance.itit.dto.response; +import com.dissonance.itit.domain.enums.SocialLoginProvider; + public record LoginUserInfoRes( boolean isAdmin, - String provider) { + SocialLoginProvider provider) { } diff --git a/src/main/java/com/dissonance/itit/dto/response/OAuthUserInformation.java b/src/main/java/com/dissonance/itit/dto/response/OAuthUserInformation.java index 3442eb2..d6c79ce 100644 --- a/src/main/java/com/dissonance/itit/dto/response/OAuthUserInformation.java +++ b/src/main/java/com/dissonance/itit/dto/response/OAuthUserInformation.java @@ -1,9 +1,15 @@ package com.dissonance.itit.dto.response; +import com.dissonance.itit.domain.enums.SocialLoginProvider; + public interface OAuthUserInformation { - String getProvider(); - String getProviderId(); - String getProfileImgUrl(); - String getNickname(); - String getEmail(); + SocialLoginProvider getProvider(); + + String getProviderId(); + + String getProfileImgUrl(); + + String getNickname(); + + String getEmail(); } diff --git a/src/main/java/com/dissonance/itit/repository/UserRepository.java b/src/main/java/com/dissonance/itit/repository/UserRepository.java index 6513f14..e4764b8 100644 --- a/src/main/java/com/dissonance/itit/repository/UserRepository.java +++ b/src/main/java/com/dissonance/itit/repository/UserRepository.java @@ -1,11 +1,14 @@ package com.dissonance.itit.repository; -import com.dissonance.itit.domain.entity.User; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; -import java.util.Optional; +import com.dissonance.itit.domain.entity.User; +import com.dissonance.itit.domain.enums.SocialLoginProvider; public interface UserRepository extends JpaRepository { - boolean existsByProviderAndProviderId(String provider, String providerId); - Optional findByEmail(String email); + boolean existsByProviderAndProviderId(SocialLoginProvider provider, String providerId); + + Optional findByEmail(String email); } diff --git a/src/main/resources/application-oauth.yml b/src/main/resources/application-oauth.yml index af16822..2c5ee01 100644 --- a/src/main/resources/application-oauth.yml +++ b/src/main/resources/application-oauth.yml @@ -2,3 +2,6 @@ kakao: api_url: information: https://kapi.kakao.com/v2/user/me +apple: + api_url: + information: https://appleid.apple.com/auth/keys \ No newline at end of file From 8385e3f56092175c291a145527dd5b2fa0afd15c Mon Sep 17 00:00:00 2001 From: jiseon Date: Mon, 9 Sep 2024 02:00:25 +0900 Subject: [PATCH 07/11] =?UTF-8?q?ITDS-25=20feat:=20=EC=95=A0=ED=94=8C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +- .../client/AppleInformationFeignClient.java | 10 ++ .../itit/common/exception/ErrorCode.java | 5 +- .../dto/response/AppleUserInfomation.java | 39 ++++++++ .../dissonance/itit/dto/response/Keys.java | 23 +++++ .../itit/service/AppleOAuthServiceImpl.java | 99 +++++++++++++++++++ 6 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/dissonance/itit/client/AppleInformationFeignClient.java create mode 100644 src/main/java/com/dissonance/itit/dto/response/AppleUserInfomation.java create mode 100644 src/main/java/com/dissonance/itit/dto/response/Keys.java create mode 100644 src/main/java/com/dissonance/itit/service/AppleOAuthServiceImpl.java diff --git a/build.gradle b/build.gradle index 4374bdb..63ec671 100644 --- a/build.gradle +++ b/build.gradle @@ -53,6 +53,7 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' implementation 'org.springframework.cloud:spring-cloud-starter-openfeign:4.1.0' implementation 'io.jsonwebtoken:jjwt:0.9.1' + implementation 'com.nimbusds:nimbus-jose-jwt:9.29' implementation group: 'javax.xml.bind', name: 'jaxb-api', version: '2.1' //QueryDSL @@ -61,7 +62,7 @@ dependencies { annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" - implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' } def QDomains = [] diff --git a/src/main/java/com/dissonance/itit/client/AppleInformationFeignClient.java b/src/main/java/com/dissonance/itit/client/AppleInformationFeignClient.java new file mode 100644 index 0000000..a61971a --- /dev/null +++ b/src/main/java/com/dissonance/itit/client/AppleInformationFeignClient.java @@ -0,0 +1,10 @@ +package com.dissonance.itit.client; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; + +@FeignClient(name = "AppleInformationFeignClient", url = "${apple.api_url.information}") +public interface AppleInformationFeignClient { + @GetMapping + String call(); +} diff --git a/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java b/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java index e71ee78..c65db57 100644 --- a/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java +++ b/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java @@ -13,6 +13,8 @@ public enum ErrorCode { INVALID_FILE_TYPE(HttpStatus.BAD_REQUEST, "파일 형식은 이미지만 가능합니다."), INVALID_FILE_SIZE(HttpStatus.BAD_REQUEST, "파일 용량은 10MB를 넘을 수 없습니다."), INVALID_DATE_FORMAT(HttpStatus.BAD_REQUEST, "날짜 변환에 실패했습니다."), + INVALID_APPLE_TOKEN(HttpStatus.BAD_REQUEST, "유효하지 않는 Apple Token입니다."), + INVALID_JSON_FORMAT(HttpStatus.BAD_REQUEST, "잘못된 JSON 형식입니다."), // 404 NON_EXISTENT_USER_ID(HttpStatus.NOT_FOUND, "해당 id의 사용자가 존재하지 않습니다."), @@ -23,7 +25,8 @@ public enum ErrorCode { REPORTED_INFO_POST_ID(HttpStatus.NOT_FOUND, "해당 id의 게시글은 신고 처리되었습니다."), // 500 - IO_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "파일 입출력 에러"); + IO_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "파일 입출력 에러"), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 에러"); private final HttpStatus httpStatus; private final String message; diff --git a/src/main/java/com/dissonance/itit/dto/response/AppleUserInfomation.java b/src/main/java/com/dissonance/itit/dto/response/AppleUserInfomation.java new file mode 100644 index 0000000..d265b72 --- /dev/null +++ b/src/main/java/com/dissonance/itit/dto/response/AppleUserInfomation.java @@ -0,0 +1,39 @@ +package com.dissonance.itit.dto.response; + +import com.dissonance.itit.domain.enums.SocialLoginProvider; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Builder +@NoArgsConstructor +@AllArgsConstructor +@ToString +@Getter +public class AppleUserInfomation implements OAuthUserInformation { + private String providerId; + private String email; + + @Override + public SocialLoginProvider getProvider() { + return SocialLoginProvider.APPLE; + } + + @Override + public String getProviderId() { + return providerId; + } + + @Override + public String getProfileImgUrl() { + return null; + } + + @Override + public String getNickname() { + return null; + } +} diff --git a/src/main/java/com/dissonance/itit/dto/response/Keys.java b/src/main/java/com/dissonance/itit/dto/response/Keys.java new file mode 100644 index 0000000..44dcac5 --- /dev/null +++ b/src/main/java/com/dissonance/itit/dto/response/Keys.java @@ -0,0 +1,23 @@ +package com.dissonance.itit.dto.response; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; + +@Getter +public class Keys { + @JsonProperty(value = "keys") + private List keyList; + + @Getter + public static class Key { + private String kty; + private String kid; + private String use; + private String alg; + private String n; + private String e; + } +} \ No newline at end of file diff --git a/src/main/java/com/dissonance/itit/service/AppleOAuthServiceImpl.java b/src/main/java/com/dissonance/itit/service/AppleOAuthServiceImpl.java new file mode 100644 index 0000000..6627c59 --- /dev/null +++ b/src/main/java/com/dissonance/itit/service/AppleOAuthServiceImpl.java @@ -0,0 +1,99 @@ +package com.dissonance.itit.service; + +import java.security.interfaces.RSAPublicKey; +import java.text.ParseException; + +import org.springframework.stereotype.Service; + +import com.dissonance.itit.client.AppleInformationFeignClient; +import com.dissonance.itit.common.exception.CustomException; +import com.dissonance.itit.common.exception.ErrorCode; +import com.dissonance.itit.domain.enums.SocialLoginProvider; +import com.dissonance.itit.dto.response.AppleUserInfomation; +import com.dissonance.itit.dto.response.Keys; +import com.dissonance.itit.dto.response.OAuthUserInformation; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.crypto.RSASSAVerifier; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@Service +public class AppleOAuthServiceImpl implements OAuthService { + private final AppleInformationFeignClient appleAuthKeyFeignClient; + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final String EMAIL_CLAIM = "email"; + + @Override + public SocialLoginProvider getProvider() { + return SocialLoginProvider.APPLE; + } + + @Override + public OAuthUserInformation requestUserInformation(String token) { + try { + // Apple의 공개 키 가져오기 및 JWT 파싱 + Keys keys = getApplePublicKeys(); + SignedJWT signedJWT = SignedJWT.parse(token); + + // 서명이 유효하면 사용자 정보를 반환 + if (isVerifiedToken(keys, signedJWT)) { + return extractUserInfo(signedJWT); + } else { + log.warn("[AppleOAuthServiceImpl] requestUserInformation, 유효하지 않은 토큰 : {}", token); + throw new CustomException(ErrorCode.INVALID_APPLE_TOKEN); + } + + } catch (JsonProcessingException | ParseException | JOSEException e) { + log.error("[AppleOAuthServiceImpl] requestUserInformation, Exception: {}", token, e); + throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + // Apple에서 공개 키를 가져오고 JSON을 Keys 객체로 변환 + private Keys getApplePublicKeys() throws JsonProcessingException { + String publicKeys = appleAuthKeyFeignClient.call(); + return objectMapper.readValue(publicKeys, Keys.class); + } + + // JWT의 서명을 검증 + private boolean isVerifiedToken(Keys keys, SignedJWT signedJWT) throws + ParseException, + JOSEException, + JsonProcessingException { + return keys.getKeyList().stream() + .anyMatch(key -> verifySignature(key, signedJWT)); + } + + // 개별 키에 대해 서명을 검증 + private boolean verifySignature(Keys.Key key, SignedJWT signedJWT) { + try { + RSAKey rsaKey = (RSAKey)JWK.parse(objectMapper.writeValueAsString(key)); + RSAPublicKey publicKey = rsaKey.toRSAPublicKey(); + RSASSAVerifier verifier = new RSASSAVerifier(publicKey); + + return signedJWT.verify(verifier); + } catch (Exception e) { + log.error("[AppleOAuthServiceImpl] 서명 검증 실패: {}", key.getKid(), e); + return false; + } + } + + // JWT에서 사용자 정보를 추출 + private OAuthUserInformation extractUserInfo(SignedJWT signedJWT) throws ParseException { + JWTClaimsSet jwtClaimsSet = signedJWT.getJWTClaimsSet(); + return AppleUserInfomation.builder() + .providerId(jwtClaimsSet.getSubject()) + .email(jwtClaimsSet.getStringClaim(EMAIL_CLAIM)) + .build(); + } +} From 43c01e10096c118310ec3f6545679b5778d2e5a6 Mon Sep 17 00:00:00 2001 From: jiseon Date: Mon, 9 Sep 2024 02:01:08 +0900 Subject: [PATCH 08/11] =?UTF-8?q?ITDS-25=20feat:=20provider=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EB=B6=84=EA=B8=B0=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dissonance/itit/service/UserService.java | 47 ++++++++++++------- 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/dissonance/itit/service/UserService.java b/src/main/java/com/dissonance/itit/service/UserService.java index 0872e8c..ac5e503 100644 --- a/src/main/java/com/dissonance/itit/service/UserService.java +++ b/src/main/java/com/dissonance/itit/service/UserService.java @@ -8,6 +8,7 @@ import com.dissonance.itit.common.jwt.util.JwtUtil; import com.dissonance.itit.domain.entity.User; import com.dissonance.itit.domain.enums.Role; +import com.dissonance.itit.domain.enums.SocialLoginProvider; import com.dissonance.itit.dto.response.GeneratedToken; import com.dissonance.itit.dto.response.LoginUserInfoRes; import com.dissonance.itit.dto.response.OAuthUserInformation; @@ -20,16 +21,21 @@ @RequiredArgsConstructor @Service public class UserService { - private final OauthService oauthService; - private final UserRepository userRepository; private final JwtUtil jwtUtil; + private final UserRepository userRepository; + + private final KakaoOAuthServiceImpl kakaoOAuthService; + private final AppleOAuthServiceImpl appleOAuthService; @Transactional public GeneratedToken login(String provider, String token) { OAuthUserInformation userInformation; + SocialLoginProvider providerEnum = SocialLoginProvider.valueOf(provider.toUpperCase()); - if (provider.equals("kakao")) { - userInformation = oauthService.requestUserInformation(token); + if (providerEnum.equals(SocialLoginProvider.APPLE)) { + userInformation = appleOAuthService.requestUserInformation(token); + } else if (providerEnum.equals(SocialLoginProvider.KAKAO)) { + userInformation = kakaoOAuthService.requestUserInformation(token); } else { log.info("존재하지 않는 provider: " + provider); throw new CustomException(ErrorCode.INVALID_PROVIDER); @@ -37,7 +43,7 @@ public GeneratedToken login(String provider, String token) { User user; - if (userRepository.existsByProviderAndProviderId(provider, userInformation.getProviderId())) { + if (isExistsByProviderAndProviderId(providerEnum, userInformation.getProviderId())) { log.info("[UserService] login, response: {}", userInformation); user = findByEmail(userInformation.getEmail()); @@ -45,27 +51,36 @@ public GeneratedToken login(String provider, String token) { } else { log.info("[UserService] signUp, response: {}", userInformation); - user = User.builder() - .email(userInformation.getEmail()) - .password("kakao") - .name(userInformation.getNickname()) - .provider(userInformation.getProvider()) - .providerId(userInformation.getProviderId()) - .profileImgUrl(userInformation.getProfileImgUrl()) - .role(Role.USER) - .build(); - - userRepository.save(user); + user = saveUser(userInformation); } return jwtUtil.generateToken(user.getEmail(), user.getRole().getKey()); } + private boolean isExistsByProviderAndProviderId(SocialLoginProvider provider, String providerId) { + return userRepository.existsByProviderAndProviderId(provider, providerId); + } + private User findByEmail(String email) { return userRepository.findByEmail(email) .orElseThrow(() -> new CustomException(ErrorCode.NON_EXISTENT_EMAIL)); } + private User saveUser(OAuthUserInformation userInformation) { + User user = User.builder() + .email(userInformation.getEmail()) + .name(userInformation.getNickname()) + .provider(userInformation.getProvider()) + .providerId(userInformation.getProviderId()) + .profileImgUrl(userInformation.getProfileImgUrl()) + .role(Role.USER) + .build(); + + userRepository.save(user); + + return user; + } + @Transactional(readOnly = true) public LoginUserInfoRes getUserInfo(User loginUser) { return new LoginUserInfoRes(isAdmin(loginUser), loginUser.getProvider()); From e7d4766648a22c7ba005e7a865688533b04f4997 Mon Sep 17 00:00:00 2001 From: jiseon Date: Mon, 9 Sep 2024 19:24:35 +0900 Subject: [PATCH 09/11] =?UTF-8?q?ITDS-25=20refactor:=20=EC=86=8C=EC=85=9C?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20Factory=20Pattern=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../itit/factory/OAuthServiceFactory.java | 33 ++++++++++++ .../itit/service/KakaoOAuthServiceImpl.java | 54 +++++++++++++++++++ .../dissonance/itit/service/OAuthService.java | 10 ++++ .../dissonance/itit/service/OauthService.java | 46 ---------------- .../dissonance/itit/service/UserService.java | 18 ++----- 5 files changed, 101 insertions(+), 60 deletions(-) create mode 100644 src/main/java/com/dissonance/itit/factory/OAuthServiceFactory.java create mode 100644 src/main/java/com/dissonance/itit/service/KakaoOAuthServiceImpl.java create mode 100644 src/main/java/com/dissonance/itit/service/OAuthService.java delete mode 100644 src/main/java/com/dissonance/itit/service/OauthService.java diff --git a/src/main/java/com/dissonance/itit/factory/OAuthServiceFactory.java b/src/main/java/com/dissonance/itit/factory/OAuthServiceFactory.java new file mode 100644 index 0000000..6ac7a1e --- /dev/null +++ b/src/main/java/com/dissonance/itit/factory/OAuthServiceFactory.java @@ -0,0 +1,33 @@ +package com.dissonance.itit.factory; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import com.dissonance.itit.common.exception.CustomException; +import com.dissonance.itit.common.exception.ErrorCode; +import com.dissonance.itit.domain.enums.SocialLoginProvider; +import com.dissonance.itit.service.OAuthService; + +@Component +public class OAuthServiceFactory { + private final Map oAuthServices; + + @Autowired + public OAuthServiceFactory(List oAuthServiceList) { + oAuthServices = oAuthServiceList.stream() + .collect(Collectors.toMap(OAuthService::getProvider, Function.identity())); + } + + public OAuthService getOAuthService(SocialLoginProvider provider) { + OAuthService service = oAuthServices.get(provider); + if (service == null) { + throw new CustomException(ErrorCode.INVALID_PROVIDER); + } + return service; + } +} diff --git a/src/main/java/com/dissonance/itit/service/KakaoOAuthServiceImpl.java b/src/main/java/com/dissonance/itit/service/KakaoOAuthServiceImpl.java new file mode 100644 index 0000000..d01b299 --- /dev/null +++ b/src/main/java/com/dissonance/itit/service/KakaoOAuthServiceImpl.java @@ -0,0 +1,54 @@ +package com.dissonance.itit.service; + +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import com.dissonance.itit.client.KakaoInformationFeignClient; +import com.dissonance.itit.domain.enums.SocialLoginProvider; +import com.dissonance.itit.dto.response.KakaoUserInformation; +import com.dissonance.itit.dto.response.OAuthUserInformation; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +@Service +public class KakaoOAuthServiceImpl implements OAuthService { + private static final String TOKEN_PREFIX = "Bearer "; + private final KakaoInformationFeignClient kakaoInformationFeignClient; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public SocialLoginProvider getProvider() { + return SocialLoginProvider.KAKAO; + } + + @Override + public OAuthUserInformation requestUserInformation(String token) { + log.info("Requesting user information with token: {}", token); + + // Feign Client 호출 + ResponseEntity responseEntity = kakaoInformationFeignClient.call( + "application/x-www-form-urlencoded;charset=utf-8", + TOKEN_PREFIX + token + ); + + // 원본 JSON 응답을 로그에 출력 + String jsonResponse = responseEntity.getBody(); + log.info("Raw JSON response: {}", jsonResponse); + + // JSON 응답을 KakaoUserInformation 객체로 변환 + KakaoUserInformation userInformation = null; + try { + userInformation = objectMapper.readValue(jsonResponse, KakaoUserInformation.class); + } catch (JsonProcessingException e) { + log.error("Error parsing JSON response", e); + } + + log.info("Parsed user information: {}", userInformation); + return userInformation; + } +} \ No newline at end of file diff --git a/src/main/java/com/dissonance/itit/service/OAuthService.java b/src/main/java/com/dissonance/itit/service/OAuthService.java new file mode 100644 index 0000000..4ab6bd1 --- /dev/null +++ b/src/main/java/com/dissonance/itit/service/OAuthService.java @@ -0,0 +1,10 @@ +package com.dissonance.itit.service; + +import com.dissonance.itit.domain.enums.SocialLoginProvider; +import com.dissonance.itit.dto.response.OAuthUserInformation; + +public interface OAuthService { + SocialLoginProvider getProvider(); + + OAuthUserInformation requestUserInformation(String token); +} diff --git a/src/main/java/com/dissonance/itit/service/OauthService.java b/src/main/java/com/dissonance/itit/service/OauthService.java deleted file mode 100644 index f8b18a8..0000000 --- a/src/main/java/com/dissonance/itit/service/OauthService.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.dissonance.itit.service; - -import com.dissonance.itit.client.KakaoInformationFeignClient; -import com.dissonance.itit.dto.response.KakaoUserInformation; -import com.dissonance.itit.dto.response.OAuthUserInformation; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Service; - - -@Slf4j -@RequiredArgsConstructor -@Service -public class OauthService { - private static final String TOKEN_PREFIX = "Bearer "; - private final KakaoInformationFeignClient kakaoInformationFeignClient; - private final ObjectMapper objectMapper = new ObjectMapper(); - - public OAuthUserInformation requestUserInformation(String token) { - log.info("Requesting user information with token: {}", token); - - // Feign Client 호출 - ResponseEntity responseEntity = kakaoInformationFeignClient.call( - "application/x-www-form-urlencoded;charset=utf-8", - TOKEN_PREFIX + token - ); - - // 원본 JSON 응답을 로그에 출력 - String jsonResponse = responseEntity.getBody(); - log.info("Raw JSON response: {}", jsonResponse); - - // JSON 응답을 KakaoUserInformation 객체로 변환 - KakaoUserInformation userInformation = null; - try { - userInformation = objectMapper.readValue(jsonResponse, KakaoUserInformation.class); - } catch (JsonProcessingException e) { - log.error("Error parsing JSON response", e); - } - - log.info("Parsed user information: {}", userInformation); - return userInformation; - } -} \ No newline at end of file diff --git a/src/main/java/com/dissonance/itit/service/UserService.java b/src/main/java/com/dissonance/itit/service/UserService.java index ac5e503..97467d3 100644 --- a/src/main/java/com/dissonance/itit/service/UserService.java +++ b/src/main/java/com/dissonance/itit/service/UserService.java @@ -12,6 +12,7 @@ import com.dissonance.itit.dto.response.GeneratedToken; import com.dissonance.itit.dto.response.LoginUserInfoRes; import com.dissonance.itit.dto.response.OAuthUserInformation; +import com.dissonance.itit.factory.OAuthServiceFactory; import com.dissonance.itit.repository.UserRepository; import lombok.RequiredArgsConstructor; @@ -24,33 +25,22 @@ public class UserService { private final JwtUtil jwtUtil; private final UserRepository userRepository; - private final KakaoOAuthServiceImpl kakaoOAuthService; - private final AppleOAuthServiceImpl appleOAuthService; + private final OAuthServiceFactory oAuthServiceFactory; @Transactional public GeneratedToken login(String provider, String token) { - OAuthUserInformation userInformation; SocialLoginProvider providerEnum = SocialLoginProvider.valueOf(provider.toUpperCase()); + OAuthService oAuthService = oAuthServiceFactory.getOAuthService(providerEnum); - if (providerEnum.equals(SocialLoginProvider.APPLE)) { - userInformation = appleOAuthService.requestUserInformation(token); - } else if (providerEnum.equals(SocialLoginProvider.KAKAO)) { - userInformation = kakaoOAuthService.requestUserInformation(token); - } else { - log.info("존재하지 않는 provider: " + provider); - throw new CustomException(ErrorCode.INVALID_PROVIDER); - } + OAuthUserInformation userInformation = oAuthService.requestUserInformation(token); User user; if (isExistsByProviderAndProviderId(providerEnum, userInformation.getProviderId())) { log.info("[UserService] login, response: {}", userInformation); - user = findByEmail(userInformation.getEmail()); - } else { log.info("[UserService] signUp, response: {}", userInformation); - user = saveUser(userInformation); } From 591ba92d10b578ff62301f71a597f2a5c2625252 Mon Sep 17 00:00:00 2001 From: jiseon Date: Fri, 13 Sep 2024 15:28:48 +0900 Subject: [PATCH 10/11] =?UTF-8?q?ITDS-43=20feat:=20=EC=8B=A0=EA=B3=A0=20ap?= =?UTF-8?q?i=20=EA=B8=B0=ED=9A=8D=20=EB=B3=80=EA=B2=BD=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=ED=95=98=EC=97=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../itit/common/exception/ErrorCode.java | 3 ++ .../itit/controller/InfoPostController.java | 8 ++-- .../dissonance/itit/domain/entity/Report.java | 42 ++++++++++++------- .../itit/repository/ReportRepository.java | 9 ++++ .../itit/service/InfoPostService.java | 8 ++-- .../itit/service/ReportService.java | 38 +++++++++++++++++ 6 files changed, 85 insertions(+), 23 deletions(-) create mode 100644 src/main/java/com/dissonance/itit/repository/ReportRepository.java create mode 100644 src/main/java/com/dissonance/itit/service/ReportService.java diff --git a/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java b/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java index c65db57..36dabbb 100644 --- a/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java +++ b/src/main/java/com/dissonance/itit/common/exception/ErrorCode.java @@ -24,6 +24,9 @@ public enum ErrorCode { NON_EXISTENT_INFO_POST_ID(HttpStatus.NOT_FOUND, "해당 id의 공고 게시글이 존재하지 않습니다."), REPORTED_INFO_POST_ID(HttpStatus.NOT_FOUND, "해당 id의 게시글은 신고 처리되었습니다."), + // 409 + ALREADY_REPORTED_POST(HttpStatus.CONFLICT, "이미 신고한 공고입니다."), + // 500 IO_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "파일 입출력 에러"), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 에러"); diff --git a/src/main/java/com/dissonance/itit/controller/InfoPostController.java b/src/main/java/com/dissonance/itit/controller/InfoPostController.java index cd965ae..5a60e2b 100644 --- a/src/main/java/com/dissonance/itit/controller/InfoPostController.java +++ b/src/main/java/com/dissonance/itit/controller/InfoPostController.java @@ -20,6 +20,7 @@ import com.dissonance.itit.dto.response.InfoPostDetailRes; import com.dissonance.itit.dto.response.InfoPostRes; import com.dissonance.itit.service.InfoPostService; +import com.dissonance.itit.service.ReportService; import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; @@ -30,6 +31,7 @@ @RequestMapping("/info-posts") public class InfoPostController { private final InfoPostService infoPostService; + private final ReportService reportService; @PostMapping(consumes = {MediaType.MULTIPART_FORM_DATA_VALUE, MediaType.APPLICATION_JSON_VALUE}) @Operation(summary = "공고 게시글 등록", description = "공고 게시글을 등록합니다.") @@ -47,9 +49,9 @@ public ResponseEntity getInfoPostDetail(@PathVariable Long in } @PatchMapping("/{infoPostId}/reports") - @Operation(summary = "공고 게시글 신고", description = "공고 게시글을 신고 처리합니다. (즉시 반영)") - public ResponseEntity reportedInfoPost(@PathVariable Long infoPostId) { - Long resultId = infoPostService.reportedInfoPost(infoPostId); + @Operation(summary = "공고 게시글 신고", description = "공고 게시글을 신고 처리합니다.") + public ResponseEntity reportedInfoPost(@PathVariable Long infoPostId, @CurrentUser User loginUser) { + Long resultId = reportService.reportedInfoPost(infoPostId, loginUser); return ResponseEntity.ok(resultId + "번 게시글의 신고가 성공적으로 접수되었습니다."); } diff --git a/src/main/java/com/dissonance/itit/domain/entity/Report.java b/src/main/java/com/dissonance/itit/domain/entity/Report.java index 1e92ae9..2b0f677 100644 --- a/src/main/java/com/dissonance/itit/domain/entity/Report.java +++ b/src/main/java/com/dissonance/itit/domain/entity/Report.java @@ -1,8 +1,20 @@ package com.dissonance.itit.domain.entity; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; import jakarta.validation.constraints.Size; -import lombok.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Getter @Builder @@ -11,20 +23,20 @@ @Entity @Table(name = "report") public class Report { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id") - private String id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - private User user; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "info_post_id") - private InfoPost infoPost; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "info_post_id") + private InfoPost infoPost; - @Size(max = 255) - @Column(name = "content") - private String content; + @Size(max = 255) + @Column(name = "content") + private String content; } \ No newline at end of file diff --git a/src/main/java/com/dissonance/itit/repository/ReportRepository.java b/src/main/java/com/dissonance/itit/repository/ReportRepository.java new file mode 100644 index 0000000..f8f898d --- /dev/null +++ b/src/main/java/com/dissonance/itit/repository/ReportRepository.java @@ -0,0 +1,9 @@ +package com.dissonance.itit.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.dissonance.itit.domain.entity.Report; + +public interface ReportRepository extends JpaRepository { + boolean existsByInfoPostIdAndUserId(Long infoPostId, Long UserId); +} diff --git a/src/main/java/com/dissonance/itit/service/InfoPostService.java b/src/main/java/com/dissonance/itit/service/InfoPostService.java index 362cb8b..bfe6971 100644 --- a/src/main/java/com/dissonance/itit/service/InfoPostService.java +++ b/src/main/java/com/dissonance/itit/service/InfoPostService.java @@ -66,12 +66,10 @@ public InfoPostDetailRes getInfoPostDetailById(Long infoPostId) { return InfoPostDetailRes.of(infoPostInfo, positionInfos); } - @Transactional - public Long reportedInfoPost(Long infoPostId) { - InfoPost infoPost = infoPostRepository.findById(infoPostId) + @Transactional(readOnly = true) + public InfoPost findById(Long infoPostId) { + return infoPostRepository.findById(infoPostId) .orElseThrow(() -> new CustomException(ErrorCode.NON_EXISTENT_INFO_POST_ID)); - infoPost.updateReported(); - return infoPost.getId(); } @Transactional(readOnly = true) diff --git a/src/main/java/com/dissonance/itit/service/ReportService.java b/src/main/java/com/dissonance/itit/service/ReportService.java new file mode 100644 index 0000000..485fcc0 --- /dev/null +++ b/src/main/java/com/dissonance/itit/service/ReportService.java @@ -0,0 +1,38 @@ +package com.dissonance.itit.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dissonance.itit.common.exception.CustomException; +import com.dissonance.itit.common.exception.ErrorCode; +import com.dissonance.itit.domain.entity.InfoPost; +import com.dissonance.itit.domain.entity.Report; +import com.dissonance.itit.domain.entity.User; +import com.dissonance.itit.repository.ReportRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ReportService { + private final ReportRepository reportRepository; + + private final InfoPostService infoPostService; + + @Transactional + public Long reportedInfoPost(Long infoPostId, User user) { + InfoPost infoPost = infoPostService.findById(infoPostId); + + if (reportRepository.existsByInfoPostIdAndUserId(infoPostId, user.getId())) { + throw new CustomException(ErrorCode.ALREADY_REPORTED_POST); + } + + Report report = Report.builder() + .infoPost(infoPost) + .user(user) + .build(); + reportRepository.save(report); + + return infoPost.getId(); + } +} From c48b5007770ee57f1648c0af86fd46d788db3eb0 Mon Sep 17 00:00:00 2001 From: jiseon Date: Fri, 13 Sep 2024 15:29:29 +0900 Subject: [PATCH 11/11] =?UTF-8?q?ITDS-43=20test:=20=EC=8B=A0=EA=B3=A0=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=ED=9B=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../itit/service/InfoPostServiceTest.java | 47 ++++--------- .../itit/service/ReportServiceTest.java | 69 +++++++++++++++++++ 2 files changed, 82 insertions(+), 34 deletions(-) create mode 100644 src/test/java/com/dissonance/itit/service/ReportServiceTest.java diff --git a/src/test/java/com/dissonance/itit/service/InfoPostServiceTest.java b/src/test/java/com/dissonance/itit/service/InfoPostServiceTest.java index 70dad5f..c36e350 100644 --- a/src/test/java/com/dissonance/itit/service/InfoPostServiceTest.java +++ b/src/test/java/com/dissonance/itit/service/InfoPostServiceTest.java @@ -136,40 +136,6 @@ void getInfoPostDetailById_throwCustomException_givenReportedInfoPostId() { .hasMessage(ErrorCode.REPORTED_INFO_POST_ID.getMessage()); } - @Test - @DisplayName("게시글 신고") - void reportedInfoPost_returnInfoPostId() { - // given - Long infoPostId = 1L; - InfoPostReq infoPostReq = TestFixture.createInfoPostReq(); - User author = TestFixture.createUser(); - Image image = TestFixture.createImage(); - Category category = TestFixture.createCategory(); - InfoPost infoPost = TestFixture.createInfoPost(infoPostReq, author, image, category); - - given(infoPostRepository.findById(infoPostId)).willReturn(Optional.of(infoPost)); - - // when - Long result = infoPostService.reportedInfoPost(infoPostId); - - // then - assertThat(result).isEqualTo(infoPostId); - verify(infoPostRepository).findById(infoPostId); - } - - @Test - @DisplayName("게시글 신고시 존재하지 않는 ID로 조회하여 exception 발생") - void reportedInfoPost_throwCustomException_givenNonExistentId() { - // given - Long infoPostId = 999L; - given(infoPostRepository.findById(infoPostId)).willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> infoPostService.reportedInfoPost(infoPostId)) - .isInstanceOf(CustomException.class) - .hasMessage(ErrorCode.NON_EXISTENT_INFO_POST_ID.getMessage()); - } - @Test @DisplayName("공고 게시글 목록 page 조회") void getInfoPostsByCategoryId_returnInfoPostResPage() { @@ -195,4 +161,17 @@ void getInfoPostsByCategoryId_returnInfoPostResPage() { () -> assertThat(content.get(2).getRemainingDays()).isEqualTo("D+3") ); } + + @Test + @DisplayName("존재하지 않는 ID로 조회하여 exception 발생") + void findById_throwCustomException_givenNonExistentId() { + // given + Long infoPostId = 999L; + given(infoPostRepository.findById(infoPostId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> infoPostService.findById(infoPostId)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.NON_EXISTENT_INFO_POST_ID.getMessage()); + } } diff --git a/src/test/java/com/dissonance/itit/service/ReportServiceTest.java b/src/test/java/com/dissonance/itit/service/ReportServiceTest.java new file mode 100644 index 0000000..92a9fc2 --- /dev/null +++ b/src/test/java/com/dissonance/itit/service/ReportServiceTest.java @@ -0,0 +1,69 @@ +package com.dissonance.itit.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.dissonance.itit.common.exception.CustomException; +import com.dissonance.itit.common.exception.ErrorCode; +import com.dissonance.itit.domain.entity.Category; +import com.dissonance.itit.domain.entity.Image; +import com.dissonance.itit.domain.entity.InfoPost; +import com.dissonance.itit.domain.entity.User; +import com.dissonance.itit.dto.request.InfoPostReq; +import com.dissonance.itit.fixture.TestFixture; +import com.dissonance.itit.repository.ReportRepository; + +@ExtendWith(MockitoExtension.class) +public class ReportServiceTest { + @InjectMocks + private ReportService reportService; + @Mock + private ReportRepository reportRepository; + @Mock + private InfoPostService infoPostService; + + @Test + @DisplayName("게시글 신고") + void reportedInfoPost_returnInfoPostId() { + // given + Long infoPostId = 1L; + InfoPostReq infoPostReq = TestFixture.createInfoPostReq(); + User author = TestFixture.createUser(); + Image image = TestFixture.createImage(); + Category category = TestFixture.createCategory(); + InfoPost infoPost = TestFixture.createInfoPost(infoPostReq, author, image, category); + + given(infoPostService.findById(infoPostId)).willReturn(infoPost); + given(reportRepository.existsByInfoPostIdAndUserId(infoPostId, author.getId())) + .willReturn(false); + + // when + Long result = reportService.reportedInfoPost(infoPostId, author); + + // then + assertThat(result).isEqualTo(infoPostId); + verify(reportRepository, times(1)).save(any()); + } + + @Test + @DisplayName("게시글 신고시 중복 신고로 인한 exception 발생") + void reportedInfoPost_throwCustomException_givenDuplicateReports() { + // given + Long infoPostId = 999L; + User loginUser = TestFixture.createUser(); + given(reportRepository.existsByInfoPostIdAndUserId(infoPostId, loginUser.getId())) + .willReturn(true); + + // when & then + assertThatThrownBy(() -> reportService.reportedInfoPost(infoPostId, loginUser)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.ALREADY_REPORTED_POST.getMessage()); + } +}