diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 9a7cdd5..0777047 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { 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") + implementation("org.springframework.boot:spring-boot-starter-data-redis") modules { module("org.springframework.boot:spring-boot-starter-logging") { diff --git a/server/src/main/java/com/ticketing/server/ServerApplication.java b/server/src/main/java/com/ticketing/server/ServerApplication.java index 4afc993..caa59da 100644 --- a/server/src/main/java/com/ticketing/server/ServerApplication.java +++ b/server/src/main/java/com/ticketing/server/ServerApplication.java @@ -1,6 +1,6 @@ package com.ticketing.server; -import com.ticketing.server.global.jwt.JwtProperties; +import com.ticketing.server.global.security.jwt.JwtProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; diff --git a/server/src/main/java/com/ticketing/server/global/exception/token/NotFindTokenException.java b/server/src/main/java/com/ticketing/server/global/exception/token/NotFindTokenException.java new file mode 100644 index 0000000..5884e72 --- /dev/null +++ b/server/src/main/java/com/ticketing/server/global/exception/token/NotFindTokenException.java @@ -0,0 +1,10 @@ +package com.ticketing.server.global.exception.token; + +public class NotFindTokenException extends TokenException { + + private static final String MESSAGE = "일치하는 토큰을 찾지 못하였습니다."; + + public NotFindTokenException() { + super(MESSAGE); + } +} diff --git a/server/src/main/java/com/ticketing/server/global/exception/token/TokenException.java b/server/src/main/java/com/ticketing/server/global/exception/token/TokenException.java new file mode 100644 index 0000000..7baa754 --- /dev/null +++ b/server/src/main/java/com/ticketing/server/global/exception/token/TokenException.java @@ -0,0 +1,9 @@ +package com.ticketing.server.global.exception.token; + +public class TokenException extends RuntimeException { + + public TokenException(String message) { + super(message); + } + +} diff --git a/server/src/main/java/com/ticketing/server/global/exception/token/TokenTypeException.java b/server/src/main/java/com/ticketing/server/global/exception/token/TokenTypeException.java new file mode 100644 index 0000000..cea8003 --- /dev/null +++ b/server/src/main/java/com/ticketing/server/global/exception/token/TokenTypeException.java @@ -0,0 +1,10 @@ +package com.ticketing.server.global.exception.token; + +public class TokenTypeException extends TokenException { + + private static final String MESSAGE = "토큰 타입이 일치하지 않습니다."; + + public TokenTypeException() { + super(MESSAGE); + } +} diff --git a/server/src/main/java/com/ticketing/server/global/exception/token/UnavailableRefreshTokenException.java b/server/src/main/java/com/ticketing/server/global/exception/token/UnavailableRefreshTokenException.java new file mode 100644 index 0000000..23c370e --- /dev/null +++ b/server/src/main/java/com/ticketing/server/global/exception/token/UnavailableRefreshTokenException.java @@ -0,0 +1,11 @@ +package com.ticketing.server.global.exception.token; + +public class UnavailableRefreshTokenException extends TokenException { + + private static final String MESSAGE = "사용할 수 없는 refresh Token 입니다."; + + public UnavailableRefreshTokenException() { + super(MESSAGE); + } + +} diff --git a/server/src/main/java/com/ticketing/server/global/redis/RedisConfig.java b/server/src/main/java/com/ticketing/server/global/redis/RedisConfig.java new file mode 100644 index 0000000..1249dec --- /dev/null +++ b/server/src/main/java/com/ticketing/server/global/redis/RedisConfig.java @@ -0,0 +1,44 @@ +package com.ticketing.server.global.redis; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +@EnableRedisRepositories +public class RedisConfig { + + @Value("${spring.redis.host}") + private String host; + + @Value("${spring.redis.port}") + private int port; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(host, port); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + + return redisTemplate; + } + + @Bean + public PlatformTransactionManager transactionManager() { + return new JpaTransactionManager(); + } + +} diff --git a/server/src/main/java/com/ticketing/server/global/redis/RefreshRedisRepository.java b/server/src/main/java/com/ticketing/server/global/redis/RefreshRedisRepository.java new file mode 100644 index 0000000..b5e4430 --- /dev/null +++ b/server/src/main/java/com/ticketing/server/global/redis/RefreshRedisRepository.java @@ -0,0 +1,10 @@ +package com.ticketing.server.global.redis; + +import java.util.Optional; +import org.springframework.data.repository.CrudRepository; + +public interface RefreshRedisRepository extends CrudRepository { + + Optional findByEmail(String email); + +} diff --git a/server/src/main/java/com/ticketing/server/global/redis/RefreshToken.java b/server/src/main/java/com/ticketing/server/global/redis/RefreshToken.java new file mode 100644 index 0000000..b83771d --- /dev/null +++ b/server/src/main/java/com/ticketing/server/global/redis/RefreshToken.java @@ -0,0 +1,35 @@ +package com.ticketing.server.global.redis; + +import javax.persistence.Column; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.index.Indexed; + +@Getter +@RedisHash("RefreshToken") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RefreshToken { + + @Id + @GeneratedValue + @Column(name = "refresh_token_id") + private Long id; + + @Indexed + private String email; + private String token; + + public RefreshToken(String email, String token) { + this.email = email; + this.token = token; + } + + public void changeToken(String token) { + this.token = token; + } + +} 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 index 9d88b04..3e6a14e 100644 --- a/server/src/main/java/com/ticketing/server/global/security/WebSecurityConfig.java +++ b/server/src/main/java/com/ticketing/server/global/security/WebSecurityConfig.java @@ -1,9 +1,9 @@ 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 com.ticketing.server.global.security.jwt.JwtFilter; +import com.ticketing.server.global.security.jwt.JwtSecurityConfig; +import com.ticketing.server.global.security.jwt.handle.JwtAccessDeniedHandler; +import com.ticketing.server.global.security.jwt.handle.JwtAuthenticationEntryPoint; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -53,6 +53,7 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter { .and() .authorizeRequests() .antMatchers(HttpMethod.POST, "/api/user/login").permitAll() + .antMatchers(HttpMethod.POST, "/api/user/refresh").permitAll() .antMatchers(HttpMethod.POST, "/api/user").permitAll() .antMatchers("/l7check").permitAll() .antMatchers("/actuator/health").permitAll() diff --git a/server/src/main/java/com/ticketing/server/global/jwt/JwtFilter.java b/server/src/main/java/com/ticketing/server/global/security/jwt/JwtFilter.java similarity index 80% rename from server/src/main/java/com/ticketing/server/global/jwt/JwtFilter.java rename to server/src/main/java/com/ticketing/server/global/security/jwt/JwtFilter.java index 7f62c9e..dfbe84f 100644 --- a/server/src/main/java/com/ticketing/server/global/jwt/JwtFilter.java +++ b/server/src/main/java/com/ticketing/server/global/security/jwt/JwtFilter.java @@ -1,4 +1,4 @@ -package com.ticketing.server.global.jwt; +package com.ticketing.server.global.security.jwt; import java.io.IOException; import javax.servlet.FilterChain; @@ -17,12 +17,10 @@ import org.springframework.web.filter.OncePerRequestFilter; public class JwtFilter extends OncePerRequestFilter { private final JwtProvider tokenProvider; - private final String accessHeader; - private final String tokenPrefix; + private final JwtProperties jwtProperties; public JwtFilter(JwtProperties jwtProperties, JwtProvider tokenProvider) { - this.accessHeader = jwtProperties.getAccessHeader(); - this.tokenPrefix = jwtProperties.getPrefix(); + this.jwtProperties = jwtProperties; this.tokenProvider = tokenProvider; } @@ -40,11 +38,10 @@ public class JwtFilter extends OncePerRequestFilter { } private String resolveToken(HttpServletRequest request) { - String bearerToken = request.getHeader(accessHeader); - if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(tokenPrefix)) { + String bearerToken = request.getHeader(jwtProperties.getAccessHeader()); + if (StringUtils.hasText(bearerToken) && jwtProperties.hasTokenStartsWith(bearerToken)) { 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/security/jwt/JwtProperties.java similarity index 85% rename from server/src/main/java/com/ticketing/server/global/jwt/JwtProperties.java rename to server/src/main/java/com/ticketing/server/global/security/jwt/JwtProperties.java index c40941e..ef1679f 100644 --- a/server/src/main/java/com/ticketing/server/global/jwt/JwtProperties.java +++ b/server/src/main/java/com/ticketing/server/global/security/jwt/JwtProperties.java @@ -1,4 +1,4 @@ -package com.ticketing.server.global.jwt; +package com.ticketing.server.global.security.jwt; import com.ticketing.server.global.factory.YamlPropertySourceFactory; import lombok.Getter; @@ -21,4 +21,7 @@ public class JwtProperties { private final Integer accessTokenValidityInSeconds; private final Integer refreshTokenValidityInSeconds; + public boolean hasTokenStartsWith(String token) { + return token.startsWith(prefix); + } } diff --git a/server/src/main/java/com/ticketing/server/global/jwt/JwtProvider.java b/server/src/main/java/com/ticketing/server/global/security/jwt/JwtProvider.java similarity index 80% rename from server/src/main/java/com/ticketing/server/global/jwt/JwtProvider.java rename to server/src/main/java/com/ticketing/server/global/security/jwt/JwtProvider.java index 26442a7..3e0be0c 100644 --- a/server/src/main/java/com/ticketing/server/global/jwt/JwtProvider.java +++ b/server/src/main/java/com/ticketing/server/global/security/jwt/JwtProvider.java @@ -1,14 +1,11 @@ -package com.ticketing.server.global.jwt; +package com.ticketing.server.global.security.jwt; +import com.ticketing.server.user.application.response.TokenDto; 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; @@ -31,19 +28,29 @@ public class JwtProvider { private static final String AUTHORITIES_KEY = "auth"; private static final String AUTHORITIES_DELIMITER = ","; + private final Key key; + private final String prefix; private final long accessTokenValidityInMilliseconds; private final long refreshTokenValidityInMilliseconds; - private final Key key; public JwtProvider(JwtProperties jwtProperties) { - this.accessTokenValidityInMilliseconds = jwtProperties.getAccessTokenValidityInSeconds() * 1000L; - this.refreshTokenValidityInMilliseconds = jwtProperties.getRefreshTokenValidityInSeconds() * 1000L; - byte[] keyBytes = Decoders.BASE64.decode(jwtProperties.getSecretKey()); this.key = Keys.hmacShaKeyFor(keyBytes); + + this.prefix = jwtProperties.getPrefix(); + this.accessTokenValidityInMilliseconds = jwtProperties.getAccessTokenValidityInSeconds() * 1000L; + this.refreshTokenValidityInMilliseconds = jwtProperties.getRefreshTokenValidityInSeconds() * 1000L; } - public String createAccessToken(Authentication authentication) { + public TokenDto generateTokenDto(Authentication authentication) { + String accessToken = createAccessToken(authentication); + String refreshToken = createRefreshToken(authentication); + long expiresIn = accessTokenValidityInMilliseconds / 1000L; + + return TokenDto.of(accessToken, refreshToken, prefix, expiresIn); + } + + private String createAccessToken(Authentication authentication) { // 만료시간 계산 long now = (new Date()).getTime(); Date accessTokenExpiresIn = new Date(now + this.accessTokenValidityInMilliseconds); @@ -51,7 +58,7 @@ public class JwtProvider { return createToken(authentication, accessTokenExpiresIn); } - public String createRefreshToken(Authentication authentication) { + private String createRefreshToken(Authentication authentication) { // 만료시간 계산 long now = (new Date()).getTime(); Date refreshTokenExpiresIn = new Date(now + this.refreshTokenValidityInMilliseconds); @@ -89,6 +96,7 @@ public class JwtProvider { // 토큰 복호화 Claims claims = parseClaims(token); + // 권한조회 List authorities = Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(AUTHORITIES_DELIMITER)) .map(SimpleGrantedAuthority::new) @@ -99,25 +107,12 @@ public class JwtProvider { } 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; + parseClaims(token); + return true; } private Claims parseClaims(String token) { - return Jwts - .parserBuilder() + return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) diff --git a/server/src/main/java/com/ticketing/server/global/jwt/JwtSecurityConfig.java b/server/src/main/java/com/ticketing/server/global/security/jwt/JwtSecurityConfig.java similarity index 93% rename from server/src/main/java/com/ticketing/server/global/jwt/JwtSecurityConfig.java rename to server/src/main/java/com/ticketing/server/global/security/jwt/JwtSecurityConfig.java index 18b99e8..7e4f3bf 100644 --- a/server/src/main/java/com/ticketing/server/global/jwt/JwtSecurityConfig.java +++ b/server/src/main/java/com/ticketing/server/global/security/jwt/JwtSecurityConfig.java @@ -1,4 +1,4 @@ -package com.ticketing.server.global.jwt; +package com.ticketing.server.global.security.jwt; import lombok.RequiredArgsConstructor; import org.springframework.security.config.annotation.SecurityConfigurerAdapter; diff --git a/server/src/main/java/com/ticketing/server/global/jwt/handle/JwtAccessDeniedHandler.java b/server/src/main/java/com/ticketing/server/global/security/jwt/handle/JwtAccessDeniedHandler.java similarity index 91% rename from server/src/main/java/com/ticketing/server/global/jwt/handle/JwtAccessDeniedHandler.java rename to server/src/main/java/com/ticketing/server/global/security/jwt/handle/JwtAccessDeniedHandler.java index 61b4040..9cad636 100644 --- a/server/src/main/java/com/ticketing/server/global/jwt/handle/JwtAccessDeniedHandler.java +++ b/server/src/main/java/com/ticketing/server/global/security/jwt/handle/JwtAccessDeniedHandler.java @@ -1,4 +1,4 @@ -package com.ticketing.server.global.jwt.handle; +package com.ticketing.server.global.security.jwt.handle; import java.io.IOException; import javax.servlet.http.HttpServletRequest; diff --git a/server/src/main/java/com/ticketing/server/global/jwt/handle/JwtAuthenticationEntryPoint.java b/server/src/main/java/com/ticketing/server/global/security/jwt/handle/JwtAuthenticationEntryPoint.java similarity index 91% rename from server/src/main/java/com/ticketing/server/global/jwt/handle/JwtAuthenticationEntryPoint.java rename to server/src/main/java/com/ticketing/server/global/security/jwt/handle/JwtAuthenticationEntryPoint.java index 2fc5b0b..aef6c0d 100644 --- a/server/src/main/java/com/ticketing/server/global/jwt/handle/JwtAuthenticationEntryPoint.java +++ b/server/src/main/java/com/ticketing/server/global/security/jwt/handle/JwtAuthenticationEntryPoint.java @@ -1,4 +1,4 @@ -package com.ticketing.server.global.jwt.handle; +package com.ticketing.server.global.security.jwt.handle; import java.io.IOException; import javax.servlet.http.HttpServletRequest; diff --git a/server/src/main/java/com/ticketing/server/global/service/CustomUserDetailsService.java b/server/src/main/java/com/ticketing/server/global/security/service/CustomUserDetailsService.java similarity index 95% rename from server/src/main/java/com/ticketing/server/global/service/CustomUserDetailsService.java rename to server/src/main/java/com/ticketing/server/global/security/service/CustomUserDetailsService.java index e16573a..056a33e 100644 --- a/server/src/main/java/com/ticketing/server/global/service/CustomUserDetailsService.java +++ b/server/src/main/java/com/ticketing/server/global/security/service/CustomUserDetailsService.java @@ -1,4 +1,4 @@ -package com.ticketing.server.global.service; +package com.ticketing.server.global.security.service; import com.ticketing.server.user.domain.User; import com.ticketing.server.user.domain.repository.UserRepository; 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 070686a..1fdeb26 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,16 +1,17 @@ package com.ticketing.server.user.application; -import com.ticketing.server.global.jwt.JwtProperties; +import com.ticketing.server.global.security.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.TokenDto; 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 com.ticketing.server.user.service.interfaces.AuthenticationService; import javax.servlet.http.HttpServletResponse; import javax.validation.Valid; import lombok.RequiredArgsConstructor; @@ -24,6 +25,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -34,6 +36,7 @@ import org.springframework.web.bind.annotation.RestController; public class UserController { private final UserServiceImpl userService; + private final AuthenticationService authenticationService; private final PasswordEncoder passwordEncoder; private final JwtProperties jwtProperties; @@ -63,8 +66,17 @@ public class UserController { } @PostMapping("/login") - public ResponseEntity login(@RequestBody LoginRequest loginRequest, HttpServletResponse response) { - LoginResponse tokenDto = userService.login(loginRequest.toAuthentication()); + public ResponseEntity login(@RequestBody LoginRequest loginRequest, HttpServletResponse response) { + TokenDto tokenDto = authenticationService.login(loginRequest.toAuthentication()); + + response.setHeader("Cache-Control", "no-store"); + response.setHeader("Pragma", "no-store"); + return ResponseEntity.status(HttpStatus.OK).body(tokenDto); + } + + @PostMapping("/refresh") + public ResponseEntity refreshToken(@RequestParam("refreshToken") String refreshToken, HttpServletResponse response) { + TokenDto tokenDto = authenticationService.reissueAccessToken(refreshToken); response.setHeader(jwtProperties.getAccessHeader(), tokenDto.getAccessToken()); response.setHeader(jwtProperties.getRefreshHeader(), tokenDto.getRefreshToken()); 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 deleted file mode 100644 index 2e459c9..0000000 --- a/server/src/main/java/com/ticketing/server/user/application/response/LoginResponse.java +++ /dev/null @@ -1,18 +0,0 @@ -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; - private String refreshToken; - - public static LoginResponse of(String accessToken, String refreshToken) { - return new LoginResponse(accessToken, refreshToken); - } - -} diff --git a/server/src/main/java/com/ticketing/server/user/application/response/TokenDto.java b/server/src/main/java/com/ticketing/server/user/application/response/TokenDto.java new file mode 100644 index 0000000..3b4470f --- /dev/null +++ b/server/src/main/java/com/ticketing/server/user/application/response/TokenDto.java @@ -0,0 +1,20 @@ +package com.ticketing.server.user.application.response; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class TokenDto { + + private final String accessToken; + private final String refreshToken; + private final String tokenType; + private final long expiresIn; + + public static TokenDto of(String accessToken, String refreshToken, String tokenType, long expiresIn) { + return new TokenDto(accessToken, refreshToken, tokenType, expiresIn); + } + +} diff --git a/server/src/main/java/com/ticketing/server/user/service/AuthenticationServiceImpl.java b/server/src/main/java/com/ticketing/server/user/service/AuthenticationServiceImpl.java new file mode 100644 index 0000000..aab1a64 --- /dev/null +++ b/server/src/main/java/com/ticketing/server/user/service/AuthenticationServiceImpl.java @@ -0,0 +1,87 @@ +package com.ticketing.server.user.service; + +import com.ticketing.server.global.exception.token.NotFindTokenException; +import com.ticketing.server.global.exception.token.TokenTypeException; +import com.ticketing.server.global.exception.token.UnavailableRefreshTokenException; +import com.ticketing.server.global.redis.RefreshRedisRepository; +import com.ticketing.server.global.redis.RefreshToken; +import com.ticketing.server.global.security.jwt.JwtProperties; +import com.ticketing.server.global.security.jwt.JwtProvider; +import com.ticketing.server.user.application.response.TokenDto; +import com.ticketing.server.user.service.interfaces.AuthenticationService; +import lombok.RequiredArgsConstructor; +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.util.StringUtils; + +@Service +@RequiredArgsConstructor +public class AuthenticationServiceImpl implements AuthenticationService { + + private final RefreshRedisRepository refreshRedisRepository; + + private final JwtProvider jwtProvider; + private final JwtProperties jwtProperties; + private final AuthenticationManagerBuilder authenticationManagerBuilder; + + @Override + @Transactional + public TokenDto login(UsernamePasswordAuthenticationToken authenticationToken) { + // 회원인증 + Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken); + + String email = authenticationToken.getName(); + + // 토큰 발급 + TokenDto tokenDto = jwtProvider.generateTokenDto(authentication); + + // refresh 토큰이 있으면 수정, 없으면 생성 + refreshRedisRepository.findByEmail(email) + .ifPresentOrElse( + tokenEntity -> tokenEntity.changeToken(tokenDto.getRefreshToken()), + () -> refreshRedisRepository.save(new RefreshToken(email, tokenDto.getRefreshToken())) + ); + + return tokenDto; + } + + @Override + @Transactional + public TokenDto reissueAccessToken(String bearerRefreshToken) { + String refreshToken = resolveToken(bearerRefreshToken); + + // 토큰 검증 + jwtProvider.validateToken(refreshToken); + + Authentication authentication = jwtProvider.getAuthentication(refreshToken); + + // Redis 에 토큰이 있는지 검증 + RefreshToken findTokenEntity = refreshRedisRepository.findByEmail(authentication.getName()) + .orElseThrow(NotFindTokenException::new); + + // redis 토큰과 input 토큰이 일치한지 확인 + if (!refreshToken.equals(findTokenEntity.getToken())) { + throw new UnavailableRefreshTokenException(); + } + + // 토큰 발급 + TokenDto tokenDto = jwtProvider.generateTokenDto(authentication); + + // 토큰 최신화 + findTokenEntity.changeToken(tokenDto.getRefreshToken()); + refreshRedisRepository.save(findTokenEntity); + + return tokenDto; + } + + private String resolveToken(String bearerToken) { + if (StringUtils.hasText(bearerToken) && jwtProperties.hasTokenStartsWith(bearerToken)) { + return bearerToken.substring(7); + } + throw new TokenTypeException(); + } + +} 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 136ff02..c42ba49 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,8 +1,6 @@ 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.application.response.LoginResponse; import com.ticketing.server.user.domain.User; import com.ticketing.server.user.domain.repository.UserRepository; import com.ticketing.server.user.service.dto.ChangePasswordDTO; @@ -13,9 +11,6 @@ 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; @@ -28,14 +23,6 @@ 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 LoginResponse login(UsernamePasswordAuthenticationToken authenticationToken) { - Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken); - return LoginResponse.of(jwtProvider.createAccessToken(authentication), jwtProvider.createRefreshToken(authentication)); - } @Override @Transactional diff --git a/server/src/main/java/com/ticketing/server/user/service/interfaces/AuthenticationService.java b/server/src/main/java/com/ticketing/server/user/service/interfaces/AuthenticationService.java new file mode 100644 index 0000000..2e95919 --- /dev/null +++ b/server/src/main/java/com/ticketing/server/user/service/interfaces/AuthenticationService.java @@ -0,0 +1,12 @@ +package com.ticketing.server.user.service.interfaces; + +import com.ticketing.server.user.application.response.TokenDto; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; + +public interface AuthenticationService { + + TokenDto login(UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken); + + TokenDto reissueAccessToken(String bearerRefreshToken); + +} 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 d6eb85f..76a0232 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 @@ -1,17 +1,13 @@ package com.ticketing.server.user.service.interfaces; -import com.ticketing.server.user.application.response.LoginResponse; import com.ticketing.server.user.domain.User; 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; public interface UserService { - LoginResponse 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 f916b53..d41cd67 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -4,6 +4,9 @@ server: spring: profiles: active: local + redis: + host: localhost + port: 6379 jpa: hibernate: diff --git a/server/src/test/java/com/ticketing/server/global/redis/RefreshRedisRepositoryTest.java b/server/src/test/java/com/ticketing/server/global/redis/RefreshRedisRepositoryTest.java new file mode 100644 index 0000000..4c5aeaa --- /dev/null +++ b/server/src/test/java/com/ticketing/server/global/redis/RefreshRedisRepositoryTest.java @@ -0,0 +1,75 @@ +package com.ticketing.server.global.redis; + +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.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class RefreshRedisRepositoryTest { + + @Autowired + RefreshRedisRepository refreshRedisRepository; + + @AfterEach + void tearDown() { + refreshRedisRepository.deleteAll(); + } + + @Test + @DisplayName("기본 등록 및 조회기능") + void saveAndFind() { + // given + RefreshToken refreshToken = new RefreshToken("ticketing@gmail.com", "refreshToken"); + + // when + refreshRedisRepository.save(refreshToken); + + // then + RefreshToken findRefreshToken = refreshRedisRepository.findById(refreshToken.getId()).get(); + assertAll( + () -> assertThat(findRefreshToken.getEmail()).isEqualTo("ticketing@gmail.com") + , () -> assertThat(findRefreshToken.getToken()).isEqualTo("refreshToken") + ); + } + + @Test + @DisplayName("기본 등록 및 이메일 조회") + void saveAndFindByEmail() { + // given + RefreshToken refreshToken = new RefreshToken("ticketing@gmail.com", "refreshToken"); + + // when + refreshRedisRepository.save(refreshToken); + + // then + RefreshToken findRefreshToken = refreshRedisRepository.findByEmail(refreshToken.getEmail()).get(); + assertAll( + () -> assertThat(findRefreshToken.getEmail()).isEqualTo("ticketing@gmail.com") + , () -> assertThat(findRefreshToken.getToken()).isEqualTo("refreshToken") + ); + } + + @Test + @DisplayName("기본 등록 및 수정기능") + void saveAndSave() { + // given + RefreshToken refreshToken = new RefreshToken("ticketing@gmail.com", "refreshToken"); + refreshRedisRepository.save(refreshToken); + Long id = refreshToken.getId(); + + // when + RefreshToken savedRefreshToken = refreshRedisRepository.findById(id).get(); + savedRefreshToken.changeToken("refreshToken2"); + refreshRedisRepository.save(savedRefreshToken); + + // then + RefreshToken lastSavedRefreshToken = refreshRedisRepository.findById(id).get(); + assertThat(lastSavedRefreshToken.getToken()).isEqualTo("refreshToken2"); + } + +} diff --git a/server/src/test/java/com/ticketing/server/global/jwt/JwtPropertiesTest.java b/server/src/test/java/com/ticketing/server/global/security/jwt/JwtPropertiesTest.java similarity index 70% rename from server/src/test/java/com/ticketing/server/global/jwt/JwtPropertiesTest.java rename to server/src/test/java/com/ticketing/server/global/security/jwt/JwtPropertiesTest.java index 7df31f6..da33963 100644 --- a/server/src/test/java/com/ticketing/server/global/jwt/JwtPropertiesTest.java +++ b/server/src/test/java/com/ticketing/server/global/security/jwt/JwtPropertiesTest.java @@ -1,4 +1,4 @@ -package com.ticketing.server.global.jwt; +package com.ticketing.server.global.security.jwt; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; @@ -35,4 +35,30 @@ class JwtPropertiesTest { , () -> assertThat(jwtProperties.getSecretKey()).isNotEmpty()); } + @Test + @DisplayName("token prefix 가 일치할 경우") + void hasTokenStartsWithTrue() { + // given + String token = jwtProperties.getPrefix() + " " + "tokenPrefixTest"; + + // when + boolean result = jwtProperties.hasTokenStartsWith(token); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("token prefix 가 일치하지 않을 경우") + void hasTokenStartsWithFalse() { + // given + String token = "tokenPrefixTest"; + + // when + boolean result = jwtProperties.hasTokenStartsWith(token); + + // then + assertThat(result).isFalse(); + } + } diff --git a/server/src/test/java/com/ticketing/server/global/jwt/TokenProviderTest.java b/server/src/test/java/com/ticketing/server/global/security/jwt/TokenProviderTest.java similarity index 80% rename from server/src/test/java/com/ticketing/server/global/jwt/TokenProviderTest.java rename to server/src/test/java/com/ticketing/server/global/security/jwt/TokenProviderTest.java index 707a28e..df81114 100644 --- a/server/src/test/java/com/ticketing/server/global/jwt/TokenProviderTest.java +++ b/server/src/test/java/com/ticketing/server/global/security/jwt/TokenProviderTest.java @@ -1,8 +1,9 @@ -package com.ticketing.server.global.jwt; +package com.ticketing.server.global.security.jwt; import static org.assertj.core.api.Assertions.assertThat; import com.ticketing.server.global.factory.YamlPropertySourceFactory; +import com.ticketing.server.user.application.response.TokenDto; import com.ticketing.server.user.domain.UserGrade; import java.util.Collections; import org.junit.jupiter.api.BeforeEach; @@ -25,12 +26,12 @@ class TokenProviderTest { @Autowired private JwtProperties jwtProperties; - JwtProvider tokenProvider; + JwtProvider jwtProvider; @BeforeEach void init() { - tokenProvider = new JwtProvider(jwtProperties); + jwtProvider = new JwtProvider(jwtProperties); } @Test @@ -42,10 +43,10 @@ class TokenProviderTest { new UsernamePasswordAuthenticationToken("ticketing@gmail.com", "123456", Collections.singleton(grantedAuthority)); // when - String token = tokenProvider.createAccessToken(authenticationToken); + TokenDto tokenDto = jwtProvider.generateTokenDto(authenticationToken); // then - assertThat(token).isNotEmpty(); + assertThat(tokenDto).isInstanceOf(TokenDto.class); } @Test @@ -57,8 +58,8 @@ class TokenProviderTest { new UsernamePasswordAuthenticationToken("ticketing@gmail.com", "123456", Collections.singleton(grantedAuthority)); // when - String token = tokenProvider.createAccessToken(authenticationToken); - Authentication authentication = tokenProvider.getAuthentication(token); + TokenDto tokenDto = jwtProvider.generateTokenDto(authenticationToken); + Authentication authentication = jwtProvider.getAuthentication(tokenDto.getAccessToken()); // then assertThat(authentication.getName()).isEqualTo("ticketing@gmail.com"); 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 index a88f1e5..34f462b 100644 --- a/server/src/test/java/com/ticketing/server/user/application/UserControllerTest.java +++ b/server/src/test/java/com/ticketing/server/user/application/UserControllerTest.java @@ -3,14 +3,15 @@ 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.global.redis.RefreshRedisRepository; 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.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -40,22 +41,11 @@ class UserControllerTest { @Autowired ObjectMapper objectMapper; + @Autowired + RefreshRedisRepository refreshRedisRepository; + 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("/api/user") - .content(asJsonString(signUpRequest)) - .contentType(MediaType.APPLICATION_JSON)); - } - @Test @DisplayName("로그인 인증 성공") void loginSuccess() throws Exception { @@ -69,9 +59,7 @@ class UserControllerTest { // then actions.andDo(print()) - .andExpect(status().isOk()) - .andExpect(header().exists("ACCESS_TOKEN")) - .andExpect(header().exists("REFRESH_TOKEN")); + .andExpect(status().isOk()); } @Test @@ -94,4 +82,23 @@ class UserControllerTest { return objectMapper.writeValueAsString(object); } + @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("/api/user") + .content(asJsonString(signUpRequest)) + .contentType(MediaType.APPLICATION_JSON)); + } + + @AfterEach + void tearDown() { + refreshRedisRepository.deleteAll(); + } + } diff --git a/server/src/test/java/com/ticketing/server/user/service/AuthenticationServiceImplTest.java b/server/src/test/java/com/ticketing/server/user/service/AuthenticationServiceImplTest.java new file mode 100644 index 0000000..90420cb --- /dev/null +++ b/server/src/test/java/com/ticketing/server/user/service/AuthenticationServiceImplTest.java @@ -0,0 +1,84 @@ +package com.ticketing.server.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import com.ticketing.server.global.factory.YamlPropertySourceFactory; +import com.ticketing.server.global.redis.RefreshRedisRepository; +import com.ticketing.server.global.redis.RefreshToken; +import com.ticketing.server.global.security.jwt.JwtProperties; +import com.ticketing.server.global.security.jwt.JwtProvider; +import com.ticketing.server.user.application.response.TokenDto; +import com.ticketing.server.user.domain.UserGrade; +import java.util.Collections; +import java.util.Optional; +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.InjectMocks; +import org.mockito.Mock; +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.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 AuthenticationServiceImplTest { + + @Autowired + private JwtProperties useJwtProperties; + private JwtProvider useJwtProvider; + + @Mock + JwtProperties jwtProperties; + + @Mock + JwtProvider jwtProvider; + + @Mock + RefreshRedisRepository redisRepository; + + @InjectMocks + AuthenticationServiceImpl authenticationService; + + UsernamePasswordAuthenticationToken authenticationToken; + + @BeforeEach + void init() { + useJwtProvider = new JwtProvider(useJwtProperties); + SimpleGrantedAuthority grantedAuthority = new SimpleGrantedAuthority(UserGrade.GUEST.name()); + authenticationToken = + new UsernamePasswordAuthenticationToken("ticketing@gmail.com", "123456", Collections.singleton(grantedAuthority)); + } + + @Test + @DisplayName("토큰 재발급 성공") + void reissueAccessToken() { + // given + String refreshToken = "Bearer eyJhbGciOiJIUzUxMiJ9"; + when(jwtProvider.validateToken(any())).thenReturn(true); + when(jwtProvider.getAuthentication(any())).thenReturn(authenticationToken); + when(jwtProvider.generateTokenDto(any())).thenReturn(useJwtProvider.generateTokenDto(authenticationToken)); + when(redisRepository.findByEmail("ticketing@gmail.com")).thenReturn(Optional.of(new RefreshToken("ticketing@gmail.com", "eyJhbGciOiJIUzUxMiJ9"))); + when(jwtProperties.hasTokenStartsWith(refreshToken)).thenReturn(true); + + // when + TokenDto tokenDto = authenticationService.reissueAccessToken(refreshToken); + + // then + assertAll( + () -> assertThat(tokenDto.getAccessToken()).isNotEmpty() + , () -> assertThat(tokenDto.getRefreshToken()).isNotEmpty() + , () -> assertThat(tokenDto.getTokenType()).isEqualTo("Bearer") + , () -> assertThat(tokenDto.getExpiresIn()).isEqualTo(60) + ); + } + +} diff --git a/server/src/test/resources/application.yml b/server/src/test/resources/application.yml index 80291c6..68a9f13 100644 --- a/server/src/test/resources/application.yml +++ b/server/src/test/resources/application.yml @@ -4,6 +4,9 @@ spring: username: ENC(LowN1n4w0Ep/DqLD8+q5Bq6AXM4b8e3V) password: ENC(OMvGcpZLpggFTiGNkqNe66Zq/SmJXF6o) driver-class-name: com.mysql.cj.jdbc.Driver + redis: + host: localhost + port: 6379 jpa: properties: