본문 바로가기

Spring

pc ↔ 모바일 전환 시 일회용 토큰을 이용하여 로그인 기능 유지하기

개요 :

  • 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을 이용해서 액세스 토큰 및 리프레시 토큰을 만든 후 클라이언트에 반환하면 된다.