diff --git a/build.gradle b/build.gradle index 19739c3..310d899 100644 --- a/build.gradle +++ b/build.gradle @@ -14,6 +14,14 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'com.auth0:java-jwt:4.2.0' + + runtimeOnly 'com.h2database:h2' + + testImplementation 'org.springframework.security:spring-security-test' testImplementation 'org.springframework.boot:spring-boot-starter-test' } diff --git a/src/main/java/prgms/lecture/order_management/SpringOrderManagementApplication.java b/src/main/java/prgms/lecture/order_management/SpringOrderManagementApplication.java index a6859c4..598ce88 100644 --- a/src/main/java/prgms/lecture/order_management/SpringOrderManagementApplication.java +++ b/src/main/java/prgms/lecture/order_management/SpringOrderManagementApplication.java @@ -2,7 +2,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; - +// @SpringBootApplication public class SpringOrderManagementApplication { diff --git a/src/main/java/prgms/lecture/order_management/config/ServiceConfigure.java b/src/main/java/prgms/lecture/order_management/config/ServiceConfigure.java new file mode 100644 index 0000000..6316db5 --- /dev/null +++ b/src/main/java/prgms/lecture/order_management/config/ServiceConfigure.java @@ -0,0 +1,16 @@ +package prgms.lecture.order_management.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import prgms.lecture.order_management.security.Jwt; +import prgms.lecture.order_management.security.JwtTokenConfigure; + +@Configuration +public class ServiceConfigure { + + @Bean + public Jwt jwt(JwtTokenConfigure jwtTokenConfigure) { + return new Jwt(jwtTokenConfigure.getIssuer(), jwtTokenConfigure.getClientSecret(), jwtTokenConfigure.getExpirySeconds()); + } + +} \ No newline at end of file diff --git a/src/main/java/prgms/lecture/order_management/config/WebSecurityConfigure.java b/src/main/java/prgms/lecture/order_management/config/WebSecurityConfigure.java new file mode 100644 index 0000000..05d8297 --- /dev/null +++ b/src/main/java/prgms/lecture/order_management/config/WebSecurityConfigure.java @@ -0,0 +1,96 @@ +package prgms.lecture.order_management.config; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.builders.WebSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import prgms.lecture.order_management.security.*; +import prgms.lecture.order_management.user.domain.Role; +import prgms.lecture.order_management.user.service.UserService; + +@Configuration +@EnableWebSecurity +public class WebSecurityConfigure extends WebSecurityConfigurerAdapter { + + private final Jwt jwt; + + private final JwtTokenConfigure jwtTokenConfigure; + + private final JwtAccessDeniedHandler accessDeniedHandler; + + private final EntryPointUnauthorizedHandler unauthorizedHandler; + + public WebSecurityConfigure(Jwt jwt, JwtTokenConfigure jwtTokenConfigure, JwtAccessDeniedHandler accessDeniedHandler, EntryPointUnauthorizedHandler unauthorizedHandler) { + this.jwt = jwt; + this.jwtTokenConfigure = jwtTokenConfigure; + this.accessDeniedHandler = accessDeniedHandler; + this.unauthorizedHandler = unauthorizedHandler; + } + + @Bean + public JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter() { + return new JwtAuthenticationTokenFilter(jwtTokenConfigure.getHeader(), jwt); + } + + @Override + public void configure(WebSecurity web) { + web.ignoring().antMatchers("/webjars/**", "/static/**", "/templates/**", "/h2/**"); + } + + @Autowired + public void configureAuthentication(AuthenticationManagerBuilder builder, JwtAuthenticationProvider authenticationProvider) { + builder.authenticationProvider(authenticationProvider); + } + + @Bean + public JwtAuthenticationProvider jwtAuthenticationProvider(UserService userService) { + return new JwtAuthenticationProvider(userService); + } + + @Bean + @Override + public AuthenticationManager authenticationManagerBean() throws Exception { + return super.authenticationManagerBean(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Override + protected void configure(HttpSecurity http) throws Exception { + http + .csrf() + .disable() + .headers() + .disable() + .exceptionHandling() + .accessDeniedHandler(accessDeniedHandler) + .authenticationEntryPoint(unauthorizedHandler) + .and() + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and() + .authorizeRequests() + .antMatchers("/api/users/login").permitAll() + .antMatchers("/api/products/**").permitAll() + .antMatchers("/api/**").hasRole(Role.USER.name()) + .anyRequest().permitAll() + .and() + .formLogin() + .disable(); + http + .addFilterBefore(jwtAuthenticationTokenFilter(), UsernamePasswordAuthenticationFilter.class); + } + +} \ No newline at end of file diff --git a/src/main/java/prgms/lecture/order_management/exception/GeneralExceptionHandler.java b/src/main/java/prgms/lecture/order_management/exception/GeneralExceptionHandler.java new file mode 100644 index 0000000..b815a30 --- /dev/null +++ b/src/main/java/prgms/lecture/order_management/exception/GeneralExceptionHandler.java @@ -0,0 +1,84 @@ +package prgms.lecture.order_management.exception; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.HttpMediaTypeException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.servlet.NoHandlerFoundException; +import prgms.lecture.order_management.utils.ApiUtils; + +import javax.validation.ConstraintViolationException; + +import static prgms.lecture.order_management.utils.ApiUtils.error; + + +@ControllerAdvice +public class GeneralExceptionHandler { + + private final Logger log = LoggerFactory.getLogger(getClass()); + + private ResponseEntity> newResponse(Throwable throwable, HttpStatus status) { + return newResponse(throwable.getMessage(), status); + } + + private ResponseEntity> newResponse(String message, HttpStatus status) { + HttpHeaders headers = new HttpHeaders(); + headers.add("Content-Type", "application/json"); + return new ResponseEntity<>(error(message, status), headers, status); + } + + // 필요한 경우 적절한 예외타입을 선언하고 newResponse 메소드를 통해 응답을 생성하도록 합니다. + + @ExceptionHandler({ + NoHandlerFoundException.class, + NotFoundException.class + }) + public ResponseEntity handleNotFoundException(Exception e) { + return newResponse(e, HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(UnauthorizedException.class) + public ResponseEntity handleUnauthorizedException(Exception e) { + return newResponse(e, HttpStatus.UNAUTHORIZED); + } + + @ExceptionHandler({ + IllegalArgumentException.class, + IllegalStateException.class, + ConstraintViolationException.class, + MethodArgumentNotValidException.class + }) + public ResponseEntity handleBadRequestException(Exception e) { + log.debug("Bad request exception occurred: {}", e.getMessage(), e); + if (e instanceof MethodArgumentNotValidException) { + return newResponse( + ((MethodArgumentNotValidException) e).getBindingResult().getAllErrors().get(0).getDefaultMessage(), + HttpStatus.BAD_REQUEST + ); + } + return newResponse(e, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(HttpMediaTypeException.class) + public ResponseEntity handleHttpMediaTypeException(Exception e) { + return newResponse(e, HttpStatus.UNSUPPORTED_MEDIA_TYPE); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity handleMethodNotAllowedException(Exception e) { + return newResponse(e, HttpStatus.METHOD_NOT_ALLOWED); + } + + @ExceptionHandler({Exception.class, RuntimeException.class}) + public ResponseEntity handleException(Exception e) { + log.error("Unexpected exception occurred: {}", e.getMessage(), e); + return newResponse(e, HttpStatus.INTERNAL_SERVER_ERROR); + } + +} diff --git a/src/main/java/prgms/lecture/order_management/exception/NotFoundException.java b/src/main/java/prgms/lecture/order_management/exception/NotFoundException.java new file mode 100644 index 0000000..97490ed --- /dev/null +++ b/src/main/java/prgms/lecture/order_management/exception/NotFoundException.java @@ -0,0 +1,13 @@ +package prgms.lecture.order_management.exception; + +public class NotFoundException extends RuntimeException { + + public NotFoundException(String message) { + super(message); + } + + public NotFoundException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/src/main/java/prgms/lecture/order_management/exception/UnauthorizedException.java b/src/main/java/prgms/lecture/order_management/exception/UnauthorizedException.java new file mode 100644 index 0000000..30d6df4 --- /dev/null +++ b/src/main/java/prgms/lecture/order_management/exception/UnauthorizedException.java @@ -0,0 +1,13 @@ +package prgms.lecture.order_management.exception; + +public class UnauthorizedException extends RuntimeException { + + public UnauthorizedException(String message) { + super(message); + } + + public UnauthorizedException(String message, Throwable cause) { + super(message, cause); + } + +} \ No newline at end of file diff --git a/src/main/java/prgms/lecture/order_management/security/Claims.java b/src/main/java/prgms/lecture/order_management/security/Claims.java new file mode 100644 index 0000000..65c72a1 --- /dev/null +++ b/src/main/java/prgms/lecture/order_management/security/Claims.java @@ -0,0 +1,54 @@ +package prgms.lecture.order_management.security; + +import com.auth0.jwt.interfaces.Claim; +import com.auth0.jwt.interfaces.DecodedJWT; + +import java.util.Arrays; +import java.util.Date; + +public class Claims { + Long userKey; + String name; + String[] roles; + Date iat; + Date exp; + + private Claims() {} + + Claims(DecodedJWT decodedJWT) { + Claim userKey = decodedJWT.getClaim("userKey"); + Claim name = decodedJWT.getClaim("name"); + Claim roles = decodedJWT.getClaim("roles"); + if (!userKey.isNull()) { + this.userKey = userKey.asLong(); + } + if (!name.isNull()) { + this.name = name.asString(); + } + if (!roles.isNull()) { + this.roles = roles.asArray(String.class); + } + this.iat = decodedJWT.getIssuedAt(); + this.exp = decodedJWT.getExpiresAt(); + } + + public static Claims of(long userKey, String name, String[] roles) { + Claims claims = new Claims(); + claims.userKey = userKey; + claims.name = name; + claims.roles = roles; + return claims; + } + + @Override + public String toString() { + return "Claims{" + + "userKey=" + userKey + + ", name='" + name + '\'' + + ", roles=" + Arrays.toString(roles) + + ", iat=" + iat + + ", exp=" + exp + + '}'; + } +} + diff --git a/src/main/java/prgms/lecture/order_management/security/EntryPointUnauthorizedHandler.java b/src/main/java/prgms/lecture/order_management/security/EntryPointUnauthorizedHandler.java new file mode 100644 index 0000000..3d474e6 --- /dev/null +++ b/src/main/java/prgms/lecture/order_management/security/EntryPointUnauthorizedHandler.java @@ -0,0 +1,26 @@ +package prgms.lecture.order_management.security; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +@Component +public class EntryPointUnauthorizedHandler implements AuthenticationEntryPoint { + + private static final String _401 = "{\"success\":false,\"response\":null,\"error\":{\"message\":\"Unauthorized\",\"status\":401}}"; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write(_401); + response.getWriter().flush(); + response.getWriter().close(); + } +} diff --git a/src/main/java/prgms/lecture/order_management/security/Jwt.java b/src/main/java/prgms/lecture/order_management/security/Jwt.java new file mode 100644 index 0000000..3ada155 --- /dev/null +++ b/src/main/java/prgms/lecture/order_management/security/Jwt.java @@ -0,0 +1,71 @@ +package prgms.lecture.order_management.security; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.JWTCreator; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTVerificationException; +import com.auth0.jwt.interfaces.JWTVerifier; +import org.springframework.stereotype.Component; + +import java.util.Date; + +public final class Jwt { + private final String issuer; + + private final String clientSecret; + + private final int expirySeconds; + + private final Algorithm algorithm; + + private final JWTVerifier jwtVerifier; + + public Jwt(String issuer, String clientSecret, int expirySeconds) { + this.issuer = issuer; + this.clientSecret = clientSecret; + this.expirySeconds = expirySeconds; + this.algorithm = Algorithm.HMAC512(clientSecret); + this.jwtVerifier = JWT.require(algorithm) + .withIssuer(issuer) + .build(); + } + + public String create(Claims claims) { + Date now = new Date(); + JWTCreator.Builder builder = com.auth0.jwt.JWT.create(); + builder.withIssuer(issuer); + builder.withIssuedAt(now); + if (expirySeconds > 0) { + builder.withExpiresAt(new Date(now.getTime() + expirySeconds * 1_000L)); + } + builder.withClaim("userKey", claims.userKey); + builder.withClaim("name", claims.name); + builder.withArrayClaim("roles", claims.roles); + return builder.sign(algorithm); + } + + public Claims verify(String token) throws JWTVerificationException { + return new Claims(jwtVerifier.verify(token)); + } + + public String getIssuer() { + return issuer; + } + + public String getClientSecret() { + return clientSecret; + } + + public int getExpirySeconds() { + return expirySeconds; + } + + public Algorithm getAlgorithm() { + return algorithm; + } + + public JWTVerifier getJwtVerifier() { + return jwtVerifier; + } + +} diff --git a/src/main/java/prgms/lecture/order_management/security/JwtAccessDeniedHandler.java b/src/main/java/prgms/lecture/order_management/security/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..3da064b --- /dev/null +++ b/src/main/java/prgms/lecture/order_management/security/JwtAccessDeniedHandler.java @@ -0,0 +1,25 @@ +package prgms.lecture.order_management.security; + +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + static String _403 = "{\"success\":false,\"response\":null,\"error\":{\"message\":\"Forbidden\",\"status\":403}}"; + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setHeader("content-type", "application/json"); + response.getWriter().write(_403); + response.getWriter().flush(); + response.getWriter().close(); + } + +} \ No newline at end of file diff --git a/src/main/java/prgms/lecture/order_management/security/JwtAuthentication.java b/src/main/java/prgms/lecture/order_management/security/JwtAuthentication.java new file mode 100644 index 0000000..bf3cbad --- /dev/null +++ b/src/main/java/prgms/lecture/order_management/security/JwtAuthentication.java @@ -0,0 +1,19 @@ +package prgms.lecture.order_management.security; + +import org.springframework.util.Assert; + +public class JwtAuthentication { + + public final Long id; + + public final String name; + + public JwtAuthentication(Long id, String name) { + Assert.notNull(id, "id must be provided"); + Assert.notNull(name, "name must be provided"); + + this.id = id; + this.name = name; + } + +} diff --git a/src/main/java/prgms/lecture/order_management/security/JwtAuthenticationProvider.java b/src/main/java/prgms/lecture/order_management/security/JwtAuthenticationProvider.java new file mode 100644 index 0000000..cf7a448 --- /dev/null +++ b/src/main/java/prgms/lecture/order_management/security/JwtAuthenticationProvider.java @@ -0,0 +1,61 @@ +package prgms.lecture.order_management.security; + +import org.springframework.dao.DataAccessException; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import prgms.lecture.order_management.exception.NotFoundException; +import prgms.lecture.order_management.user.domain.Email; +import prgms.lecture.order_management.user.domain.Role; +import prgms.lecture.order_management.user.domain.User; +import prgms.lecture.order_management.user.service.UserService; + +import static org.springframework.security.core.authority.AuthorityUtils.createAuthorityList; +import static org.springframework.util.TypeUtils.isAssignable; + +public class JwtAuthenticationProvider implements AuthenticationProvider { + + private final UserService userService; + + public JwtAuthenticationProvider(UserService userService) { + this.userService = userService; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + JwtAuthenticationToken authenticationToken = (JwtAuthenticationToken) authentication; + return processUserAuthentication( + Email.of(String.valueOf(authenticationToken.getPrincipal())), + authenticationToken.getCredentials() + ); + } + + private Authentication processUserAuthentication(Email email, String password) { + try { + User user = userService.login(email, password); + JwtAuthenticationToken authenticated = + new JwtAuthenticationToken( + new JwtAuthentication(user.getSeq(), user.getName()), + null, + createAuthorityList(Role.USER.value()) + ); + authenticated.setDetails(user); + return authenticated; + } catch (NotFoundException e) { + throw new UsernameNotFoundException(e.getMessage()); + } catch (IllegalArgumentException e) { + throw new BadCredentialsException(e.getMessage()); + } catch (DataAccessException e) { + throw new AuthenticationServiceException(e.getMessage(), e); + } + } + + @Override + public boolean supports(Class authentication) { + return isAssignable(JwtAuthenticationToken.class, authentication); + } + +} \ No newline at end of file diff --git a/src/main/java/prgms/lecture/order_management/security/JwtAuthenticationToken.java b/src/main/java/prgms/lecture/order_management/security/JwtAuthenticationToken.java new file mode 100644 index 0000000..df722bc --- /dev/null +++ b/src/main/java/prgms/lecture/order_management/security/JwtAuthenticationToken.java @@ -0,0 +1,76 @@ +package prgms.lecture.order_management.security; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; +import java.util.Objects; + +public class JwtAuthenticationToken extends AbstractAuthenticationToken { + + private final Object principal; + + private String credentials; + + public JwtAuthenticationToken(String principal, String credentials) { + super(null); + super.setAuthenticated(false); + + this.principal = principal; + this.credentials = credentials; + } + + public JwtAuthenticationToken(Object principal, String credentials, Collection authorities) { + super(authorities); + super.setAuthenticated(true); + + this.principal = principal; + this.credentials = credentials; + } + + @Override + public Object getPrincipal() { + return principal; + } + + @Override + public String getCredentials() { + return credentials; + } + + @Override + public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException { + if (isAuthenticated) { + throw new IllegalArgumentException("Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead"); + } + super.setAuthenticated(false); + } + + @Override + public void eraseCredentials() { + super.eraseCredentials(); + credentials = null; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + JwtAuthenticationToken that = (JwtAuthenticationToken) o; + return Objects.equals(principal, that.principal) && Objects.equals(credentials, that.credentials); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), principal, credentials); + } + + @Override + public String toString() { + return "JwtAuthenticationToken{" + + "principal=" + principal + + ", credentials='" + credentials + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/prgms/lecture/order_management/security/JwtAuthenticationTokenFilter.java b/src/main/java/prgms/lecture/order_management/security/JwtAuthenticationTokenFilter.java new file mode 100644 index 0000000..d6870cf --- /dev/null +++ b/src/main/java/prgms/lecture/order_management/security/JwtAuthenticationTokenFilter.java @@ -0,0 +1,113 @@ +package prgms.lecture.order_management.security; + + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.web.filter.GenericFilterBean; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static java.util.Objects.nonNull; +import static org.apache.logging.log4j.util.Strings.isNotEmpty; + +public class JwtAuthenticationTokenFilter extends GenericFilterBean { + + private static final Pattern BEARER = Pattern.compile("^Bearer$", Pattern.CASE_INSENSITIVE); + + private static final Logger log = LoggerFactory.getLogger(JwtAuthenticationTokenFilter.class); + + private final String headerKey; + + private final Jwt jwt; + + public JwtAuthenticationTokenFilter(String headerKey, Jwt jwt) { + this.headerKey = headerKey; + this.jwt = jwt; + } + + @Override + public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest request = (HttpServletRequest) req; + HttpServletResponse response = (HttpServletResponse) res; + + if (SecurityContextHolder.getContext().getAuthentication() == null) { + String authorizationToken = obtainAuthorizationToken(request); + if (authorizationToken != null) { + try { + Claims claims = verify(authorizationToken); + log.debug("Jwt parse result: {}", claims); + + Long userKey = claims.userKey; + String name = claims.name; + List authorities = obtainAuthorities(claims); + + if (nonNull(userKey) && isNotEmpty(name) && !authorities.isEmpty()) { + JwtAuthenticationToken authentication = + new JwtAuthenticationToken(new JwtAuthentication(userKey, name), null, authorities); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception e) { + log.warn("Jwt processing failed: {}", e.getMessage()); + } + } + } else { + log.debug("SecurityContextHolder not populated with security token, as it already contained: '{}'", + SecurityContextHolder.getContext().getAuthentication()); + } + + chain.doFilter(request, response); + } + + private List obtainAuthorities(Claims claims) { + String[] roles = claims.roles; + + if (roles == null || roles.length == 0) { + return Collections.emptyList(); + } + + return Arrays.stream(roles) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + } + + private String obtainAuthorizationToken(HttpServletRequest request) { + String token = request.getHeader(headerKey); + if (token != null) { + if (log.isDebugEnabled()) + log.debug("Jwt authorization api detected: {}", token); + token = URLDecoder.decode(token, StandardCharsets.UTF_8); + String[] parts = token.split(" "); + if (parts.length == 2) { + String scheme = parts[0]; + String credentials = parts[1]; + return BEARER.matcher(scheme).matches() ? credentials : null; + } + } + + return null; + } + + private Claims verify(String token) { + return jwt.verify(token); + } + +} \ No newline at end of file diff --git a/src/main/java/prgms/lecture/order_management/security/JwtTokenConfigure.java b/src/main/java/prgms/lecture/order_management/security/JwtTokenConfigure.java new file mode 100644 index 0000000..89a942a --- /dev/null +++ b/src/main/java/prgms/lecture/order_management/security/JwtTokenConfigure.java @@ -0,0 +1,59 @@ +package prgms.lecture.order_management.security; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "jwt.token") +public class JwtTokenConfigure { + + private String header; + + private String issuer; + + private String clientSecret; + + private int expirySeconds; + + public String getHeader() { + return header; + } + + public void setHeader(String header) { + this.header = header; + } + + public String getIssuer() { + return issuer; + } + + public void setIssuer(String issuer) { + this.issuer = issuer; + } + + public String getClientSecret() { + return clientSecret; + } + + public void setClientSecret(String clientSecret) { + this.clientSecret = clientSecret; + } + + public int getExpirySeconds() { + return expirySeconds; + } + + public void setExpirySeconds(int expirySeconds) { + this.expirySeconds = expirySeconds; + } + + @Override + public String toString() { + return "JwtTokenConfigure{" + + "header='" + header + '\'' + + ", issuer='" + issuer + '\'' + + ", clientSecret='" + clientSecret + '\'' + + ", expirySeconds=" + expirySeconds + + '}'; + } +} diff --git a/src/main/java/prgms/lecture/order_management/user/controller/UserController.java b/src/main/java/prgms/lecture/order_management/user/controller/UserController.java new file mode 100644 index 0000000..18dba41 --- /dev/null +++ b/src/main/java/prgms/lecture/order_management/user/controller/UserController.java @@ -0,0 +1,75 @@ +package prgms.lecture.order_management.user.controller; + +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import prgms.lecture.order_management.exception.NotFoundException; +import prgms.lecture.order_management.exception.UnauthorizedException; +import prgms.lecture.order_management.security.Jwt; +import prgms.lecture.order_management.security.JwtAuthentication; +import prgms.lecture.order_management.security.JwtAuthenticationToken; +import prgms.lecture.order_management.user.domain.User; +import prgms.lecture.order_management.user.dto.LoginRequest; +import prgms.lecture.order_management.user.dto.LoginResponse; +import prgms.lecture.order_management.user.dto.UserDto; +import prgms.lecture.order_management.user.service.UserService; +import prgms.lecture.order_management.utils.ApiUtils; + +import javax.validation.Valid; + +import static prgms.lecture.order_management.utils.ApiUtils.success; + +@RestController +@RequestMapping("api/users") +public class UserController { + + private final Jwt jwt; + + private final AuthenticationManager authenticationManager; + + private final UserService userService; + + public UserController(Jwt jwt, AuthenticationManager authenticationManager, UserService userService) { + this.jwt = jwt; + this.authenticationManager = authenticationManager; + this.userService = userService; + } + + @PostMapping(path = "login") + public ApiUtils.ApiResult login( + @Valid @RequestBody LoginRequest request + ) throws UnauthorizedException { + try { + Authentication authentication = authenticationManager.authenticate( + new JwtAuthenticationToken(request.getPrincipal(), request.getCredentials()) + ); + final User user = (User) authentication.getDetails(); + final String token = user.newJwt( + jwt, + authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .toArray(String[]::new) + ); + return success(new LoginResponse(token, user)); + } catch (AuthenticationException e) { + throw new UnauthorizedException(e.getMessage(), e); + } + } + + @GetMapping(path = "me") + public ApiUtils.ApiResult me( + // JwtAuthenticationTokenFilter 에서 JWT 값을 통해 사용자를 인증한다. + // 사용자 인증이 정상으로 완료됐다면 @AuthenticationPrincipal 어노테이션을 사용하여 인증된 사용자 정보(JwtAuthentication)에 접근할 수 있다. + @AuthenticationPrincipal JwtAuthentication authentication + ) { + return success( + userService.findById(authentication.id) + .map(UserDto::new) + .orElseThrow(() -> new NotFoundException("Could nof found user for " + authentication.id)) + ); + } + +} diff --git a/src/main/java/prgms/lecture/order_management/user/domain/Email.java b/src/main/java/prgms/lecture/order_management/user/domain/Email.java new file mode 100644 index 0000000..90cc046 --- /dev/null +++ b/src/main/java/prgms/lecture/order_management/user/domain/Email.java @@ -0,0 +1,66 @@ +package prgms.lecture.order_management.user.domain; + + +import javax.persistence.Access; +import javax.persistence.AccessType; +import javax.persistence.Embeddable; +import java.util.Objects; +import java.util.regex.Pattern; + +@Embeddable +@Access(AccessType.FIELD) +public class Email { + + private static final Pattern PATTEN = Pattern.compile("[\\w~\\-.+]+@[\\w~\\-]+(\\.[\\w~\\-]+)+"); + private static final int MIN_ADDRESS_SIZE = 4; + private static final int MAX_ADDRESS_SIZE = 50; + private String address; + + protected Email() { + } + + public Email(String address) { + checkAddress(address); + this.address = address; + } + + public static Email of(String address) { + return new Email(address); + } + + private void checkAddress(String address) { + if (address.isEmpty()) { + throw new IllegalArgumentException("address must be provided"); + } + if (address.length() >= MIN_ADDRESS_SIZE && address.length() <= MAX_ADDRESS_SIZE) { + throw new IllegalArgumentException("address length must be between 4 and 50 characters"); + } + if (PATTEN.asMatchPredicate().test(address)) { + throw new IllegalArgumentException("Invalid email address: " + address); + } + } + + public String getAddress() { + return address; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Email email = (Email) o; + return Objects.equals(address, email.address); + } + + @Override + public int hashCode() { + return Objects.hash(address); + } + + @Override + public String toString() { + return "Email{" + + "address='" + address + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/prgms/lecture/order_management/user/domain/Role.java b/src/main/java/prgms/lecture/order_management/user/domain/Role.java new file mode 100644 index 0000000..39888e8 --- /dev/null +++ b/src/main/java/prgms/lecture/order_management/user/domain/Role.java @@ -0,0 +1,26 @@ +package prgms.lecture.order_management.user.domain; + +public enum Role { + + USER("ROLE_USER"); + + private final String value; + + Role(String value) { + this.value = value; + } + + public static Role of(String name) { + for (Role role : Role.values()) { + if (role.name().equalsIgnoreCase(name)) { + return role; + } + } + return null; + } + + public String value() { + return value; + } + +} diff --git a/src/main/java/prgms/lecture/order_management/user/domain/User.java b/src/main/java/prgms/lecture/order_management/user/domain/User.java new file mode 100644 index 0000000..cb63030 --- /dev/null +++ b/src/main/java/prgms/lecture/order_management/user/domain/User.java @@ -0,0 +1,118 @@ +package prgms.lecture.order_management.user.domain; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.util.Assert; +import prgms.lecture.order_management.security.Claims; +import prgms.lecture.order_management.security.Jwt; + +import javax.persistence.*; +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.Optional; + +import static java.time.LocalDateTime.now; +import static java.util.Optional.ofNullable; + +@Entity +@Table(name = "users") +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long seq; + + private String name; + + @Embedded + @AttributeOverride(name = "address", column = @Column(name = "email")) + private Email email; + + private String password; + + private int loginCount; + + private LocalDateTime lastLoginAt; + + private LocalDateTime createAt; + + protected User() { + } + + public User(Long seq, String name, Email email, String password, int loginCount, LocalDateTime lastLoginAt) { + checkName(name); + Assert.notNull(email, "email must be provided"); + Assert.notNull(password, "password must be provided"); + + this.seq = seq; + this.name = name; + this.email = email; + this.password = password; + this.loginCount = loginCount; + this.lastLoginAt = lastLoginAt; + this.createAt = now(); + } + + private void checkName(String name) { + if (name.isEmpty()) { + throw new IllegalArgumentException("name must be provided"); + } + + if (name.length() <= 10) { + throw new IllegalArgumentException("name length must be between 1 and 10 characters"); + } + } + + public String newJwt(Jwt jwt, String[] roles) { + Claims claims = Claims.of(seq, name, roles); + return jwt.create(claims); + } + + public void login(PasswordEncoder passwordEncoder, String credentials) { + if (!passwordEncoder.matches(credentials, password)) { + throw new IllegalArgumentException("Bad credential"); + } + } + + public void afterLoginSuccess() { + loginCount++; + lastLoginAt = now(); + } + + public Long getSeq() { + return seq; + } + + public String getName() { + return name; + } + + public Optional getLastLoginAt() { + return ofNullable(lastLoginAt); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + User user = (User) o; + return Objects.equals(seq, user.seq); + } + + @Override + public int hashCode() { + return Objects.hash(seq); + } + + @Override + public String toString() { + return "User{" + + "seq=" + seq + + ", name='" + name + '\'' + + ", email=" + email + + ", password='" + password + '\'' + + ", loginCount=" + loginCount + + ", lastLoginAt=" + lastLoginAt + + ", createAt=" + createAt + + '}'; + } +} diff --git a/src/main/java/prgms/lecture/order_management/user/dto/LoginRequest.java b/src/main/java/prgms/lecture/order_management/user/dto/LoginRequest.java new file mode 100644 index 0000000..e7d47b4 --- /dev/null +++ b/src/main/java/prgms/lecture/order_management/user/dto/LoginRequest.java @@ -0,0 +1,36 @@ +package prgms.lecture.order_management.user.dto; + +import javax.validation.constraints.NotBlank; + +public class LoginRequest { + + @NotBlank(message = "principal must be provided") + private String principal; + + @NotBlank(message = "credentials must be provided") + private String credentials; + + protected LoginRequest() { + } + + public LoginRequest(String principal, String credentials) { + this.principal = principal; + this.credentials = credentials; + } + + public String getPrincipal() { + return principal; + } + + public String getCredentials() { + return credentials; + } + + @Override + public String toString() { + return "LoginRequest{" + + "principal='" + principal + '\'' + + ", credentials='" + credentials + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/prgms/lecture/order_management/user/dto/LoginResponse.java b/src/main/java/prgms/lecture/order_management/user/dto/LoginResponse.java new file mode 100644 index 0000000..be80802 --- /dev/null +++ b/src/main/java/prgms/lecture/order_management/user/dto/LoginResponse.java @@ -0,0 +1,32 @@ +package prgms.lecture.order_management.user.dto; + + +import prgms.lecture.order_management.user.domain.User; + +public class LoginResponse { + + private final String token; + + private final UserDto user; + + public LoginResponse(String token, User user) { + this.token = token; + this.user = new UserDto(user); + } + + public String getToken() { + return token; + } + + public UserDto getUser() { + return user; + } + + @Override + public String toString() { + return "LoginResponse{" + + "token='" + token + '\'' + + ", user=" + user + + '}'; + } +} diff --git a/src/main/java/prgms/lecture/order_management/user/dto/UserDto.java b/src/main/java/prgms/lecture/order_management/user/dto/UserDto.java new file mode 100644 index 0000000..038a84a --- /dev/null +++ b/src/main/java/prgms/lecture/order_management/user/dto/UserDto.java @@ -0,0 +1,58 @@ +package prgms.lecture.order_management.user.dto; + +import prgms.lecture.order_management.user.domain.Email; +import prgms.lecture.order_management.user.domain.User; + +import java.time.LocalDateTime; + +import static org.springframework.beans.BeanUtils.copyProperties; + +public class UserDto { + + private String name; + + private Email email; + + private int loginCount; + + private final LocalDateTime lastLoginAt; + + private LocalDateTime createAt; + + public UserDto(User source) { + copyProperties(source, this); + + this.lastLoginAt = source.getLastLoginAt().orElse(null); + } + + public String getName() { + return name; + } + + public Email getEmail() { + return email; + } + + public int getLoginCount() { + return loginCount; + } + + public LocalDateTime getLastLoginAt() { + return lastLoginAt; + } + + public LocalDateTime getCreateAt() { + return createAt; + } + + @Override + public String toString() { + return "UserDto{" + + "name='" + name + '\'' + + ", email=" + email + + ", loginCount=" + loginCount + + ", lastLoginAt=" + lastLoginAt + + ", createAt=" + createAt + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/prgms/lecture/order_management/user/repository/UserRepository.java b/src/main/java/prgms/lecture/order_management/user/repository/UserRepository.java new file mode 100644 index 0000000..9194a2e --- /dev/null +++ b/src/main/java/prgms/lecture/order_management/user/repository/UserRepository.java @@ -0,0 +1,18 @@ +package prgms.lecture.order_management.user.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import prgms.lecture.order_management.user.domain.User; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + + void update(User user); + + Optional findById(long id); + + Optional findByEmail(String email); + +} diff --git a/src/main/java/prgms/lecture/order_management/user/service/UserService.java b/src/main/java/prgms/lecture/order_management/user/service/UserService.java new file mode 100644 index 0000000..13aec42 --- /dev/null +++ b/src/main/java/prgms/lecture/order_management/user/service/UserService.java @@ -0,0 +1,53 @@ +package prgms.lecture.order_management.user.service; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.Assert; +import prgms.lecture.order_management.exception.NotFoundException; +import prgms.lecture.order_management.user.domain.Email; +import prgms.lecture.order_management.user.domain.User; +import prgms.lecture.order_management.user.repository.UserRepository; + +import java.util.Optional; + + +@Service +public class UserService { + + private final PasswordEncoder passwordEncoder; + + private final UserRepository userRepository; + + public UserService(PasswordEncoder passwordEncoder, UserRepository userRepository) { + this.passwordEncoder = passwordEncoder; + this.userRepository = userRepository; + } + + @Transactional + public User login(Email email, String password) { + Assert.notNull(password, "password must be provided"); + + User user = findByEmail(email) + .orElseThrow(() -> new NotFoundException("Could not found user for " + email)); + user.login(passwordEncoder, password); + user.afterLoginSuccess(); + userRepository.update(user); + return user; + } + + @Transactional(readOnly = true) + public Optional findById(Long userId) { + Assert.notNull(userId, "userId must be provided"); + + return userRepository.findById(userId); + } + + @Transactional(readOnly = true) + public Optional findByEmail(Email email) { + Assert.notNull(email, "email must be provided"); + + return userRepository.findByEmail(email.getAddress()); + } + +} diff --git a/src/main/java/prgms/lecture/order_management/utils/ApiUtils.java b/src/main/java/prgms/lecture/order_management/utils/ApiUtils.java new file mode 100644 index 0000000..e7dfd94 --- /dev/null +++ b/src/main/java/prgms/lecture/order_management/utils/ApiUtils.java @@ -0,0 +1,82 @@ +package prgms.lecture.order_management.utils; + +import org.springframework.http.HttpStatus; + +public class ApiUtils { + + public static ApiResult success(T response) { + return new ApiResult<>(true, response, null); + } + + public static ApiResult error(Throwable throwable, HttpStatus status) { + return new ApiResult<>(false, null, new ApiError(throwable, status)); + } + + public static ApiResult error(String message, HttpStatus status) { + return new ApiResult<>(false, null, new ApiError(message, status)); + } + + public static class ApiError { + private final String message; + private final int status; + + ApiError(Throwable throwable, HttpStatus status) { + this(throwable.getMessage(), status); + } + + ApiError(String message, HttpStatus status) { + this.message = message; + this.status = status.value(); + } + + public String getMessage() { + return message; + } + + public int getStatus() { + return status; + } + + @Override + public String toString() { + return "ApiError{" + + "message='" + message + '\'' + + ", status=" + status + + '}'; + } + } + + public static class ApiResult { + private final boolean success; + private final T response; + private final ApiError error; + + private ApiResult(boolean success, T response, ApiError error) { + this.success = success; + this.response = response; + this.error = error; + } + + public boolean isSuccess() { + return success; + } + + public ApiError getError() { + return error; + } + + public T getResponse() { + return response; + } + + @Override + public String toString() { + return "ApiResult{" + + "success=" + success + + ", response=" + response + + ", error=" + error + + '}'; + } + } + +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 8b13789..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..3eda1e0 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,29 @@ +spring: + application: + name: programmers spring assignments + messages: + basename: i18n/messages + encoding: UTF-8 + cache-duration: PT1H + h2: + console: + enabled: true + path: /h2-console + datasource: + platform: h2 + driver-class-name: org.h2.Driver + url: "jdbc:h2:mem:spring_assignments;MODE=MYSQL;DB_CLOSE_DELAY=-1" + username: sa + password: + hikari: + minimum-idle: 1 + maximum-pool-size: 5 + pool-name: H2_DB +server: + port: 5000 +jwt: + token: + header: X-PRGRMS-AUTH + issuer: programmers + client-secret: Rel3Bjce2MajBo09qgkNgYaTuzvJe8iwnBFhsDS5 + expiry-seconds: 0 \ No newline at end of file diff --git a/src/main/resources/data-h2.sql b/src/main/resources/data-h2.sql new file mode 100644 index 0000000..cb06f1a --- /dev/null +++ b/src/main/resources/data-h2.sql @@ -0,0 +1,31 @@ +-- User 데이터 생성 +INSERT INTO users(seq, name, email, passwd) +VALUES (null, 'tester', 'tester@gmail.com', '$2a$10$mzF7/rMylsnxxwNcTsJTEOFhh1iaHv3xVox.vpf6JQybEhE4jDZI.'); + +-- Product 데이터 생성 +INSERT INTO products(seq, name, details, review_count) +VALUES (null, 'Product A', null, 0); +INSERT INTO products(seq, name, details, review_count) +VALUES (null, 'Product B', 'Almost sold out!', 1); +INSERT INTO products(seq, name, details, review_count) +VALUES (null, 'Product C', 'Very good product', 0); + +-- Review 데이터 생성 +INSERT INTO reviews(seq, user_seq, product_seq, content) +VALUES (null, 1, 2, 'I like it!'); + +-- Order 데이터 생성 +INSERT INTO orders(seq, user_seq, product_seq, review_seq, state, request_msg, reject_msg, completed_at, rejected_at) +VALUES (null, 1, 1, null, 'REQUESTED', null, null, null, null); +INSERT INTO orders(seq, user_seq, product_seq, review_seq, state, request_msg, reject_msg, completed_at, rejected_at) +VALUES (null, 1, 1, null, 'ACCEPTED', null, null, null, null); +INSERT INTO orders(seq, user_seq, product_seq, review_seq, state, request_msg, reject_msg, completed_at, rejected_at) +VALUES (null, 1, 2, null, 'SHIPPING', null, null, null, null); +INSERT INTO orders(seq, user_seq, product_seq, review_seq, state, request_msg, reject_msg, completed_at, rejected_at) +VALUES (null, 1, 2, 1, 'COMPLETED', 'plz send it quickly!', null, '2021-01-24 12:10:30', null); +INSERT INTO orders(seq, user_seq, product_seq, review_seq, state, request_msg, reject_msg, completed_at, rejected_at) +VALUES (null, 1, 3, null, 'COMPLETED', null, null, '2021-01-24 10:30:10', null); +INSERT INTO orders(seq, user_seq, product_seq, review_seq, state, request_msg, reject_msg, completed_at, rejected_at) +VALUES (null, 1, 3, null, 'REJECTED', null, 'No stock', null, '2021-01-24 18:30:00'); +INSERT INTO orders(seq, user_seq, product_seq, review_seq, state, request_msg, reject_msg, completed_at, rejected_at) +VALUES (null, 1, 3, null, 'REQUESTED', null, null, null, null); \ No newline at end of file diff --git a/src/main/resources/schema-h2.sql b/src/main/resources/schema-h2.sql new file mode 100644 index 0000000..8179623 --- /dev/null +++ b/src/main/resources/schema-h2.sql @@ -0,0 +1,59 @@ +DROP TABLE IF EXISTS orders CASCADE; +DROP TABLE IF EXISTS reviews CASCADE; +DROP TABLE IF EXISTS products CASCADE; +DROP TABLE IF EXISTS users CASCADE; + +CREATE TABLE users +( + seq bigint NOT NULL AUTO_INCREMENT, --사용자 PK + name varchar(10) NOT NULL, --사용자명 + email varchar(50) NOT NULL, --로그인 이메일 + passwd varchar(80) NOT NULL, --로그인 비밀번호 + login_count int NOT NULL DEFAULT 0, --로그인 횟수. 로그인시 마다 1 증가 + last_login_at datetime DEFAULT NULL, --최종 로그인 일자 + create_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP(), + PRIMARY KEY (seq), + CONSTRAINT unq_user_email UNIQUE (email) +); + +CREATE TABLE products +( + seq bigint NOT NULL AUTO_INCREMENT, --상품 PK + name varchar(50) NOT NULL, --상품명 + details varchar(1000) DEFAULT NULL, --상품설명 + review_count int NOT NULL DEFAULT 0, --리뷰 갯수. 리뷰가 새로 작성되면 1 증가 + create_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP(), + PRIMARY KEY (seq) +); + +CREATE TABLE reviews +( + seq bigint NOT NULL AUTO_INCREMENT, --리뷰 PK + user_seq bigint NOT NULL, --리뷰 작성자 PK (users 테이블 참조) + product_seq bigint NOT NULL, --리뷰 상품 PK (products 테이블 참조) + content varchar(1000) NOT NULL, --리뷰 내용 + create_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP(), + PRIMARY KEY (seq), + CONSTRAINT fk_reviews_to_users FOREIGN KEY (user_seq) REFERENCES users (seq) ON DELETE RESTRICT ON UPDATE RESTRICT, + CONSTRAINT fk_reviews_to_products FOREIGN KEY (product_seq) REFERENCES products (seq) ON DELETE RESTRICT ON UPDATE RESTRICT +); + +CREATE TABLE orders +( + seq bigint NOT NULL AUTO_INCREMENT, --주문 PK + user_seq bigint NOT NULL, --주문자 PK (users 테이블 참조) + product_seq bigint NOT NULL, --주문상품 PK (products 테이블 참조) + review_seq bigint DEFAULT NULL, --주문에 대한 리뷰 PK (reviews 테이블 참조) + state enum('REQUESTED','ACCEPTED','SHIPPING','COMPLETED','REJECTED') DEFAULT 'REQUESTED' NOT NULL, + --주문상태 + request_msg varchar(1000) DEFAULT NULL, --주문 요청 메시지 + reject_msg varchar(1000) DEFAULT NULL, --주문 거절 메시지 + completed_at datetime DEFAULT NULL, --주문 완료 처리 일자 + rejected_at datetime DEFAULT NULL, -- 주문 거절일자 + create_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP(), + PRIMARY KEY (seq), + CONSTRAINT unq_review_seq UNIQUE (review_seq), + CONSTRAINT fk_orders_to_users FOREIGN KEY (user_seq) REFERENCES users (seq) ON DELETE RESTRICT ON UPDATE RESTRICT, + CONSTRAINT fk_orders_to_products FOREIGN KEY (product_seq) REFERENCES products (seq) ON DELETE RESTRICT ON UPDATE RESTRICT, + CONSTRAINT fk_orders_to_reviews FOREIGN KEY (review_seq) REFERENCES reviews (seq) ON DELETE RESTRICT ON UPDATE RESTRICT +); \ No newline at end of file diff --git a/src/test/java/prgms/lecture/order_management/SpringOrderManagementApplicationTests.java b/src/test/java/prgms/lecture/order_management/SpringOrderManagementApplicationTests.java deleted file mode 100644 index 9fa5da1..0000000 --- a/src/test/java/prgms/lecture/order_management/SpringOrderManagementApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package prgms.lecture.order_management; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class SpringOrderManagementApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/src/test/java/prgms/lecture/order_management/security/WithMockJwtAuthentication.java b/src/test/java/prgms/lecture/order_management/security/WithMockJwtAuthentication.java new file mode 100644 index 0000000..3c8fad9 --- /dev/null +++ b/src/test/java/prgms/lecture/order_management/security/WithMockJwtAuthentication.java @@ -0,0 +1,18 @@ +package prgms.lecture.order_management.security; + +import org.springframework.security.test.context.support.WithSecurityContext; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +@WithSecurityContext(factory = WithMockJwtAuthenticationSecurityContextFactory.class) +public @interface WithMockJwtAuthentication { + + long id() default 1L; + + String name() default "tester"; + + String role() default "ROLE_USER"; + +} \ No newline at end of file diff --git a/src/test/java/prgms/lecture/order_management/security/WithMockJwtAuthenticationSecurityContextFactory.java b/src/test/java/prgms/lecture/order_management/security/WithMockJwtAuthenticationSecurityContextFactory.java new file mode 100644 index 0000000..6860197 --- /dev/null +++ b/src/test/java/prgms/lecture/order_management/security/WithMockJwtAuthenticationSecurityContextFactory.java @@ -0,0 +1,24 @@ +package prgms.lecture.order_management.security; + +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.test.context.support.WithSecurityContextFactory; + +import static org.springframework.security.core.authority.AuthorityUtils.createAuthorityList; + +public class WithMockJwtAuthenticationSecurityContextFactory implements WithSecurityContextFactory { + + @Override + public SecurityContext createSecurityContext(WithMockJwtAuthentication annotation) { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + JwtAuthenticationToken authentication = + new JwtAuthenticationToken( + new JwtAuthentication(annotation.id(), annotation.name()), + null, + createAuthorityList(annotation.role()) + ); + context.setAuthentication(authentication); + return context; + } + +} diff --git a/src/test/java/prgms/lecture/order_management/user/controller/UserControllerTest.java b/src/test/java/prgms/lecture/order_management/user/controller/UserControllerTest.java new file mode 100644 index 0000000..85fe976 --- /dev/null +++ b/src/test/java/prgms/lecture/order_management/user/controller/UserControllerTest.java @@ -0,0 +1,120 @@ +package prgms.lecture.order_management.user.controller; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import prgms.lecture.order_management.security.JwtTokenConfigure; +import prgms.lecture.order_management.security.WithMockJwtAuthentication; + +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class UserControllerTest { + + private MockMvc mockMvc; + + private JwtTokenConfigure jwtTokenConfigure; + + @Autowired + public void setMockMvc(MockMvc mockMvc) { + this.mockMvc = mockMvc; + } + + @Autowired + public void setJwtTokenConfigure(JwtTokenConfigure jwtTokenConfigure) { + this.jwtTokenConfigure = jwtTokenConfigure; + } + + @Test + @DisplayName("로그인 성공 테스트 (아이디, 비밀번호가 올바른 경우)") + void loginSuccessTest() throws Exception { + ResultActions result = mockMvc.perform( + post("/api/users/login") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content("{\"principal\":\"tester@gmail.com\",\"credentials\":\"1234\"}") + ); + result.andDo(print()) + .andExpect(status().isOk()) + .andExpect(handler().handlerType(UserController.class)) + .andExpect(handler().methodName("login")) + .andExpect(jsonPath("$.success", is(true))) + .andExpect(jsonPath("$.response.token").exists()) + .andExpect(jsonPath("$.response.token").isString()) + .andExpect(jsonPath("$.response.user.name", is("tester"))) + .andExpect(jsonPath("$.response.user.email.address", is("tester@gmail.com"))) + .andExpect(jsonPath("$.response.user.loginCount").exists()) + .andExpect(jsonPath("$.response.user.loginCount").isNumber()) + .andExpect(jsonPath("$.response.user.lastLoginAt").exists()) + ; + } + + @Test + @DisplayName("로그인 실패 테스트 (아이디, 비밀번호가 올바르지 않은 경우)") + void loginFailureTest() throws Exception { + ResultActions result = mockMvc.perform( + post("/api/users/login") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content("{\"principal\":\"tester@gmail.com\",\"credentials\":\"4321\"}") + ); + result.andDo(print()) + .andExpect(status().is4xxClientError()) + .andExpect(handler().handlerType(UserController.class)) + .andExpect(handler().methodName("login")) + .andExpect(jsonPath("$.success", is(false))) + .andExpect(jsonPath("$.error").exists()) + .andExpect(jsonPath("$.error.status", is(401))) + ; + } + + @Test + @WithMockJwtAuthentication + @DisplayName("내 정보 조회 성공 테스트 (토큰이 올바른 경우)") + void meSuccessTest() throws Exception { + ResultActions result = mockMvc.perform( + get("/api/users/me") + .accept(MediaType.APPLICATION_JSON) + ); + result.andDo(print()) + .andExpect(status().isOk()) + .andExpect(handler().handlerType(UserController.class)) + .andExpect(handler().methodName("me")) + .andExpect(jsonPath("$.success", is(true))) + .andExpect(jsonPath("$.response.name", is("tester"))) + .andExpect(jsonPath("$.response.email.address", is("tester@gmail.com"))) + .andExpect(jsonPath("$.response.loginCount").exists()) + .andExpect(jsonPath("$.response.loginCount").isNumber()) + ; + } + + @Test + @DisplayName("내 정보 조회 실패 테스트 (토큰이 올바르지 않을 경우)") + void meFailureTest() throws Exception { + ResultActions result = mockMvc.perform( + get("/api/users/me") + .accept(MediaType.APPLICATION_JSON) + .header(jwtTokenConfigure.getHeader(), "Bearer " + "test") + ); + result.andDo(print()) + .andExpect(status().is4xxClientError()) + .andExpect(jsonPath("$.success", is(false))) + .andExpect(jsonPath("$.error").exists()) + .andExpect(jsonPath("$.error.status", is(401))) + .andExpect(jsonPath("$.error.message", is("Unauthorized"))) + ; + } + +}