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
3 changes: 2 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Tiggle">
android:theme="@style/Theme.Tiggle"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.ssafy.tiggle.data.datasource.local

import android.content.SharedPreferences
import android.util.Log
import androidx.core.content.edit
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class AuthDataSource @Inject constructor(
private val prefs: SharedPreferences, // Hilt 모듈로부터 주입
) {

/**
* Token 관리 클래스
* - 로그인 시 저장되는 accessToken, refreshToken, userId 등을 관리
* - 앱 종료 후에도 유지되는 데이터로, 앱 재시작 시 자동 로그인 기능에 사용
*/

// SharedPreferences에 저장된 인증 토큰을 관리
fun saveTokens(accessToken: String, refreshToken: String) {
prefs.edit {
putString(KEY_ACCESS_TOKEN, accessToken)
putString(KEY_REFRESH_TOKEN, refreshToken)
}
Log.d("AuthLocalDataSource", "✅ accessToken 저장됨: $accessToken")
Log.d("AuthLocalDataSource", "✅ refreshToken 저장됨: $refreshToken")
}

// 토큰을 가져오는 메소드
fun getAccessToken(): String? {
return prefs.getString(KEY_ACCESS_TOKEN, null)
}

// 리프레시 토큰을 가져오는 메소드
fun getRefreshToken(): String? {
return prefs.getString(KEY_REFRESH_TOKEN, null)
}

/**
* 쿠키 관리 클래스
* - 서버와의 통신에서 필요한 쿠키들을 관리
* - 재발급 시 필요한 Cookie 헤더 문자열을 구성
*/

// ▼ 모든 Set-Cookie 저장
fun saveSetCookies(cookies: List<String>) {
prefs.edit { putStringSet(KEY_SET_COOKIES, cookies.toSet()) }
}

// ▼ 재발급용 Cookie 헤더 조립 (JSESSIONID 등 + refreshToken)
fun buildCookieHeaderForReissue(refreshToken: String): String {
val raw = prefs.getStringSet(KEY_SET_COOKIES, emptySet())!!.toList()
val pairs = raw.map { it.substringBefore(";") } // name=value
return (pairs + "refreshToken=$refreshToken")
.distinctBy { it.substringBefore("=") }
.joinToString("; ")
}


// 인증 데이터를 모두 지우는 메소드
fun clearAuthData() {
prefs.edit {
remove(KEY_ACCESS_TOKEN)
remove(KEY_REFRESH_TOKEN)
remove(KEY_SET_COOKIES)
}
}

// 로그인 상태 확인 메소드
fun isLoggedIn(): Boolean {
return getAccessToken() != null
}


companion object {
private const val KEY_ACCESS_TOKEN = "access_token"
private const val KEY_REFRESH_TOKEN = "refresh_token"
private const val KEY_SET_COOKIES = "set_cookies" // 쿠키 저장 키
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.ssafy.tiggle.data.datasource.remote

import com.ssafy.tiggle.data.model.BaseResponse
import com.ssafy.tiggle.data.model.LoginRequestDto
import com.ssafy.tiggle.data.model.SignUpRequestDto
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.Header
import retrofit2.http.POST

/**
* 사용자 관련 API 서비스
*/
interface AuthApiService {

/**
* 회원가입
*
* @param signUpRequest 회원가입 요청 데이터
* @return Response<SignUpResponseDto> 회원가입 응답
*/
@POST("auth/join")
suspend fun signUp(
@Body signUpRequest: SignUpRequestDto
): Response<BaseResponse<Unit>>


/**
* 로그인
*
* @param loginRequest 로그인 요청 데이터
* @return Response<BaseResponse<Unit>> 로그인 응답
*/
@POST("auth/login")
suspend fun login(
@Body loginRequest: LoginRequestDto
): Response<BaseResponse<Unit>>

@POST("auth/reissue")
suspend fun reissueTokenByCookie(
@Header("Cookie") cookie: String
): Response<BaseResponse<Unit>>


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.ssafy.tiggle.data.datasource.remote

import android.util.Log
import com.ssafy.tiggle.data.datasource.local.AuthDataSource
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
import javax.inject.Named

// 오래 방치 시 첫 요청이 동시에 여러 개 날아가면, 재발급도 동시에 여러 번 발생
// → 서버가 refreshToken을 회전시키며 둘 중 하나는 INVALID_REFRESH_TOKEN이 됨.
// 이걸 막으려면 재발급은 한 번만 수행하고, 나머지는 결과를 공유해야 한다
private val refreshMutex = Mutex()

class AuthInterceptor @Inject constructor(
private val authDataSource: AuthDataSource,
@Named("refresh") private val api: AuthApiService
) : Interceptor {

private fun stripBearer(raw: String?): String =
raw?.replaceFirst(Regex("^Bearer\\s+", RegexOption.IGNORE_CASE), "")?.trim().orEmpty()

private fun isAuthPath(url: HttpUrl) = url.encodedPath.startsWith("/auth/")

override fun intercept(chain: Interceptor.Chain): Response {
val original = chain.request()
if (isAuthPath(original.url)) return chain.proceed(original)

// 1) Authorization 부착 (항상 Bearer 1회)
val access = stripBearer(authDataSource.getAccessToken())
val req = original.newBuilder()
.removeHeader("Authorization")
.apply {
if (access.isNotBlank()) header(
"Authorization",
"Bearer $access"
)
}
.build()

var res = chain.proceed(req)
if (res.code != 401) return res

// 2) 401 → 재발급 (단일 실행)
val refreshed = runBlocking {
refreshMutex.withLock {
val latest = stripBearer(authDataSource.getAccessToken())
if (latest.isNotBlank() && latest != access) return@withLock true

val refresh = stripBearer(authDataSource.getRefreshToken())
if (refresh.isBlank()) return@withLock false

val cookieHeader = authDataSource.buildCookieHeaderForReissue(refresh)
Log.d("Reissue", "➡️ Cookie header(for reissue)=${cookieHeader}")

val r = api.reissueTokenByCookie(cookie = cookieHeader)
if (!r.isSuccessful) {
if (r.code() == 401) {
// INVALID_REFRESH_TOKEN 등: 회복 불가 → 세션 정리
authDataSource.clearAuthData()
}
return@withLock false
}

val newAccess = stripBearer(r.headers()["Authorization"])
val setCookies = r.headers().values("Set-Cookie")
authDataSource.saveSetCookies(setCookies)

val cookieRefresh = setCookies.firstOrNull { it.startsWith("refreshToken=") }
?.substringAfter("refreshToken=")?.substringBefore(";")

if (newAccess.isBlank() || cookieRefresh.isNullOrBlank()) return@withLock false
authDataSource.saveTokens(newAccess, cookieRefresh)
true
}
}

if (!refreshed) {
// 재발급 실패: 기존 401을 그대로 반환 (닫지 말고 반환)
return res
}

// 재발급 성공: 이제 원 응답 닫고 재시도
res.close()
val latest = stripBearer(authDataSource.getAccessToken())
val retry = original.newBuilder()
.removeHeader("Authorization")
.header("Authorization", "Bearer $latest")
.build()
return chain.proceed(retry)
}
}

Loading