From 2bc8db3dfb114500a80153bb85df331d39431fed Mon Sep 17 00:00:00 2001 From: Zepelown Date: Sat, 7 Dec 2024 04:34:44 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20RefreshToken=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/admin/entity/AdminRefreshToken.java | 53 +++++++++++ .../payload/response/AdminLoginResponse.kt | 3 +- .../AdminRefreshTokenRepository.java | 13 +++ .../wabi/auth/admin/service/AdminService.kt | 24 ++++- .../wabi/auth/jwt/JwtAuthenticationFilter.kt | 63 ++++++++++--- .../com/wap/wabi/auth/jwt/JwtTokenProvider.kt | 90 ++++++++++++++++--- .../wap/wabi/common/config/SwaggerConfig.kt | 16 ++++ .../resources/application-prod.properties | 3 +- 8 files changed, 235 insertions(+), 30 deletions(-) create mode 100644 wabi/src/main/kotlin/com/wap/wabi/auth/admin/entity/AdminRefreshToken.java create mode 100644 wabi/src/main/kotlin/com/wap/wabi/auth/admin/repository/AdminRefreshTokenRepository.java diff --git a/wabi/src/main/kotlin/com/wap/wabi/auth/admin/entity/AdminRefreshToken.java b/wabi/src/main/kotlin/com/wap/wabi/auth/admin/entity/AdminRefreshToken.java new file mode 100644 index 0000000..7a81b65 --- /dev/null +++ b/wabi/src/main/kotlin/com/wap/wabi/auth/admin/entity/AdminRefreshToken.java @@ -0,0 +1,53 @@ +package com.wap.wabi.auth.admin.entity; + +import jakarta.persistence.*; +import org.springframework.beans.factory.annotation.Value; + +@Entity +public class AdminRefreshToken { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String adminName; + private String refreshToken; + private int reissueCount = 0; + + public AdminRefreshToken() { + } + + public AdminRefreshToken(builder builder) { + this.adminName = builder.adminName; + this.refreshToken = builder.refreshToken; + } + + public void updateRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public boolean validateRefreshToken(String refreshToken) { + return this.refreshToken.equals(refreshToken); + } + + public void increaseReissueCount() { + reissueCount++; + } + + public static class builder { + private String adminName; + private String refreshToken; + + public builder adminName(String adminName) { + this.adminName = adminName; + return this; + } + + public builder refreshToken(String refreshToken) { + this.refreshToken = refreshToken; + return this; + } + + public AdminRefreshToken build() { + return new AdminRefreshToken(this); + } + } +} diff --git a/wabi/src/main/kotlin/com/wap/wabi/auth/admin/payload/response/AdminLoginResponse.kt b/wabi/src/main/kotlin/com/wap/wabi/auth/admin/payload/response/AdminLoginResponse.kt index 0b9562a..2c23c64 100644 --- a/wabi/src/main/kotlin/com/wap/wabi/auth/admin/payload/response/AdminLoginResponse.kt +++ b/wabi/src/main/kotlin/com/wap/wabi/auth/admin/payload/response/AdminLoginResponse.kt @@ -2,6 +2,7 @@ package com.wap.wabi.auth.admin.payload.response data class AdminLoginResponse( val name: String, - val token: String, + val accessToken: String, + val refreshToken : String, val role: String, ) diff --git a/wabi/src/main/kotlin/com/wap/wabi/auth/admin/repository/AdminRefreshTokenRepository.java b/wabi/src/main/kotlin/com/wap/wabi/auth/admin/repository/AdminRefreshTokenRepository.java new file mode 100644 index 0000000..c83fc5a --- /dev/null +++ b/wabi/src/main/kotlin/com/wap/wabi/auth/admin/repository/AdminRefreshTokenRepository.java @@ -0,0 +1,13 @@ +package com.wap.wabi.auth.admin.repository; + +import com.wap.wabi.auth.admin.entity.AdminRefreshToken; +import org.apache.poi.sl.draw.geom.GuideIf; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +@Repository +public interface AdminRefreshTokenRepository extends JpaRepository { + Optional findAdminRefreshTokenByAdminNameAndReissueCountLessThan(String name, long count); + Optional findAdminRefreshTokenByAdminName(String name); +} diff --git a/wabi/src/main/kotlin/com/wap/wabi/auth/admin/service/AdminService.kt b/wabi/src/main/kotlin/com/wap/wabi/auth/admin/service/AdminService.kt index 294d6f0..c067aec 100644 --- a/wabi/src/main/kotlin/com/wap/wabi/auth/admin/service/AdminService.kt +++ b/wabi/src/main/kotlin/com/wap/wabi/auth/admin/service/AdminService.kt @@ -1,11 +1,13 @@ package com.wap.wabi.auth.admin.service +import com.wap.wabi.auth.admin.entity.AdminRefreshToken import com.wap.wabi.auth.admin.payload.request.AdminLoginRequest import com.wap.wabi.auth.admin.payload.request.AdminRegisterRequest import com.wap.wabi.auth.admin.payload.response.AdminLoginResponse +import com.wap.wabi.auth.admin.repository.AdminRefreshTokenRepository import com.wap.wabi.auth.admin.repository.AdminRepository -import com.wap.wabi.auth.jwt.JwtTokenProvider import com.wap.wabi.auth.admin.util.AdminValidator +import com.wap.wabi.auth.jwt.JwtTokenProvider import com.wap.wabi.exception.ErrorCode import com.wap.wabi.exception.RestApiException import org.springframework.security.crypto.password.PasswordEncoder @@ -15,6 +17,7 @@ import org.springframework.transaction.annotation.Transactional @Service class AdminService( private val adminRepository: AdminRepository, + private val adminRefreshTokenRepository: AdminRefreshTokenRepository, private val tokenProvider: JwtTokenProvider, private val adminValidator: AdminValidator, private val encoder: PasswordEncoder @@ -36,11 +39,26 @@ class AdminService( encoder.matches(adminLoginRequest.password, admin.get().password) } ?: throw RestApiException(ErrorCode.BAD_REQUEST_NOT_EXIST_ADMIN) - val token = tokenProvider.createToken("${admin.get().username}:${admin.get().role}") + val refreshToken = tokenProvider.createRefreshToken() + adminRefreshTokenRepository.findAdminRefreshTokenByAdminName(admin.get().username) + .ifPresentOrElse( + { it.updateRefreshToken(refreshToken) }, + { + adminRefreshTokenRepository.save( + AdminRefreshToken.builder() + .adminName(admin.get().username) + .refreshToken(refreshToken) + .build() + ) + } + ) + + val accessToken = tokenProvider.createAccessToken("${admin.get().username}:${admin.get().role}") return AdminLoginResponse( name = admin.get().username, role = admin.get().role, - token = token + accessToken = accessToken, + refreshToken = refreshToken ) } diff --git a/wabi/src/main/kotlin/com/wap/wabi/auth/jwt/JwtAuthenticationFilter.kt b/wabi/src/main/kotlin/com/wap/wabi/auth/jwt/JwtAuthenticationFilter.kt index b39b305..4c2a498 100644 --- a/wabi/src/main/kotlin/com/wap/wabi/auth/jwt/JwtAuthenticationFilter.kt +++ b/wabi/src/main/kotlin/com/wap/wabi/auth/jwt/JwtAuthenticationFilter.kt @@ -1,5 +1,6 @@ package com.wap.wabi.auth.jwt +import io.jsonwebtoken.ExpiredJwtException import jakarta.servlet.FilterChain import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse @@ -24,28 +25,37 @@ class JwtAuthenticationFilter( filterChain: FilterChain ) { val path = request.requestURI - if (path.startsWith("/swagger-ui/") || path.startsWith("/v3/api-docs/")) { + if (path.startsWith("/swagger-ui/") || path.startsWith("/v3") || path.startsWith("/auth")) { filterChain.doFilter(request, response) return } - val token = parseBearerToken(request) + try { + parseBearerToken(request, HttpHeaders.AUTHORIZATION)?.let { accessToken -> + jwtTokenProvider.validateAndParseToken(accessToken) - if (token != null && jwtTokenProvider.validateTokenAndGetSubject(token) != null) { - val user = parseUserSpecification(token) - UsernamePasswordAuthenticationToken.authenticated(user, token, user.authorities) - .apply { details = WebAuthenticationDetails(request) } - .also { SecurityContextHolder.getContext().authentication = it } - } else { - SecurityContextHolder.clearContext() + val user = parseUserSpecification(accessToken) + val authentication = UsernamePasswordAuthenticationToken.authenticated(user, accessToken, user.authorities) + authentication.details = WebAuthenticationDetails(request) + SecurityContextHolder.getContext().authentication = authentication + } + } catch (e: ExpiredJwtException) { + // Access Token이 만료된 경우 리프레시 토큰으로 새 토큰 발급 + if (reissueAccessToken(request, response)) { + return // 새 토큰 발급 후 요청 종료 + } + } catch (e: Exception) { + response.status = HttpServletResponse.SC_UNAUTHORIZED + response.writer.write("Invalid Token") + return } filterChain.doFilter(request, response) } - private fun parseBearerToken(request: HttpServletRequest): String? { - return request.getHeader(HttpHeaders.AUTHORIZATION) - ?.takeIf { it.startsWith("Bearer ", ignoreCase = true) } + private fun parseBearerToken(request: HttpServletRequest, headerName: String): String? { + return request.getHeader(headerName) + .takeIf { it.startsWith("Bearer ", ignoreCase = true) } ?.substring(7) } @@ -55,4 +65,33 @@ class JwtAuthenticationFilter( val (username, role) = subject.split(":") return User(username, "", listOf(SimpleGrantedAuthority(role))) } + + private fun reissueAccessToken(request: HttpServletRequest, response: HttpServletResponse): Boolean { + return try { + val refreshToken = parseBearerToken(request, "Refresh-Token") + ?: throw IllegalArgumentException("Refresh token not provided") + val oldAccessToken = parseBearerToken(request, HttpHeaders.AUTHORIZATION) + ?: throw IllegalArgumentException("Access token not provided") + + // 리프레시 토큰 유효성 검사 및 새로운 액세스 토큰 발급 + jwtTokenProvider.validateRefreshToken(refreshToken, oldAccessToken) + val newAccessToken = jwtTokenProvider.recreateAccessToken(oldAccessToken) + + // 새 액세스 토큰을 응답 헤더에 추가 + response.setHeader("New-Access-Token", newAccessToken) + + // SecurityContext에 새 인증 정보 업데이트 + val user = parseUserSpecification(newAccessToken) + val authentication = + UsernamePasswordAuthenticationToken.authenticated(user, newAccessToken, user.authorities) + authentication.details = WebAuthenticationDetails(request) + SecurityContextHolder.getContext().authentication = authentication + + true + } catch (e: Exception) { + response.status = HttpServletResponse.SC_UNAUTHORIZED + response.writer.write("Refresh token invalid or expired: ${e.message}") + false + } + } } diff --git a/wabi/src/main/kotlin/com/wap/wabi/auth/jwt/JwtTokenProvider.kt b/wabi/src/main/kotlin/com/wap/wabi/auth/jwt/JwtTokenProvider.kt index cd198f1..b2df493 100644 --- a/wabi/src/main/kotlin/com/wap/wabi/auth/jwt/JwtTokenProvider.kt +++ b/wabi/src/main/kotlin/com/wap/wabi/auth/jwt/JwtTokenProvider.kt @@ -1,27 +1,37 @@ package com.wap.wabi.auth.jwt +import com.fasterxml.jackson.databind.ObjectMapper +import com.wap.wabi.auth.admin.repository.AdminRefreshTokenRepository +import io.jsonwebtoken.ExpiredJwtException import io.jsonwebtoken.Jwts import io.jsonwebtoken.SignatureAlgorithm import org.springframework.beans.factory.annotation.Value import org.springframework.security.core.context.SecurityContextHolder import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional import java.sql.Timestamp import java.time.Instant import java.time.LocalDateTime import java.time.temporal.ChronoUnit -import java.util.Date +import java.util.* import javax.crypto.spec.SecretKeySpec @Component class JwtTokenProvider( @Value("\${jwt.secret-key}") private val secretKey: String, - @Value("\${jwt.expiration-hours}") - private val expirationHours: Long, + @Value("\${jwt.expiration-minutes}") + private val expirationMinutes: Long, + @Value("\${jwt.refresh-expiration-hours}") + private val refreshExpirationHours: Long, @Value("\${jwt.issuer}") - private val issuer: String + private val issuer: String, + private val adminRefreshTokenRepository: AdminRefreshTokenRepository ) { - fun createToken(userSpecification: String) = Jwts.builder() + private val reissueLimit = refreshExpirationHours * 60 / expirationMinutes + private val objectMapper = ObjectMapper() + + fun createAccessToken(userSpecification: String) = Jwts.builder() .signWith( SecretKeySpec( secretKey.toByteArray(), @@ -31,7 +41,7 @@ class JwtTokenProvider( .setSubject(userSpecification) // JWT 토큰 제목 .setIssuer(issuer) // JWT 토큰 발급자 .setIssuedAt(Timestamp.valueOf(LocalDateTime.now())) // JWT 토큰 발급 시간 - .setExpiration(Date.from(Instant.now().plus(expirationHours, ChronoUnit.HOURS))) // JWT 토큰의 만료시간 설정 + .setExpiration(Date.from(Instant.now().plus(expirationMinutes, ChronoUnit.MINUTES))) // JWT 토큰의 만료시간 설정 .compact()!! // JWT 토큰 생성 fun validateTokenAndGetSubject(token: String): String? = Jwts.parserBuilder() @@ -41,15 +51,69 @@ class JwtTokenProvider( .body .subject - fun getAdminNameByToken(token: String): String { - val subject = - validateTokenAndGetSubject(token) ?: throw IllegalArgumentException("Invalid token") - val (username) = subject.split(":") - return username - } - fun getAdminName(): String { val authentication = SecurityContextHolder.getContext().authentication return authentication.name } + + fun createRefreshToken() = Jwts.builder() + .signWith(SecretKeySpec(secretKey.toByteArray(), SignatureAlgorithm.HS512.jcaName)) + .setIssuer(issuer) + .setIssuedAt(Timestamp.valueOf(LocalDateTime.now())) + .setExpiration(Date.from(Instant.now().plus(refreshExpirationHours, ChronoUnit.HOURS))) + .compact()!! + + @Transactional + fun recreateAccessToken(oldAccessToken: String): String { + val subject = decodeJwtPayloadSubject(oldAccessToken) + adminRefreshTokenRepository.findAdminRefreshTokenByAdminNameAndReissueCountLessThan( + (subject.split(':')[0]), + reissueLimit + ).ifPresentOrElse( + { it.increaseReissueCount() }, + { throw ExpiredJwtException(null, null, "레프레시 토큰이 만료되었습니다.") } + ) + return createAccessToken(subject) + } + + @Transactional(readOnly = true) + fun validateRefreshToken(refreshToken: String, oldAccessToken: String) { + validateAndParseToken(refreshToken) + val adminName = decodeJwtPayloadSubject(oldAccessToken).split(':')[0] + adminRefreshTokenRepository.findAdminRefreshTokenByAdminNameAndReissueCountLessThan(adminName, reissueLimit) + .ifPresentOrElse( + { it.validateRefreshToken(refreshToken) }, + { throw ExpiredJwtException(null, null, "레프레시 토큰이 만료되었습니다.") } + ) + } + + fun validateAndParseToken(token: String?) = Jwts.parserBuilder() // validateTokenAndGetSubject()에서 따로 분리 + .setSigningKey(secretKey.toByteArray()) + .build() + .parseClaimsJws(token)!! + + @Transactional + fun reissueAccessToken(refreshToken: String, oldAccessToken: String): String { + // 리프레시 토큰과 기존 액세스 토큰의 유효성 검사 + validateAndParseToken(refreshToken) + val subject = decodeJwtPayloadSubject(oldAccessToken) + val adminName = subject.split(':')[0] + + adminRefreshTokenRepository.findAdminRefreshTokenByAdminNameAndReissueCountLessThan(adminName, reissueLimit) + .ifPresentOrElse( + { it.validateRefreshToken(refreshToken) + it.increaseReissueCount() + }, + { throw ExpiredJwtException(null, null, "Refresh token expired or invalid.") } + ) + + // 새로운 액세스 토큰 발급 + return createAccessToken(subject) + } + + private fun decodeJwtPayloadSubject(oldAccessToken: String) = + objectMapper.readValue( + Base64.getUrlDecoder().decode(oldAccessToken.split('.')[1]).decodeToString(), + Map::class.java + )["sub"].toString() } diff --git a/wabi/src/main/kotlin/com/wap/wabi/common/config/SwaggerConfig.kt b/wabi/src/main/kotlin/com/wap/wabi/common/config/SwaggerConfig.kt index f379d13..e71575e 100644 --- a/wabi/src/main/kotlin/com/wap/wabi/common/config/SwaggerConfig.kt +++ b/wabi/src/main/kotlin/com/wap/wabi/common/config/SwaggerConfig.kt @@ -1,13 +1,19 @@ package com.wap.wabi.common.config +import io.swagger.v3.oas.annotations.enums.ParameterIn import io.swagger.v3.oas.models.Components import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.Operation import io.swagger.v3.oas.models.info.Info +import io.swagger.v3.oas.models.media.StringSchema +import io.swagger.v3.oas.models.parameters.Parameter import io.swagger.v3.oas.models.security.SecurityRequirement import io.swagger.v3.oas.models.security.SecurityScheme import io.swagger.v3.oas.models.servers.Server +import org.springdoc.core.customizers.OperationCustomizer import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.web.method.HandlerMethod @Configuration @@ -43,4 +49,14 @@ class SwaggerConfig { .addSecurityItem(securityRequirement) .components(components) } + + @Bean + fun globalHeader() = OperationCustomizer { operation: Operation, _: HandlerMethod -> + operation.addParametersItem( + Parameter() + .`in`(ParameterIn.HEADER.toString()) + .schema(StringSchema().name("Refresh-Token")) + .name("Refresh-Token")) + operation + } } diff --git a/wabi/src/main/resources/application-prod.properties b/wabi/src/main/resources/application-prod.properties index f8f63bb..d25bc9b 100644 --- a/wabi/src/main/resources/application-prod.properties +++ b/wabi/src/main/resources/application-prod.properties @@ -8,5 +8,6 @@ logging.level.org.springframework.security=DEBUG logging.level.org.springframework.web=DEBUG logging.level.org.springframework.http=DEBUG jwt.secret-key=${JWT.KEY} -jwt.expiration-hours=${JWT.EXPIRATION.HOURS} +jwt.expiration-minutes=${JWT.EXPIRATION.MINUTES} +jwt.refresh-expiration-hours= ${JWT.REFRESH.EXPIRATION.HOURS} jwt.issuer=${JWT.ISSUER} From ae9544e390a32f81fc517d1ff22b95dd1c614a1d Mon Sep 17 00:00:00 2001 From: Zepelown Date: Sat, 7 Dec 2024 13:53:21 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=B6=88?= =?UTF-8?q?=ED=95=84=EC=9A=94=20=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminRefreshTokenRepository.java | 3 ++- .../com/wap/wabi/auth/jwt/JwtTokenProvider.kt | 19 ------------------- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/wabi/src/main/kotlin/com/wap/wabi/auth/admin/repository/AdminRefreshTokenRepository.java b/wabi/src/main/kotlin/com/wap/wabi/auth/admin/repository/AdminRefreshTokenRepository.java index c83fc5a..6e9b2f7 100644 --- a/wabi/src/main/kotlin/com/wap/wabi/auth/admin/repository/AdminRefreshTokenRepository.java +++ b/wabi/src/main/kotlin/com/wap/wabi/auth/admin/repository/AdminRefreshTokenRepository.java @@ -1,13 +1,14 @@ package com.wap.wabi.auth.admin.repository; import com.wap.wabi.auth.admin.entity.AdminRefreshToken; -import org.apache.poi.sl.draw.geom.GuideIf; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.Optional; + @Repository public interface AdminRefreshTokenRepository extends JpaRepository { Optional findAdminRefreshTokenByAdminNameAndReissueCountLessThan(String name, long count); + Optional findAdminRefreshTokenByAdminName(String name); } diff --git a/wabi/src/main/kotlin/com/wap/wabi/auth/jwt/JwtTokenProvider.kt b/wabi/src/main/kotlin/com/wap/wabi/auth/jwt/JwtTokenProvider.kt index b2df493..80c73b8 100644 --- a/wabi/src/main/kotlin/com/wap/wabi/auth/jwt/JwtTokenProvider.kt +++ b/wabi/src/main/kotlin/com/wap/wabi/auth/jwt/JwtTokenProvider.kt @@ -92,25 +92,6 @@ class JwtTokenProvider( .build() .parseClaimsJws(token)!! - @Transactional - fun reissueAccessToken(refreshToken: String, oldAccessToken: String): String { - // 리프레시 토큰과 기존 액세스 토큰의 유효성 검사 - validateAndParseToken(refreshToken) - val subject = decodeJwtPayloadSubject(oldAccessToken) - val adminName = subject.split(':')[0] - - adminRefreshTokenRepository.findAdminRefreshTokenByAdminNameAndReissueCountLessThan(adminName, reissueLimit) - .ifPresentOrElse( - { it.validateRefreshToken(refreshToken) - it.increaseReissueCount() - }, - { throw ExpiredJwtException(null, null, "Refresh token expired or invalid.") } - ) - - // 새로운 액세스 토큰 발급 - return createAccessToken(subject) - } - private fun decodeJwtPayloadSubject(oldAccessToken: String) = objectMapper.readValue( Base64.getUrlDecoder().decode(oldAccessToken.split('.')[1]).decodeToString(),