Skip to content

Commit

Permalink
Merge pull request #51 from pknu-wap/Feat/#50
Browse files Browse the repository at this point in the history
feat: RefreshToken 추가
  • Loading branch information
Zepelown authored Dec 8, 2024
2 parents 1634629 + ae9544e commit 22bd899
Show file tree
Hide file tree
Showing 8 changed files with 217 additions and 30 deletions.
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.wap.wabi.auth.admin.repository;

import com.wap.wabi.auth.admin.entity.AdminRefreshToken;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface AdminRefreshTokenRepository extends JpaRepository<AdminRefreshToken, Long> {
Optional<AdminRefreshToken> findAdminRefreshTokenByAdminNameAndReissueCountLessThan(String name, long count);

Optional<AdminRefreshToken> findAdminRefreshTokenByAdminName(String name);
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
}

Expand All @@ -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
}
}
}
71 changes: 58 additions & 13 deletions wabi/src/main/kotlin/com/wap/wabi/auth/jwt/JwtTokenProvider.kt
Original file line number Diff line number Diff line change
@@ -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(),
Expand All @@ -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()
Expand All @@ -41,15 +51,50 @@ 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)!!

private fun decodeJwtPayloadSubject(oldAccessToken: String) =
objectMapper.readValue(
Base64.getUrlDecoder().decode(oldAccessToken.split('.')[1]).decodeToString(),
Map::class.java
)["sub"].toString()
}
16 changes: 16 additions & 0 deletions wabi/src/main/kotlin/com/wap/wabi/common/config/SwaggerConfig.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
}
}
3 changes: 2 additions & 1 deletion wabi/src/main/resources/application-prod.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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}

0 comments on commit 22bd899

Please sign in to comment.