diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml new file mode 100644 index 0000000..0ab4bce --- /dev/null +++ b/docker-compose-dev.yml @@ -0,0 +1,12 @@ +services: + redis: + image: redis + container_name: redis + volumes: + - /var/lib/docker/volumes/redis-volume/_data:/data + ports: + - "6379:6379" + hostname: redis + + + diff --git a/document/refreshToken sequence.puml b/document/refreshToken sequence.puml new file mode 100644 index 0000000..bf763fc --- /dev/null +++ b/document/refreshToken sequence.puml @@ -0,0 +1,43 @@ +@startuml +Actor Client +Participant Server +Database MySql +Database Redis + +== 로그인/회원가입 시 == +activate Client +Client -> Server: 로그인/회원가입 요청 +activate Server +Server -> MySql: 회원 정보 요청 +activate MySql +MySql --> Server: 회원 정보 +deactivate MySql +Server -> Redis: refreshToken 저장 +Redis --> Server: refreshToken +Server --> Client: 회원 정보/accessToken/refreshToken 반환 +deactivate Server + +== api 요청 시 accessToken 이 만료된 경우== +Client -> Server: api 요청 +activate Server +Server -> Server: accessToken 유효성 체크 +Server --> Client: 403 에러 반환 +deactivate Server + +== refreshToken으로 accessToken 갱신== +Client -> Server: accessToken 갱신 요청(refreshToken) +activate Server +Server -> Redis: refreshToken 조회 +activate Redis +alt refreshToken이 존재할 경우 + Redis --> Server: refreshToken entity + Server -> Server: accessToken 갱신 + Server --> Client: accessToken +else refreshToken이 만료된 경우 + Redis --> Server!!: + Server --> Client: 401 에러 + end + +deactivate Redis + +@enduml diff --git a/src/main/kotlin/com/swm_standard/phote/common/authority/JwtAuthenticationFilter.kt b/src/main/kotlin/com/swm_standard/phote/common/authority/JwtAuthenticationFilter.kt index 97407dd..10ccd75 100644 --- a/src/main/kotlin/com/swm_standard/phote/common/authority/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/com/swm_standard/phote/common/authority/JwtAuthenticationFilter.kt @@ -9,13 +9,16 @@ import org.springframework.util.StringUtils import org.springframework.web.filter.GenericFilterBean class JwtAuthenticationFilter( - private val jwtTokenProvider: JwtTokenProvider + private val jwtTokenProvider: JwtTokenProvider, ) : GenericFilterBean() { - override fun doFilter(request: ServletRequest, response: ServletResponse?, chain: FilterChain?) { - val token = resolveToken(request as HttpServletRequest) - - if (token != null && jwtTokenProvider.validateToken(token)) { - val authentication = jwtTokenProvider.getAuthentication(token) + override fun doFilter( + request: ServletRequest, + response: ServletResponse?, + chain: FilterChain?, + ) { + val accessToken = resolveToken(request as HttpServletRequest) + if (accessToken != null && jwtTokenProvider.validateToken(accessToken)) { + val authentication = jwtTokenProvider.getAuthentication(accessToken) SecurityContextHolder.getContext().authentication = authentication } diff --git a/src/main/kotlin/com/swm_standard/phote/common/authority/JwtTokenProvider.kt b/src/main/kotlin/com/swm_standard/phote/common/authority/JwtTokenProvider.kt index b5f0e2c..d3ef131 100644 --- a/src/main/kotlin/com/swm_standard/phote/common/authority/JwtTokenProvider.kt +++ b/src/main/kotlin/com/swm_standard/phote/common/authority/JwtTokenProvider.kt @@ -1,7 +1,12 @@ package com.swm_standard.phote.common.authority -import com.swm_standard.phote.dto.UserInfoResponse -import io.jsonwebtoken.* +import com.swm_standard.phote.common.exception.ExpiredTokenException +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 @@ -16,9 +21,7 @@ import org.springframework.stereotype.Component import java.util.Date import java.util.UUID -const val ACCESS_TOKEN_EXPIRATION: Long = 1000 * 60 * 60 * 24 - -const val REFRESH_TOKEN_EXPIRATION: Long = 1000 * 60 * 60 +const val ACCESS_TOKEN_EXPIRATION: Long = 1000 * 60 * 60 @Component class JwtTokenProvider { @@ -29,17 +32,14 @@ class JwtTokenProvider { Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey)) } - fun createToken( - userInfoResponseDto: UserInfoResponse, - memberId: UUID, - ): String { + fun createToken(memberId: UUID): String { val now = Date() val accessExpiration = Date(now.time + ACCESS_TOKEN_EXPIRATION) val accessToken = Jwts .builder() - .setSubject(userInfoResponseDto.email) + .setSubject(memberId.toString()) .claim("memberId", memberId) .setIssuedAt(now) .setExpiration(accessExpiration) @@ -49,17 +49,6 @@ class JwtTokenProvider { return "Bearer $accessToken" } -// fun createRefreshToken(): String { -// val now = Date() -// val refreshExpiration = Date(now.time + REFRESH_TOKEN_EXPIRATION) -// -// return Jwts.builder() -// .setIssuedAt(now) -// .setExpiration(refreshExpiration) -// .signWith(key, SignatureAlgorithm.HS256) -// .compact() -// } - fun getAuthentication(token: String): Authentication { val claims: Claims = getClaims(token) @@ -75,15 +64,17 @@ class JwtTokenProvider { return UsernamePasswordAuthenticationToken(principal, "", authorities) } - fun validateToken(token: String): Boolean { + fun validateToken(accessToken: String): Boolean { try { - getClaims(token) + getClaims(accessToken) return true } catch (e: Exception) { when (e) { is SecurityException -> {} is MalformedJwtException -> {} - is ExpiredJwtException -> {} + is ExpiredJwtException -> { + throw ExpiredTokenException() + } is UnsupportedJwtException -> {} is IllegalArgumentException -> {} else -> {} @@ -100,7 +91,7 @@ class JwtTokenProvider { .parseClaimsJws(token) .body - public fun getJwtContents(bearerToken: String): UUID { + fun getJwtContents(bearerToken: String): UUID { val token = bearerToken.substring(7) val claims = getClaims(token) val auth = claims["memberId"] ?: throw RuntimeException("잘못된 토큰입니다.") diff --git a/src/main/kotlin/com/swm_standard/phote/common/authority/SecurityConfig.kt b/src/main/kotlin/com/swm_standard/phote/common/authority/SecurityConfig.kt index d185da0..00255b4 100644 --- a/src/main/kotlin/com/swm_standard/phote/common/authority/SecurityConfig.kt +++ b/src/main/kotlin/com/swm_standard/phote/common/authority/SecurityConfig.kt @@ -12,7 +12,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic @Configuration @EnableWebSecurity class SecurityConfig( - private val jwtTokenProvider: JwtTokenProvider + private val jwtTokenProvider: JwtTokenProvider, ) { @Bean fun filterChain(http: HttpSecurity): SecurityFilterChain { @@ -22,13 +22,16 @@ class SecurityConfig( .cors(Customizer.withDefaults()) .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } .authorizeHttpRequests { - it.requestMatchers("/api/auth/*").anonymous() - .requestMatchers("/v3/**", "/swagger-ui/**").permitAll() - .anyRequest().authenticated() - } - .addFilterBefore( + it + .requestMatchers("/api/auth/*") + .anonymous() + .requestMatchers("/v3/**", "/swagger-ui/**", "api/token") + .permitAll() + .anyRequest() + .authenticated() + }.addFilterBefore( JwtAuthenticationFilter(jwtTokenProvider), - UsernamePasswordAuthenticationFilter::class.java + UsernamePasswordAuthenticationFilter::class.java, ) return http.build() diff --git a/src/main/kotlin/com/swm_standard/phote/common/exception/CustomExceptionHandler.kt b/src/main/kotlin/com/swm_standard/phote/common/exception/CustomExceptionHandler.kt index 3e23532..7812eeb 100644 --- a/src/main/kotlin/com/swm_standard/phote/common/exception/CustomExceptionHandler.kt +++ b/src/main/kotlin/com/swm_standard/phote/common/exception/CustomExceptionHandler.kt @@ -94,7 +94,7 @@ class CustomExceptionHandler { } @ExceptionHandler(AlreadyExistedException::class) - protected fun alreadyDeletedException( + protected fun alreadyExistedException( ex: AlreadyExistedException, ): ResponseEntity>> { val errors = mapOf(ex.fieldName to (ex.message ?: "Not Exception RequestMessage")) @@ -109,6 +109,20 @@ class CustomExceptionHandler { ) } + @ExceptionHandler(ExpiredTokenException::class) + protected fun expiredTokenException(ex: ExpiredTokenException): ResponseEntity>> { + val errors = mapOf(ex.fieldName to (ex.message ?: "Not Exception RequestMessage")) + return ResponseEntity( + BaseResponse( + ErrorCode.ERROR.name, + ErrorCode.EXPIRED_TOKEN.statusCode, + ErrorCode.EXPIRED_TOKEN.msg, + errors, + ), + HttpStatus.UNAUTHORIZED, + ) + } + @ExceptionHandler(NotFoundException::class) protected fun notFoundException(ex: NotFoundException): ResponseEntity>> { val errors = mapOf(ex.fieldName to (ex.message ?: "Not Exception RequestMessage")) diff --git a/src/main/kotlin/com/swm_standard/phote/common/exception/ExpiredTokenException.kt b/src/main/kotlin/com/swm_standard/phote/common/exception/ExpiredTokenException.kt new file mode 100644 index 0000000..48fe130 --- /dev/null +++ b/src/main/kotlin/com/swm_standard/phote/common/exception/ExpiredTokenException.kt @@ -0,0 +1,8 @@ +package com.swm_standard.phote.common.exception + +class ExpiredTokenException( + val fieldName: String = "", + message: String = "유효하지 않는 token입니다.", +) : RuntimeException(message) { + constructor(message: String) : this(message = message, fieldName = "") +} diff --git a/src/main/kotlin/com/swm_standard/phote/common/resolver/memberId/MemberIdResolver.kt b/src/main/kotlin/com/swm_standard/phote/common/resolver/memberId/MemberIdResolver.kt index e68579a..1206a8a 100644 --- a/src/main/kotlin/com/swm_standard/phote/common/resolver/memberId/MemberIdResolver.kt +++ b/src/main/kotlin/com/swm_standard/phote/common/resolver/memberId/MemberIdResolver.kt @@ -13,9 +13,8 @@ import java.util.* @Component class MemberIdResolver( - private val jwtTokenProvider: JwtTokenProvider + private val jwtTokenProvider: JwtTokenProvider, ) : HandlerMethodArgumentResolver { - // supportsParameter: 어떤 파라미터를 처리할 것인지 override fun supportsParameter(parameter: MethodParameter): Boolean { parameter.getParameterAnnotation(MemberId::class.java) ?: return false @@ -31,7 +30,7 @@ class MemberIdResolver( parameter: MethodParameter, mavContainer: ModelAndViewContainer?, webRequest: NativeWebRequest, - binderFactory: WebDataBinderFactory? + binderFactory: WebDataBinderFactory?, ): Any? { val request = webRequest.nativeRequest as HttpServletRequest diff --git a/src/main/kotlin/com/swm_standard/phote/common/responsebody/EnumStatus.kt b/src/main/kotlin/com/swm_standard/phote/common/responsebody/EnumStatus.kt index 270b068..0df02f8 100644 --- a/src/main/kotlin/com/swm_standard/phote/common/responsebody/EnumStatus.kt +++ b/src/main/kotlin/com/swm_standard/phote/common/responsebody/EnumStatus.kt @@ -17,4 +17,5 @@ enum class ErrorCode( BAD_REQUEST(HttpStatus.BAD_REQUEST.value(), "요청 값이 올바르지 않습니다."), NOT_FOUND(HttpStatus.NOT_FOUND.value(), "요청 값을 찾을 수 없습니다."), BAD_GATEWAY(HttpStatus.BAD_GATEWAY.value(), "서버가 요청을 처리하는데 실패했습니다."), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED.value(), "token에 오류가 발생했습니다."), } diff --git a/src/main/kotlin/com/swm_standard/phote/controller/TokenController.kt b/src/main/kotlin/com/swm_standard/phote/controller/TokenController.kt new file mode 100644 index 0000000..f4f5586 --- /dev/null +++ b/src/main/kotlin/com/swm_standard/phote/controller/TokenController.kt @@ -0,0 +1,25 @@ +package com.swm_standard.phote.controller + +import com.swm_standard.phote.common.responsebody.BaseResponse +import com.swm_standard.phote.dto.RenewAccessTokenResponse +import com.swm_standard.phote.service.TokenService +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +@RestController +@RequestMapping("/api") +class TokenController( + private val tokenService: TokenService, +) { + @PostMapping("/token") + fun renewAccessToken( + @RequestHeader refreshToken: UUID, + ): BaseResponse = + BaseResponse( + data = RenewAccessTokenResponse(tokenService.generateAccessToken(refreshToken)), + msg = "accessToken 갱신 성공", + ) +} diff --git a/src/main/kotlin/com/swm_standard/phote/dto/AuthDtos.kt b/src/main/kotlin/com/swm_standard/phote/dto/AuthDtos.kt index 511c056..2907896 100644 --- a/src/main/kotlin/com/swm_standard/phote/dto/AuthDtos.kt +++ b/src/main/kotlin/com/swm_standard/phote/dto/AuthDtos.kt @@ -23,4 +23,10 @@ data class UserInfoResponse( var picture: String, var isMember: Boolean? = true, var userId: UUID?, +) { + var refreshToken: UUID? = null +} + +data class RenewAccessTokenResponse( + val accessToken: String, ) diff --git a/src/main/kotlin/com/swm_standard/phote/entity/RefreshToken.kt b/src/main/kotlin/com/swm_standard/phote/entity/RefreshToken.kt new file mode 100644 index 0000000..8ee8dba --- /dev/null +++ b/src/main/kotlin/com/swm_standard/phote/entity/RefreshToken.kt @@ -0,0 +1,22 @@ +package com.swm_standard.phote.entity + +import org.springframework.data.annotation.CreatedDate +import org.springframework.data.annotation.Id +import org.springframework.data.domain.Persistable +import org.springframework.data.redis.core.RedisHash +import java.time.LocalDateTime +import java.util.UUID + +@RedisHash(value = "refreshToken", timeToLive = 129600) +data class RefreshToken( + @Id + val refreshToken: UUID, + val memberId: UUID, +) : Persistable { + @CreatedDate + var createdDate: LocalDateTime? = null + + override fun isNew() = createdDate == null + + override fun getId(): UUID = refreshToken +} diff --git a/src/main/kotlin/com/swm_standard/phote/external/redis/RedisConfig.kt b/src/main/kotlin/com/swm_standard/phote/external/redis/RedisConfig.kt new file mode 100644 index 0000000..6606115 --- /dev/null +++ b/src/main/kotlin/com/swm_standard/phote/external/redis/RedisConfig.kt @@ -0,0 +1,41 @@ +package com.swm_standard.phote.external.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.RedisPassword +import org.springframework.data.redis.connection.RedisStandaloneConfiguration +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.serializer.StringRedisSerializer + +@Configuration +class RedisConfig { + @Value("\${spring.data.redis.host}") + private lateinit var redisHost: String + + @Value("\${spring.data.redis.port}") + private lateinit var redisPort: String + + @Value("\${spring.data.redis.password}") + private lateinit var redisPassword: String + + @Bean + fun redisConnectionFactory(): RedisConnectionFactory { + val redisStandaloneConfiguration = RedisStandaloneConfiguration() + redisStandaloneConfiguration.hostName = redisHost + redisStandaloneConfiguration.port = redisPort.toInt() + redisStandaloneConfiguration.password = RedisPassword.of(redisPassword) + return LettuceConnectionFactory(redisStandaloneConfiguration) + } + + @Bean + fun redisTemplate(): RedisTemplate { + val redisTemplate = RedisTemplate() + redisTemplate.connectionFactory = redisConnectionFactory() + redisTemplate.keySerializer = StringRedisSerializer() + redisTemplate.valueSerializer = StringRedisSerializer() + return redisTemplate + } +} diff --git a/src/main/kotlin/com/swm_standard/phote/repository/RefreshTokenRepository.kt b/src/main/kotlin/com/swm_standard/phote/repository/RefreshTokenRepository.kt new file mode 100644 index 0000000..f696b5a --- /dev/null +++ b/src/main/kotlin/com/swm_standard/phote/repository/RefreshTokenRepository.kt @@ -0,0 +1,11 @@ +package com.swm_standard.phote.repository + +import com.swm_standard.phote.entity.RefreshToken +import org.springframework.data.repository.CrudRepository +import org.springframework.stereotype.Repository +import java.util.UUID + +@Repository +interface RefreshTokenRepository : CrudRepository { + fun findByMemberId(memberId: UUID): RefreshToken? +} diff --git a/src/main/kotlin/com/swm_standard/phote/service/GoogleAuthService.kt b/src/main/kotlin/com/swm_standard/phote/service/GoogleAuthService.kt index 2b9525b..9b5f8b7 100644 --- a/src/main/kotlin/com/swm_standard/phote/service/GoogleAuthService.kt +++ b/src/main/kotlin/com/swm_standard/phote/service/GoogleAuthService.kt @@ -19,6 +19,7 @@ import org.springframework.web.client.RestTemplate class GoogleAuthService( private val memberRepository: MemberRepository, private val jwtTokenProvider: JwtTokenProvider, + private val tokenService: TokenService, ) { @Value("\${GOOGLE_CLIENT_ID}") lateinit var clientId: String @@ -75,7 +76,8 @@ class GoogleAuthService( member = memberRepository.save(Member(initName, dto.email, dto.picture, Provider.GOOGLE)) } - dto.accessToken = jwtTokenProvider.createToken(dto, member.id) + dto.accessToken = jwtTokenProvider.createToken(member.id) + dto.refreshToken = tokenService.generateRefreshToken(member.id) dto.userId = member.id dto.name = member.name diff --git a/src/main/kotlin/com/swm_standard/phote/service/KaKaoAuthService.kt b/src/main/kotlin/com/swm_standard/phote/service/KaKaoAuthService.kt index 9d591fe..71fb531 100644 --- a/src/main/kotlin/com/swm_standard/phote/service/KaKaoAuthService.kt +++ b/src/main/kotlin/com/swm_standard/phote/service/KaKaoAuthService.kt @@ -20,9 +20,9 @@ import org.springframework.web.client.RestTemplate @Service class KaKaoAuthService( private val memberRepository: MemberRepository, - private val jwtTokenProvider: JwtTokenProvider + private val jwtTokenProvider: JwtTokenProvider, + private val tokenService: TokenService, ) { - @Value("\${KAKAO_REST_API_KEY}") lateinit var kakaokey: String @@ -30,7 +30,6 @@ class KaKaoAuthService( lateinit var kakaoRedirectUri: String fun getTokenFromKakao(code: String): String { - val headers = HttpHeaders() headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8") @@ -41,12 +40,13 @@ class KaKaoAuthService( body.add("code", code) val kakaoTokenRequest = HttpEntity(body, headers) - val response = RestTemplate().exchange( - "https://kauth.kakao.com/oauth/token", - HttpMethod.POST, - kakaoTokenRequest, - String::class.java - ) + val response = + RestTemplate().exchange( + "https://kauth.kakao.com/oauth/token", + HttpMethod.POST, + kakaoTokenRequest, + String::class.java, + ) val responseBody = response.body val objectMapper = ObjectMapper() @@ -56,18 +56,18 @@ class KaKaoAuthService( } fun getUserInfoFromKakao(token: String): UserInfoResponse { - val headers = HttpHeaders() headers.add("Authorization", "Bearer $token") headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8") val kakaoUserInfoRequest = HttpEntity>(headers) - val response = RestTemplate().exchange( - "https://kapi.kakao.com/v2/user/me", - HttpMethod.POST, - kakaoUserInfoRequest, - String::class.java - ) + val response = + RestTemplate().exchange( + "https://kapi.kakao.com/v2/user/me", + HttpMethod.POST, + kakaoUserInfoRequest, + String::class.java, + ) val responseBody = response.body val objectMapper = ObjectMapper() @@ -80,15 +80,15 @@ class KaKaoAuthService( val isMember: Boolean if (member == null) { - val nicknameGenerator = NicknameGenerator() - member = Member( - name = nicknameGenerator.randomNickname(), - email = email, - image = ProfileImageGenerator().imageGenerator(), - provider = Provider.KAKAO - ) + member = + Member( + name = nicknameGenerator.randomNickname(), + email = email, + image = ProfileImageGenerator().imageGenerator(), + provider = Provider.KAKAO, + ) memberRepository.save(member) @@ -97,12 +97,17 @@ class KaKaoAuthService( isMember = true } - val dto = UserInfoResponse( - name = member.name, email = member.email, - picture = member.image, isMember = isMember, userId = member.id - ) + val dto = + UserInfoResponse( + name = member.name, + email = member.email, + picture = member.image, + isMember = isMember, + userId = member.id, + accessToken = jwtTokenProvider.createToken(member.id), + ) - dto.accessToken = jwtTokenProvider.createToken(dto, member.id) + dto.refreshToken = tokenService.generateRefreshToken(member.id) return dto } diff --git a/src/main/kotlin/com/swm_standard/phote/service/TokenService.kt b/src/main/kotlin/com/swm_standard/phote/service/TokenService.kt new file mode 100644 index 0000000..bc52043 --- /dev/null +++ b/src/main/kotlin/com/swm_standard/phote/service/TokenService.kt @@ -0,0 +1,34 @@ +package com.swm_standard.phote.service + +import com.swm_standard.phote.common.authority.JwtTokenProvider +import com.swm_standard.phote.common.exception.ExpiredTokenException +import com.swm_standard.phote.entity.RefreshToken +import com.swm_standard.phote.repository.RefreshTokenRepository +import org.springframework.stereotype.Service +import java.util.UUID +import kotlin.jvm.optionals.getOrElse + +@Service +class TokenService( + private val refreshTokenRepository: RefreshTokenRepository, + private val jwtTokenProvider: JwtTokenProvider, +) { + fun generateAccessToken(refreshToken: UUID): String { + val refresh = + refreshTokenRepository.findById(refreshToken).getOrElse { + throw ExpiredTokenException(fieldName = "refreshToken", message = "로그인이 만료됐습니다. 재로그인해주세요.") + } + + return jwtTokenProvider.createToken(refresh.memberId) + } + + fun generateRefreshToken(memberId: UUID): UUID { + val refreshToken = + refreshTokenRepository.findByMemberId(memberId) ?: RefreshToken(UUID.randomUUID(), memberId) + .let { + refreshTokenRepository.save(it) + } + + return refreshToken.refreshToken + } +}