diff --git a/application/build.gradle b/application/build.gradle index b831cbec..86034f21 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -21,6 +21,7 @@ dependencies { // Spring Boot implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-aop' implementation 'org.springframework.boot:spring-boot-starter-validation' diff --git a/application/src/main/java/com/yapp/ndgl/application/common/logging/MdcFilter.java b/application/src/main/java/com/yapp/ndgl/application/common/logging/MdcFilter.java index 78f56361..12834684 100644 --- a/application/src/main/java/com/yapp/ndgl/application/common/logging/MdcFilter.java +++ b/application/src/main/java/com/yapp/ndgl/application/common/logging/MdcFilter.java @@ -1,20 +1,24 @@ package com.yapp.ndgl.application.common.logging; import java.io.IOException; -import java.util.UUID; import java.util.List; +import java.util.UUID; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.extern.slf4j.Slf4j; import org.slf4j.MDC; import org.springframework.core.annotation.Order; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +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 jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + @Slf4j @Component @Order(20) @@ -38,12 +42,12 @@ public class MdcFilter extends OncePerRequestFilter { @Override protected void doFilterInternal( - HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain + final HttpServletRequest request, + final HttpServletResponse response, + final FilterChain filterChain ) throws ServletException, IOException { String requestId = resolveRequestId(request); - String userId = resolveUserId(request); + String userId = resolveUserId(); String clientIp = resolveClientIp(request); MDC.put(REQUEST_ID_KEY, requestId); @@ -71,12 +75,13 @@ private String resolveRequestId(HttpServletRequest request) { return UUID.randomUUID().toString(); } - private String resolveUserId(HttpServletRequest request) { - Object attribute = request.getAttribute(USER_ID_ATTRIBUTE); - if (attribute instanceof String userId && StringUtils.hasText(userId)) { - return userId; + private String resolveUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || authentication instanceof AnonymousAuthenticationToken) { + return "anonymous"; } - return "anonymous"; + + return authentication.getPrincipal().toString(); } private String resolveClientIp(HttpServletRequest request) { diff --git a/application/src/main/java/com/yapp/ndgl/application/config/CustomAuthenticationEntryPoint.java b/application/src/main/java/com/yapp/ndgl/application/config/CustomAuthenticationEntryPoint.java new file mode 100644 index 00000000..0b2628bd --- /dev/null +++ b/application/src/main/java/com/yapp/ndgl/application/config/CustomAuthenticationEntryPoint.java @@ -0,0 +1,33 @@ +package com.yapp.ndgl.application.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yapp.ndgl.common.exception.CommonErrorCode; +import com.yapp.ndgl.common.response.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence( + final HttpServletRequest request, + final HttpServletResponse response, + final AuthenticationException authException) throws IOException { + + CommonErrorCode errorCode = CommonErrorCode.UNAUTHORIZED; + response.setStatus(errorCode.getStatusCode().getCode()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8"); + objectMapper.writeValue(response.getWriter(), ErrorResponse.error(errorCode)); + } +} diff --git a/application/src/main/java/com/yapp/ndgl/application/config/SecurityConfig.java b/application/src/main/java/com/yapp/ndgl/application/config/SecurityConfig.java new file mode 100644 index 00000000..7fce67d3 --- /dev/null +++ b/application/src/main/java/com/yapp/ndgl/application/config/SecurityConfig.java @@ -0,0 +1,73 @@ +package com.yapp.ndgl.application.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yapp.ndgl.application.domains.auth.component.JwtTokenProvider; +import com.yapp.ndgl.application.domains.auth.filter.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private static final String[] PUBLIC_PATHS = { + "/api/v1/auth/**", // 기존 v1 인증 (하위 호환) + "/api/v2/auth/social/**", // v2 소셜 로그인 + "/api/v2/auth/reissue", // v2 토큰 재발급 + "/swagger", + "/swagger-ui/**", + "/api-docs/**", + "/v3/api-docs/**", + "/admin/**", // AdminAuthInterceptor가 별도 토큰 검증 + "/actuator/**" + }; + + private final JwtTokenProvider jwtTokenProvider; + private final ObjectMapper objectMapper; + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter() { + return new JwtAuthenticationFilter(jwtTokenProvider, objectMapper); + } + + @Bean + public FilterRegistrationBean jwtFilterRegistration( + final JwtAuthenticationFilter jwtAuthenticationFilter) { + FilterRegistrationBean registration = + new FilterRegistrationBean<>(jwtAuthenticationFilter); + registration.setEnabled(false); + return registration; + } + + @Bean + public SecurityFilterChain securityFilterChain( + final HttpSecurity http, + final JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers(PUBLIC_PATHS).permitAll() + .anyRequest().authenticated() + ) + .exceptionHandling(ex -> + ex.authenticationEntryPoint(customAuthenticationEntryPoint) + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} diff --git a/application/src/main/java/com/yapp/ndgl/application/domains/auth/component/JwtTokenProvider.java b/application/src/main/java/com/yapp/ndgl/application/domains/auth/component/JwtTokenProvider.java index 975bc465..ab3daa76 100644 --- a/application/src/main/java/com/yapp/ndgl/application/domains/auth/component/JwtTokenProvider.java +++ b/application/src/main/java/com/yapp/ndgl/application/domains/auth/component/JwtTokenProvider.java @@ -23,7 +23,7 @@ public JwtTokenProvider( this.expiration = expiration; } - public String generateToken(String uuid) { + public String generateToken(final String uuid) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + expiration); @@ -35,25 +35,11 @@ public String generateToken(String uuid) { .compact(); } - public String getUuidFromToken(String token) { - Claims claims = Jwts.parser() + public Claims parseClaims(final String token) { + return Jwts.parser() .verifyWith(secretKey) .build() .parseSignedClaims(token) .getPayload(); - - return claims.getSubject(); - } - - public boolean validateToken(String token) { - try { - Jwts.parser() - .verifyWith(secretKey) - .build() - .parseSignedClaims(token); - return true; - } catch (Exception e) { - return false; - } } } diff --git a/application/src/main/java/com/yapp/ndgl/application/domains/auth/filter/CurrentUuidArgumentResolver.java b/application/src/main/java/com/yapp/ndgl/application/domains/auth/filter/CurrentUuidArgumentResolver.java index 75697a3c..f87e06ab 100644 --- a/application/src/main/java/com/yapp/ndgl/application/domains/auth/filter/CurrentUuidArgumentResolver.java +++ b/application/src/main/java/com/yapp/ndgl/application/domains/auth/filter/CurrentUuidArgumentResolver.java @@ -3,8 +3,10 @@ import com.yapp.ndgl.application.domains.auth.annotation.CurrentUuid; import com.yapp.ndgl.common.exception.CommonErrorCode; import com.yapp.ndgl.common.exception.GlobalException; -import jakarta.servlet.http.HttpServletRequest; import org.springframework.core.MethodParameter; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.bind.support.WebDataBinderFactory; import org.springframework.web.context.request.NativeWebRequest; @@ -14,28 +16,26 @@ @Component public class CurrentUuidArgumentResolver implements HandlerMethodArgumentResolver { - private static final String UUID_ATTRIBUTE = "uuid"; + @Override + public boolean supportsParameter(final MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUuid.class) && + parameter.getParameterType().equals(String.class); + } - @Override - public boolean supportsParameter(MethodParameter parameter) { - return parameter.hasParameterAnnotation(CurrentUuid.class) && - parameter.getParameterType().equals(String.class); - } + @Override + public Object resolveArgument( + final MethodParameter parameter, + final ModelAndViewContainer mavContainer, + final NativeWebRequest webRequest, + final WebDataBinderFactory binderFactory) { - @Override - public Object resolveArgument( - MethodParameter parameter, - ModelAndViewContainer mavContainer, - NativeWebRequest webRequest, - WebDataBinderFactory binderFactory) { - HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); - if (request != null) { - String uuid = (String) request.getAttribute(UUID_ATTRIBUTE); - if (uuid == null) { - throw new GlobalException(CommonErrorCode.UNAUTHORIZED); - } - return uuid; + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || authentication instanceof AnonymousAuthenticationToken) { + throw new GlobalException(CommonErrorCode.UNAUTHORIZED); + } + if (!(authentication.getPrincipal() instanceof String uuid)) { + throw new GlobalException(CommonErrorCode.UNAUTHORIZED); + } + return uuid; } - throw new GlobalException(CommonErrorCode.UNAUTHORIZED); - } } diff --git a/application/src/main/java/com/yapp/ndgl/application/domains/auth/filter/JwtAuthenticationFilter.java b/application/src/main/java/com/yapp/ndgl/application/domains/auth/filter/JwtAuthenticationFilter.java index a2901842..140b2c46 100644 --- a/application/src/main/java/com/yapp/ndgl/application/domains/auth/filter/JwtAuthenticationFilter.java +++ b/application/src/main/java/com/yapp/ndgl/application/domains/auth/filter/JwtAuthenticationFilter.java @@ -1,50 +1,85 @@ package com.yapp.ndgl.application.domains.auth.filter; +import com.fasterxml.jackson.databind.ObjectMapper; import com.yapp.ndgl.application.domains.auth.component.JwtTokenProvider; +import com.yapp.ndgl.common.exception.CommonErrorCode; +import com.yapp.ndgl.common.response.ErrorResponse; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.SignatureException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; -import org.springframework.core.annotation.Order; -import org.springframework.stereotype.Component; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.util.Collections; -@Component -@Order(10) @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { - private static final String AUTHORIZATION_HEADER = "Authorization"; - private static final String BEARER_PREFIX = "Bearer "; - private static final String UUID_ATTRIBUTE = "uuid"; + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; - private final JwtTokenProvider jwtTokenProvider; + private final JwtTokenProvider jwtTokenProvider; + private final ObjectMapper objectMapper; - @Override - protected void doFilterInternal( - HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain) throws ServletException, IOException { + @Override + protected void doFilterInternal( + final HttpServletRequest request, + final HttpServletResponse response, + final FilterChain filterChain) throws ServletException, IOException { - String token = resolveToken(request); + String token = resolveToken(request); - if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) { - String uuid = jwtTokenProvider.getUuidFromToken(token); - request.setAttribute(UUID_ATTRIBUTE, uuid); + if (StringUtils.hasText(token)) { + try { + Claims claims = jwtTokenProvider.parseClaims(token); + String uuid = claims.getSubject(); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(uuid, null, Collections.emptyList()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (ExpiredJwtException e) { + sendErrorResponse(response, CommonErrorCode.EXPIRED_TOKEN); + return; + } catch (SignatureException e) { + sendErrorResponse(response, CommonErrorCode.INVALID_TOKEN_SIGNATURE); + return; + } catch (MalformedJwtException e) { + sendErrorResponse(response, CommonErrorCode.MALFORMED_TOKEN); + return; + } catch (UnsupportedJwtException e) { + sendErrorResponse(response, CommonErrorCode.UNSUPPORTED_TOKEN); + return; + } catch (IllegalArgumentException e) { + sendErrorResponse(response, CommonErrorCode.INVALID_TOKEN); + return; + } + } + + filterChain.doFilter(request, response); } - filterChain.doFilter(request, response); - } + private void sendErrorResponse(final HttpServletResponse response, final CommonErrorCode errorCode) throws IOException { + SecurityContextHolder.clearContext(); + response.setStatus(errorCode.getStatusCode().getCode()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8"); + objectMapper.writeValue(response.getWriter(), ErrorResponse.error(errorCode)); + } - private String resolveToken(HttpServletRequest request) { - String bearerToken = request.getHeader(AUTHORIZATION_HEADER); - if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { - return bearerToken.substring(BEARER_PREFIX.length()); + private String resolveToken(final HttpServletRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { + return bearerToken.substring(BEARER_PREFIX.length()); + } + return null; } - return null; - } } diff --git a/application/src/test/java/com/yapp/ndgl/application/domains/auth/component/JwtTokenProviderTest.java b/application/src/test/java/com/yapp/ndgl/application/domains/auth/component/JwtTokenProviderTest.java new file mode 100644 index 00000000..00e0015d --- /dev/null +++ b/application/src/test/java/com/yapp/ndgl/application/domains/auth/component/JwtTokenProviderTest.java @@ -0,0 +1,113 @@ +package com.yapp.ndgl.application.domains.auth.component; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("JwtTokenProvider") +class JwtTokenProviderTest { + + private static final String SECRET = "test-secret-key-for-unit-test-minimum-256-bits!!"; + private static final long EXPIRATION = 86400000L; + + private JwtTokenProvider jwtTokenProvider; + private SecretKey secretKey; + + @BeforeEach + void setUp() { + jwtTokenProvider = new JwtTokenProvider(SECRET, EXPIRATION); + secretKey = Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8)); + } + + @Nested + @DisplayName("parseClaims()는") + class ParseClaims { + + @Test + @DisplayName("유효한 토큰이면 Claims를 반환한다") + void 유효한_토큰이면_Claims를_반환한다() { + final String token = jwtTokenProvider.generateToken("test-uuid"); + + final Claims claims = jwtTokenProvider.parseClaims(token); + + assertThat(claims.getSubject()).isEqualTo("test-uuid"); + } + + @Nested + @DisplayName("예외 케이스") + class 예외_케이스 { + + @Test + @DisplayName("만료된 토큰이면 ExpiredJwtException을 던진다") + void 만료된_토큰이면_ExpiredJwtException을_던진다() { + final String expiredToken = Jwts.builder() + .subject("test-uuid") + .issuedAt(new Date(System.currentTimeMillis() - 86400000L)) + .expiration(new Date(System.currentTimeMillis() - 1000L)) + .signWith(secretKey) + .compact(); + + assertThatThrownBy(() -> jwtTokenProvider.parseClaims(expiredToken)) + .isInstanceOf(ExpiredJwtException.class); + } + + @Test + @DisplayName("서명이 변조된 토큰이면 SignatureException을 던진다") + void 서명이_변조된_토큰이면_SignatureException을_던진다() { + final SecretKey wrongKey = Keys.hmacShaKeyFor( + "wrong-secret-key-minimum-256-bits-long!!!!!!".getBytes(StandardCharsets.UTF_8)); + final String tamperedToken = Jwts.builder() + .subject("test-uuid") + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + EXPIRATION)) + .signWith(wrongKey) + .compact(); + + assertThatThrownBy(() -> jwtTokenProvider.parseClaims(tamperedToken)) + .isInstanceOf(SignatureException.class); + } + + @Test + @DisplayName("형식이 깨진 토큰이면 MalformedJwtException을 던진다") + void 형식이_깨진_토큰이면_MalformedJwtException을_던진다() { + final String malformedToken = "this.is.not.a.jwt"; + + assertThatThrownBy(() -> jwtTokenProvider.parseClaims(malformedToken)) + .isInstanceOf(MalformedJwtException.class); + } + + @Test + @DisplayName("서명 없는 토큰이면 UnsupportedJwtException을 던진다") + void 서명_없는_토큰이면_UnsupportedJwtException을_던진다() { + final String unsignedToken = Jwts.builder() + .subject("test-uuid") + .compact(); + + assertThatThrownBy(() -> jwtTokenProvider.parseClaims(unsignedToken)) + .isInstanceOf(UnsupportedJwtException.class); + } + + @Test + @DisplayName("빈 문자열이면 IllegalArgumentException을 던진다") + void 빈_문자열이면_IllegalArgumentException을_던진다() { + assertThatThrownBy(() -> jwtTokenProvider.parseClaims("")) + .isInstanceOf(IllegalArgumentException.class); + } + } + } +} diff --git a/common/src/main/java/com/yapp/ndgl/common/exception/CommonErrorCode.java b/common/src/main/java/com/yapp/ndgl/common/exception/CommonErrorCode.java index 18e03fc0..085baef3 100644 --- a/common/src/main/java/com/yapp/ndgl/common/exception/CommonErrorCode.java +++ b/common/src/main/java/com/yapp/ndgl/common/exception/CommonErrorCode.java @@ -56,6 +56,12 @@ public enum CommonErrorCode implements BaseErrorCode { CategoryCode.AUTHENTICATION, "002", "유효하지 않은 JWT 토큰입니다."), EXPIRED_TOKEN(StatusCode.UNAUTHORIZED, DomainCode.COMM, CategoryCode.AUTHENTICATION, "003", "만료된 JWT 토큰입니다."), + INVALID_TOKEN_SIGNATURE(StatusCode.UNAUTHORIZED, DomainCode.COMM, + CategoryCode.AUTHENTICATION, "004", "토큰 서명이 유효하지 않습니다."), + MALFORMED_TOKEN(StatusCode.UNAUTHORIZED, DomainCode.COMM, + CategoryCode.AUTHENTICATION, "005", "토큰 형식이 올바르지 않습니다."), + UNSUPPORTED_TOKEN(StatusCode.UNAUTHORIZED, DomainCode.COMM, + CategoryCode.AUTHENTICATION, "006", "지원하지 않는 토큰 형식입니다."), /** * COMM-06-xxx diff --git a/common/src/main/java/com/yapp/ndgl/common/type/SocialProvider.java b/common/src/main/java/com/yapp/ndgl/common/type/SocialProvider.java new file mode 100644 index 00000000..2b92259e --- /dev/null +++ b/common/src/main/java/com/yapp/ndgl/common/type/SocialProvider.java @@ -0,0 +1,13 @@ +package com.yapp.ndgl.common.type; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SocialProvider { + KAKAO("카카오"), + APPLE("애플"); + + private final String label; +} diff --git a/domain/domain-rdb/src/main/java/com/yapp/ndgl/domain/user/entity/UserEntity.java b/domain/domain-rdb/src/main/java/com/yapp/ndgl/domain/user/entity/UserEntity.java index 68d0ef14..876b4cef 100644 --- a/domain/domain-rdb/src/main/java/com/yapp/ndgl/domain/user/entity/UserEntity.java +++ b/domain/domain-rdb/src/main/java/com/yapp/ndgl/domain/user/entity/UserEntity.java @@ -1,16 +1,23 @@ package com.yapp.ndgl.domain.user.entity; +import com.yapp.ndgl.common.type.SocialProvider; import com.yapp.ndgl.domain.common.entity.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @Entity -@Table(name = "users") +@Table( + name = "users", + uniqueConstraints = @UniqueConstraint(columnNames = {"provider", "provider_id"}) +) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class UserEntity extends BaseEntity { @@ -18,7 +25,7 @@ public class UserEntity extends BaseEntity { @Column(nullable = false, unique = true, length = 36) private String uuid; - @Column(nullable = false, length = 500) + @Column(length = 500) private String fcmToken; @Column(length = 100) @@ -36,6 +43,16 @@ public class UserEntity extends BaseEntity { @Column(nullable = false, length = 50) private String nickname; + @Enumerated(EnumType.STRING) + @Column(length = 20) + private SocialProvider provider; + + @Column(name = "provider_id", length = 100) + private String providerId; + + @Column(length = 200) + private String email; + @Builder public UserEntity( final String uuid, @@ -44,7 +61,10 @@ public UserEntity( final String deviceOs, final String deviceOsVersion, final String appVersion, - final String nickname + final String nickname, + final SocialProvider provider, + final String providerId, + final String email ) { this.uuid = uuid; this.fcmToken = fcmToken; @@ -53,5 +73,8 @@ public UserEntity( this.deviceOsVersion = deviceOsVersion; this.appVersion = appVersion; this.nickname = nickname; + this.provider = provider; + this.providerId = providerId; + this.email = email; } } diff --git a/domain/domain-rdb/src/main/java/com/yapp/ndgl/domain/user/repository/UserRepository.java b/domain/domain-rdb/src/main/java/com/yapp/ndgl/domain/user/repository/UserRepository.java index 5cd9ff62..477cfa47 100644 --- a/domain/domain-rdb/src/main/java/com/yapp/ndgl/domain/user/repository/UserRepository.java +++ b/domain/domain-rdb/src/main/java/com/yapp/ndgl/domain/user/repository/UserRepository.java @@ -4,10 +4,12 @@ import org.springframework.data.jpa.repository.JpaRepository; +import com.yapp.ndgl.common.type.SocialProvider; import com.yapp.ndgl.domain.user.entity.UserEntity; public interface UserRepository extends JpaRepository { - Optional findByUuid(String uuid); + Optional findByUuid(String uuid); + Optional findByProviderAndProviderId(SocialProvider provider, String providerId); } diff --git a/domain/domain-service/src/main/java/com/yapp/ndgl/domain/user/User.java b/domain/domain-service/src/main/java/com/yapp/ndgl/domain/user/User.java index bfeddb52..ef743ecd 100644 --- a/domain/domain-service/src/main/java/com/yapp/ndgl/domain/user/User.java +++ b/domain/domain-service/src/main/java/com/yapp/ndgl/domain/user/User.java @@ -3,6 +3,8 @@ import java.time.LocalDateTime; import java.util.UUID; +import com.yapp.ndgl.common.type.SocialProvider; + import lombok.Builder; import lombok.Getter; @@ -10,38 +12,51 @@ @Builder public class User { - private final Long id; - private final String uuid; - private final String fcmToken; - private final String deviceModel; - private final String deviceOs; - private final String deviceOsVersion; - private final String appVersion; - private final String nickname; - private final LocalDateTime createdAt; - private final LocalDateTime updatedAt; + private final Long id; + private final String uuid; + private final String fcmToken; + private final String deviceModel; + private final String deviceOs; + private final String deviceOsVersion; + private final String appVersion; + private final String nickname; + private final SocialProvider provider; + private final String providerId; + private final String email; + private final LocalDateTime createdAt; + private final LocalDateTime updatedAt; - public static User create( - final String fcmToken, - final String deviceModel, - final String deviceOs, - final String deviceOsVersion, - final String appVersion, - final String nickname - ) { - String uuid = UUID.randomUUID().toString(); - LocalDateTime now = LocalDateTime.now(); + public static User create( + final String fcmToken, + final String deviceModel, + final String deviceOs, + final String deviceOsVersion, + final String appVersion, + final String nickname + ) { + return User.builder() + .uuid(UUID.randomUUID().toString()) + .fcmToken(fcmToken) + .deviceModel(deviceModel) + .deviceOs(deviceOs) + .deviceOsVersion(deviceOsVersion) + .appVersion(appVersion) + .nickname(nickname) + .build(); + } - return User.builder() - .uuid(uuid) - .fcmToken(fcmToken) - .deviceModel(deviceModel) - .deviceOs(deviceOs) - .deviceOsVersion(deviceOsVersion) - .appVersion(appVersion) - .nickname(nickname) - .createdAt(now) - .updatedAt(now) - .build(); - } + public static User createSocialUser( + final SocialProvider provider, + final String providerId, + final String email, + final String nickname + ) { + return User.builder() + .uuid(UUID.randomUUID().toString()) + .provider(provider) + .providerId(providerId) + .email(email) + .nickname(nickname) + .build(); + } } diff --git a/domain/domain-service/src/main/java/com/yapp/ndgl/domain/user/mapper/UserMapper.java b/domain/domain-service/src/main/java/com/yapp/ndgl/domain/user/mapper/UserMapper.java index 9b568b79..78c1f28c 100644 --- a/domain/domain-service/src/main/java/com/yapp/ndgl/domain/user/mapper/UserMapper.java +++ b/domain/domain-service/src/main/java/com/yapp/ndgl/domain/user/mapper/UserMapper.java @@ -5,30 +5,36 @@ public class UserMapper { - public static UserEntity toEntity(final User user) { - return UserEntity.builder() - .uuid(user.getUuid()) - .fcmToken(user.getFcmToken()) - .deviceModel(user.getDeviceModel()) - .deviceOs(user.getDeviceOs()) - .deviceOsVersion(user.getDeviceOsVersion()) - .appVersion(user.getAppVersion()) - .nickname(user.getNickname()) - .build(); - } + public static UserEntity toEntity(final User user) { + return UserEntity.builder() + .uuid(user.getUuid()) + .fcmToken(user.getFcmToken()) + .deviceModel(user.getDeviceModel()) + .deviceOs(user.getDeviceOs()) + .deviceOsVersion(user.getDeviceOsVersion()) + .appVersion(user.getAppVersion()) + .nickname(user.getNickname()) + .provider(user.getProvider()) + .providerId(user.getProviderId()) + .email(user.getEmail()) + .build(); + } - public static User toDomain(final UserEntity entity) { - return User.builder() - .id(entity.getId()) - .uuid(entity.getUuid()) - .fcmToken(entity.getFcmToken()) - .deviceModel(entity.getDeviceModel()) - .deviceOs(entity.getDeviceOs()) - .deviceOsVersion(entity.getDeviceOsVersion()) - .appVersion(entity.getAppVersion()) - .nickname(entity.getNickname()) - .createdAt(entity.getCreatedAt()) - .updatedAt(entity.getUpdatedAt()) - .build(); - } + public static User toDomain(final UserEntity entity) { + return User.builder() + .id(entity.getId()) + .uuid(entity.getUuid()) + .fcmToken(entity.getFcmToken()) + .deviceModel(entity.getDeviceModel()) + .deviceOs(entity.getDeviceOs()) + .deviceOsVersion(entity.getDeviceOsVersion()) + .appVersion(entity.getAppVersion()) + .nickname(entity.getNickname()) + .provider(entity.getProvider()) + .providerId(entity.getProviderId()) + .email(entity.getEmail()) + .createdAt(entity.getCreatedAt()) + .updatedAt(entity.getUpdatedAt()) + .build(); + } } diff --git a/domain/domain-service/src/main/java/com/yapp/ndgl/domain/user/service/UserDomainService.java b/domain/domain-service/src/main/java/com/yapp/ndgl/domain/user/service/UserDomainService.java index 5ddbebc6..0ffc2771 100644 --- a/domain/domain-service/src/main/java/com/yapp/ndgl/domain/user/service/UserDomainService.java +++ b/domain/domain-service/src/main/java/com/yapp/ndgl/domain/user/service/UserDomainService.java @@ -1,14 +1,17 @@ package com.yapp.ndgl.domain.user.service; +import java.util.Optional; + import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.yapp.ndgl.common.exception.GlobalException; import com.yapp.ndgl.common.exception.UserErrorCode; +import com.yapp.ndgl.common.type.SocialProvider; import com.yapp.ndgl.domain.user.User; -import com.yapp.ndgl.domain.user.mapper.UserMapper; import com.yapp.ndgl.domain.user.UserNicknameGenerator; import com.yapp.ndgl.domain.user.entity.UserEntity; +import com.yapp.ndgl.domain.user.mapper.UserMapper; import com.yapp.ndgl.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; @@ -28,24 +31,34 @@ public User createUser( final String deviceOsVersion, final String appVersion) { String nickname = UserNicknameGenerator.generate(); - - User user = User.create( - fcmToken, - deviceModel, - deviceOs, - deviceOsVersion, - appVersion, - nickname - ); - + User user = User.create(fcmToken, deviceModel, deviceOs, deviceOsVersion, appVersion, nickname); UserEntity savedUserEntity = userRepository.save(UserMapper.toEntity(user)); return UserMapper.toDomain(savedUserEntity); } + @Transactional + public User createSocialUser( + final SocialProvider provider, + final String providerId, + final String email) { + String nickname = UserNicknameGenerator.generate(); + User user = User.createSocialUser(provider, providerId, email, nickname); + UserEntity savedEntity = userRepository.save(UserMapper.toEntity(user)); + return UserMapper.toDomain(savedEntity); + } + @Transactional(readOnly = true) public User findByUuid(final String uuid) { return userRepository.findByUuid(uuid) .map(UserMapper::toDomain) .orElseThrow(() -> new GlobalException(UserErrorCode.NOT_FOUND_USER)); } + + @Transactional(readOnly = true) + public Optional findByProviderAndProviderId( + final SocialProvider provider, + final String providerId) { + return userRepository.findByProviderAndProviderId(provider, providerId) + .map(UserMapper::toDomain); + } }