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
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class AccountAdminController(
private val manageAccountUseCase: ManageAccountUseCase,
) {
@PostMapping
@Operation(summary = "회비 총 금액 기입")
@Operation(summary = "회비 총 금액 기입", hidden = true)
fun save(
@TsidParam
@TsidPathVariable clubId: Long,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class AccountController(
private val getAccountQueryService: GetAccountQueryService,
) {
@GetMapping("/{cardinal}")
@Operation(summary = "회비 내역 조회")
@Operation(summary = "회비 내역 조회", hidden = true)
fun find(
@TsidParam
@TsidPathVariable clubId: Long,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class ReceiptAdminController(
private val manageReceiptUseCase: ManageReceiptUseCase,
) {
@PostMapping
@Operation(summary = "회비 사용 내역 기입")
@Operation(summary = "회비 사용 내역 기입", hidden = true)
fun save(
@TsidParam
@TsidPathVariable clubId: Long,
Expand All @@ -44,7 +44,7 @@ class ReceiptAdminController(
}

@DeleteMapping("/{receiptId}")
@Operation(summary = "회비 사용 내역 취소")
@Operation(summary = "회비 사용 내역 취소", hidden = true)
fun delete(
@TsidParam
@TsidPathVariable clubId: Long,
Expand All @@ -56,7 +56,7 @@ class ReceiptAdminController(
}

@PatchMapping("/{receiptId}")
@Operation(summary = "회비 사용 내역 수정")
@Operation(summary = "회비 사용 내역 수정", hidden = true)
fun update(
@TsidParam
@TsidPathVariable clubId: Long,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class AttendanceController(
}

@GetMapping
@Operation(summary = "출석 메인페이지")
@Operation(summary = "출석 요약 조회")
fun find(
@TsidParam
@TsidPathVariable clubId: Long,
Expand All @@ -53,7 +53,7 @@ class AttendanceController(
)

@GetMapping("/detail")
@Operation(summary = "출석 내역 상세조회")
@Operation(summary = "출석 상세 내역 조회")
fun findAll(
@TsidParam
@TsidPathVariable clubId: Long,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ class ClubAdminController(
}

@PatchMapping("/members/apply-ob")
@Operation(summary = "멤버 OB 기수 등록")
@Operation(summary = "멤버 OB 기수 등록", deprecated = true)
fun applyOb(
@Parameter(hidden = true) @CurrentUser userId: Long,
@TsidParam
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class PenaltyAdminController(
private val getPenaltyQueryService: GetPenaltyQueryService,
) {
@PostMapping
@Operation(summary = "패널티 부여")
@Operation(summary = "패널티 부여", hidden = true)
fun assignPenalty(
@TsidParam
@TsidPathVariable clubId: Long,
Expand All @@ -49,7 +49,7 @@ class PenaltyAdminController(
}

@PatchMapping
@Operation(summary = "패널티 수정")
@Operation(summary = "패널티 수정", hidden = true)
fun update(
@TsidParam
@TsidPathVariable clubId: Long,
Expand All @@ -61,7 +61,7 @@ class PenaltyAdminController(
}

@GetMapping
@Operation(summary = "전체 패널티 조회")
@Operation(summary = "전체 패널티 조회", hidden = true)
fun findAll(
@TsidParam
@TsidPathVariable clubId: Long,
Expand All @@ -74,7 +74,7 @@ class PenaltyAdminController(
)

@DeleteMapping
@Operation(summary = "패널티 삭제")
@Operation(summary = "패널티 삭제", hidden = true)
fun delete(
@TsidParam
@TsidPathVariable clubId: Long,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class PenaltyUserController(
private val getPenaltyQueryService: GetPenaltyQueryService,
) {
@GetMapping
@Operation(summary = "본인 패널티 조회")
@Operation(summary = "본인 패널티 조회", hidden = true)
fun findAllPenalties(
@TsidParam
@TsidPathVariable clubId: Long,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package com.weeth.domain.user.application.dto.response
import io.swagger.v3.oas.annotations.media.Schema

data class SocialLoginResponse(
@field:Schema(description = "사용자 이름")
val name: String,
@field:Schema(description = "액세스 토큰")
val accessToken: String,
@field:Schema(description = "리프레시 토큰")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ class UserMapper(
private val fileAccessUrlPort: FileAccessUrlPort,
) {
fun toSocialLoginResponse(
userName: String,
token: JwtDto,
registered: Boolean,
): SocialLoginResponse =
SocialLoginResponse(
name = userName,
accessToken = token.accessToken,
refreshToken = token.refreshToken,
registered = registered,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.weeth.domain.user.application.usecase.command

import com.fasterxml.jackson.databind.ObjectMapper
import com.weeth.domain.user.application.dto.request.SocialLoginRequest
import com.weeth.domain.user.application.dto.response.SocialLoginResponse
import com.weeth.domain.user.application.exception.EmailNotFoundException
Expand All @@ -15,6 +16,7 @@ import com.weeth.domain.user.domain.vo.SocialAuthResult
import com.weeth.domain.user.infrastructure.SocialAuthPortRegistry
import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase
import com.weeth.global.auth.jwt.domain.enums.TokenType
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

Expand All @@ -25,7 +27,10 @@ class SocialLoginUseCase(
private val socialAuthPortRegistry: SocialAuthPortRegistry,
private val jwtManageUseCase: JwtManageUseCase,
private val userMapper: UserMapper,
private val objectMapper: ObjectMapper,
) {
private val log = LoggerFactory.getLogger(javaClass)

@Transactional
fun socialLoginByKakao(request: SocialLoginRequest): SocialLoginResponse =
socialLogin(SocialProvider.KAKAO, request)
Expand All @@ -34,18 +39,45 @@ class SocialLoginUseCase(
fun socialLoginByApple(request: SocialLoginRequest): SocialLoginResponse =
socialLogin(SocialProvider.APPLE, request)

/**
* Apple form_post 콜백 전용 로그인.
* id_token을 직접 검증하여 code 교환 과정을 생략하고,
* Apple이 최초 인가 시에만 전달하는 user JSON의 이름을 반영한다.
*
* TODO: 탈퇴 기능 구현 시 Apple 계정 연결 해제(revoke)를 위해
* 콜백의 code를 Apple 토큰 엔드포인트에 교환하여 refresh token을 받고 DB에 저장해야 한다.
* (Apple Revoke Tokens API: POST https://appleid.apple.com/auth/revoke)
*/
@Transactional
fun socialLoginByAppleCallback(
idToken: String,
userJson: String?,
): SocialLoginResponse {
val authResult = socialAuthPortRegistry.get(SocialProvider.APPLE).authenticateWithIdToken(idToken)
val userName = parseAppleUserName(userJson)
val effectiveResult =
if (!userName.isNullOrBlank() && authResult.name.isNullOrBlank()) {
authResult.copy(name = userName)
} else {
authResult
}
return processLogin(effectiveResult)
}

private fun socialLogin(
provider: SocialProvider,
request: SocialLoginRequest,
): SocialLoginResponse {
val user = findOrCreateUser(authResult = socialAuthPortRegistry.get(provider).authenticate(request.authCode))
): SocialLoginResponse = processLogin(socialAuthPortRegistry.get(provider).authenticate(request.authCode))

private fun processLogin(authResult: SocialAuthResult): SocialLoginResponse {
val user = findOrCreateUser(authResult)

if (user.isBannedOrLeft()) throw UserInActiveException()

val tokenType = if (user.isRegistered()) TokenType.ACCESS else TokenType.TEMPORARY
val token = jwtManageUseCase.create(user.id, user.emailValue, tokenType)

return userMapper.toSocialLoginResponse(token, user.isRegistered())
return userMapper.toSocialLoginResponse(user.name, token, user.isRegistered())
}

// TODO: 실제 서비스 출시 시 이메일 기반 기존 사용자 연동 및 유저 알림 기능 필요
Expand Down Expand Up @@ -79,4 +111,18 @@ class SocialLoginUseCase(

return user
}

private fun parseAppleUserName(userJson: String?): String? {
if (userJson.isNullOrBlank()) return null
return try {
val node = objectMapper.readTree(userJson)
val nameNode = node["name"] ?: return null
val firstName = nameNode["firstName"]?.asText()?.trim() ?: ""
val lastName = nameNode["lastName"]?.asText()?.trim() ?: ""
"$lastName$firstName".trim().takeIf { it.isNotBlank() }
} catch (e: Exception) {
log.warn("Apple user JSON 파싱 실패: {}", e.message)
null
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,7 @@ interface SocialAuthPort {
fun provider(): SocialProvider

fun authenticate(authCode: String): SocialAuthResult

fun authenticateWithIdToken(idToken: String): SocialAuthResult =
throw UnsupportedOperationException("${provider()}은(는) ID token 직접 인증을 지원하지 않습니다")
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.weeth.domain.user.domain.enums.SocialProvider
import com.weeth.domain.user.domain.port.SocialAuthPort
import com.weeth.domain.user.domain.vo.SocialAuthResult
import com.weeth.global.auth.apple.AppleAuthService
import com.weeth.global.auth.apple.dto.AppleUserInfo
import org.springframework.stereotype.Component

@Component
Expand All @@ -15,15 +16,20 @@ class AppleSocialAuthAdapter(
override fun authenticate(authCode: String): SocialAuthResult {
val appleToken = appleAuthService.getAppleToken(authCode)
val userInfo = appleAuthService.verifyAndDecodeIdToken(appleToken.idToken)
val email = userInfo.email?.trim()?.lowercase() ?: ""
val providerName = userInfo.name?.trim()?.takeIf { it.isNotBlank() }
return toSocialAuthResult(userInfo)
}

override fun authenticateWithIdToken(idToken: String): SocialAuthResult {
val userInfo = appleAuthService.verifyAndDecodeIdToken(idToken)
return toSocialAuthResult(userInfo)
}

return SocialAuthResult(
private fun toSocialAuthResult(userInfo: AppleUserInfo): SocialAuthResult =
SocialAuthResult(
provider = SocialProvider.APPLE,
providerUserId = userInfo.appleId,
email = email,
email = userInfo.email?.trim()?.lowercase() ?: "",
emailVerified = userInfo.emailVerified,
name = providerName,
name = userInfo.name?.trim()?.takeIf { it.isNotBlank() },
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package com.weeth.domain.user.presentation

import com.weeth.domain.user.application.usecase.command.SocialLoginUseCase
import com.weeth.global.auth.jwt.application.service.TokenCookieProvider
import com.weeth.global.config.properties.OAuthProperties
import io.swagger.v3.oas.annotations.Hidden
import org.slf4j.LoggerFactory
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.util.UriComponentsBuilder

/**
* Apple Sign in with Apple의 form_post 콜백을 처리하는 컨트롤러.
*/
@Hidden
@RestController
class SocialCallbackController(
private val socialLoginUseCase: SocialLoginUseCase,
private val tokenCookieProvider: TokenCookieProvider,
oAuthProperties: OAuthProperties,
) {
private val log = LoggerFactory.getLogger(javaClass)
private val frontendRedirectUri = oAuthProperties.apple.frontendRedirectUri

@PostMapping(
"/api/v4/users/social/apple/callback",
consumes = [MediaType.APPLICATION_FORM_URLENCODED_VALUE],
)
fun handleCallback(
@RequestParam("id_token", required = false) idToken: String?,
@RequestParam("user", required = false) userJson: String?,
@RequestParam("error", required = false) error: String?,
): ResponseEntity<Void> {
if (error != null || idToken.isNullOrBlank()) {
return redirect(
UriComponentsBuilder
.fromUriString(frontendRedirectUri)
.queryParam("error", error ?: "unknown")
.toUriString(),
)
}

return try {
val response = socialLoginUseCase.socialLoginByAppleCallback(idToken, userJson)

val redirectUri =
UriComponentsBuilder
.fromUriString(frontendRedirectUri)
.queryParam("registered", response.registered)
.queryParam("name", response.name)
.toUriString()

ResponseEntity
.status(HttpStatus.FOUND)
.header(HttpHeaders.LOCATION, redirectUri)
.header(
HttpHeaders.SET_COOKIE,
tokenCookieProvider.createAccessTokenCookie(response.accessToken).toString(),
).header(
HttpHeaders.SET_COOKIE,
tokenCookieProvider.createRefreshTokenCookie(response.refreshToken).toString(),
).build()
Comment on lines +58 to +67
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

SameSite cookie behavior on cross-origin POST redirect 302 response

💡 Result:

SameSite cookie behavior on a cross-origin POST request followed by a 302 redirect depends on the SameSite attribute value (Strict, Lax, or None) and whether the subsequent redirect request is classified as "same-site" or "cross-site". Cookies set in the 302 response are always stored by the browser before following the redirect (result 1), but whether existing cookies (including those just set) are sent on the follow-up request depends on SameSite rules. Key concepts from specs (results 9,10,13,14,18): - A request is "cross-site" if the initiator site (top-level browsing context) differs from the request URL's site (scheme+registrable domain). - For redirects: HTTP 302 redirects preserve the initiator context, so a cross-site POST keeps the chain cross-site for follow-up requests (results 3,4,22). Sec-Fetch-Site remains "cross-site". - SameSite=Strict: Cookie sent ONLY on same-site requests, never cross-site (results 3,4,9,13). - SameSite=Lax: Sent on same-site requests + cross-site top-level navigations with "safe" methods (GET, HEAD). Cross-site POST or unsafe methods: not sent. Lax-allowing-unsafe mode (some browsers) sends on cross-site top-level regardless of method, but POST after cross-origin POST redirect typically blocked (results 2,5,6,9). - SameSite=None (requires Secure): Sent on all requests, cross-site included (results 9,11). Scenario example (OAuth/login flow, results 5,6): 1. Cross-origin POST to /login (cross-site, initiator from other site). 2. Server responds 302 Location: /dashboard + Set-Cookie (session). 3. Browser stores cookie, follows with GET /dashboard. - If chain cross-site: Strict=None sent; Lax=not sent (not safe method exception); None=sent. - Common breakage with Strict/Lax leads to workarounds like meta-refresh 200 (changes to same-site navigation, result 6). For cross-origin POST 302 specifically: Cookies with Lax/Strict typically NOT sent on redirect leg if chain cross-site; use None for cross-site compatibility.

Citations:


🏁 Script executed:

# TokenCookieProvider와 쿠키 설정 관련 파일 찾기
fd -t f "TokenCookieProvider\|CookieProperties" --case-sensitive

Repository: Team-Weeth/weeth-server

Length of output: 49


🏁 Script executed:

# SocialCallbackController 전체 내용 확인
cat -n src/main/kotlin/com/weeth/domain/user/presentation/SocialCallbackController.kt

Repository: Team-Weeth/weeth-server

Length of output: 4817


🏁 Script executed:

# cookieProperties 또는 Cookie 관련 설정 파일 찾기
fd -t f -i "cookie" | grep -i "properties\|config"

Repository: Team-Weeth/weeth-server

Length of output: 137


🏁 Script executed:

# TokenCookieProvider 파일 찾기
fd -t f "TokenCookieProvider"

Repository: Team-Weeth/weeth-server

Length of output: 240


🏁 Script executed:

# CookieProperties 내용 확인
cat -n src/main/kotlin/com/weeth/global/config/properties/CookieProperties.kt

Repository: Team-Weeth/weeth-server

Length of output: 850


🏁 Script executed:

# TokenCookieProvider 내용 확인
cat -n src/main/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProvider.kt

Repository: Team-Weeth/weeth-server

Length of output: 1994


🏁 Script executed:

# Verify if there are any alternative token handling methods or other Apple callback implementations
rg -i "apple|samesite" --type kt -A 3 -B 3 | head -80

Repository: Team-Weeth/weeth-server

Length of output: 93


🏁 Script executed:

# Check if there's any frontend token handling via query params or other methods
rg "accessToken|refreshToken" src/main/kotlin/com/weeth/domain/user/presentation/ --type kt

Repository: Team-Weeth/weeth-server

Length of output: 93


🏁 Script executed:

# Verify the scenario more directly
rg "apple" -i --max-count=20

Repository: Team-Weeth/weeth-server

Length of output: 13812


Apple 콜백의 SameSite 쿠키 이슈로 인해 토큰이 프론트엔드에 전달되지 않습니다.

Apple의 form_post 방식은 appleid.apple.com에서 크로스 오리진 POST 요청을 보내므로, 이 엔드포인트의 302 응답에 포함된 SameSite=Lax 쿠키는 브라우저가 무시합니다. 현재 CookieProperties의 기본값이 sameSite = "Lax"이고, TokenCookieProvider.buildCookie()가 이 값을 사용하기 때문에 액세스/리프레시 토큰 쿠키가 프론트엔드에 전달되지 않아 Apple 로그인이 실패합니다.

해결 방안:

  1. Apple 콜백 전용으로 SameSite=None; Secure 쿠키를 생성하는 별도 메서드 제공
  2. 또는 토큰을 쿠키 대신 리다이렉트 URL fragment(#) 또는 localStorage 스크립트로 전달하고 프론트엔드에서 처리
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/kotlin/com/weeth/domain/user/presentation/SocialCallbackController.kt`
around lines 62 - 71, Apple's form_post callback is dropping cookies due to
SameSite=Lax; update the cookie creation for the Apple callback in
SocialCallbackController to use SameSite=None and Secure. Add a dedicated method
on TokenCookieProvider (e.g., createAppleAccessTokenCookie and
createAppleRefreshTokenCookie or a buildCookie variant with a sameSiteOverride
param) that constructs cookies using CookieProperties or buildCookie with
sameSite="None" and secure=true, then call those methods instead of
createAccessTokenCookie/createRefreshTokenCookie in the Apple callback response
construction so the headers set HttpHeaders.SET_COOKIE include SameSite=None;
Secure.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

백엔드와 프론트 도메인이 맞으면 문제 없지 않아?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hyxklee, 맞습니다. 제 분석에 보완이 필요합니다.

Set-Cookie 헤더는 응답에서 쿠키를 저장하는 역할이고, SameSite 속성은 쿠키를 요청에 포함해 전송할 때만 적용됩니다. 즉, Apple에서 크로스 오리진 POST로 들어와도 302 응답의 Set-Cookie는 브라우저에 정상적으로 저장됩니다.

이후 백엔드와 프론트엔드가 동일한 registrable domain(e.g., api.weeth.krx.weeth.kr → 둘 다 weeth.kr)을 공유하고, 쿠키의 Domain 속성이 .weeth.kr로 설정되어 있다면, SameSite=Lax 쿠키는 same-site 요청으로 간주되어 정상적으로 전송됩니다.

따라서 두 도메인이 같은 eTLD+1을 공유한다면 제가 지적한 SameSite=Lax 이슈는 해당되지 않습니다. 지적이 맞습니다.


✏️ Learnings added
Learnt from: hyxklee
Repo: Team-Weeth/weeth-server PR: 44
File: src/main/kotlin/com/weeth/domain/user/presentation/SocialCallbackController.kt:62-71
Timestamp: 2026-03-31T06:54:59.595Z
Learning: In the weeth-server project (Kotlin/Spring), the Apple Sign In callback (SocialCallbackController) sets SameSite=Lax cookies in the 302 redirect response. This is acceptable as long as the backend and frontend share the same registrable domain (e.g., both under weeth.kr), because Set-Cookie stores cookies regardless of SameSite, and SameSite=Lax allows same-site requests. The SameSite=None concern would only apply if backend and frontend are on completely different domains.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

} catch (e: Exception) {
log.error("Apple 콜백 처리 중 오류 발생", e)
redirect(
UriComponentsBuilder
.fromUriString(frontendRedirectUri)
.queryParam("error", "login_failed")
.toUriString(),
)
}
}

private fun redirect(uri: String): ResponseEntity<Void> =
ResponseEntity
.status(HttpStatus.FOUND)
.header(HttpHeaders.LOCATION, uri)
.build()
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ class ExceptionDocController {
fun userErrorCodes() {
}

// todo: SAS 관련 예외도 추가
@GetMapping("/auth")
@Operation(summary = "인증/인가 에러 코드 목록")
@ApiErrorCodeExample(JwtErrorCode::class)
Expand Down
2 changes: 2 additions & 0 deletions src/main/kotlin/com/weeth/global/config/SecurityConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class SecurityConfig(
.requestMatchers(
"/api/v4/users/social/kakao",
"/api/v4/users/social/apple",
"/api/v4/users/social/apple/callback",
"/api/v4/users/social/refresh",
).permitAll()
.requestMatchers("/health-check")
Expand Down Expand Up @@ -87,6 +88,7 @@ class SecurityConfig(
"http://127.0.0.1:*",
"https://13.124.170.169.nip.io",
"https://develop.d2o3vlabneheuu.amplifyapp.com",
"https://appleid.apple.com",
)
allowedMethods = listOf("GET", "POST", "PATCH", "DELETE", "OPTIONS")
allowedHeaders = listOf("*")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,7 @@ data class OAuthProperties(
val keysUri: String,
@field:NotBlank
val privateKeyPath: String,
@field:NotBlank
val frontendRedirectUri: String,
)
}
Loading