-
Notifications
You must be signed in to change notification settings - Fork 0
NR-172 로그인이 자주 풀리는 이슈 수정 #173
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -2,11 +2,14 @@ package com.nextroom.nextroom.data.network | |||||||
|
|
||||||||
| import com.nextroom.nextroom.data.datasource.AuthDataSource | ||||||||
| import com.nextroom.nextroom.data.datasource.TokenDataSource | ||||||||
| import com.nextroom.nextroom.domain.model.Result | ||||||||
| import com.nextroom.nextroom.domain.model.mapOnSuccess | ||||||||
| import com.nextroom.nextroom.domain.model.onFailure | ||||||||
| import com.nextroom.nextroom.domain.model.onSuccess | ||||||||
| import com.nextroom.nextroom.domain.request.TokenRefreshRequest | ||||||||
| import kotlinx.coroutines.runBlocking | ||||||||
| import kotlinx.coroutines.sync.Mutex | ||||||||
| import kotlinx.coroutines.sync.withLock | ||||||||
| import okhttp3.Authenticator | ||||||||
| import okhttp3.Request | ||||||||
| import okhttp3.Response | ||||||||
|
|
@@ -19,25 +22,76 @@ class AuthAuthenticator @Inject constructor( | |||||||
| private val authDataSource: AuthDataSource, | ||||||||
| private val apiService: ApiService, | ||||||||
| ) : Authenticator { | ||||||||
|
|
||||||||
| /** | ||||||||
| * 재발급을 한 번에 하나만 수행하기 위한 잠금. | ||||||||
| * | ||||||||
| * 액세스 토큰이 만료된 시점에 요청이 여러 개 떠 있으면 모두 401 을 받고 | ||||||||
| * 각자 [authenticate] 로 들어온다. 잠금이 없으면 모두 같은 리프레시 토큰으로 | ||||||||
| * 재발급을 요청하는데, 서버가 리프레시 토큰을 한 번 쓰고 폐기하는 방식이면 | ||||||||
| * 먼저 도착한 하나만 성공하고 나머지는 이미 무효해진 토큰을 내밀어 401 을 받는다. | ||||||||
| * 그 결과 재발급에 성공했는데도 세션이 날아간다. | ||||||||
| */ | ||||||||
| private val refreshMutex = Mutex() | ||||||||
|
|
||||||||
| override fun authenticate(route: Route?, response: Response): Request? { | ||||||||
| Timber.tag("MANGBAAM-AuthAuthenticator(authenticate)").d("Access Token EXPIRED!! try refresh...") | ||||||||
| Timber.tag("AuthAuthenticator").d("Access Token EXPIRED!! try refresh...") | ||||||||
| return runBlocking { | ||||||||
| val tokenRequest: TokenRefreshRequest = run { | ||||||||
| refreshMutex.withLock { | ||||||||
| val (access, refresh) = tokenDataSource.getTokenPair() | ||||||||
| TokenRefreshRequest(accessToken = access, refreshToken = refresh) | ||||||||
|
|
||||||||
| // 잠금을 기다리는 동안 다른 요청이 이미 갱신했다면 재발급 없이 새 토큰으로 재시도만 한다. | ||||||||
| if (access.isNotEmpty() && access != response.usedAccessToken()) { | ||||||||
| Timber.tag("AuthAuthenticator").d("이미 갱신된 토큰이 있어 재발급을 생략한다") | ||||||||
| return@withLock response.retryWith(access) | ||||||||
| } | ||||||||
|
|
||||||||
| // 로그아웃 직후처럼 리프레시 토큰이 없으면 재발급할 것이 없다. | ||||||||
| if (refresh.isEmpty()) { | ||||||||
| Timber.tag("AuthAuthenticator").d("리프레시 토큰이 없어 재발급하지 않는다") | ||||||||
| return@withLock null | ||||||||
| } | ||||||||
|
|
||||||||
| apiService.refreshToken(TokenRefreshRequest(accessToken = access, refreshToken = refresh)) | ||||||||
| .onSuccess { newToken -> tokenDataSource.saveTokens(newToken.data.accessToken, newToken.data.refreshToken) } | ||||||||
| .mapOnSuccess { newToken -> | ||||||||
| Timber.tag("AuthAuthenticator").d("Refresh Token Success: $newToken") | ||||||||
| return@mapOnSuccess response.retryWith(newToken.data.accessToken) | ||||||||
| }.onFailure { failure -> | ||||||||
| if (failure.isSessionRejected()) { | ||||||||
| Timber.tag("AuthAuthenticator").d("Refresh Token REJECTED!!: $failure") | ||||||||
| authDataSource.logout() | ||||||||
| authDataSource.emitRefreshTokenExpired() | ||||||||
| } else { | ||||||||
| Timber.tag("AuthAuthenticator").d("Refresh 일시적 실패, 세션 유지: $failure") | ||||||||
| } | ||||||||
| }.getOrNull | ||||||||
| } | ||||||||
| apiService.refreshToken(tokenRequest) | ||||||||
| .onSuccess { newToken -> tokenDataSource.saveTokens(newToken.data.accessToken, newToken.data.refreshToken) } | ||||||||
| .mapOnSuccess { newToken -> | ||||||||
| Timber.tag("MANGBAAM-AuthAuthenticator").d("Refresh Token Success: $newToken") | ||||||||
| return@mapOnSuccess response.request.newBuilder() | ||||||||
| .header("Authorization", "Bearer ${newToken.data.accessToken}") | ||||||||
| .build() | ||||||||
| }.onFailure { | ||||||||
| Timber.tag("MANGBAAM-AuthAuthenticator").d("Refresh Token EXPIRED!!: $it") | ||||||||
| authDataSource.logout() | ||||||||
| authDataSource.emitRefreshTokenExpired() | ||||||||
| }.getOrNull | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| /** 401 을 받은 요청이 실제로 달고 나갔던 액세스 토큰. */ | ||||||||
| private fun Response.usedAccessToken(): String? = | ||||||||
| request.header(AUTHORIZATION)?.removePrefix(BEARER_PREFIX) | ||||||||
|
|
||||||||
| private fun Response.retryWith(accessToken: String): Request = | ||||||||
| request.newBuilder() | ||||||||
| .header(AUTHORIZATION, "$BEARER_PREFIX$accessToken") | ||||||||
| .build() | ||||||||
|
|
||||||||
| /** | ||||||||
| * 서버가 세션 자체를 거절했는지 여부. | ||||||||
| * | ||||||||
| * 401/400 은 리프레시 토큰이 더 이상 유효하지 않다는 서버의 답이므로 로그아웃한다. | ||||||||
| * 반면 네트워크 오류나 서버 장애는 리프레시 토큰이 멀쩡한데 잠깐 닿지 못한 것뿐이다. | ||||||||
| * 이 경우까지 로그아웃하면 잠깐의 끊김 때문에 매번 재로그인을 요구하게 되므로, | ||||||||
| * 해당 요청만 실패시키고 다음 요청에서 다시 재발급을 시도하게 둔다. | ||||||||
| */ | ||||||||
| private fun Result.Failure.isSessionRejected(): Boolean = | ||||||||
| this is Result.Failure.HttpError && (code == 401 || code == 400) | ||||||||
|
Comment on lines
+90
to
+91
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: rg -n -C 5 'refreshToken|TokenRefreshRequest|BadRequest|400|invalid.*token|expired' data domain app presentation . --glob '*.kt' --glob '*.json' --glob '*.md' --glob '*.yaml' --glob '*.yml' 2>/dev/null
sed -n '1,130p' data/src/main/java/com/nextroom/nextroom/data/network/AuthAuthenticator.kt
sed -n '45,90p' data/src/main/java/com/nextroom/nextroom/data/network/ResultCallAdapter.ktRepository: Next-Room/NextRoom-Android Length of output: 50382 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- endpoint and server/fixture references ---'
rg -n -S -C 3 'api/v1/auth/reissue|/reissue|refreshToken|TokenRefreshRequest|refresh token|refresh_token|invalid.*(token|request)|expired' \
--glob '*.kt' --glob '*.java' --glob '*.json' --glob '*.yaml' --glob '*.yml' --glob '*.md' --glob '*.gradle*' \
. 2>/dev/null | head -n 500
printf '%s\n' '--- AuthAuthenticator ---'
sed -n '1,125p' data/src/main/java/com/nextroom/nextroom/data/network/AuthAuthenticator.kt
printf '%s\n' '--- ResultCallAdapter ---'
sed -n '1,115p' data/src/main/java/com/nextroom/nextroom/data/network/ResultCallAdapter.kt
printf '%s\n' '--- Result model ---'
sed -n '1,100p' domain/src/main/java/com/nextroom/nextroom/domain/model/Result.kt
printf '%s\n' '--- tracked top-level server-like files ---'
git ls-files | rg -i '(^|/)(server|backend|fixture|mock|api|contract|openapi|swagger)|auth|reissue'Repository: Next-Room/NextRoom-Android Length of output: 28448 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- network configuration and endpoint contract references ---'
rg -n -S -C 4 'baseUrl|BASE_URL|BuildConfig|api/v1/auth|reissue|server|backend|swagger|openapi|refresh token|refreshToken' \
--glob '*.kt' --glob '*.java' --glob '*.xml' --glob '*.properties' --glob '*.gradle*' \
--glob '*.toml' --glob '*.json' --glob '*.yaml' --glob '*.yml' --glob '*.md' \
data app build.gradle* settings.gradle* gradle.properties README* CLAUDE.md 2>/dev/null | head -n 400
printf '%s\n' '--- network client wiring ---'
rg -n -S -C 5 'AuthAuthenticator|OkHttpClient|Retrofit|ApiService|addInterceptor|authenticator' \
--glob '*.kt' data/src/main 2>/dev/null | head -n 300Repository: Next-Room/NextRoom-Android Length of output: 50384 🌐 Web query:
💡 Result: <source_evidence> Citations:
🌐 Web query:
💡 Result: <source_evidence> Citations:
🏁 Script executed: #!/bin/bash
set -e
python3 - <<'PY'
import json
import urllib.request
repo = "Next-Room/NextRoom-be"
url = f"https://api.github.com/repos/{repo}/git/trees/develop?recursive=1"
with urllib.request.urlopen(url) as response:
tree = json.load(response)["tree"]
paths = [
item["path"] for item in tree
if any(term in item["path"].lower() for term in ("auth", "token", "exception", "response"))
and item["type"] == "blob"
]
for path in paths:
print(path)
PYRepository: Next-Room/NextRoom-Android Length of output: 2450 🏁 Script executed: #!/bin/bash
set -e
tree_json="$(curl -ksSfL 'https://api.github.com/repos/Next-Room/NextRoom-be/git/trees/develop?recursive=1')"
printf '%s\n' '--- backend auth/token paths ---'
printf '%s' "$tree_json" | jq -r '.tree[] | select(.type == "blob") | .path' |
rg -i 'auth|token|exception|response' | head -n 300Repository: Next-Room/NextRoom-Android Length of output: 1408 🏁 Script executed: #!/bin/bash
set -e
base='https://raw.githubusercontent.com/Next-Room/NextRoom-be/develop/'
for path in \
src/main/java/com/nextroom/nextRoomServer/controller/AuthController.java \
src/main/java/com/nextroom/nextRoomServer/service/AuthService.java \
src/main/java/com/nextroom/nextRoomServer/exceptions/StatusCode.java \
src/main/java/com/nextroom/nextRoomServer/exceptions/GlobalExceptionHandler.java \
src/main/java/com/nextroom/nextRoomServer/dto/BaseResponse.java \
src/main/java/com/nextroom/nextRoomServer/dto/TokenDto.java \
src/test/java/com/nextroom/nextRoomServer/controller/AuthControllerTest.java
do
printf '\n--- %s ---\n' "$path"
curl -ksSfL "${base}${path}" | nl -ba | sed -n '1,260p'
doneRepository: Next-Room/NextRoom-Android Length of output: 25371 400 응답을 세션 거절로 분류하지 마세요.
Suggested change
🤖 Prompt for AI Agents |
||||||||
|
|
||||||||
| companion object { | ||||||||
| private const val AUTHORIZATION = "Authorization" | ||||||||
| private const val BEARER_PREFIX = "Bearer " | ||||||||
| } | ||||||||
| } | ||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: Next-Room/NextRoom-Android
Length of output: 43614
Sensitive Data Exposure
Reachability: Internal
Exploitability: Difficult
CWE: CWE-532 — Insertion of Sensitive Information into Log File
토큰 객체를 Timber 로그에 기록하지 마세요.
newToken은accessToken과refreshToken을 포함한TokenDto입니다. 문자열 보간은 생성된toString()을 호출하므로 DEBUG 빌드의 Logcat에 자격 증명이 기록됩니다. 토큰 대신 고정된 성공 메시지 또는 마스킹된 메타데이터만 기록하세요.🤖 Prompt for AI Agents
Source: Path instructions