From 270edc20b5084737dc2044f81bab3410b866b4ec Mon Sep 17 00:00:00 2001 From: Kim DongHyo <60608509+kdhyo@users.noreply.github.com> Date: Sun, 5 Jun 2022 23:00:20 +0900 Subject: [PATCH] Feature/security set up (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: jwt 의존성 추가 * fix: jjwt version 명시 * feat: login 서비스 구현 * refactor: findNotDeletedUserByEmail 메소드 추가 * refactor: checkPassword private -> public 범위변경 * feat: configuration-processor 추가 * feat: jwt 설정파일 Loading 추가 * refactor: @Getter 추가 * refactor: UserSservice login 메소드 AuthenticationManagerBuilder 사용으로 변경 * feat: UserDetailsService CustomService 구현 * feat: Jwt 예외 핸들러 구현 * refactor: header -> accessHeader 네이밍 변경 * feat: JwtProvider 구현 * feat: JwtFilter 구현 * feat: Jwt Filter configure 등록 * feat: SecurityContextHolder 에서 email 리턴 메서드 구현 * feat: Spring Security - Costom JWT 적용 * feat: UserController Login 메서드 구현 * fix: health check permitAll 추가 * feat: @PreAuthorize("hasRole('GUEST')") 추가 * feat: SecurityContextHolder Test 코드 작성 * feat: UserController login 메서드 테스트 구현 * refactor: string 수기 작성한 json DTO로 테스트하도록 변경 * refactor: 하단 공백 추가 * refactor: 주석코드 제거 * refactor: 피드백 반영 * refactor: 규칙에 맞게 수정 --- server/build.gradle.kts | 9 +- .../ticketing/server/ServerApplication.java | 3 + .../global/config/WebSecurityConfig.java | 29 ----- .../factory/YamlPropertySourceFactory.java | 23 ++++ .../server/global/jwt/JwtFilter.java | 51 ++++++++ .../server/global/jwt/JwtProperties.java | 22 ++++ .../server/global/jwt/JwtProvider.java | 114 ++++++++++++++++++ .../server/global/jwt/JwtSecurityConfig.java | 18 +++ .../jwt/handle/JwtAccessDeniedHandler.java | 17 +++ .../handle/JwtAuthenticationEntryPoint.java | 17 +++ .../server/global/security/SecurityUtil.java | 21 ++++ .../global/security/WebSecurityConfig.java | 65 ++++++++++ .../service/CustomUserDetailsService.java | 35 ++++++ .../user/application/UserController.java | 27 ++++- .../application/request/LoginRequest.java | 26 ++++ .../application/request/SignUpRequest.java | 4 + .../request/UserDeleteRequest.java | 4 + .../request/UserModifyPasswordRequest.java | 4 + .../application/response/LoginResponse.java | 17 +++ .../application/response/SignUpResponse.java | 18 +-- .../response/UserChangePasswordResponse.java | 18 +-- .../response/UserDeleteResponse.java | 18 +-- .../ticketing/server/user/domain/User.java | 2 +- .../server/user/service/UserServiceImpl.java | 40 ++++-- .../user/service/interfaces/UserService.java | 4 + server/src/main/resources/application.yml | 6 + .../server/global/jwt/JwtPropertiesTest.java | 36 ++++++ .../server/global/jwt/TokenProviderTest.java | 67 ++++++++++ .../security/SecurityContextHolderTest.java | 66 ++++++++++ .../user/application/UserControllerTest.java | 96 +++++++++++++++ .../user/service/dto/SignUpDtoTest.java | 2 +- server/src/test/resources/application.yml | 6 + 32 files changed, 808 insertions(+), 77 deletions(-) delete mode 100644 server/src/main/java/com/ticketing/server/global/config/WebSecurityConfig.java create mode 100644 server/src/main/java/com/ticketing/server/global/factory/YamlPropertySourceFactory.java create mode 100644 server/src/main/java/com/ticketing/server/global/jwt/JwtFilter.java create mode 100644 server/src/main/java/com/ticketing/server/global/jwt/JwtProperties.java create mode 100644 server/src/main/java/com/ticketing/server/global/jwt/JwtProvider.java create mode 100644 server/src/main/java/com/ticketing/server/global/jwt/JwtSecurityConfig.java create mode 100644 server/src/main/java/com/ticketing/server/global/jwt/handle/JwtAccessDeniedHandler.java create mode 100644 server/src/main/java/com/ticketing/server/global/jwt/handle/JwtAuthenticationEntryPoint.java create mode 100644 server/src/main/java/com/ticketing/server/global/security/SecurityUtil.java create mode 100644 server/src/main/java/com/ticketing/server/global/security/WebSecurityConfig.java create mode 100644 server/src/main/java/com/ticketing/server/global/service/CustomUserDetailsService.java create mode 100644 server/src/main/java/com/ticketing/server/user/application/request/LoginRequest.java create mode 100644 server/src/main/java/com/ticketing/server/user/application/response/LoginResponse.java create mode 100644 server/src/test/java/com/ticketing/server/global/jwt/JwtPropertiesTest.java create mode 100644 server/src/test/java/com/ticketing/server/global/jwt/TokenProviderTest.java create mode 100644 server/src/test/java/com/ticketing/server/global/security/SecurityContextHolderTest.java create mode 100644 server/src/test/java/com/ticketing/server/user/application/UserControllerTest.java diff --git a/server/build.gradle.kts b/server/build.gradle.kts index c1680af..9a7cdd5 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { implementation("io.springfox:springfox-swagger-ui:3.0.0") implementation("com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.4") implementation("com.lmax:disruptor:3.4.2") + implementation("io.jsonwebtoken:jjwt-api:0.11.2") modules { module("org.springframework.boot:spring-boot-starter-logging") { @@ -50,8 +51,14 @@ dependencies { compileOnly("org.projectlombok:lombok") runtimeOnly("mysql:mysql-connector-java") - testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.8.1") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.2") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.2") annotationProcessor("org.projectlombok:lombok") + annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") + + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.8.1") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.security:spring-security-test") } tasks.withType { diff --git a/server/src/main/java/com/ticketing/server/ServerApplication.java b/server/src/main/java/com/ticketing/server/ServerApplication.java index 5264f3e..4afc993 100644 --- a/server/src/main/java/com/ticketing/server/ServerApplication.java +++ b/server/src/main/java/com/ticketing/server/ServerApplication.java @@ -1,11 +1,14 @@ package com.ticketing.server; +import com.ticketing.server.global.jwt.JwtProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @EnableJpaAuditing @SpringBootApplication +@EnableConfigurationProperties(JwtProperties.class) public class ServerApplication { public static void main(String[] args) { diff --git a/server/src/main/java/com/ticketing/server/global/config/WebSecurityConfig.java b/server/src/main/java/com/ticketing/server/global/config/WebSecurityConfig.java deleted file mode 100644 index c570bce..0000000 --- a/server/src/main/java/com/ticketing/server/global/config/WebSecurityConfig.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.ticketing.server.global.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; -import org.springframework.security.crypto.factory.PasswordEncoderFactories; -import org.springframework.security.crypto.password.PasswordEncoder; - -@Configuration -@EnableWebSecurity -public class WebSecurityConfig extends WebSecurityConfigurerAdapter { - - @Bean - public PasswordEncoder passwordEncoder() { - return PasswordEncoderFactories.createDelegatingPasswordEncoder(); - } - - @Override - protected void configure(HttpSecurity http) throws Exception { - http - .cors().disable() - .csrf().disable() - .formLogin().disable() - .headers().frameOptions().disable(); - } - -} diff --git a/server/src/main/java/com/ticketing/server/global/factory/YamlPropertySourceFactory.java b/server/src/main/java/com/ticketing/server/global/factory/YamlPropertySourceFactory.java new file mode 100644 index 0000000..457e925 --- /dev/null +++ b/server/src/main/java/com/ticketing/server/global/factory/YamlPropertySourceFactory.java @@ -0,0 +1,23 @@ +package com.ticketing.server.global.factory; + +import java.util.Objects; +import java.util.Properties; +import org.springframework.beans.factory.config.YamlPropertiesFactoryBean; +import org.springframework.core.env.PropertiesPropertySource; +import org.springframework.core.env.PropertySource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.EncodedResource; +import org.springframework.core.io.support.PropertySourceFactory; + +public class YamlPropertySourceFactory implements PropertySourceFactory { + + @Override + public PropertySource createPropertySource(String name, EncodedResource encodedResource) { + Resource resource = encodedResource.getResource(); + YamlPropertiesFactoryBean factoryBean = new YamlPropertiesFactoryBean(); + factoryBean.setResources(resource); + + Properties properties = factoryBean.getObject(); + return new PropertiesPropertySource(Objects.requireNonNull(resource.getFilename()), Objects.requireNonNull(properties)); + } +} diff --git a/server/src/main/java/com/ticketing/server/global/jwt/JwtFilter.java b/server/src/main/java/com/ticketing/server/global/jwt/JwtFilter.java new file mode 100644 index 0000000..7f62c9e --- /dev/null +++ b/server/src/main/java/com/ticketing/server/global/jwt/JwtFilter.java @@ -0,0 +1,51 @@ +package com.ticketing.server.global.jwt; + +import java.io.IOException; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +@Slf4j +@Configuration +public class JwtFilter extends OncePerRequestFilter { + + private final JwtProvider tokenProvider; + private final String accessHeader; + private final String tokenPrefix; + + public JwtFilter(JwtProperties jwtProperties, JwtProvider tokenProvider) { + this.accessHeader = jwtProperties.getAccessHeader(); + this.tokenPrefix = jwtProperties.getPrefix(); + this.tokenProvider = tokenProvider; + } + + @Override + public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { + String jwt = resolveToken(request); + + // 토큰이 정상이면 Authentication 을 가져와서 SecurityContext 에 저장 + if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) { + Authentication authentication = tokenProvider.getAuthentication(jwt); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + chain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader(accessHeader); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(tokenPrefix)) { + return bearerToken.substring(7); + } + + return null; + } + +} diff --git a/server/src/main/java/com/ticketing/server/global/jwt/JwtProperties.java b/server/src/main/java/com/ticketing/server/global/jwt/JwtProperties.java new file mode 100644 index 0000000..c727cdb --- /dev/null +++ b/server/src/main/java/com/ticketing/server/global/jwt/JwtProperties.java @@ -0,0 +1,22 @@ +package com.ticketing.server.global.jwt; + +import com.ticketing.server.global.factory.YamlPropertySourceFactory; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.ConstructorBinding; +import org.springframework.context.annotation.PropertySource; + +@Getter +@RequiredArgsConstructor +@ConstructorBinding +@ConfigurationProperties("jwt") +@PropertySource(value = "classpath:application.yml", factory = YamlPropertySourceFactory.class) +public class JwtProperties { + + private final String accessHeader; + private final String prefix; + private final String secretKey; + private final Integer tokenValidityInSeconds; + +} diff --git a/server/src/main/java/com/ticketing/server/global/jwt/JwtProvider.java b/server/src/main/java/com/ticketing/server/global/jwt/JwtProvider.java new file mode 100644 index 0000000..ac5fe7c --- /dev/null +++ b/server/src/main/java/com/ticketing/server/global/jwt/JwtProvider.java @@ -0,0 +1,114 @@ +package com.ticketing.server.global.jwt; + +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.io.Decoders; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SecurityException; +import java.security.Key; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.StringJoiner; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +public class JwtProvider { + + private static final String AUTHORITIES_KEY = "auth"; + private static final String AUTHORITIES_DELIMITER = ","; + + private final long tokenValidityInMilliseconds; + private final Key key; + + public JwtProvider(JwtProperties jwtProperties) { + this.tokenValidityInMilliseconds = jwtProperties.getTokenValidityInSeconds(); + + byte[] keyBytes = Decoders.BASE64.decode(jwtProperties.getSecretKey()); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + public String createToken(Authentication authentication) { + // 권한 정보 가져오기 + String authorities = generateStringToAuthorities(authentication); + + // 만료시간 계산 + long now = new Date().getTime(); + Date accessTokenExpiresIn = new Date(now + this.tokenValidityInMilliseconds); + + // JWT 생성 + return Jwts.builder() + .setSubject(authentication.getName()) // email + .claim(AUTHORITIES_KEY, authorities) // payload + .setExpiration(accessTokenExpiresIn) // 만료일 + .signWith(key, SignatureAlgorithm.HS512) // 서명 키 값 + .compact(); + } + + private String generateStringToAuthorities(Authentication authentication) { + StringJoiner authorities = new StringJoiner(AUTHORITIES_DELIMITER); + for (GrantedAuthority grantedAuthority : authentication.getAuthorities()) { + String roleName = makeRoleName(grantedAuthority.getAuthority()); + authorities.add(roleName); + } + return authorities.toString(); + } + + private String makeRoleName(String role) { + return "ROLE_" + role.toUpperCase(); + } + + public Authentication getAuthentication(String token) { + // 토큰 복호화 + Claims claims = parseClaims(token); + + List authorities = + Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(AUTHORITIES_DELIMITER)) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + + UserDetails principal = new User(claims.getSubject(), "", authorities); + return new UsernamePasswordAuthenticationToken(principal, token, authorities); + } + + public boolean validateToken(String token) { + try { + parseClaims(token); + return true; + } catch (SecurityException | MalformedJwtException exception) { + log.info("잘못된 JWT 서명입니다."); + } catch (ExpiredJwtException e) { + log.info("잘못된 JWT 토큰입니다."); + } catch (UnsupportedJwtException e) { + log.info("지원되지 않는 JWT 토큰입니다."); + } catch (IllegalArgumentException e) { + log.info("JWT 토큰이 잘못되었습니다."); + } + + return false; + } + + private Claims parseClaims(String token) { + return Jwts + .parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + } + +} + diff --git a/server/src/main/java/com/ticketing/server/global/jwt/JwtSecurityConfig.java b/server/src/main/java/com/ticketing/server/global/jwt/JwtSecurityConfig.java new file mode 100644 index 0000000..18b99e8 --- /dev/null +++ b/server/src/main/java/com/ticketing/server/global/jwt/JwtSecurityConfig.java @@ -0,0 +1,18 @@ +package com.ticketing.server.global.jwt; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.config.annotation.SecurityConfigurerAdapter; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.DefaultSecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@RequiredArgsConstructor +public class JwtSecurityConfig extends SecurityConfigurerAdapter { + + private final JwtFilter jwtFilter; + + @Override + public void configure(HttpSecurity builder) { + builder.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + } +} diff --git a/server/src/main/java/com/ticketing/server/global/jwt/handle/JwtAccessDeniedHandler.java b/server/src/main/java/com/ticketing/server/global/jwt/handle/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..61b4040 --- /dev/null +++ b/server/src/main/java/com/ticketing/server/global/jwt/handle/JwtAccessDeniedHandler.java @@ -0,0 +1,17 @@ +package com.ticketing.server.global.jwt.handle; + +import java.io.IOException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { + response.sendError(HttpServletResponse.SC_FORBIDDEN); + } +} diff --git a/server/src/main/java/com/ticketing/server/global/jwt/handle/JwtAuthenticationEntryPoint.java b/server/src/main/java/com/ticketing/server/global/jwt/handle/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..2fc5b0b --- /dev/null +++ b/server/src/main/java/com/ticketing/server/global/jwt/handle/JwtAuthenticationEntryPoint.java @@ -0,0 +1,17 @@ +package com.ticketing.server.global.jwt.handle; + +import java.io.IOException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + } +} diff --git a/server/src/main/java/com/ticketing/server/global/security/SecurityUtil.java b/server/src/main/java/com/ticketing/server/global/security/SecurityUtil.java new file mode 100644 index 0000000..ba60a5d --- /dev/null +++ b/server/src/main/java/com/ticketing/server/global/security/SecurityUtil.java @@ -0,0 +1,21 @@ +package com.ticketing.server.global.security; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +public class SecurityUtil { + + private SecurityUtil() { + } + + public static String getCurrentUserEmail() { + final Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || authentication.getName() == null) { + throw new IllegalStateException("Security Context 에 인증 정보가 없습니다."); + } + + return authentication.getName(); + } + +} diff --git a/server/src/main/java/com/ticketing/server/global/security/WebSecurityConfig.java b/server/src/main/java/com/ticketing/server/global/security/WebSecurityConfig.java new file mode 100644 index 0000000..2fceedd --- /dev/null +++ b/server/src/main/java/com/ticketing/server/global/security/WebSecurityConfig.java @@ -0,0 +1,65 @@ +package com.ticketing.server.global.security; + +import com.ticketing.server.global.jwt.JwtFilter; +import com.ticketing.server.global.jwt.JwtSecurityConfig; +import com.ticketing.server.global.jwt.handle.JwtAccessDeniedHandler; +import com.ticketing.server.global.jwt.handle.JwtAuthenticationEntryPoint; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +@EnableWebSecurity +@EnableGlobalMethodSecurity(securedEnabled = true) +@RequiredArgsConstructor +public class WebSecurityConfig extends WebSecurityConfigurerAdapter { + + private final JwtFilter jwtFilter; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + + @Override + protected void configure(HttpSecurity http) throws Exception { + + http + .csrf().disable() + .exceptionHandling() + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(jwtAccessDeniedHandler) + + .and() + .headers() + .frameOptions() + .sameOrigin() + + // 시큐리티는 기본적으로 세션을 사용하지만, jwt 을 위해 세션을 Stateless 로 설정 + .and() + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + + .and() + .authorizeRequests() + .antMatchers(HttpMethod.POST, "/user").permitAll() + .antMatchers(HttpMethod.POST, "/user/login").permitAll() + .antMatchers("/l7check").permitAll() + .antMatchers("/actuator/health").permitAll() + .anyRequest().authenticated() + + .and() + .apply(new JwtSecurityConfig(jwtFilter)); + } + +} diff --git a/server/src/main/java/com/ticketing/server/global/service/CustomUserDetailsService.java b/server/src/main/java/com/ticketing/server/global/service/CustomUserDetailsService.java new file mode 100644 index 0000000..e16573a --- /dev/null +++ b/server/src/main/java/com/ticketing/server/global/service/CustomUserDetailsService.java @@ -0,0 +1,35 @@ +package com.ticketing.server.global.service; + +import com.ticketing.server.user.domain.User; +import com.ticketing.server.user.domain.repository.UserRepository; +import java.util.Collections; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + return userRepository.findByEmailAndIsDeletedFalse(email) + .map(this::createUserDetails) + .orElseThrow(() -> new UsernameNotFoundException("존재하지 않는 email 입니다. :: " + email)); + } + + private UserDetails createUserDetails(User user) { + SimpleGrantedAuthority grantedAuthority = new SimpleGrantedAuthority(user.getGrade().name()); + + return new org.springframework.security.core.userdetails.User( + user.getEmail() + , user.getPassword() + , Collections.singleton(grantedAuthority) + ); + } +} diff --git a/server/src/main/java/com/ticketing/server/user/application/UserController.java b/server/src/main/java/com/ticketing/server/user/application/UserController.java index cc153f9..178e191 100644 --- a/server/src/main/java/com/ticketing/server/user/application/UserController.java +++ b/server/src/main/java/com/ticketing/server/user/application/UserController.java @@ -1,18 +1,23 @@ package com.ticketing.server.user.application; +import com.ticketing.server.global.jwt.JwtProperties; +import com.ticketing.server.user.application.request.LoginRequest; import com.ticketing.server.user.application.request.SignUpRequest; import com.ticketing.server.user.application.request.UserDeleteRequest; import com.ticketing.server.user.application.request.UserModifyPasswordRequest; +import com.ticketing.server.user.application.response.LoginResponse; import com.ticketing.server.user.application.response.SignUpResponse; -import com.ticketing.server.user.application.response.UserDeleteResponse; import com.ticketing.server.user.application.response.UserChangePasswordResponse; +import com.ticketing.server.user.application.response.UserDeleteResponse; import com.ticketing.server.user.domain.User; import com.ticketing.server.user.service.UserServiceImpl; +import javax.servlet.http.HttpServletResponse; import javax.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.annotation.Secured; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -23,27 +28,31 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequiredArgsConstructor -@RequestMapping("/user") +@RequestMapping("/api/user") @Slf4j + public class UserController { private final UserServiceImpl userService; private final PasswordEncoder passwordEncoder; + private final JwtProperties jwtProperties; @PostMapping - public ResponseEntity register(@RequestBody @Valid SignUpRequest request) { + public ResponseEntity register(@RequestBody @Valid SignUpRequest request) { User user = userService.register(request.toSignUpDto(passwordEncoder)); return ResponseEntity.status(HttpStatus.CREATED).body(SignUpResponse.of(user)); } @DeleteMapping - public ResponseEntity deleteUser(@RequestBody @Valid UserDeleteRequest request) { + @Secured("ROLE_GUEST") + public ResponseEntity deleteUser(@RequestBody @Valid UserDeleteRequest request) { User user = userService.delete(request.toDeleteUserDto(passwordEncoder)); return ResponseEntity.status(HttpStatus.OK).body(UserDeleteResponse.of(user)); } @PatchMapping("/password") - public ResponseEntity changePassword(@RequestBody @Valid UserModifyPasswordRequest request) { + @Secured("ROLE_GUEST") + public ResponseEntity changePassword(@RequestBody @Valid UserModifyPasswordRequest request) { if (request.oldEqualNew()) { log.error("기존 패스워드와 동일한 패스워드로 변경할 수 없습니다."); return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); @@ -53,4 +62,12 @@ public class UserController { return ResponseEntity.status(HttpStatus.OK).body(UserChangePasswordResponse.of(user)); } + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequest loginRequest, HttpServletResponse response) { + String accessToken = userService.login(loginRequest.toAuthentication()); + + response.setHeader(jwtProperties.getAccessHeader(), accessToken); + return ResponseEntity.status(HttpStatus.OK).body(LoginResponse.of(accessToken)); + } + } diff --git a/server/src/main/java/com/ticketing/server/user/application/request/LoginRequest.java b/server/src/main/java/com/ticketing/server/user/application/request/LoginRequest.java new file mode 100644 index 0000000..eebe565 --- /dev/null +++ b/server/src/main/java/com/ticketing/server/user/application/request/LoginRequest.java @@ -0,0 +1,26 @@ +package com.ticketing.server.user.application.request; + +import javax.validation.constraints.Email; +import javax.validation.constraints.NotEmpty; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class LoginRequest { + + @NotEmpty(message = "{validation.not.empty.email}") + @Email(message = "{validation.email}") + private String email; + + @NotEmpty(message = "{validation.not.empty.password}") + private String password; + + public UsernamePasswordAuthenticationToken toAuthentication() { + return new UsernamePasswordAuthenticationToken(email, password); + } + +} diff --git a/server/src/main/java/com/ticketing/server/user/application/request/SignUpRequest.java b/server/src/main/java/com/ticketing/server/user/application/request/SignUpRequest.java index 45b0a7b..185ed91 100644 --- a/server/src/main/java/com/ticketing/server/user/application/request/SignUpRequest.java +++ b/server/src/main/java/com/ticketing/server/user/application/request/SignUpRequest.java @@ -4,10 +4,14 @@ import com.ticketing.server.global.validator.constraints.Phone; import com.ticketing.server.user.service.dto.SignUpDTO; import javax.validation.constraints.Email; import javax.validation.constraints.NotEmpty; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; @Getter +@NoArgsConstructor +@AllArgsConstructor public class SignUpRequest { @NotEmpty(message = "{validation.not.empty.name}") diff --git a/server/src/main/java/com/ticketing/server/user/application/request/UserDeleteRequest.java b/server/src/main/java/com/ticketing/server/user/application/request/UserDeleteRequest.java index 3cb6487..ca54366 100644 --- a/server/src/main/java/com/ticketing/server/user/application/request/UserDeleteRequest.java +++ b/server/src/main/java/com/ticketing/server/user/application/request/UserDeleteRequest.java @@ -3,10 +3,14 @@ package com.ticketing.server.user.application.request; import com.ticketing.server.user.service.dto.DeleteUserDTO; import javax.validation.constraints.Email; import javax.validation.constraints.NotEmpty; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; @Getter +@NoArgsConstructor +@AllArgsConstructor public class UserDeleteRequest { @NotEmpty(message = "{validation.not.empty.email}") diff --git a/server/src/main/java/com/ticketing/server/user/application/request/UserModifyPasswordRequest.java b/server/src/main/java/com/ticketing/server/user/application/request/UserModifyPasswordRequest.java index fa6fe11..fc4b8b2 100644 --- a/server/src/main/java/com/ticketing/server/user/application/request/UserModifyPasswordRequest.java +++ b/server/src/main/java/com/ticketing/server/user/application/request/UserModifyPasswordRequest.java @@ -3,10 +3,14 @@ package com.ticketing.server.user.application.request; import com.ticketing.server.user.service.dto.ChangePasswordDTO; import javax.validation.constraints.Email; import javax.validation.constraints.NotEmpty; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; @Getter +@NoArgsConstructor +@AllArgsConstructor public class UserModifyPasswordRequest { @NotEmpty(message = "{validation.not.empty.email}") diff --git a/server/src/main/java/com/ticketing/server/user/application/response/LoginResponse.java b/server/src/main/java/com/ticketing/server/user/application/response/LoginResponse.java new file mode 100644 index 0000000..004173b --- /dev/null +++ b/server/src/main/java/com/ticketing/server/user/application/response/LoginResponse.java @@ -0,0 +1,17 @@ +package com.ticketing.server.user.application.response; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class LoginResponse { + + private String accessToken; + + public static LoginResponse of(String accessToken) { + return new LoginResponse(accessToken); + } + +} diff --git a/server/src/main/java/com/ticketing/server/user/application/response/SignUpResponse.java b/server/src/main/java/com/ticketing/server/user/application/response/SignUpResponse.java index d5ad086..1bad9d9 100644 --- a/server/src/main/java/com/ticketing/server/user/application/response/SignUpResponse.java +++ b/server/src/main/java/com/ticketing/server/user/application/response/SignUpResponse.java @@ -1,20 +1,20 @@ package com.ticketing.server.user.application.response; import com.ticketing.server.user.domain.User; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) public class SignUpResponse { - public static SignUpResponse of(User user) { - return new SignUpResponse(user.getName(), user.getEmail()); - } - - public SignUpResponse(String name, String email) { - this.name = name; - this.email = email; - } - private String name; private String email; + public static SignUpResponse of(User user) { + return new SignUpResponse(user.getName(), user.getEmail()); + } + } diff --git a/server/src/main/java/com/ticketing/server/user/application/response/UserChangePasswordResponse.java b/server/src/main/java/com/ticketing/server/user/application/response/UserChangePasswordResponse.java index b3d2fa3..8601328 100644 --- a/server/src/main/java/com/ticketing/server/user/application/response/UserChangePasswordResponse.java +++ b/server/src/main/java/com/ticketing/server/user/application/response/UserChangePasswordResponse.java @@ -1,20 +1,20 @@ package com.ticketing.server.user.application.response; import com.ticketing.server.user.domain.User; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) public class UserChangePasswordResponse { - public static SignUpResponse of(User user) { - return new SignUpResponse(user.getName(), user.getEmail()); - } - - public UserChangePasswordResponse(String name, String email) { - this.name = name; - this.email = email; - } - private String name; private String email; + public static UserChangePasswordResponse of(User user) { + return new UserChangePasswordResponse(user.getName(), user.getEmail()); + } + } diff --git a/server/src/main/java/com/ticketing/server/user/application/response/UserDeleteResponse.java b/server/src/main/java/com/ticketing/server/user/application/response/UserDeleteResponse.java index 0b82f23..d3c4ea1 100644 --- a/server/src/main/java/com/ticketing/server/user/application/response/UserDeleteResponse.java +++ b/server/src/main/java/com/ticketing/server/user/application/response/UserDeleteResponse.java @@ -1,20 +1,20 @@ package com.ticketing.server.user.application.response; import com.ticketing.server.user.domain.User; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) public class UserDeleteResponse { - public static SignUpResponse of(User user) { - return new SignUpResponse(user.getName(), user.getEmail()); - } - - public UserDeleteResponse(String name, String email) { - this.name = name; - this.email = email; - } - private String name; private String email; + public static UserDeleteResponse of(User user) { + return new UserDeleteResponse(user.getName(), user.getEmail()); + } + } diff --git a/server/src/main/java/com/ticketing/server/user/domain/User.java b/server/src/main/java/com/ticketing/server/user/domain/User.java index 2a6df63..f990643 100644 --- a/server/src/main/java/com/ticketing/server/user/domain/User.java +++ b/server/src/main/java/com/ticketing/server/user/domain/User.java @@ -77,7 +77,7 @@ public class User extends AbstractEntity { return this; } - private void checkPassword(PasswordMatches passwordMatches) { + public void checkPassword(PasswordMatches passwordMatches) { if (!passwordMatches.passwordMatches(password)) { throw new PasswordMismatchException(); } diff --git a/server/src/main/java/com/ticketing/server/user/service/UserServiceImpl.java b/server/src/main/java/com/ticketing/server/user/service/UserServiceImpl.java index 7650fc2..1b35cb7 100644 --- a/server/src/main/java/com/ticketing/server/user/service/UserServiceImpl.java +++ b/server/src/main/java/com/ticketing/server/user/service/UserServiceImpl.java @@ -1,6 +1,7 @@ package com.ticketing.server.user.service; import com.ticketing.server.global.exception.NotFoundEmailException; +import com.ticketing.server.global.jwt.JwtProvider; import com.ticketing.server.user.domain.User; import com.ticketing.server.user.domain.repository.UserRepository; import com.ticketing.server.user.service.dto.ChangePasswordDTO; @@ -11,6 +12,9 @@ import java.util.Optional; import javax.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; @@ -23,6 +27,14 @@ import org.springframework.validation.annotation.Validated; public class UserServiceImpl implements UserService { private final UserRepository userRepository; + private final AuthenticationManagerBuilder authenticationManagerBuilder; + private final JwtProvider jwtProvider; + + @Override + public String login(UsernamePasswordAuthenticationToken authenticationToken) { + Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken); + return jwtProvider.createToken(authentication); + } @Override @Transactional @@ -39,27 +51,29 @@ public class UserServiceImpl implements UserService { @Override @Transactional public User delete(@Valid DeleteUserDTO deleteUserDto) { - Optional optionalUser = userRepository.findByEmail(deleteUserDto.getEmail()); - if (optionalUser.isEmpty()) { - log.error("존재하지 않는 이메일 입니다. :: {}", deleteUserDto); - throw new NotFoundEmailException(); - } + User user = userRepository.findByEmail(deleteUserDto.getEmail()) + .orElseThrow(() -> { + log.error("존재하지 않는 이메일 입니다. :: {}", deleteUserDto.getEmail()); + throw new NotFoundEmailException(); + } + ); - User user = optionalUser.get(); return user.delete(deleteUserDto); } @Override @Transactional public User changePassword(@Valid ChangePasswordDTO changePasswordDto) { - Optional optionalUser = userRepository.findByEmailAndIsDeletedFalse(changePasswordDto.getEmail()); - if (optionalUser.isEmpty()) { - log.error("존재하지 않는 이메일 입니다. :: {}", changePasswordDto); - throw new NotFoundEmailException(); - } - - User user = optionalUser.get(); + User user = findNotDeletedUserByEmail(changePasswordDto.getEmail()); return user.changePassword(changePasswordDto); } + private User findNotDeletedUserByEmail(String email) { + return userRepository.findByEmailAndIsDeletedFalse(email) + .orElseThrow(() -> { + log.error("존재하지 않는 이메일 입니다. :: {}", email); + throw new NotFoundEmailException(); + }); + } + } diff --git a/server/src/main/java/com/ticketing/server/user/service/interfaces/UserService.java b/server/src/main/java/com/ticketing/server/user/service/interfaces/UserService.java index 76a0232..02a6097 100644 --- a/server/src/main/java/com/ticketing/server/user/service/interfaces/UserService.java +++ b/server/src/main/java/com/ticketing/server/user/service/interfaces/UserService.java @@ -5,9 +5,13 @@ import com.ticketing.server.user.service.dto.ChangePasswordDTO; import com.ticketing.server.user.service.dto.DeleteUserDTO; import com.ticketing.server.user.service.dto.SignUpDTO; import javax.validation.Valid; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; public interface UserService { + String login(UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken); + User register(@Valid SignUpDTO signUpDto); User delete(@Valid DeleteUserDTO deleteUserDto); diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 6dcad0f..af6de3d 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -18,3 +18,9 @@ spring: jasypt: encryptor: bean: jasyptStringEncryptor + +jwt: + access-header: ACCESS_TOKEN + prefix: Bearer + secret-key: Zi1sYWItdGlja2V0aW5nLXByb2plY3Qtc3ByaW5nLWJvb3Qtc2VjdXJpdHktand0LXNlY3JldC1rZXktZi1sYWItdGlja2V0aW5nLXByb2plY3Qtc3ByaW5nLWJvb3Qtc2VjdXJpdHktand0LXNlY3JldC1rZXkK + token-validity-in-seconds: 86400 diff --git a/server/src/test/java/com/ticketing/server/global/jwt/JwtPropertiesTest.java b/server/src/test/java/com/ticketing/server/global/jwt/JwtPropertiesTest.java new file mode 100644 index 0000000..65fd266 --- /dev/null +++ b/server/src/test/java/com/ticketing/server/global/jwt/JwtPropertiesTest.java @@ -0,0 +1,36 @@ +package com.ticketing.server.global.jwt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.ticketing.server.global.factory.YamlPropertySourceFactory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.PropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +@ExtendWith(SpringExtension.class) +@EnableConfigurationProperties(value = JwtProperties.class) +@PropertySource(value = "classpath:application.yml", factory = YamlPropertySourceFactory.class) +class JwtPropertiesTest { + + @Autowired + private JwtProperties jwtProperties; + + @Test + @DisplayName("yml - jwt 설정파일 체크") + void jwtPropertiesCheck() { + // given + // when + // then + assertAll( + () -> assertThat(jwtProperties.getAccessHeader()).isEqualTo("ACCESS_TOKEN") + , () -> assertThat(jwtProperties.getPrefix()).isEqualTo("Bearer") + , () -> assertThat(jwtProperties.getTokenValidityInSeconds()).isEqualTo(86400) + , () -> assertThat(jwtProperties.getSecretKey()).isNotEmpty()); + } + +} diff --git a/server/src/test/java/com/ticketing/server/global/jwt/TokenProviderTest.java b/server/src/test/java/com/ticketing/server/global/jwt/TokenProviderTest.java new file mode 100644 index 0000000..1d25679 --- /dev/null +++ b/server/src/test/java/com/ticketing/server/global/jwt/TokenProviderTest.java @@ -0,0 +1,67 @@ +package com.ticketing.server.global.jwt; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ticketing.server.global.factory.YamlPropertySourceFactory; +import com.ticketing.server.user.domain.UserGrade; +import java.util.Collections; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.PropertySource; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +@ExtendWith(SpringExtension.class) +@EnableConfigurationProperties(value = JwtProperties.class) +@PropertySource(value = "classpath:application.yml", factory = YamlPropertySourceFactory.class) +class TokenProviderTest { + + @Autowired + private JwtProperties jwtProperties; + + JwtProvider tokenProvider; + + + @BeforeEach + void init() { + tokenProvider = new JwtProvider(jwtProperties); + } + + @Test + @DisplayName("토큰 생성 성공") + void createTokenSuccess() { + // given + SimpleGrantedAuthority grantedAuthority = new SimpleGrantedAuthority(UserGrade.GUEST.name()); + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken("ticketing@gmail.com", "123456", Collections.singleton(grantedAuthority)); + + // when + String token = tokenProvider.createToken(authenticationToken); + + // then + assertThat(token).isNotEmpty(); + } + + @Test + @DisplayName("토큰 복호화 성공") + void getAuthentication() { + // given + SimpleGrantedAuthority grantedAuthority = new SimpleGrantedAuthority(UserGrade.GUEST.name()); + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken("ticketing@gmail.com", "123456", Collections.singleton(grantedAuthority)); + + // when + String token = tokenProvider.createToken(authenticationToken); + Authentication authentication = tokenProvider.getAuthentication(token); + + // then + assertThat(authentication.getName()).isEqualTo("ticketing@gmail.com"); + } + +} diff --git a/server/src/test/java/com/ticketing/server/global/security/SecurityContextHolderTest.java b/server/src/test/java/com/ticketing/server/global/security/SecurityContextHolderTest.java new file mode 100644 index 0000000..e6ee171 --- /dev/null +++ b/server/src/test/java/com/ticketing/server/global/security/SecurityContextHolderTest.java @@ -0,0 +1,66 @@ +package com.ticketing.server.global.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +@ExtendWith(MockitoExtension.class) +class SecurityContextHolderTest { + + private static final String ANY_USER = "ticketing"; + private static final String ANY_PASSWORD = "password"; + private static final String ROLE_GUEST = "ROLE_GUEST"; + + @BeforeEach + void init() { + SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_THREADLOCAL); + setMockAuthentication(); + } + + @AfterEach + void clear() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("SecurityContextHolder 에 현재 인증된 사용자 정보를 확인한다.") + void SecurityContextHolder() { + // given + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // when + // then + assertAll( + () -> assertThat(authentication.isAuthenticated()).isTrue() + , () -> assertThat(authentication.getName()).isEqualTo(ANY_USER) + , () -> assertThat(authentication.getCredentials()).isEqualTo(ANY_PASSWORD) + , () -> assertThat(authentication.getAuthorities()) + .extracting(GrantedAuthority::getAuthority) + .contains(ROLE_GUEST) + ); + + } + + + private void setMockAuthentication() { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + + TestingAuthenticationToken mockAuthentication + = new TestingAuthenticationToken(SecurityContextHolderTest.ANY_USER, SecurityContextHolderTest.ANY_PASSWORD, SecurityContextHolderTest.ROLE_GUEST); + context.setAuthentication(mockAuthentication); + + SecurityContextHolder.setContext(context); + } + +} diff --git a/server/src/test/java/com/ticketing/server/user/application/UserControllerTest.java b/server/src/test/java/com/ticketing/server/user/application/UserControllerTest.java new file mode 100644 index 0000000..deaac47 --- /dev/null +++ b/server/src/test/java/com/ticketing/server/user/application/UserControllerTest.java @@ -0,0 +1,96 @@ +package com.ticketing.server.user.application; + +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +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.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ticketing.server.user.application.request.LoginRequest; +import com.ticketing.server.user.application.request.SignUpRequest; +import com.ticketing.server.user.service.interfaces.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; + +@SpringBootTest +@Transactional +class UserControllerTest { + + @Autowired + WebApplicationContext context; + + @Autowired + UserService userService; + + @Autowired + PasswordEncoder passwordEncoder; + + @Autowired + ObjectMapper objectMapper; + + MockMvc mvc; + + @BeforeEach + void init() throws Exception { + mvc = MockMvcBuilders + .webAppContextSetup(context) + .apply(springSecurity()) + .build(); + + SignUpRequest signUpRequest = new SignUpRequest("ticketing", "ticketing@gmail.com", "qwe123", "010-2240-7920"); + + mvc.perform(post("/user") + .content(asJsonString(signUpRequest)) + .contentType(MediaType.APPLICATION_JSON)); + } + + @Test + @DisplayName("로그인 인증 성공") + void loginSuccess() throws Exception { + // given + LoginRequest request = new LoginRequest("ticketing@gmail.com", "qwe123"); + + // when + ResultActions actions = mvc.perform(post("/user/login") + .content(asJsonString(request)) + .contentType(MediaType.APPLICATION_JSON)); + + // then + actions.andDo(print()) + .andExpect(status().isOk()) + .andExpect(header().exists("ACCESS_TOKEN")); + } + + @Test + @DisplayName("로그인 패스워드 인증 실패") + void loginPasswordFail() throws Exception { + // given + LoginRequest request = new LoginRequest("ticketing@gmail.com", "qwe1234"); + + // when + ResultActions actions = mvc.perform(post("/user/login") + .content(asJsonString(request)) + .contentType(MediaType.APPLICATION_JSON)); + + // then + actions.andDo(print()) + .andExpect(status().isUnauthorized()); + } + + private String asJsonString(Object object) throws JsonProcessingException { + return objectMapper.writeValueAsString(object); + } + +} diff --git a/server/src/test/java/com/ticketing/server/user/service/dto/SignUpDtoTest.java b/server/src/test/java/com/ticketing/server/user/service/dto/SignUpDtoTest.java index 2b57fa8..c3dd90f 100644 --- a/server/src/test/java/com/ticketing/server/user/service/dto/SignUpDtoTest.java +++ b/server/src/test/java/com/ticketing/server/user/service/dto/SignUpDtoTest.java @@ -21,4 +21,4 @@ class SignUpDtoTest { assertThat(user).isInstanceOf(User.class); } -} \ No newline at end of file +} diff --git a/server/src/test/resources/application.yml b/server/src/test/resources/application.yml index f586e51..a846681 100644 --- a/server/src/test/resources/application.yml +++ b/server/src/test/resources/application.yml @@ -16,3 +16,9 @@ spring: jasypt: encryptor: bean: jasyptStringEncryptor + +jwt: + access-header: ACCESS_TOKEN + prefix: Bearer + secret-key: Zi1sYWItdGlja2V0aW5nLXByb2plY3Qtc3ByaW5nLWJvb3Qtc2VjdXJpdHktand0LXNlY3JldC1rZXktZi1sYWItdGlja2V0aW5nLXByb2plY3Qtc3ByaW5nLWJvb3Qtc2VjdXJpdHktand0LXNlY3JldC1rZXkK + token-validity-in-seconds: 86400