diff --git a/build.gradle.kts b/build.gradle.kts index 0a97996..07ec97b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -63,6 +63,10 @@ dependencies { // db runtimeOnly("org.mariadb.jdbc:mariadb-java-client") + // redis + implementation("org.springframework.data:spring-data-redis:3.1.2") + implementation("io.lettuce:lettuce-core:6.2.5.RELEASE") + // s3 implementation("org.springframework.cloud:spring-cloud-starter-aws:2.0.1.RELEASE") implementation("javax.xml.bind:jaxb-api:2.3.1") diff --git a/src/main/kotlin/mara/server/auth/jwt/JwtFilter.kt b/src/main/kotlin/mara/server/auth/jwt/JwtFilter.kt index a4984f6..8ecd2fc 100644 --- a/src/main/kotlin/mara/server/auth/jwt/JwtFilter.kt +++ b/src/main/kotlin/mara/server/auth/jwt/JwtFilter.kt @@ -9,6 +9,8 @@ import org.springframework.web.filter.OncePerRequestFilter @Component class JwtFilter(private val jwtProvider: JwtProvider) : OncePerRequestFilter() { + private val ok: String = "ok" + private val reissue: String = "reissue" private fun HttpServletRequest.getToken(): String? { val rawToken = this.getHeader("Authorization") @@ -23,11 +25,35 @@ class JwtFilter(private val jwtProvider: JwtProvider) : OncePerRequestFilter() { filterChain: FilterChain, ) { val jwt = request.getToken() + if (jwt != null) { + if (jwtProvider.validate(jwt) == ok) { + SecurityContextHolder.getContext().authentication = + jwtProvider.getAuthentication(jwt) + } + if (jwtProvider.validate(jwt) == reissue) { + reissueAccessToken(request, response) + } + } + + filterChain.doFilter(request, response) + } - if (jwt != null && jwtProvider.validate(jwt)) { + private fun reissueAccessToken(request: HttpServletRequest, response: HttpServletResponse) { + try { + val refreshToken = jwtProvider.validRefreshToken(request.getHeader("Refresh-Token")).refreshToken + val oldAccessToken = request.getToken() + var newAccessToken = "" + if (oldAccessToken != null) { + newAccessToken = jwtProvider.recreateAccessToken(oldAccessToken) + } SecurityContextHolder.getContext().authentication = - jwtProvider.getAuthentication(jwt) + jwtProvider.getAuthentication(newAccessToken) + + response.setHeader("New-Access-Token", newAccessToken) + response.setHeader("Refresh-Token", refreshToken) + } catch (e: Exception) { + e.printStackTrace() + request.setAttribute("exception", e) } - filterChain.doFilter(request, response) } } diff --git a/src/main/kotlin/mara/server/auth/jwt/JwtProvider.kt b/src/main/kotlin/mara/server/auth/jwt/JwtProvider.kt index 56ebff7..a5744d6 100644 --- a/src/main/kotlin/mara/server/auth/jwt/JwtProvider.kt +++ b/src/main/kotlin/mara/server/auth/jwt/JwtProvider.kt @@ -1,15 +1,22 @@ package mara.server.auth.jwt +import com.fasterxml.jackson.databind.ObjectMapper +import io.jsonwebtoken.ExpiredJwtException import io.jsonwebtoken.Jwts import io.jsonwebtoken.security.Keys import mara.server.auth.security.PrincipalDetailsService +import mara.server.config.redis.RefreshToken +import mara.server.config.redis.RefreshTokenRepository import mara.server.domain.user.User +import mara.server.domain.user.UserRepository import mara.server.util.logger import org.springframework.beans.factory.annotation.Value import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.Authentication import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional import java.security.Key +import java.util.Base64 import java.util.Date @Component @@ -17,9 +24,15 @@ class JwtProvider( @Value("\${jwt.secret-key}") private val secretKey: String, @Value("\${jwt.access-duration-mils}") private val accessDurationMils: Long, private val principalDetailsService: PrincipalDetailsService, + private val userRepository: UserRepository, + private val refreshTokenRepository: RefreshTokenRepository ) { val key: Key = Keys.hmacShaKeyFor(secretKey.toByteArray()) val log = logger() + private val objectMapper = ObjectMapper() + private val ok: String = "ok" + private val reissue: String = "reissue" + private val fail: String = "fail" fun generateToken(user: User): String { val now = Date(System.currentTimeMillis()) @@ -39,16 +52,32 @@ class JwtProvider( .compact() } - fun validate(token: String): Boolean { - return try { + @Transactional + fun recreateAccessToken(oldAccessToken: String): String { + val subject = decodeJwtPayloadSubject(oldAccessToken) + val user = userRepository.findById(subject.toLong()).get() + return generateToken(user) + } + + fun validRefreshToken(refreshToken: String): RefreshToken { + val token = refreshTokenRepository.findByRefreshToken(refreshToken) + ?: throw NullPointerException("만료된 RefreshToken 입니다.") + return token + } + + fun validate(token: String): String { + try { Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) - true + return ok + } catch (ex: ExpiredJwtException) { + log.warn("JWT 토큰 만료 [{}] {}", ex.javaClass.simpleName, ex.message) + return reissue } catch (e: Exception) { log.warn("JWT 오류 발생 [{}] {}", e.javaClass.simpleName, e.message) - false + return fail } } @@ -66,4 +95,10 @@ class JwtProvider( userDetails.authorities, ) } + + private fun decodeJwtPayloadSubject(oldAccessToken: String) = + objectMapper.readValue( + Base64.getUrlDecoder().decode(oldAccessToken.split('.')[1]).decodeToString(), + Map::class.java + )["sub"].toString() } diff --git a/src/main/kotlin/mara/server/config/redis/RedisConfig.kt b/src/main/kotlin/mara/server/config/redis/RedisConfig.kt new file mode 100644 index 0000000..13c8f6b --- /dev/null +++ b/src/main/kotlin/mara/server/config/redis/RedisConfig.kt @@ -0,0 +1,25 @@ +package mara.server.config.redis + +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 + +@Configuration +@EnableRedisRepositories +class RedisConfig() { + + @Bean + fun redisConnectionFactory(): RedisConnectionFactory { + return LettuceConnectionFactory() + } + + @Bean + fun redisTemplate(): RedisTemplate { + val redisTemplate = RedisTemplate() + redisTemplate.connectionFactory = redisConnectionFactory() + return redisTemplate + } +} diff --git a/src/main/kotlin/mara/server/config/redis/RefreshToken.kt b/src/main/kotlin/mara/server/config/redis/RefreshToken.kt new file mode 100644 index 0000000..6ba1ea5 --- /dev/null +++ b/src/main/kotlin/mara/server/config/redis/RefreshToken.kt @@ -0,0 +1,19 @@ +package mara.server.config.redis + +import org.springframework.data.annotation.Id +import org.springframework.data.redis.core.RedisHash +import org.springframework.data.redis.core.TimeToLive +import org.springframework.data.redis.core.index.Indexed +import java.util.concurrent.TimeUnit + +@RedisHash(value = "refreshToken") +data class RefreshToken( + @Id + @Indexed + val refreshToken: String, + + val userId: Long, + + @TimeToLive(unit = TimeUnit.MINUTES) + val expiration: Int, +) diff --git a/src/main/kotlin/mara/server/config/redis/RefreshTokenRepository.kt b/src/main/kotlin/mara/server/config/redis/RefreshTokenRepository.kt new file mode 100644 index 0000000..b76857c --- /dev/null +++ b/src/main/kotlin/mara/server/config/redis/RefreshTokenRepository.kt @@ -0,0 +1,7 @@ +package mara.server.config.redis + +import org.springframework.data.repository.CrudRepository + +interface RefreshTokenRepository : CrudRepository { + fun findByRefreshToken(refreshToken: String?): RefreshToken? +} diff --git a/src/main/kotlin/mara/server/config/swagger/SwaggerConfig.kt b/src/main/kotlin/mara/server/config/swagger/SwaggerConfig.kt index df2281b..7f2c412 100644 --- a/src/main/kotlin/mara/server/config/swagger/SwaggerConfig.kt +++ b/src/main/kotlin/mara/server/config/swagger/SwaggerConfig.kt @@ -1,35 +1,39 @@ package mara.server.config.swagger -import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.annotations.OpenAPIDefinition +import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType +import io.swagger.v3.oas.annotations.security.SecurityScheme +import io.swagger.v3.oas.annotations.security.SecuritySchemes import io.swagger.v3.oas.models.OpenAPI import io.swagger.v3.oas.models.info.Info import io.swagger.v3.oas.models.security.SecurityRequirement -import io.swagger.v3.oas.models.security.SecurityScheme import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @Configuration +@OpenAPIDefinition +@SecuritySchemes( + SecurityScheme( + name = "jwtAuth", + type = SecuritySchemeType.HTTP, + scheme = "bearer", + bearerFormat = "JWT", + description = "JWT (JSON Web Token) 인증을 위한 헤더" + ), + SecurityScheme( + name = "Refresh-Token", + type = SecuritySchemeType.APIKEY, + `in` = SecuritySchemeIn.HEADER, + description = "Refresh Token을 포함한 헤더" + ) +) class SwaggerConfig { @Bean fun openAPI(): OpenAPI { - val jwtSchemeName = "jwtAuth" - // API 요청 헤더에 인증 정보 포함 - val securityRequirement = SecurityRequirement().addList(jwtSchemeName) - // SecuritySchemes 등록 - val components = Components() - .addSecuritySchemes( - jwtSchemeName, - SecurityScheme() - .name(jwtSchemeName) - .type(SecurityScheme.Type.HTTP) // HTTP 방식 - .scheme("bearer") - .bearerFormat("JWT") - ) - return OpenAPI() - .components(Components()) - .addSecurityItem(securityRequirement) - .components(components) + .addSecurityItem(SecurityRequirement().addList("jwtAuth")) + .addSecurityItem(SecurityRequirement().addList("Refresh-Token")) .info(apiInfo()) } diff --git a/src/main/kotlin/mara/server/domain/user/AuthDto.kt b/src/main/kotlin/mara/server/domain/user/AuthDto.kt index 802e57a..2b6529a 100644 --- a/src/main/kotlin/mara/server/domain/user/AuthDto.kt +++ b/src/main/kotlin/mara/server/domain/user/AuthDto.kt @@ -3,8 +3,8 @@ package mara.server.domain.user sealed class AuthDto data class JwtDto( - val accessToken: String?, - val refreshToken: String?, + val accessToken: String, + val refreshToken: String, ) : AuthDto() data class KakaoAuthInfo( diff --git a/src/main/kotlin/mara/server/domain/user/UserDto.kt b/src/main/kotlin/mara/server/domain/user/UserDto.kt index cb50f0c..e35ff32 100644 --- a/src/main/kotlin/mara/server/domain/user/UserDto.kt +++ b/src/main/kotlin/mara/server/domain/user/UserDto.kt @@ -10,6 +10,7 @@ data class UserRequest( data class CheckDuplicateResponse(val isDuplicated: Boolean) +data class RefreshAccessTokenRequest(val refreshToken: String) class UserResponse( val nickName: String?, val kakaoId: Long?, diff --git a/src/main/kotlin/mara/server/domain/user/UserService.kt b/src/main/kotlin/mara/server/domain/user/UserService.kt index 6a1e9ce..52ec7db 100644 --- a/src/main/kotlin/mara/server/domain/user/UserService.kt +++ b/src/main/kotlin/mara/server/domain/user/UserService.kt @@ -4,8 +4,11 @@ import mara.server.auth.google.GoogleApiClient import mara.server.auth.jwt.JwtProvider import mara.server.auth.kakao.KakaoApiClient import mara.server.auth.security.getCurrentLoginUserId +import mara.server.config.redis.RefreshToken +import mara.server.config.redis.RefreshTokenRepository import mara.server.util.StringUtil import mara.server.util.logger +import org.springframework.beans.factory.annotation.Value import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder @@ -18,8 +21,11 @@ class UserService( private val userRepository: UserRepository, private val jwtProvider: JwtProvider, private val passwordEncoder: BCryptPasswordEncoder, + private val refreshTokenRepository: RefreshTokenRepository, private val kakaoApiClient: KakaoApiClient, private val googleApiClient: GoogleApiClient, + @Value("\${jwt.refresh-duration-mins}") private val refreshDurationMins: Int, + ) { val log = logger() @@ -39,9 +45,8 @@ class UserService( SecurityContextHolder.getContext().authentication = UsernamePasswordAuthenticationToken(authId, newUser.password) - val refreshToken = UUID.randomUUID().toString() - // JWT 발급 + val refreshToken = createRefreshToken(newUser) return JwtDto(jwtProvider.generateToken(newUser), refreshToken) } private fun createUser(userRequest: UserRequest): User { @@ -75,7 +80,7 @@ class UserService( SecurityContextHolder.getContext().authentication = UsernamePasswordAuthenticationToken(authId, password) - val refreshToken = UUID.randomUUID().toString() + val refreshToken = createRefreshToken(user) return JwtDto(jwtProvider.generateToken(user), refreshToken) } @@ -99,7 +104,8 @@ class UserService( SecurityContextHolder.getContext().authentication = UsernamePasswordAuthenticationToken(authId, authId) - val refreshToken = UUID.randomUUID().toString() + val refreshToken = createRefreshToken(user) + return JwtDto(jwtProvider.generateToken(user), refreshToken) } @@ -107,4 +113,9 @@ class UserService( googleEmail = authId, ) } + + fun createRefreshToken(user: User): String { + val refreshToken = refreshTokenRepository.save(RefreshToken(UUID.randomUUID().toString(), user.userId, refreshDurationMins)) + return refreshToken.refreshToken + } } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 1364125..a8a9b62 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -35,7 +35,8 @@ cloud: jwt: secret-key: ${jwt-secret-key} - access-duration-mils: 2147483647 + access-duration-mils: 1800000 + refresh-duration-mins: 20160 logging: level: