Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ jobs:
port: ${{ secrets.PROD_EC2_PORT }}
script: |
export DOCKER_CONTAINER_REGISTRY=${{ secrets.DOCKERHUB_USERNAME }}
export REDIS_PASSWORD=${{ secrets.PROD_REDIS_PASSWORD }}
export REDIS_PASSWORD='${{ secrets.PROD_REDIS_PASSWORD }}'
export GITHUB_SHA=${{ github.sha }}
sudo chmod +x ./deploy.sh
./deploy.sh
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# 큐-첵 (Ku-check): 큐시즘의 모든 일을 한큐에 체크하다, 큐첵
> 🔗 **서비스 링크**: [`https://ku-check.vercel.app`](https://ku-check.vercel.app)
<img width="7680" height="4320" alt="큐첵 소개" src="https://github.com/user-attachments/assets/e6375d54-366a-429d-a771-dfd6ef557d3d" />

## 🎨 서비스 설명
- 큐첵은 큐시즘 운영에 흩어져 있던 출결, 상벌점, 공지, 불참사유서 제출을 하나로 통합해 학회 운영을 더 정확하고 간편하게 만드는 전용 관리 서비스입니다.
- 운영진은 반복적인 수기 행정을 줄일 수 있고, 학회원은 내 활동 현황과 이번 주 핵심 정보를 한 곳에서 확인하며 불참사유서까지 앱에서 바로 제출할 수 있습니다.

## 💻 Backend Members
| **김영록** | **김민지** |
|:-------------------------------------------------------------------------------------------------------------------:|:----------------------------------------------------------------------------------------------------------------:|
| <a href="https://github.com/kimyeoungrok"><img src="https://avatars.githubusercontent.com/u/127182406?v=4" width="150"> | <a href="https://github.com/minzix"><img src="https://avatars.githubusercontent.com/u/126869805?v=4" width="150"> |
| `backend` | `backend` |

## 📜 API 명세서
[큐첵 개발 서버 API 명세서](https://dev.ku-check.o-r.kr/swagger-ui/index.html)

## 🗃️ ERD
<img width="2110" height="1272" alt="Ku-Check (1) (1)" src="https://github.com/user-attachments/assets/85afd83a-16c8-4c63-943b-98eaff150a29" />

## 🛠️ 기술 스택
| 기술 스택 | 사용 이유 |
|----------|-----------|
| **Spring Kotlin** | Kotlin은 간결하고 읽기 쉬운 문법을 제공하여 개발 생산성이 높아진다고 판단해 사용했습니다.<br>Java 기반 기술 스택과 완벽히 호환되어 러닝커브가 적다고 생각했습니다.<br>Null-safety 덕분에 런타임 오류(NPE)를 줄일 수 있어 선택했습니다. |
| **Spring Data JPA** | SQL을 직접 작성하지 않고 객체지향적인 방식으로 DB를 다루기 위해 JPA를 사용하였으며, Spring 환경에서 이를 쉽게 활용할 수 있도록 지원하는 Spring Data JPA를 선택했습니다. |
| **AWS EC2** | 서비스 배포 서버로 사용했습니다. |
| **AWS S3** | 큐픽 증빙사진, 불참증명서 파일 등을 저장하기 위한 파일 저장소로 사용했습니다. |
| **AWS RDS** | 타 클라우드 대비 저렴하고, VPC·보안 그룹과 통합되어 안전한 접근 제어가 가능하여 사용했습니다. |
| **Docker** | 개발 및 배포 환경을 컨테이너화하여 일관된 환경을 유지하기 위해 사용했습니다. |
| **GitHub Actions** | GitHub 기반으로 CI 환경을 일원화하여 자동화된 빌드 및 테스트 환경을 구축하기 위해 사용했습니다. |
| **MySQL 8.x** | MySQL 5 대비 향상된 성능, 강화된 보안 기능, 공간 데이터 처리 기능을 제공하여 사용했습니다. |
| **Redis** | TTL을 제공해 토큰과 같은 세션 정보를 유효시간 기반으로 관리할 수 있습니다.<br>메모리 기반 저장소로 DB 대비 빠른 속도를 제공하여 로그인 등 세션 관리에 적합합니다. |
| **JUnit** | JUnit Jupiter/Platform/Vintage 등 모듈형 구성 덕분에 유연하고 확장 가능한 테스트 환경을 제공하여 사용했습니다. |
| **AssertJ** | 외부 의존성을 모킹해 특정 단위만 독립적으로 테스트할 수 있습니다.<br>호출 검증, 다양한 입력 조건 설정 등 정교한 테스트 시나리오 구현이 가능하여 선택했습니다. |
| **Swagger** | 클라이언트–서버 간 API 명세서로 활용하기 위해 사용했습니다. |


## 💬 Commit Convention
> e.g. feat: 카카오 로그인 구현 #1

| Type | 내용 |
|------------| --------------------------------- |
| `feat` | 새로운 기능 추가 |
| `fix` | 버그 수정 |
| `hotfix` | 서비스 장애 등 긴급 이슈 수정 |
| `test` | 테스트 코드 추가 및 수정, 삭제 |
| `refactor` | 코드 리팩토링 |
| `deploy` | 배포 관련 작업 (CI/CD, 서버 설정, 배포 스크립트 등) |
| `setting` | 개발 환경 세팅|
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ dependencies {
implementation("com.google.auth:google-auth-library-oauth2-http:1.33.1")
//test
testImplementation("io.mockk:mockk:1.13.5")
//aws secretmanager
implementation(platform("io.awspring.cloud:spring-cloud-aws-dependencies:3.1.0"))
implementation("io.awspring.cloud:spring-cloud-aws-starter-secrets-manager")
}

kotlin {
Expand Down
8 changes: 6 additions & 2 deletions src/main/kotlin/onku/backend/domain/member/MemberProfile.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package onku.backend.domain.member

import jakarta.persistence.*
import onku.backend.domain.member.enums.Part
import onku.backend.global.crypto.converter.EncryptedStringConverter

@Entity
class MemberProfile(
Expand All @@ -14,7 +15,8 @@ class MemberProfile(
@JoinColumn(name = "member_id")
val member: Member,

@Column(length = 100)
@Column(length = 512)
@Convert(converter = EncryptedStringConverter::class)
var name: String? = null,

@Column(length = 100)
Expand All @@ -27,10 +29,12 @@ class MemberProfile(
@Enumerated(EnumType.STRING)
var part: Part,

@Column(name = "phone_number", length = 30)
@Column(name = "phone_number", length = 512)
@Convert(converter = EncryptedStringConverter::class)
var phoneNumber: String? = null,

@Column(name = "profile_image", length = 2048)
@Convert(converter = EncryptedStringConverter::class)
var profileImage: String? = null
) {
fun apply(
Expand Down
20 changes: 20 additions & 0 deletions src/main/kotlin/onku/backend/global/context/SpringContext.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package onku.backend.global.context

import org.springframework.context.ApplicationContext
import org.springframework.context.ApplicationContextAware
import org.springframework.stereotype.Component

@Component
class SpringContext : ApplicationContextAware {

override fun setApplicationContext(ctx: ApplicationContext) {
context = ctx
}

companion object {
private lateinit var context: ApplicationContext

fun <T> getBean(type: Class<T>): T =
context.getBean(type)
}
}
69 changes: 69 additions & 0 deletions src/main/kotlin/onku/backend/global/crypto/AESService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package onku.backend.global.crypto

import onku.backend.global.crypto.exception.DecryptionException
import onku.backend.global.crypto.exception.EncryptionException
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.nio.charset.StandardCharsets
import java.security.SecureRandom
import java.util.*
import javax.crypto.Cipher
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec

@Service
class AESService(
@Value("\${aes.secret-key-base64}")
secretKeyBase64: String
) : PrivacyEncryptor {

companion object {
private const val AES = "AES"
private const val AES_GCM_NO_PADDING = "AES/GCM/NoPadding"
private const val GCM_IV_LENGTH = 12 // bytes
private const val GCM_TAG_LENGTH_BITS = 128
}

private val key: SecretKey =
Base64.getDecoder().decode(secretKeyBase64).let { keyBytes ->
require(keyBytes.size == 16 || keyBytes.size == 24 || keyBytes.size == 32) {
"AES key must be 16/24/32 bytes after Base64 decode."
}
SecretKeySpec(keyBytes, AES)
}

private val secureRandom = SecureRandom()

override fun encrypt(raw: String?): String? {
if (raw == null) return null
return try {
val iv = ByteArray(GCM_IV_LENGTH).also { secureRandom.nextBytes(it) }
val cipher = Cipher.getInstance(AES_GCM_NO_PADDING).apply {
init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv))
}
val cipherText = cipher.doFinal(raw.toByteArray(StandardCharsets.UTF_8))
Base64.getEncoder().encodeToString(iv + cipherText)
} catch (e: Exception) {
throw EncryptionException(e)
}
}

override fun decrypt(encrypted: String?): String? {
if (encrypted == null) return null
return try {
val decoded = Base64.getDecoder().decode(encrypted)
require(decoded.size > GCM_IV_LENGTH + (GCM_TAG_LENGTH_BITS / 8)) { "Invalid encrypted text" }

val iv = decoded.copyOfRange(0, GCM_IV_LENGTH)
val cipherText = decoded.copyOfRange(GCM_IV_LENGTH, decoded.size)

val cipher = Cipher.getInstance(AES_GCM_NO_PADDING).apply {
init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv))
}
String(cipher.doFinal(cipherText), StandardCharsets.UTF_8)
} catch (e: Exception) {
throw DecryptionException(e)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package onku.backend.global.crypto

interface PrivacyEncryptor {
@Throws(Exception::class)
fun encrypt(raw: String?): String?
@Throws(Exception::class)
fun decrypt(encrypted: String?): String?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package onku.backend.global.crypto.converter;

import jakarta.persistence.AttributeConverter
import jakarta.persistence.Converter
import onku.backend.global.context.SpringContext
import onku.backend.global.crypto.PrivacyEncryptor
import onku.backend.global.crypto.exception.DecryptionException
import onku.backend.global.crypto.exception.EncryptionException


@Converter
class EncryptedStringConverter : AttributeConverter<String, String> {
private fun encryptor(): PrivacyEncryptor {
return SpringContext.getBean(PrivacyEncryptor::class.java)
}

override fun convertToDatabaseColumn(raw: String?): String? {
if (raw.isNullOrBlank()) return null
try {
return encryptor().encrypt(raw)
} catch (e: Exception) {
throw EncryptionException(e)
}
}

override fun convertToEntityAttribute(encrypted: String?): String? {
if (encrypted == null) return null
try {
return encryptor().decrypt(encrypted)
} catch (e: Exception) {
throw DecryptionException(e)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package onku.backend.global.crypto.exception

open class CryptoException(message: String, cause: Throwable? = null)
: RuntimeException(message, cause)
class EncryptionException(cause: Throwable? = null) : CryptoException("encryption failed", cause)
class DecryptionException(cause: Throwable? = null) : CryptoException("decryption failed", cause)
66 changes: 66 additions & 0 deletions src/main/kotlin/onku/backend/global/exception/ExceptionAdvice.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package onku.backend.global.exception

import jakarta.servlet.http.HttpServletRequest
import jakarta.validation.ConstraintViolationException
import onku.backend.global.crypto.exception.CryptoException
import onku.backend.global.response.ErrorResponse
import onku.backend.global.response.result.ExceptionResult
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.http.converter.HttpMessageNotReadableException
Expand All @@ -16,6 +18,9 @@ import org.springframework.web.bind.annotation.RestControllerAdvice
@RestControllerAdvice
class ExceptionAdvice {

private val log = LoggerFactory.getLogger(javaClass)


/**
* 등록되지 않은 에러
*/
Expand Down Expand Up @@ -132,4 +137,65 @@ class ExceptionAdvice {
return ResponseEntity(body, code.status)
}

/**
* 암호화 관련 에러
*/
@ExceptionHandler(CryptoException::class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
fun handleCryptoException(e: CryptoException, req: HttpServletRequest): ErrorResponse<ExceptionResult.ServerErrorData> {

// ✅ 민감정보(raw/encrypted) 절대 로그에 찍지 말기
// ✅ 대신 요청정보/경로/메서드/원인 예외 타입 정도만
log.error(
"[CRYPTO_ERROR] {} {} uri={} remote={} ua={} cause={}",
req.method,
req.requestURI,
req.requestURL,
req.remoteAddr,
req.getHeader("User-Agent"),
e.cause?.javaClass?.name ?: "none",
e
)

val serverErrorData = ExceptionResult.ServerErrorData(
errorClass = null,
errorMessage = "internal server error" // 암호화 관련 에러인건 프론트에 숨기기
)

return ErrorResponse.ok(
ErrorCode.SERVER_UNTRACKED_ERROR.errorCode,
ErrorCode.SERVER_UNTRACKED_ERROR.message,
serverErrorData
)
}

@ExceptionHandler(org.springframework.orm.jpa.JpaSystemException::class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
fun handleJpaSystemException(
e: org.springframework.orm.jpa.JpaSystemException,
req: HttpServletRequest
): ErrorResponse<ExceptionResult.ServerErrorData> {

val isConverter = e.message?.contains("AttributeConverter", ignoreCase = true) == true

if (isConverter) {
log.error("[CRYPTO_ERROR][JPA_CONVERTER] {} {} class={} msg={}",
req.method, req.requestURI, e.javaClass.name, e.message, e
)
} else {
log.error("[JPA_ERROR] {} {}", req.method, req.requestURI, e)
}

val serverErrorData = ExceptionResult.ServerErrorData(
errorClass = "INTERNAL_SERVER_ERROR",
errorMessage = "internal server error"
)

return ErrorResponse.ok(
ErrorCode.SERVER_UNTRACKED_ERROR.errorCode,
ErrorCode.SERVER_UNTRACKED_ERROR.message,
serverErrorData
)
}

}
46 changes: 46 additions & 0 deletions src/test/kotlin/onku/backend/crypto/CryptoErrorTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package onku.backend.crypto

import onku.backend.global.crypto.AESService
import onku.backend.global.crypto.exception.DecryptionException
import org.junit.jupiter.api.Assertions.assertThrows
import java.util.*
import kotlin.test.Test

class CryptoErrorTest {
private fun keyA(): String =
Base64.getEncoder().encodeToString(ByteArray(32) { 1 })

private fun keyB(): String =
Base64.getEncoder().encodeToString(ByteArray(32) { 2 })

@Test
fun `decrypt - invalid base64 throws DecryptionException`() {
val aes = AESService(keyA())

assertThrows(DecryptionException::class.java) {
aes.decrypt("%%%not-base64%%%")
}
}

@Test
fun `decrypt - too short ciphertext throws DecryptionException`() {
val aes = AESService(keyA())

val tooShort = Base64.getEncoder().encodeToString(byteArrayOf(0x01))
assertThrows(DecryptionException::class.java) {
aes.decrypt(tooShort)
}
}

@Test
fun `decrypt - wrong key throws DecryptionException`() {
val encryptor = AESService(keyA())
val decryptor = AESService(keyB())

val cipher = encryptor.encrypt("hello")!!

assertThrows(DecryptionException::class.java) {
decryptor.decrypt(cipher)
}
}
}
Loading