From 76edd3b30bd25adee6e1dddc9869cf21ac95a105 Mon Sep 17 00:00:00 2001 From: Wo-ogie Date: Sat, 17 Feb 2024 15:11:56 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20#22=20`User`=20entity=EC=97=90=20?= =?UTF-8?q?=EC=97=AD=ED=95=A0(`RoleTypes`)=20=ED=95=84=EB=93=9C=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 --- .../hertz/domain/user/constant/RoleType.java | 16 ++++++++++ .../user/converter/RoleTypesConverter.java | 32 +++++++++++++++++++ .../ajou/hertz/domain/user/entity/User.java | 11 +++++-- 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/ajou/hertz/domain/user/constant/RoleType.java create mode 100644 src/main/java/com/ajou/hertz/domain/user/converter/RoleTypesConverter.java diff --git a/src/main/java/com/ajou/hertz/domain/user/constant/RoleType.java b/src/main/java/com/ajou/hertz/domain/user/constant/RoleType.java new file mode 100644 index 0000000..3b91ec9 --- /dev/null +++ b/src/main/java/com/ajou/hertz/domain/user/constant/RoleType.java @@ -0,0 +1,16 @@ +package com.ajou.hertz.domain.user.constant; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum RoleType { + + USER("ROLE_USER", "사용자"), + ADMIN("ROLE_ADMIN", "관리자"), + ; + + private final String roleName; + private final String description; +} diff --git a/src/main/java/com/ajou/hertz/domain/user/converter/RoleTypesConverter.java b/src/main/java/com/ajou/hertz/domain/user/converter/RoleTypesConverter.java new file mode 100644 index 0000000..9212e83 --- /dev/null +++ b/src/main/java/com/ajou/hertz/domain/user/converter/RoleTypesConverter.java @@ -0,0 +1,32 @@ +package com.ajou.hertz.domain.user.converter; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +import com.ajou.hertz.domain.user.constant.RoleType; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter +public class RoleTypesConverter implements AttributeConverter, String> { + + private static final String DELIMITER = ","; + + @Override + public String convertToDatabaseColumn(Set attribute) { + return attribute.stream() + .map(RoleType::name) + .sorted() + .collect(Collectors.joining(DELIMITER)); + } + + @Override + public Set convertToEntityAttribute(String dbData) { + return Arrays.stream(dbData.split(DELIMITER)) + .map(RoleType::valueOf) + .collect(Collectors.toSet()); + } +} + diff --git a/src/main/java/com/ajou/hertz/domain/user/entity/User.java b/src/main/java/com/ajou/hertz/domain/user/entity/User.java index 14ef3ca..409221e 100644 --- a/src/main/java/com/ajou/hertz/domain/user/entity/User.java +++ b/src/main/java/com/ajou/hertz/domain/user/entity/User.java @@ -1,11 +1,14 @@ package com.ajou.hertz.domain.user.entity; import java.time.LocalDate; +import java.util.Set; import org.springframework.lang.NonNull; -import com.ajou.hertz.domain.user.constant.Gender; import com.ajou.hertz.common.entity.TimeTrackedBaseEntity; +import com.ajou.hertz.domain.user.constant.Gender; +import com.ajou.hertz.domain.user.constant.RoleType; +import com.ajou.hertz.domain.user.converter.RoleTypesConverter; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -30,6 +33,10 @@ public class User extends TimeTrackedBaseEntity { @Column(name = "user_id", nullable = false, updatable = false) private Long id; + @Column(nullable = false) + @Convert(converter = RoleTypesConverter.class) + private Set roleTypes; + @Column(nullable = false, unique = true) private String email; @@ -57,6 +64,6 @@ public static User create( @NonNull Gender gender, String phone ) { - return new User(null, email, password, null, birth, gender, phone, null); + return new User(null, Set.of(RoleType.USER), email, password, null, birth, gender, phone, null); } } From 1234a0e1c0dd1367edc8e87cc4c6282046ec0e35 Mon Sep 17 00:00:00 2001 From: Wo-ogie Date: Sat, 17 Feb 2024 15:12:32 +0900 Subject: [PATCH 2/7] =?UTF-8?q?chore:=20jacoco=20test=20coverage=20verific?= =?UTF-8?q?ation=20target=20=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 9cab917..9f15983 100644 --- a/build.gradle +++ b/build.gradle @@ -126,8 +126,8 @@ jacocoTestCoverageVerification { } includes = [ - '*.*Service*', - '*.*Controller*' + '*service.*Service*', + '*controller.*Controller*' ] } } From 808b6fdb77ebbb8b960e6e21f246d9c4e73b0980 Mon Sep 17 00:00:00 2001 From: Wo-ogie Date: Sat, 17 Feb 2024 15:12:45 +0900 Subject: [PATCH 3/7] =?UTF-8?q?chore:=20#22=20JWT=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/build.gradle b/build.gradle index 9f15983..3cea095 100644 --- a/build.gradle +++ b/build.gradle @@ -56,6 +56,11 @@ dependencies { // Spring Configuration Processor annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' + // AWS S3 implementation 'com.amazonaws:aws-java-sdk-s3:1.12.658' From 6d3e6827f5e001879d75e173e1580a0e770f4942 Mon Sep 17 00:00:00 2001 From: Wo-ogie Date: Sat, 17 Feb 2024 15:14:41 +0900 Subject: [PATCH 4/7] =?UTF-8?q?refactor:=20enum=20value=EC=9D=98=20DB=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=ED=98=95=ED=83=9C=EB=A5=BC=20string?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/ajou/hertz/domain/user/entity/User.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/ajou/hertz/domain/user/entity/User.java b/src/main/java/com/ajou/hertz/domain/user/entity/User.java index 409221e..fdc8881 100644 --- a/src/main/java/com/ajou/hertz/domain/user/entity/User.java +++ b/src/main/java/com/ajou/hertz/domain/user/entity/User.java @@ -49,6 +49,7 @@ public class User extends TimeTrackedBaseEntity { private LocalDate birth; @Column(nullable = false) + @Enumerated(EnumType.STRING) private Gender gender; @Column(unique = true) From 02caa9591dfdb7028bc84d616d66cebe03b98e95 Mon Sep 17 00:00:00 2001 From: Wo-ogie Date: Sat, 17 Feb 2024 15:18:37 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20#22=20Spring=20Security,=20JWT=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=9D=B8=EC=A6=9D/=EC=9D=B8=EA=B0=80=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/auth/CustomUserDetailsService.java | 18 +++ .../common/auth/JwtAccessDeniedHandler.java | 55 +++++++ .../auth/JwtAuthenticationEntryPoint.java | 55 +++++++ .../common/auth/JwtAuthenticationFilter.java | 67 ++++++++ .../hertz/common/auth/JwtExceptionFilter.java | 52 ++++++ .../hertz/common/auth/JwtTokenProvider.java | 149 ++++++++++++++++++ .../ajou/hertz/common/auth/UserPrincipal.java | 63 ++++++++ .../common/auth/dto/JwtTokenInfoDto.java | 9 ++ .../exception/TokenValidateException.java | 11 ++ .../ajou/hertz/common/config/JpaConfig.java | 22 ++- .../hertz/common/config/SecurityConfig.java | 19 +++ .../constant/CustomExceptionType.java | 2 +- .../common/properties/JwtProperties.java | 9 ++ .../ajou/hertz/domain/user/dto/UserDto.java | 4 + .../exception/UserNotFoundByIdException.java | 10 ++ .../domain/user/service/UserQueryService.java | 25 +++ src/main/resources/application.properties | 2 + .../ajou/hertz/config/TestSecurityConfig.java | 55 ++++++- .../user/controller/UserControllerV1Test.java | 37 ++++- .../user/service/UserCommandServiceTest.java | 5 +- .../user/service/UserQueryServiceTest.java | 65 ++++++++ 21 files changed, 725 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/ajou/hertz/common/auth/CustomUserDetailsService.java create mode 100644 src/main/java/com/ajou/hertz/common/auth/JwtAccessDeniedHandler.java create mode 100644 src/main/java/com/ajou/hertz/common/auth/JwtAuthenticationEntryPoint.java create mode 100644 src/main/java/com/ajou/hertz/common/auth/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/ajou/hertz/common/auth/JwtExceptionFilter.java create mode 100644 src/main/java/com/ajou/hertz/common/auth/JwtTokenProvider.java create mode 100644 src/main/java/com/ajou/hertz/common/auth/UserPrincipal.java create mode 100644 src/main/java/com/ajou/hertz/common/auth/dto/JwtTokenInfoDto.java create mode 100644 src/main/java/com/ajou/hertz/common/auth/exception/TokenValidateException.java create mode 100644 src/main/java/com/ajou/hertz/common/properties/JwtProperties.java create mode 100644 src/main/java/com/ajou/hertz/domain/user/exception/UserNotFoundByIdException.java diff --git a/src/main/java/com/ajou/hertz/common/auth/CustomUserDetailsService.java b/src/main/java/com/ajou/hertz/common/auth/CustomUserDetailsService.java new file mode 100644 index 0000000..6ed520a --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/auth/CustomUserDetailsService.java @@ -0,0 +1,18 @@ +package com.ajou.hertz.common.auth; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.userdetails.UserDetailsService; + +import com.ajou.hertz.domain.user.service.UserQueryService; + +@Configuration +public class CustomUserDetailsService { + + @Bean + public UserDetailsService userDetailsService(UserQueryService userQueryService) { + return username -> new UserPrincipal( + userQueryService.getDtoById(Long.parseLong(username)) + ); + } +} diff --git a/src/main/java/com/ajou/hertz/common/auth/JwtAccessDeniedHandler.java b/src/main/java/com/ajou/hertz/common/auth/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..12fd8fa --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/auth/JwtAccessDeniedHandler.java @@ -0,0 +1,55 @@ +package com.ajou.hertz.common.auth; + +import static org.springframework.http.HttpStatus.*; +import static org.springframework.http.MediaType.*; + +import java.io.IOException; + +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import com.ajou.hertz.common.exception.constant.CustomExceptionType; +import com.ajou.hertz.common.exception.dto.response.ErrorResponse; +import com.ajou.hertz.common.exception.util.ExceptionUtils; +import com.ajou.hertz.common.logger.Logger; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + /** + * Endpoint에 대해 접근 권한이 존재하지 않을 때 동작하는 handler. + * + * @param request that resulted in an AccessDeniedException + * @param response so that the user agent can be advised of the failure + * @param accessDeniedException that caused the invocation + * @throws IOException if an input or output exception occurred + */ + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException { + Logger.warn(String.format( + "JwtAccessDeniedHandler.handle() ex=%s", + ExceptionUtils.getExceptionStackTrace(accessDeniedException) + )); + + response.setStatus(FORBIDDEN.value()); + response.setContentType(APPLICATION_JSON_VALUE); + response.setCharacterEncoding("utf-8"); + response.getWriter().write( + new ObjectMapper().writeValueAsString( + new ErrorResponse( + CustomExceptionType.ACCESS_DENIED.getCode(), + CustomExceptionType.ACCESS_DENIED.getMessage() + ) + ) + ); + } +} diff --git a/src/main/java/com/ajou/hertz/common/auth/JwtAuthenticationEntryPoint.java b/src/main/java/com/ajou/hertz/common/auth/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..aca807c --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/auth/JwtAuthenticationEntryPoint.java @@ -0,0 +1,55 @@ +package com.ajou.hertz.common.auth; + +import static org.springframework.http.HttpStatus.*; +import static org.springframework.http.MediaType.*; + +import java.io.IOException; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import com.ajou.hertz.common.exception.constant.CustomExceptionType; +import com.ajou.hertz.common.exception.dto.response.ErrorResponse; +import com.ajou.hertz.common.exception.util.ExceptionUtils; +import com.ajou.hertz.common.logger.Logger; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + /** + * 인증이 필요한 endpoint에 대해 인증되지 않았을 때 동작하는 handler. + * + * @param request that resulted in an AuthenticationException + * @param response so that the user agent can begin authentication + * @param authenticationException that caused the invocation + * @throws IOException if an input or output exception occurred + */ + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authenticationException + ) throws IOException { + Logger.warn(String.format( + "JwtAuthenticationEntryPoint.commence() ex=%s", + ExceptionUtils.getExceptionStackTrace(authenticationException) + )); + + response.setStatus(UNAUTHORIZED.value()); + response.setContentType(APPLICATION_JSON_VALUE); + response.setCharacterEncoding("utf-8"); + response.getWriter().write( + new ObjectMapper().writeValueAsString( + new ErrorResponse( + CustomExceptionType.ACCESS_DENIED.getCode(), + CustomExceptionType.ACCESS_DENIED.getMessage() + ) + ) + ); + } +} diff --git a/src/main/java/com/ajou/hertz/common/auth/JwtAuthenticationFilter.java b/src/main/java/com/ajou/hertz/common/auth/JwtAuthenticationFilter.java new file mode 100644 index 0000000..bef93d2 --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/auth/JwtAuthenticationFilter.java @@ -0,0 +1,67 @@ +package com.ajou.hertz.common.auth; + +import java.io.IOException; + +import org.springframework.http.HttpHeaders; +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.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final String TOKEN_TYPE_BEARER_PREFIX = "Bearer "; + + private final JwtTokenProvider jwtTokenProvider; + + /** + * 모든 요청마다 작동하여, jwt access token을 확인한다. + * 유효한 token이 있는 경우 token을 parsing해서 사용자 정보를 읽고 SecurityContext에 사용자 정보를 저장한다. + * + * @param request request 객체 + * @param response response 객체 + * @param filterChain FilterChain 객체 + */ + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + String accessToken = getAccessToken(request); + + if (StringUtils.hasText(accessToken)) { + try { + jwtTokenProvider.validateToken(accessToken); + Authentication authentication = jwtTokenProvider.getAuthentication(accessToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (Exception ignored) { + // 인증 권한 설정 중 에러가 발생하면 권한을 부여하지 않고 다음 단계로 진행 + } + } + filterChain.doFilter(request, response); + } + + /** + * Request의 header에서 token을 읽어온다. + * + * @param request Request 객체 + * @return Header에서 추출한 token + */ + public String getAccessToken(HttpServletRequest request) { + String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + if (authorizationHeader == null || !authorizationHeader.startsWith(TOKEN_TYPE_BEARER_PREFIX)) { + return null; + } + return authorizationHeader.substring(TOKEN_TYPE_BEARER_PREFIX.length()); + } +} diff --git a/src/main/java/com/ajou/hertz/common/auth/JwtExceptionFilter.java b/src/main/java/com/ajou/hertz/common/auth/JwtExceptionFilter.java new file mode 100644 index 0000000..e039431 --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/auth/JwtExceptionFilter.java @@ -0,0 +1,52 @@ +package com.ajou.hertz.common.auth; + +import java.io.IOException; + +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.ajou.hertz.common.auth.exception.TokenValidateException; +import com.ajou.hertz.common.exception.constant.CustomExceptionType; +import com.ajou.hertz.common.exception.dto.response.ErrorResponse; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * JwtAuthenticationFilter에서 발생하는 에러를 처리하기 위한 filter + * + * @see JwtAuthenticationFilter + */ +@Component +public class JwtExceptionFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (TokenValidateException ex) { + setErrorResponse(CustomExceptionType.TOKEN_VALIDATE, response); + } + } + + /** + * Exception 정보를 입력받아 응답할 error response를 설정한다. + * + * @param exceptionType exception type + * @param response HttpServletResponse 객체 + */ + private void setErrorResponse( + CustomExceptionType exceptionType, + HttpServletResponse response + ) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setCharacterEncoding("utf-8"); + response.setContentType("application/json; charset=UTF-8"); + ErrorResponse errorResponse = new ErrorResponse(exceptionType.getCode(), exceptionType.getMessage()); + new ObjectMapper().writeValue(response.getOutputStream(), errorResponse); + } +} diff --git a/src/main/java/com/ajou/hertz/common/auth/JwtTokenProvider.java b/src/main/java/com/ajou/hertz/common/auth/JwtTokenProvider.java new file mode 100644 index 0000000..6ac7ede --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/auth/JwtTokenProvider.java @@ -0,0 +1,149 @@ +package com.ajou.hertz.common.auth; + +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.sql.Timestamp; +import java.util.Date; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Component; + +import com.ajou.hertz.common.auth.dto.JwtTokenInfoDto; +import com.ajou.hertz.common.auth.exception.TokenValidateException; +import com.ajou.hertz.domain.user.dto.UserDto; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Component +public class JwtTokenProvider { + + private final UserDetailsService userDetailsService; + + private static final long MINUTE = 1000 * 60L; + private static final long HOUR = 60 * MINUTE; + private static final long ACCESS_TOKEN_EXPIRED_DURATION = 2 * HOUR; // Access token 만료시간: 2시간 + private static final String ROLE_CLAIM_KEY = "role"; + + @Value("${jwt.secret-key}") + private String salt; + + private Key secretKey; + + /** + * 객체 초기화, jwt secret key를 Base64로 인코딩 + */ + @PostConstruct + protected void init() { + secretKey = Keys.hmacShaKeyFor(salt.getBytes(StandardCharsets.UTF_8)); + } + + /** + * Jwt access token을 생성하여 반환한다. + * + * @param userDto 회원 정보가 담긴 dto + * @return 생성한 jwt token + */ + public JwtTokenInfoDto createAccessToken(UserDto userDto) { + return createJwtToken(userDto, ACCESS_TOKEN_EXPIRED_DURATION); + } + + /** + * JWT token에서 사용자 정보 조회 후 security login 과정(UsernamePasswordAuthenticationToken)을 수행한다. + * + * @param token Jwt token + * @return Token을 통해 조회한 사용자 정보 + */ + public Authentication getAuthentication(String token) { + UserDetails principal = userDetailsService.loadUserByUsername(getUsernameFromToken(token)); + return new UsernamePasswordAuthenticationToken(principal, "", principal.getAuthorities()); + } + + /** + * 토큰의 유효성, 만료일자 검증 + * + * @param token 검증하고자 하는 JWT token + * @throws TokenValidateException Token 값이 잘못되거나 만료되어 유효하지 않은 경우 + */ + public void validateToken(String token) { + try { + Jwts + .parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token); + } catch (UnsupportedJwtException ex) { + throw new TokenValidateException("The claimsJws argument does not represent an Claims JWS", ex); + } catch (MalformedJwtException ex) { + throw new TokenValidateException("The claimsJws string is not a valid JWS", ex); + } catch (SignatureException ex) { + throw new TokenValidateException("The claimsJws JWS signature validation fails", ex); + } catch (ExpiredJwtException ex) { + throw new TokenValidateException( + "The specified JWT is a Claims JWT and the Claims has an expiration time before the time this method is invoked.", + ex + ); + } catch (IllegalArgumentException ex) { + throw new TokenValidateException("The claimsJws string is null or empty or only whitespace", ex); + } + } + + /** + * Subject(socialUid), 로그인 type, token 만료 시간을 전달받아 JWT token을 생성한다. + * 현재 access token과 refresh token을 생성할 때 만료 시간 외의 정보는 동일하므로 method를 통일하였다. + * + * @param userDto 회원 정보가 담긴 dto + * @param tokenExpiredDuration Token 만료 시간 + * @return 생성된 jwt token과 만료 시각이 포함된 JwtTokenInfoDto 객체 + */ + private JwtTokenInfoDto createJwtToken(UserDto userDto, Long tokenExpiredDuration) { + Date now = new Date(); + Date expiresAt = new Date(now.getTime() + tokenExpiredDuration); + String token = Jwts.builder() + .setHeaderParam("typ", "JWT") + .setSubject(String.valueOf(userDto.getId())) + .claim(ROLE_CLAIM_KEY, userDto.getRoleTypes()) + .setIssuedAt(now) + .setExpiration(expiresAt) + .signWith(secretKey, SignatureAlgorithm.HS256) + .compact(); + return new JwtTokenInfoDto(token, new Timestamp(expiresAt.getTime()).toLocalDateTime()); + } + + /** + * 토큰에서 회원 정보(username)를 추출한다. 이 때 username은 회원의 id(PK) 값. + * + * @param token Jwt token + * @return 추출한 회원 정보(username == email) + */ + public String getUsernameFromToken(String token) { + return getClaimsFromToken(token).getSubject(); + } + + /** + * Claims 정보를 추출한다. + * + * @param token 정보를 추출하고자 하는 jwt token + * @return token에서 추출한 Claims 정보 + */ + private Claims getClaimsFromToken(String token) { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + } +} diff --git a/src/main/java/com/ajou/hertz/common/auth/UserPrincipal.java b/src/main/java/com/ajou/hertz/common/auth/UserPrincipal.java new file mode 100644 index 0000000..a526cb3 --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/auth/UserPrincipal.java @@ -0,0 +1,63 @@ +package com.ajou.hertz.common.auth; + +import java.util.Collection; +import java.util.stream.Collectors; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import com.ajou.hertz.domain.user.constant.RoleType; +import com.ajou.hertz.domain.user.dto.UserDto; + +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public class UserPrincipal implements UserDetails { + + private UserDto userDto; + + public Long getUserId() { + return userDto.getId(); + } + + @Override + public Collection getAuthorities() { + return userDto + .getRoleTypes() + .stream() + .map(RoleType::getRoleName) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toUnmodifiableSet()); + } + + @Override + public String getUsername() { + return String.valueOf(getUserId()); + } + + @Override + public String getPassword() { + return userDto.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/ajou/hertz/common/auth/dto/JwtTokenInfoDto.java b/src/main/java/com/ajou/hertz/common/auth/dto/JwtTokenInfoDto.java new file mode 100644 index 0000000..21ea6e1 --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/auth/dto/JwtTokenInfoDto.java @@ -0,0 +1,9 @@ +package com.ajou.hertz.common.auth.dto; + +import java.time.LocalDateTime; + +public record JwtTokenInfoDto( + String token, + LocalDateTime expiresAt +) { +} diff --git a/src/main/java/com/ajou/hertz/common/auth/exception/TokenValidateException.java b/src/main/java/com/ajou/hertz/common/auth/exception/TokenValidateException.java new file mode 100644 index 0000000..fc92d26 --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/auth/exception/TokenValidateException.java @@ -0,0 +1,11 @@ +package com.ajou.hertz.common.auth.exception; + +import com.ajou.hertz.common.exception.UnauthorizedException; +import com.ajou.hertz.common.exception.constant.CustomExceptionType; + +public class TokenValidateException extends UnauthorizedException { + + public TokenValidateException(String optionalMessage, Throwable cause) { + super(CustomExceptionType.TOKEN_VALIDATE, optionalMessage, cause); + } +} diff --git a/src/main/java/com/ajou/hertz/common/config/JpaConfig.java b/src/main/java/com/ajou/hertz/common/config/JpaConfig.java index cdda4a8..f03e44d 100644 --- a/src/main/java/com/ajou/hertz/common/config/JpaConfig.java +++ b/src/main/java/com/ajou/hertz/common/config/JpaConfig.java @@ -6,14 +6,32 @@ import org.springframework.context.annotation.Configuration; import org.springframework.data.domain.AuditorAware; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +import com.ajou.hertz.common.auth.UserPrincipal; @EnableJpaAuditing @Configuration public class JpaConfig { - // TODO: 로그인 및 인증/인가 로직 작성 후 변경 필요 @Bean public AuditorAware auditorAware() { - return () -> Optional.of(1L); + return () -> { + Optional principal = Optional.ofNullable( + SecurityContextHolder.getContext() + ).map(SecurityContext::getAuthentication) + .filter(Authentication::isAuthenticated) + .map(Authentication::getPrincipal); + + if (principal.isEmpty() || principal.get().equals("anonymousUser")) { + return Optional.empty(); + } + + return principal + .map(UserPrincipal.class::cast) + .map(UserPrincipal::getUserId); + }; } } diff --git a/src/main/java/com/ajou/hertz/common/config/SecurityConfig.java b/src/main/java/com/ajou/hertz/common/config/SecurityConfig.java index bca32b2..4aaf159 100644 --- a/src/main/java/com/ajou/hertz/common/config/SecurityConfig.java +++ b/src/main/java/com/ajou/hertz/common/config/SecurityConfig.java @@ -16,10 +16,24 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import com.ajou.hertz.common.auth.JwtAccessDeniedHandler; +import com.ajou.hertz.common.auth.JwtAuthenticationEntryPoint; +import com.ajou.hertz.common.auth.JwtAuthenticationFilter; +import com.ajou.hertz.common.auth.JwtExceptionFilter; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor @Configuration public class SecurityConfig { + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final JwtExceptionFilter jwtExceptionFilter; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private static final String[] AUTH_WHITE_PATHS = { "/swagger-ui/**", "/v3/api-docs/**" @@ -45,6 +59,11 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti ); auth.anyRequest().authenticated(); }) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(jwtExceptionFilter, jwtAuthenticationFilter.getClass()) + .exceptionHandling(exceptionHandlingConfigurer -> exceptionHandlingConfigurer + .accessDeniedHandler(jwtAccessDeniedHandler) + .authenticationEntryPoint(jwtAuthenticationEntryPoint)) .build(); } diff --git a/src/main/java/com/ajou/hertz/common/exception/constant/CustomExceptionType.java b/src/main/java/com/ajou/hertz/common/exception/constant/CustomExceptionType.java index d331f35..ddd113d 100644 --- a/src/main/java/com/ajou/hertz/common/exception/constant/CustomExceptionType.java +++ b/src/main/java/com/ajou/hertz/common/exception/constant/CustomExceptionType.java @@ -31,7 +31,7 @@ public enum CustomExceptionType { * 유저 관련 예외 */ USER_EMAIL_DUPLICATION(2200, "이미 다른 회원이 사용 중인 이메일입니다."), - ; + USER_NOT_FOUND_BY_ID(2201, "일치하는 회원을 찾을 수 없습니다."), private final Integer code; private final String message; diff --git a/src/main/java/com/ajou/hertz/common/properties/JwtProperties.java b/src/main/java/com/ajou/hertz/common/properties/JwtProperties.java new file mode 100644 index 0000000..483489b --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/properties/JwtProperties.java @@ -0,0 +1,9 @@ +package com.ajou.hertz.common.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("jwt") +public record JwtProperties( + String secretKey +) { +} \ No newline at end of file diff --git a/src/main/java/com/ajou/hertz/domain/user/dto/UserDto.java b/src/main/java/com/ajou/hertz/domain/user/dto/UserDto.java index 39e5eb8..370d37c 100644 --- a/src/main/java/com/ajou/hertz/domain/user/dto/UserDto.java +++ b/src/main/java/com/ajou/hertz/domain/user/dto/UserDto.java @@ -1,8 +1,10 @@ package com.ajou.hertz.domain.user.dto; import java.time.LocalDate; +import java.util.Set; import com.ajou.hertz.domain.user.constant.Gender; +import com.ajou.hertz.domain.user.constant.RoleType; import com.ajou.hertz.domain.user.entity.User; import lombok.AccessLevel; @@ -14,6 +16,7 @@ public class UserDto { private Long id; + private Set roleTypes; private String email; private String password; private String kakaoUid; @@ -25,6 +28,7 @@ public class UserDto { public static UserDto from(User user) { return new UserDto( user.getId(), + user.getRoleTypes(), user.getEmail(), user.getPassword(), user.getKakaoUid(), diff --git a/src/main/java/com/ajou/hertz/domain/user/exception/UserNotFoundByIdException.java b/src/main/java/com/ajou/hertz/domain/user/exception/UserNotFoundByIdException.java new file mode 100644 index 0000000..e22200a --- /dev/null +++ b/src/main/java/com/ajou/hertz/domain/user/exception/UserNotFoundByIdException.java @@ -0,0 +1,10 @@ +package com.ajou.hertz.domain.user.exception; + +import com.ajou.hertz.common.exception.NotFoundException; +import com.ajou.hertz.common.exception.constant.CustomExceptionType; + +public class UserNotFoundByIdException extends NotFoundException { + public UserNotFoundByIdException(Long userId) { + super(CustomExceptionType.USER_NOT_FOUND_BY_ID, "userId=" + userId); + } +} diff --git a/src/main/java/com/ajou/hertz/domain/user/service/UserQueryService.java b/src/main/java/com/ajou/hertz/domain/user/service/UserQueryService.java index f24f849..e524290 100644 --- a/src/main/java/com/ajou/hertz/domain/user/service/UserQueryService.java +++ b/src/main/java/com/ajou/hertz/domain/user/service/UserQueryService.java @@ -3,6 +3,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.ajou.hertz.domain.user.dto.UserDto; +import com.ajou.hertz.domain.user.entity.User; +import com.ajou.hertz.domain.user.exception.UserNotFoundByIdException; import com.ajou.hertz.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; @@ -14,6 +17,28 @@ public class UserQueryService { private final UserRepository userRepository; + /** + * 유저 id user entity를 조회한다. + * + * @param id 조회하고자 하는 user의 id + * @return 조회한 user entity + * @throws UserNotFoundByIdException 일치하는 유저를 찾지 못한 경우 + */ + private User getById(Long id) { + return userRepository.findById(id).orElseThrow(() -> new UserNotFoundByIdException(id)); + } + + /** + * 유저 id로 유저를 조회한다. + * + * @param id 조회하고자 하는 user의 id + * @return 조회한 유저 정보가 담긴 dto + * @throws UserNotFoundByIdException 일치하는 유저를 찾지 못한 경우 + */ + public UserDto getDtoById(Long id) { + return UserDto.from(getById(id)); + } + /** * 전달된 email을 사용 중인 회원의 존재 여부를 조회한다. * diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 68c36f3..5b4f7b5 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,5 +1,7 @@ hertz.app-version=0.0.1 +jwt.secret-key=${JWT_SECRET_KEY} + springdoc.swagger-ui.operations-sorter=method springdoc.use-fqn=true diff --git a/src/test/java/com/ajou/hertz/config/TestSecurityConfig.java b/src/test/java/com/ajou/hertz/config/TestSecurityConfig.java index 7b6b730..8fed18a 100644 --- a/src/test/java/com/ajou/hertz/config/TestSecurityConfig.java +++ b/src/test/java/com/ajou/hertz/config/TestSecurityConfig.java @@ -1,11 +1,64 @@ package com.ajou.hertz.config; +import static org.mockito.BDDMockito.*; + +import java.lang.reflect.Constructor; +import java.time.LocalDate; +import java.util.Set; + import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; +import org.springframework.test.context.event.annotation.BeforeTestMethod; +import com.ajou.hertz.common.auth.CustomUserDetailsService; +import com.ajou.hertz.common.auth.JwtAccessDeniedHandler; +import com.ajou.hertz.common.auth.JwtAuthenticationEntryPoint; +import com.ajou.hertz.common.auth.JwtAuthenticationFilter; +import com.ajou.hertz.common.auth.JwtExceptionFilter; +import com.ajou.hertz.common.auth.JwtTokenProvider; import com.ajou.hertz.common.config.SecurityConfig; +import com.ajou.hertz.domain.user.constant.Gender; +import com.ajou.hertz.domain.user.constant.RoleType; +import com.ajou.hertz.domain.user.dto.UserDto; +import com.ajou.hertz.domain.user.service.UserQueryService; -@Import(SecurityConfig.class) +@Import({ + SecurityConfig.class, + JwtAccessDeniedHandler.class, + JwtAuthenticationFilter.class, + JwtAuthenticationEntryPoint.class, + JwtExceptionFilter.class, + JwtTokenProvider.class, + CustomUserDetailsService.class +}) @TestConfiguration public class TestSecurityConfig { + + @MockBean + private UserQueryService userQueryService; + + @BeforeTestMethod + public void securitySetUp() throws Exception { + given(userQueryService.getDtoById(anyLong())).willReturn(createUserDto()); + } + + private UserDto createUserDto() throws Exception { + Constructor userResponseConstructor = UserDto.class.getDeclaredConstructor( + Long.class, Set.class, String.class, String.class, String.class, + LocalDate.class, Gender.class, String.class, String.class + ); + userResponseConstructor.setAccessible(true); + return userResponseConstructor.newInstance( + 1L, + Set.of(RoleType.USER), + "test@mail.com", + "$2a$abc123", + "kakao-user-id", + LocalDate.of(2024, 1, 1), + Gender.ETC, + "01012345678", + "https://contack-link" + ); + } } diff --git a/src/test/java/com/ajou/hertz/unit/domain/user/controller/UserControllerV1Test.java b/src/test/java/com/ajou/hertz/unit/domain/user/controller/UserControllerV1Test.java index 403d14d..d9d6c9a 100644 --- a/src/test/java/com/ajou/hertz/unit/domain/user/controller/UserControllerV1Test.java +++ b/src/test/java/com/ajou/hertz/unit/domain/user/controller/UserControllerV1Test.java @@ -7,6 +7,7 @@ import java.lang.reflect.Constructor; import java.time.LocalDate; +import java.util.Set; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -14,11 +15,20 @@ import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; import org.springframework.http.MediaType; +import org.springframework.test.context.event.annotation.BeforeTestMethod; import org.springframework.test.web.servlet.MockMvc; -import com.ajou.hertz.config.ControllerTestConfig; +import com.ajou.hertz.common.auth.CustomUserDetailsService; +import com.ajou.hertz.common.auth.JwtAccessDeniedHandler; +import com.ajou.hertz.common.auth.JwtAuthenticationEntryPoint; +import com.ajou.hertz.common.auth.JwtAuthenticationFilter; +import com.ajou.hertz.common.auth.JwtExceptionFilter; +import com.ajou.hertz.common.auth.JwtTokenProvider; +import com.ajou.hertz.common.config.SecurityConfig; import com.ajou.hertz.domain.user.constant.Gender; +import com.ajou.hertz.domain.user.constant.RoleType; import com.ajou.hertz.domain.user.controller.UserControllerV1; import com.ajou.hertz.domain.user.dto.UserDto; import com.ajou.hertz.domain.user.dto.request.SignUpRequest; @@ -26,8 +36,17 @@ import com.ajou.hertz.domain.user.service.UserQueryService; import com.fasterxml.jackson.databind.ObjectMapper; -@DisplayName("[Unit] Controller - User") -@Import(ControllerTestConfig.class) +@DisplayName("[Unit] Controller - User(V1)") +@MockBean(JpaMetamodelMappingContext.class) +@Import({ + SecurityConfig.class, + JwtAccessDeniedHandler.class, + JwtAuthenticationFilter.class, + JwtAuthenticationEntryPoint.class, + JwtExceptionFilter.class, + JwtTokenProvider.class, + CustomUserDetailsService.class +}) @WebMvcTest(controllers = UserControllerV1.class) class UserControllerV1Test { @@ -47,6 +66,11 @@ public UserControllerV1Test(MockMvc mvc, ObjectMapper objectMapper) { this.objectMapper = objectMapper; } + @BeforeTestMethod + public void securitySetUp() throws Exception { + given(userQueryService.getDtoById(anyLong())).willReturn(createUserDto()); + } + @Test void 이메일이_주어지고_주어진_이메일을_사용_중인_회원의_존재_여부를_조회한다() throws Exception { // given @@ -160,12 +184,13 @@ private SignUpRequest createSignUpRequest() throws Exception { private UserDto createUserDto(long id) throws Exception { Constructor userResponseConstructor = UserDto.class.getDeclaredConstructor( - Long.class, String.class, String.class, String.class, + Long.class, Set.class, String.class, String.class, String.class, LocalDate.class, Gender.class, String.class, String.class ); userResponseConstructor.setAccessible(true); return userResponseConstructor.newInstance( id, + Set.of(RoleType.USER), "test@mail.com", "$2a$abc123", "kakao-user-id", @@ -175,4 +200,8 @@ private UserDto createUserDto(long id) throws Exception { "https://contack-link" ); } + + private UserDto createUserDto() throws Exception { + return createUserDto(1L); + } } \ No newline at end of file diff --git a/src/test/java/com/ajou/hertz/unit/domain/user/service/UserCommandServiceTest.java b/src/test/java/com/ajou/hertz/unit/domain/user/service/UserCommandServiceTest.java index f8ff23a..8a5e772 100644 --- a/src/test/java/com/ajou/hertz/unit/domain/user/service/UserCommandServiceTest.java +++ b/src/test/java/com/ajou/hertz/unit/domain/user/service/UserCommandServiceTest.java @@ -5,6 +5,7 @@ import java.lang.reflect.Constructor; import java.time.LocalDate; +import java.util.Set; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -15,6 +16,7 @@ import org.springframework.security.crypto.password.PasswordEncoder; import com.ajou.hertz.domain.user.constant.Gender; +import com.ajou.hertz.domain.user.constant.RoleType; import com.ajou.hertz.domain.user.dto.UserDto; import com.ajou.hertz.domain.user.dto.request.SignUpRequest; import com.ajou.hertz.domain.user.entity.User; @@ -85,12 +87,13 @@ private void verifyEveryMocksShouldHaveNoMoreInteractions() { private User createUser(Long id, String password) throws Exception { Constructor userConstructor = User.class.getDeclaredConstructor( - Long.class, String.class, String.class, String.class, + Long.class, Set.class, String.class, String.class, String.class, LocalDate.class, Gender.class, String.class, String.class ); userConstructor.setAccessible(true); return userConstructor.newInstance( id, + Set.of(RoleType.USER), "test@test.com", password, "kakao-user-id", diff --git a/src/test/java/com/ajou/hertz/unit/domain/user/service/UserQueryServiceTest.java b/src/test/java/com/ajou/hertz/unit/domain/user/service/UserQueryServiceTest.java index 5cae9b1..9c61dcb 100644 --- a/src/test/java/com/ajou/hertz/unit/domain/user/service/UserQueryServiceTest.java +++ b/src/test/java/com/ajou/hertz/unit/domain/user/service/UserQueryServiceTest.java @@ -3,6 +3,11 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; +import java.lang.reflect.Constructor; +import java.time.LocalDate; +import java.util.Optional; +import java.util.Set; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -10,6 +15,11 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.ajou.hertz.domain.user.constant.Gender; +import com.ajou.hertz.domain.user.constant.RoleType; +import com.ajou.hertz.domain.user.dto.UserDto; +import com.ajou.hertz.domain.user.entity.User; +import com.ajou.hertz.domain.user.exception.UserNotFoundByIdException; import com.ajou.hertz.domain.user.repository.UserRepository; import com.ajou.hertz.domain.user.service.UserQueryService; @@ -23,6 +33,38 @@ class UserQueryServiceTest { @Mock private UserRepository userRepository; + @Test + void 유저_id가_주어지고_주어진_id로_유저를_조회하면_조회된_유저_정보가_반환된다() throws Exception { + // given + long userId = 1L; + User expectedResult = createUser(userId); + given(userRepository.findById(userId)).willReturn(Optional.of(expectedResult)); + + // when + UserDto actualResult = sut.getDtoById(userId); + + // then + then(userRepository).should().findById(userId); + verifyEveryMocksShouldHaveNoMoreInteractions(); + assertThat(actualResult) + .hasFieldOrPropertyWithValue("id", expectedResult.getId()); + } + + @Test + void 유저_id가_주어지고_주어진_id로_유저를_조회한다_만약_일치하는_유저가_없다면_예외가_발생한다() { + // given + long userId = 1L; + given(userRepository.findById(userId)).willReturn(Optional.empty()); + + // when + Throwable t = catchThrowable(() -> sut.getDtoById(userId)); + + // then + then(userRepository).should().findById(userId); + verifyEveryMocksShouldHaveNoMoreInteractions(); + assertThat(t).isInstanceOf(UserNotFoundByIdException.class); + } + @Test void 전달된_이메일을_사용_중인_회원의_존재_여부를_조회한다() { // given @@ -42,4 +84,27 @@ class UserQueryServiceTest { private void verifyEveryMocksShouldHaveNoMoreInteractions() { then(userRepository).shouldHaveNoMoreInteractions(); } + + private User createUser(Long id, String email) throws Exception { + Constructor userConstructor = User.class.getDeclaredConstructor( + Long.class, Set.class, String.class, String.class, String.class, + LocalDate.class, Gender.class, String.class, String.class + ); + userConstructor.setAccessible(true); + return userConstructor.newInstance( + id, + Set.of(RoleType.USER), + email, + "password", + "kakao-user-id", + LocalDate.of(2024, 1, 1), + Gender.ETC, + "010-1234-5678", + null + ); + } + + private User createUser(Long id) throws Exception { + return createUser(id, "test@mail.com"); + } } \ No newline at end of file From bf2443d3a96dd56f9891a14f4804c516f6a1202c Mon Sep 17 00:00:00 2001 From: Wo-ogie Date: Sat, 17 Feb 2024 15:19:06 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20#22=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthControllerV1.java | 44 ++++++ .../common/auth/dto/request/LoginRequest.java | 27 ++++ .../dto/response/JwtTokenInfoResponse.java | 30 ++++ .../exception/PasswordMismatchException.java | 11 ++ .../common/auth/service/AuthService.java | 39 ++++++ .../hertz/common/config/SecurityConfig.java | 3 +- .../constant/CustomExceptionType.java | 3 +- .../UserNotFoundByEmailException.java | 10 ++ .../user/repository/UserRepository.java | 4 + .../domain/user/service/UserQueryService.java | 23 +++ .../auth/controller/AuthControllerV1Test.java | 81 +++++++++++ .../common/auth/service/AuthServiceTest.java | 131 ++++++++++++++++++ .../user/service/UserQueryServiceTest.java | 34 +++++ 13 files changed, 438 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/ajou/hertz/common/auth/controller/AuthControllerV1.java create mode 100644 src/main/java/com/ajou/hertz/common/auth/dto/request/LoginRequest.java create mode 100644 src/main/java/com/ajou/hertz/common/auth/dto/response/JwtTokenInfoResponse.java create mode 100644 src/main/java/com/ajou/hertz/common/auth/exception/PasswordMismatchException.java create mode 100644 src/main/java/com/ajou/hertz/common/auth/service/AuthService.java create mode 100644 src/main/java/com/ajou/hertz/domain/user/exception/UserNotFoundByEmailException.java create mode 100644 src/test/java/com/ajou/hertz/unit/common/auth/controller/AuthControllerV1Test.java create mode 100644 src/test/java/com/ajou/hertz/unit/common/auth/service/AuthServiceTest.java diff --git a/src/main/java/com/ajou/hertz/common/auth/controller/AuthControllerV1.java b/src/main/java/com/ajou/hertz/common/auth/controller/AuthControllerV1.java new file mode 100644 index 0000000..cfe424e --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/auth/controller/AuthControllerV1.java @@ -0,0 +1,44 @@ +package com.ajou.hertz.common.auth.controller; + +import static com.ajou.hertz.common.constant.GlobalConstants.*; + +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.ajou.hertz.common.auth.dto.JwtTokenInfoDto; +import com.ajou.hertz.common.auth.dto.request.LoginRequest; +import com.ajou.hertz.common.auth.dto.response.JwtTokenInfoResponse; +import com.ajou.hertz.common.auth.service.AuthService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Tag(name = "로그인 등 인증 관련 API") +@RequiredArgsConstructor +@RequestMapping("/v1/auth") +@RestController +public class AuthControllerV1 { + + private final AuthService authService; + + @Operation( + summary = "로그인", + description = "이메일과 비밀번호를 전달받아 로그인을 진행합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK"), + @ApiResponse(responseCode = "400", description = "[2003] 비밀번호가 일치하지 않는 경우"), + @ApiResponse(responseCode = "404", description = "[2202] 이메일에 해당하는 유저를 찾을 수 없는 경우") + }) + @PostMapping(value = "/login", headers = API_MINOR_VERSION_HEADER_NAME + "=" + 1) + public JwtTokenInfoResponse loginV1_1(@RequestBody @Valid LoginRequest loginRequest) { + JwtTokenInfoDto jwtTokenInfoDto = authService.login(loginRequest); + return JwtTokenInfoResponse.from(jwtTokenInfoDto); + } +} diff --git a/src/main/java/com/ajou/hertz/common/auth/dto/request/LoginRequest.java b/src/main/java/com/ajou/hertz/common/auth/dto/request/LoginRequest.java new file mode 100644 index 0000000..98006fd --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/auth/dto/request/LoginRequest.java @@ -0,0 +1,27 @@ +package com.ajou.hertz.common.auth.dto.request; + +import com.ajou.hertz.common.validator.Password; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public class LoginRequest { + + @Schema(description = "이메일", example = "example@mail.com") + @NotBlank + @Email + private String email; + + @Schema(description = "비밀번호", example = "1q2w3e4r!") + @NotBlank + @Password + private String password; +} diff --git a/src/main/java/com/ajou/hertz/common/auth/dto/response/JwtTokenInfoResponse.java b/src/main/java/com/ajou/hertz/common/auth/dto/response/JwtTokenInfoResponse.java new file mode 100644 index 0000000..1725c21 --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/auth/dto/response/JwtTokenInfoResponse.java @@ -0,0 +1,30 @@ +package com.ajou.hertz.common.auth.dto.response; + +import java.time.LocalDateTime; + +import com.ajou.hertz.common.auth.dto.JwtTokenInfoDto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public class JwtTokenInfoResponse { + + @Schema(description = "Token value", example = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiwicm9sZSI6IlJPTEVfVVNFUiIsImxvZ2luVHlwZSI6IktBS0FPIiwiaWF0IjoxNjc3NDg0NzExLCJleHAiOjE2Nzc1Mjc5MTF9.eM2R_mMRqkPUsMmJN_vm2lAsIGownPJZ6Xu47K6ujrI") + private String token; + + @Schema(description = "Token 만료 시각", example = "2023-02-28T17:13:55.473") + private LocalDateTime expiresAt; + + public static JwtTokenInfoResponse from(JwtTokenInfoDto jwtTokenInfoDto) { + return new JwtTokenInfoResponse( + jwtTokenInfoDto.token(), + jwtTokenInfoDto.expiresAt() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/ajou/hertz/common/auth/exception/PasswordMismatchException.java b/src/main/java/com/ajou/hertz/common/auth/exception/PasswordMismatchException.java new file mode 100644 index 0000000..e2d86e4 --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/auth/exception/PasswordMismatchException.java @@ -0,0 +1,11 @@ +package com.ajou.hertz.common.auth.exception; + +import com.ajou.hertz.common.exception.BadRequestException; +import com.ajou.hertz.common.exception.constant.CustomExceptionType; + +public class PasswordMismatchException extends BadRequestException { + + public PasswordMismatchException() { + super(CustomExceptionType.PASSWORD_MISMATCH); + } +} diff --git a/src/main/java/com/ajou/hertz/common/auth/service/AuthService.java b/src/main/java/com/ajou/hertz/common/auth/service/AuthService.java new file mode 100644 index 0000000..787e4bc --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/auth/service/AuthService.java @@ -0,0 +1,39 @@ +package com.ajou.hertz.common.auth.service; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.ajou.hertz.common.auth.JwtTokenProvider; +import com.ajou.hertz.common.auth.dto.JwtTokenInfoDto; +import com.ajou.hertz.common.auth.dto.request.LoginRequest; +import com.ajou.hertz.common.auth.exception.PasswordMismatchException; +import com.ajou.hertz.domain.user.dto.UserDto; +import com.ajou.hertz.domain.user.service.UserQueryService; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Transactional +@Service +public class AuthService { + + private final UserQueryService userQueryService; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + + /** + * 로그인을 진행한다. + * + * @param loginRequest 로그인에 필요한 정보(이메일, 비밀번호) + * @return 로그인 성공 시 발급한 access token 정보 + * @throws PasswordMismatchException 비밀번호가 일치하지 않는 경우 + */ + public JwtTokenInfoDto login(LoginRequest loginRequest) { + UserDto user = userQueryService.getDtoByEmail(loginRequest.getEmail()); + if (!passwordEncoder.matches(loginRequest.getPassword(), user.getPassword())) { + throw new PasswordMismatchException(); + } + return jwtTokenProvider.createAccessToken(user); + } +} diff --git a/src/main/java/com/ajou/hertz/common/config/SecurityConfig.java b/src/main/java/com/ajou/hertz/common/config/SecurityConfig.java index 4aaf159..1d7708c 100644 --- a/src/main/java/com/ajou/hertz/common/config/SecurityConfig.java +++ b/src/main/java/com/ajou/hertz/common/config/SecurityConfig.java @@ -41,7 +41,8 @@ public class SecurityConfig { private static final Map AUTH_WHITE_LIST = Map.of( "/v*/users", POST, - "/v*/users/existence", GET + "/v*/users/existence", GET, + "/v*/auth/login", POST ); @Bean diff --git a/src/main/java/com/ajou/hertz/common/exception/constant/CustomExceptionType.java b/src/main/java/com/ajou/hertz/common/exception/constant/CustomExceptionType.java index ddd113d..7884203 100644 --- a/src/main/java/com/ajou/hertz/common/exception/constant/CustomExceptionType.java +++ b/src/main/java/com/ajou/hertz/common/exception/constant/CustomExceptionType.java @@ -25,13 +25,14 @@ public enum CustomExceptionType { ACCESS_DENIED(2000, "접근이 거부되었습니다."), UNAUTHORIZED(2001, "유효하지 않은 인증 정보로 인해 인증 과정에서 문제가 발생하였습니다."), TOKEN_VALIDATE(2002, "유효하지 않은 token입니다. Token 값이 잘못되었거나 만료되어 유효하지 않은 경우로 token 갱신이 필요합니다."), - ACCESS_TOKEN_VALIDATE(2003, "유효하지 않은 access token입니다. Token 값이 잘못되었거나 만료되어 유효하지 않은 경우로 token 갱신이 필요합니다."), + PASSWORD_MISMATCH(2003, "비밀번호가 일치하지 않습니다."), /** * 유저 관련 예외 */ USER_EMAIL_DUPLICATION(2200, "이미 다른 회원이 사용 중인 이메일입니다."), USER_NOT_FOUND_BY_ID(2201, "일치하는 회원을 찾을 수 없습니다."), + USER_NOT_FOUND_BY_EMAIL(2202, "일치하는 회원을 찾을 수 없습니다."); private final Integer code; private final String message; diff --git a/src/main/java/com/ajou/hertz/domain/user/exception/UserNotFoundByEmailException.java b/src/main/java/com/ajou/hertz/domain/user/exception/UserNotFoundByEmailException.java new file mode 100644 index 0000000..02a0b25 --- /dev/null +++ b/src/main/java/com/ajou/hertz/domain/user/exception/UserNotFoundByEmailException.java @@ -0,0 +1,10 @@ +package com.ajou.hertz.domain.user.exception; + +import com.ajou.hertz.common.exception.NotFoundException; +import com.ajou.hertz.common.exception.constant.CustomExceptionType; + +public class UserNotFoundByEmailException extends NotFoundException { + public UserNotFoundByEmailException(String email) { + super(CustomExceptionType.USER_NOT_FOUND_BY_EMAIL, "email=" + email); + } +} diff --git a/src/main/java/com/ajou/hertz/domain/user/repository/UserRepository.java b/src/main/java/com/ajou/hertz/domain/user/repository/UserRepository.java index b0655d1..9be5a9b 100644 --- a/src/main/java/com/ajou/hertz/domain/user/repository/UserRepository.java +++ b/src/main/java/com/ajou/hertz/domain/user/repository/UserRepository.java @@ -1,10 +1,14 @@ package com.ajou.hertz.domain.user.repository; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import com.ajou.hertz.domain.user.entity.User; public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); + boolean existsByEmail(String email); } diff --git a/src/main/java/com/ajou/hertz/domain/user/service/UserQueryService.java b/src/main/java/com/ajou/hertz/domain/user/service/UserQueryService.java index e524290..0d5a4be 100644 --- a/src/main/java/com/ajou/hertz/domain/user/service/UserQueryService.java +++ b/src/main/java/com/ajou/hertz/domain/user/service/UserQueryService.java @@ -5,6 +5,7 @@ import com.ajou.hertz.domain.user.dto.UserDto; import com.ajou.hertz.domain.user.entity.User; +import com.ajou.hertz.domain.user.exception.UserNotFoundByEmailException; import com.ajou.hertz.domain.user.exception.UserNotFoundByIdException; import com.ajou.hertz.domain.user.repository.UserRepository; @@ -28,6 +29,17 @@ private User getById(Long id) { return userRepository.findById(id).orElseThrow(() -> new UserNotFoundByIdException(id)); } + /** + * Email로 user entity를 조회한다. + * + * @param email 조회하고자 하는 user의 email + * @return 조회한 user entity + * @throws UserNotFoundByEmailException 일치하는 유저를 찾지 못한 경우 + */ + private User getByEmail(String email) { + return userRepository.findByEmail(email).orElseThrow(() -> new UserNotFoundByEmailException(email)); + } + /** * 유저 id로 유저를 조회한다. * @@ -39,6 +51,17 @@ public UserDto getDtoById(Long id) { return UserDto.from(getById(id)); } + /** + * Email로 user 정보를 조회한다. + * + * @param email 조회하고자 하는 user의 email + * @return 조회한 유저 정보가 담긴 dto + * @throws UserNotFoundByEmailException 일치하는 유저를 찾지 못한 경우 + */ + public UserDto getDtoByEmail(String email) { + return UserDto.from(getByEmail(email)); + } + /** * 전달된 email을 사용 중인 회원의 존재 여부를 조회한다. * diff --git a/src/test/java/com/ajou/hertz/unit/common/auth/controller/AuthControllerV1Test.java b/src/test/java/com/ajou/hertz/unit/common/auth/controller/AuthControllerV1Test.java new file mode 100644 index 0000000..c23fa65 --- /dev/null +++ b/src/test/java/com/ajou/hertz/unit/common/auth/controller/AuthControllerV1Test.java @@ -0,0 +1,81 @@ +package com.ajou.hertz.unit.common.auth.controller; + +import static com.ajou.hertz.common.constant.GlobalConstants.*; +import static org.mockito.BDDMockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.lang.reflect.Constructor; +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import com.ajou.hertz.common.auth.controller.AuthControllerV1; +import com.ajou.hertz.common.auth.dto.JwtTokenInfoDto; +import com.ajou.hertz.common.auth.dto.request.LoginRequest; +import com.ajou.hertz.common.auth.service.AuthService; +import com.ajou.hertz.config.ControllerTestConfig; +import com.fasterxml.jackson.databind.ObjectMapper; + +@DisplayName("[Unit] Controller - Auth(V1)") +@Import(ControllerTestConfig.class) +@WebMvcTest(controllers = AuthControllerV1.class) +class AuthControllerV1Test { + + @MockBean + private AuthService authService; + + private final MockMvc mvc; + + private final ObjectMapper objectMapper; + + @Autowired + public AuthControllerV1Test(MockMvc mvc, ObjectMapper objectMapper) { + this.mvc = mvc; + this.objectMapper = objectMapper; + } + + @Test + void 로그인_정보가_주어지고_로그인을_진행한다() throws Exception { + // given + LoginRequest loginRequest = createLoginRequest(); + JwtTokenInfoDto expectedResult = createJwtTokenInfoDto(); + given(authService.login(any(LoginRequest.class))).willReturn(expectedResult); + + // when & then + mvc.perform( + post("/v1/auth/login") + .header(API_MINOR_VERSION_HEADER_NAME, 1) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequest)) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.token").value(expectedResult.token())); + then(authService).should().login(any(LoginRequest.class)); + then(authService).shouldHaveNoMoreInteractions(); + } + + private LoginRequest createLoginRequest() throws Exception { + Constructor loginRequestConstructor = + LoginRequest.class.getDeclaredConstructor(String.class, String.class); + loginRequestConstructor.setAccessible(true); + return loginRequestConstructor.newInstance( + "test@mail.com", + "1q2w3e4r!" + ); + } + + private JwtTokenInfoDto createJwtTokenInfoDto() { + return new JwtTokenInfoDto( + "access-token", + LocalDateTime.of(2024, 1, 1, 0, 0) + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/ajou/hertz/unit/common/auth/service/AuthServiceTest.java b/src/test/java/com/ajou/hertz/unit/common/auth/service/AuthServiceTest.java new file mode 100644 index 0000000..cc12f39 --- /dev/null +++ b/src/test/java/com/ajou/hertz/unit/common/auth/service/AuthServiceTest.java @@ -0,0 +1,131 @@ +package com.ajou.hertz.unit.common.auth.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.lang.reflect.Constructor; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Set; + +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 org.springframework.security.crypto.password.PasswordEncoder; + +import com.ajou.hertz.common.auth.JwtTokenProvider; +import com.ajou.hertz.common.auth.dto.JwtTokenInfoDto; +import com.ajou.hertz.common.auth.dto.request.LoginRequest; +import com.ajou.hertz.common.auth.exception.PasswordMismatchException; +import com.ajou.hertz.common.auth.service.AuthService; +import com.ajou.hertz.domain.user.constant.Gender; +import com.ajou.hertz.domain.user.constant.RoleType; +import com.ajou.hertz.domain.user.dto.UserDto; +import com.ajou.hertz.domain.user.service.UserQueryService; + +@DisplayName("[Unit] Service - Auth") +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @InjectMocks + private AuthService sut; + + @Mock + private UserQueryService userQueryService; + + @Mock + private PasswordEncoder passwordEncoder; + + @Mock + private JwtTokenProvider jwtTokenProvider; + + @Test + void 이메일과_비밀번호가_주어지고_로그인을_진행한다() throws Exception { + // given + LoginRequest loginRequest = createLoginRequest(); + UserDto userDto = createUserDto(); + JwtTokenInfoDto expectedResult = createJwtTokenInfoDto(); + given(userQueryService.getDtoByEmail(loginRequest.getEmail())).willReturn(userDto); + given(passwordEncoder.matches(loginRequest.getPassword(), userDto.getPassword())).willReturn(true); + given(jwtTokenProvider.createAccessToken(userDto)).willReturn(expectedResult); + + // when + JwtTokenInfoDto actualResult = sut.login(loginRequest); + + // then + then(userQueryService).should().getDtoByEmail(loginRequest.getEmail()); + then(passwordEncoder).should().matches(loginRequest.getPassword(), userDto.getPassword()); + then(jwtTokenProvider).should().createAccessToken(userDto); + verifyEveryMocksShouldHaveNoMoreInteractions(); + assertThat(actualResult) + .hasFieldOrPropertyWithValue("token", expectedResult.token()) + .hasFieldOrPropertyWithValue("expiresAt", expectedResult.expiresAt()); + } + + @Test + void 이메일과_비밀번호가_주어지고_로그인을_진행한다_이때_비밀번호가_일치하지_않으면_예외가_발생한다() throws Exception { + // given + LoginRequest loginRequest = createLoginRequest(); + UserDto userDto = createUserDto(); + given(userQueryService.getDtoByEmail(loginRequest.getEmail())).willReturn(userDto); + given(passwordEncoder.matches(loginRequest.getPassword(), userDto.getPassword())).willReturn(false); + + // when + Throwable t = catchThrowable(() -> sut.login(loginRequest)); + + // then + then(userQueryService).should().getDtoByEmail(loginRequest.getEmail()); + then(passwordEncoder).should().matches(loginRequest.getPassword(), userDto.getPassword()); + verifyEveryMocksShouldHaveNoMoreInteractions(); + assertThat(t).isInstanceOf(PasswordMismatchException.class); + } + + private void verifyEveryMocksShouldHaveNoMoreInteractions() { + then(userQueryService).shouldHaveNoMoreInteractions(); + then(passwordEncoder).shouldHaveNoMoreInteractions(); + then(jwtTokenProvider).shouldHaveNoMoreInteractions(); + } + + private LoginRequest createLoginRequest() throws Exception { + Constructor loginRequestConstructor = + LoginRequest.class.getDeclaredConstructor(String.class, String.class); + loginRequestConstructor.setAccessible(true); + return loginRequestConstructor.newInstance( + "test@mail.com", + "encoded-password" + ); + } + + private JwtTokenInfoDto createJwtTokenInfoDto() { + return new JwtTokenInfoDto( + "access-token", + LocalDateTime.of(2024, 1, 1, 0, 0) + ); + } + + private UserDto createUserDto(long id) throws Exception { + Constructor userResponseConstructor = UserDto.class.getDeclaredConstructor( + Long.class, Set.class, String.class, String.class, String.class, + LocalDate.class, Gender.class, String.class, String.class + ); + userResponseConstructor.setAccessible(true); + return userResponseConstructor.newInstance( + id, + Set.of(RoleType.USER), + "test@mail.com", + "$2a$abc123", + "kakao-user-id", + LocalDate.of(2024, 1, 1), + Gender.ETC, + "01012345678", + "https://contack-link" + ); + } + + private UserDto createUserDto() throws Exception { + return createUserDto(1L); + } +} \ No newline at end of file diff --git a/src/test/java/com/ajou/hertz/unit/domain/user/service/UserQueryServiceTest.java b/src/test/java/com/ajou/hertz/unit/domain/user/service/UserQueryServiceTest.java index 9c61dcb..c0ab103 100644 --- a/src/test/java/com/ajou/hertz/unit/domain/user/service/UserQueryServiceTest.java +++ b/src/test/java/com/ajou/hertz/unit/domain/user/service/UserQueryServiceTest.java @@ -19,6 +19,7 @@ import com.ajou.hertz.domain.user.constant.RoleType; import com.ajou.hertz.domain.user.dto.UserDto; import com.ajou.hertz.domain.user.entity.User; +import com.ajou.hertz.domain.user.exception.UserNotFoundByEmailException; import com.ajou.hertz.domain.user.exception.UserNotFoundByIdException; import com.ajou.hertz.domain.user.repository.UserRepository; import com.ajou.hertz.domain.user.service.UserQueryService; @@ -65,6 +66,39 @@ class UserQueryServiceTest { assertThat(t).isInstanceOf(UserNotFoundByIdException.class); } + @Test + void 이메일이_주어지고_주어진_이메일로_유저를_조회하면_조회된_유저_정보가_반환된다() throws Exception { + // given + String email = "test@mail.com"; + User expectedResult = createUser(1L, email); + given(userRepository.findByEmail(email)).willReturn(Optional.of(expectedResult)); + + // when + UserDto actualResult = sut.getDtoByEmail(email); + + // then + then(userRepository).should().findByEmail(email); + verifyEveryMocksShouldHaveNoMoreInteractions(); + assertThat(actualResult) + .hasFieldOrPropertyWithValue("id", expectedResult.getId()) + .hasFieldOrPropertyWithValue("email", expectedResult.getEmail()); + } + + @Test + void 이메일이_주어지고_주어진_이메일로_유저를_조회한다_만약_일치하는_유저가_없다면_예외가_발생한다() { + // given + String email = "test@mail.com"; + given(userRepository.findByEmail(email)).willReturn(Optional.empty()); + + // when + Throwable t = catchThrowable(() -> sut.getDtoByEmail(email)); + + // then + then(userRepository).should().findByEmail(email); + verifyEveryMocksShouldHaveNoMoreInteractions(); + assertThat(t).isInstanceOf(UserNotFoundByEmailException.class); + } + @Test void 전달된_이메일을_사용_중인_회원의_존재_여부를_조회한다() { // given From ea3479b6bfa5ed3532e879d7e6c38145c2057388 Mon Sep 17 00:00:00 2001 From: Wo-ogie Date: Sat, 17 Feb 2024 15:19:31 +0900 Subject: [PATCH 7/7] =?UTF-8?q?refactor:=20#22=20`User`=20entity=20indexin?= =?UTF-8?q?g=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ajou/hertz/domain/user/entity/User.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/ajou/hertz/domain/user/entity/User.java b/src/main/java/com/ajou/hertz/domain/user/entity/User.java index fdc8881..6d46a59 100644 --- a/src/main/java/com/ajou/hertz/domain/user/entity/User.java +++ b/src/main/java/com/ajou/hertz/domain/user/entity/User.java @@ -11,10 +11,14 @@ import com.ajou.hertz.domain.user.converter.RoleTypesConverter; import jakarta.persistence.Column; +import jakarta.persistence.Convert; 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.Index; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -24,7 +28,14 @@ @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter -@Table(name = "users") +@Table( + name = "users", + indexes = { + @Index(name = "idx__user__email", columnList = "email"), + @Index(name = "idx__user__kakao_uid", columnList = "kakaoUid"), + @Index(name = "idx__user__phone", columnList = "phone") + } +) @Entity public class User extends TimeTrackedBaseEntity {