개요 :
- PC-모바일 전환 기능 관련해서 로그인 상태가 유지되어야 한다는 요구사항
- 인증/인가는 JWT로 관리 중
- PC API 서버와 모바일 API 서버는 분리됨
- 프론트에서 액세스 토큰이나 로그인 정보를 URL로 바로 전달하는 건 보안상 위험이 있어서 안전한 로그인 전환 방식 설계/구현 필요
1. pc<->모바일 전환 시 일회용 토큰 발급
2. 전환 페이지에 url로 일회용 토큰 전달
3. 일회용 토큰으로 액세스 토큰, 리프레시 토큰 전달 받음
4. 로그인 상태 유지
- 위와 같은 절차로 개발
- 1회용 토큰 생성 시 대칭키 이용, 암복호화 가능한 것으로 만들어서 사용
프로젝트에 AES256 복호화, 암호화하는 유틸 함수 생성
object AES256CipherUtil {
@Throws(NoSuchAlgorithmException::class, NoSuchPaddingException::class)
private fun getCipherInstance() = Cipher.getInstance(AES_CBC_ALGORITHM)
val AES = "AES"
val AES_CBC_ALGORITHM: String = "$AES/CBC/PKCS5Padding"
/**
* @param keyStr
* @param ivStr
* @param encryptedText
* @return
*/
fun decrypt(
keyStr: String,
ivStr: String,
encryptedText: String,
): String? {
if (encryptedText == null || encryptedText.length == 0) {
return null
}
try {
val c = getCipherInstance()
c.init(
Cipher.DECRYPT_MODE,
SecretKeySpec(keyStr.toByteArray(), AES),
IvParameterSpec(ivStr.toByteArray())
)
val str1 = Hex.decodeHex(encryptedText.toCharArray())
val decrypted = c.doFinal(str1)
return String(decrypted, Charsets.UTF_8)
} catch (e: java.lang.Exception) {
throw Exception("복호화 오류", e)
}
}
fun encrypt(
keyStr: String,
ivStr: String,
plainText: String,
): String {
try {
val c = getCipherInstance()
c.init(
Cipher.ENCRYPT_MODE,
SecretKeySpec(keyStr.toByteArray(), AES),
IvParameterSpec(ivStr.toByteArray())
)
val encryptedBytes = c.doFinal(plainText.toByteArray(Charsets.UTF_8))
return Hex.encodeHexString(encryptedBytes)
} catch (e: Exception) {
throw Exception("암호화 오류", e)
}
}
}
key, iv, 텍스트를 파라미터로 하는 암호화 함수이다.
2개의 프로젝트에 각각 key와 iv를 동일하게 설정한다.
data class OneTimeTokenPayloadDto(
val userId: Long,
val target: ApplicationType,
val source: ApplicationType,
val roleLevels: List<String?>,
val expiredAt: LocalDateTime,
) {
fun isExpired(): Boolean {
return LocalDateTime.now().isAfter(expiredAt)
}
}
암호화 대상 정보를 DTO로 구성한 후, JSON 문자열로 직렬화하여 암호화에 사용한다. 유효기간은 아주 짧게 설정해야 한다.
일회용 토큰 발급 로직
@Value("\\${login.transfer.key}")
lateinit var keyStr: String
@Value("\\${login.transfer.iv}")
lateinit var ivStr: String
@Value("\\${login.transfer.expire-minutes}")
lateinit var expireMinutes: String
fun createOneTimeToken(
userId: Long,
roleLevels: List<AdjusterRoleType>,
): String {
val prefixedRoleLevels = roleLevels.map { RoleLevelConverter.convertToRolePrefixed(it.toString()) }
val payload = OneTimeTokenPayloadDto(
userId = userId,
target = ApplicationType.Mo,
source = ApplicationType.Pc,
roleLevels = prefixedRoleLevels,
expiredAt = LocalDateTime.now().plusMinutes(expireMinutes.toLong())
)
val plainText = objectMapper.writeValueAsString(payload)
return AES256CipherUtil.encrypt(keyStr, ivStr, plainText)
}
암호화하는 로직에서는 설정 파일의 key, iv, 유효시간을 주입받아서 사용한다. 또한 source 애플리케이션과 target 애플리케이션의 정보를 일회용 토큰에 명시해서 유효성 검증에 사용하였다.
일회용 토큰으로 로그인 (액세스 토큰 및 리프레시 토큰 발급) 로직
fun validateAndIssueTokens(oneTimeToken: String?): Map<String, String> {
if (oneTimeToken.isNullOrBlank()) {
throw IllegalArgumentException("일회용 토큰이 누락되었습니다.")
}
val decryptText = try {
AES256CipherUtil.decrypt(keyStr, ivStr, oneTimeToken)
} catch (e: Exception) {
throw IllegalArgumentException("일회용 토큰 복호화에 실패하였습니다.")
}
val oneTimeToken = objectMapper.readValue(decryptText, OneTimeTokenPayloadDto::class.java)
// 유효성 검증
if (oneTimeToken.isExpired() || oneTimeToken.target != ApplicationType.Pc) {
throw IllegalArgumentException("유효한 토큰이 아닙니다.")
}
val userId: String = oneTimeToken.userId.toString()
val roleLevels: String = oneTimeToken.roleLevels.toString()
val tokenPayload = mapOf(
JwtPayloadKeyType.UserId.name to userId.toInt(),
JwtPayloadKeyType.RoleLevels.name to roleLevels
)
// 액세스 토큰, 리프레시 토큰 생성
val accessToken = accessJwtServiceBus.create(userId, tokenPayload)
val refreshToken = refreshJwtServiceBus.create(userId, tokenPayload)
// 리프레시 토큰 -> db 저장
refreshJwtServiceBus.save(userId.toInt(), refreshToken)
return mapOf(
authJwtProperties.config.header.authorization to
"${authJwtProperties.config.tokenPrefix} $accessToken",
authJwtProperties.config.header.refreshToken to
"${authJwtProperties.config.tokenPrefix} $refreshToken"
)
}
로그인하는 로직에서는 일회용 토큰을 복호화 후, 유효기간 초과 여부와 전환 대상 앱 정보를 검증한다. 그 후에는 토큰의 userId와 role을 이용해서 액세스 토큰 및 리프레시 토큰을 만든 후 클라이언트에 반환하면 된다.
'Spring' 카테고리의 다른 글
QueryDSL 페이징 Count 쿼리 작성 시 groupBy 사용의 문제점 분석 (0) | 2025.09.06 |
---|---|
Spring Boot JPA 네이티브 쿼리로 환경별 스키마 동적 변경하기 (0) | 2025.07.20 |
Prometheus + Grafana instance 별칭 설정하기 (0) | 2025.06.09 |
Spring Boot Actuator, Grafana & Prometheus 모니터링 구축 및 연결 (0) | 2025.05.19 |
JpaSystemException: Could not extract column [6] from JDBC ResultSet [MONTH] 오류 해결 (1) | 2025.02.18 |