From 9c2703831327578add93fe7e8bc1883c1c354551 Mon Sep 17 00:00:00 2001 From: Borgia Leiva Date: Wed, 4 Feb 2026 13:52:29 +0100 Subject: [PATCH] Started integrating JWT, cookies, sessions and auth --- .../kanban_clone_android/LoginActivity.kt | 24 ++- .../kanban_clone_android/kanban/Auth.kt | 17 ++ .../network/ApiService.kt | 60 ++++++ .../network/AuthCookieJar.kt | 186 ++++++++++++++++++ .../network/RetrofitInstance.kt | 58 ++++++ 5 files changed, 343 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/campusaula/edbole/kanban_clone_android/kanban/Auth.kt create mode 100644 app/src/main/java/com/campusaula/edbole/kanban_clone_android/network/ApiService.kt create mode 100644 app/src/main/java/com/campusaula/edbole/kanban_clone_android/network/AuthCookieJar.kt create mode 100644 app/src/main/java/com/campusaula/edbole/kanban_clone_android/network/RetrofitInstance.kt diff --git a/app/src/main/java/com/campusaula/edbole/kanban_clone_android/LoginActivity.kt b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/LoginActivity.kt index ab2dfc7..3c4ca83 100644 --- a/app/src/main/java/com/campusaula/edbole/kanban_clone_android/LoginActivity.kt +++ b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/LoginActivity.kt @@ -8,6 +8,8 @@ import androidx.core.view.WindowInsetsCompat import androidx.lifecycle.lifecycleScope import com.campusaula.edbole.kanban_clone_android.network.ApiService import com.campusaula.edbole.kanban_clone_android.network.RetrofitInstance +import com.campusaula.edbole.kanban_clone_android.kanban.ErrorResponse +import com.google.gson.Gson import kotlinx.coroutines.launch import retrofit2.Retrofit @@ -58,9 +60,11 @@ class LoginActivity : AppCompatActivity() { ) ) + val baseUrl = retrofit.baseUrl().toString() + val baseHost = retrofit.baseUrl().host + if (loginResponse.isSuccessful) { // Después del login exitoso OkHttp/CookieJar habrá guardado las cookies. - val baseUrl = retrofit.baseUrl().toString() val authValue = RetrofitInstance.getAuthCookieForUrl(baseUrl) if (authValue != null) { android.widget.Toast.makeText(this@LoginActivity, "Auth cookie guardada", android.widget.Toast.LENGTH_SHORT).show() @@ -68,7 +72,23 @@ class LoginActivity : AppCompatActivity() { android.widget.Toast.makeText(this@LoginActivity, "Login OK pero no se encontró cookie de auth", android.widget.Toast.LENGTH_SHORT).show() } } else { - android.widget.Toast.makeText(this@LoginActivity, "Login failed: ${loginResponse.code()}", android.widget.Toast.LENGTH_SHORT).show() + if (loginResponse.code() == 401) { + // parse error body if possible + val errBody = loginResponse.errorBody()?.string() + val gson = Gson() + val errMsg = try { + val err = gson.fromJson(errBody, ErrorResponse::class.java) + err.detail ?: "Unauthorized" + } catch (_: Exception) { + errBody ?: "Unauthorized" + } + // clear stored cookies for base host + RetrofitInstance.clearCookiesForHost(baseHost) + + android.widget.Toast.makeText(this@LoginActivity, "Login failed (401): $errMsg", android.widget.Toast.LENGTH_SHORT).show() + } else { + android.widget.Toast.makeText(this@LoginActivity, "Login failed: ${loginResponse.code()}", android.widget.Toast.LENGTH_SHORT).show() + } } } catch (ex: Exception){ diff --git a/app/src/main/java/com/campusaula/edbole/kanban_clone_android/kanban/Auth.kt b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/kanban/Auth.kt new file mode 100644 index 0000000..63af700 --- /dev/null +++ b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/kanban/Auth.kt @@ -0,0 +1,17 @@ +package com.campusaula.edbole.kanban_clone_android.kanban + +data class LoginResponse( + val message: String?, + val user: LoginUser? +) + +data class LoginUser( + val id: String?, + val name: String?, + val email: String? +) + +// Error response from the API (e.g. 401 Unauthorized) +data class ErrorResponse( + val detail: String? +) diff --git a/app/src/main/java/com/campusaula/edbole/kanban_clone_android/network/ApiService.kt b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/network/ApiService.kt new file mode 100644 index 0000000..e79377c --- /dev/null +++ b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/network/ApiService.kt @@ -0,0 +1,60 @@ +package com.campusaula.edbole.kanban_clone_android.network + +import com.campusaula.edbole.kanban_clone_android.kanban.* +import retrofit2.Response +import retrofit2.http.* + +interface ApiService { + @GET("/ping") + suspend fun ping(): Response + + @POST("auth/login/") + suspend fun login(@Body userLogin: UserLogin): Response + + @POST("me/logout/") + suspend fun logout(): Response + + @DELETE("me/delete-me/") + suspend fun deleteMe(): Response + + @GET("me/") + suspend fun getMe(): Response + + @GET("users/{user_id}/") + suspend fun getUserById(@Path("user_id") userId: Int): Response + + @GET("users/{user_id}/projects/") + suspend fun getUserProjectsByUserId(@Path("user_id") userId: Int): Response> + + @POST("users/") + suspend fun createUser(@Body userLogin: UserCreate): Response + + // Projects endpoints + + @GET("projects/") + suspend fun getAllProjects(): Response> + + @GET("projects/{project_id}/") + suspend fun getProjectById(@Path("project_id") projectId: Int): Response + + @GET("projects/{project_id}/users/") + suspend fun getProjectUsers(@Path("project_id") projectId: Int): Response> + + @POST("projects/") + suspend fun createProject(@Body projectCreate: ProjectCreate): Response + + @PUT("projects/{project_id}/") + suspend fun updateProject(@Path("project_id") projectId: Int, @Body projectCreate: ProjectCreate): Response + + @DELETE("projects/{project_id}/") + suspend fun deleteProject(@Path("project_id") projectId: Int): Response + + // Tasks endpoints + + @GET("projects/{project_id}/tasks/") + suspend fun getProjectTasks(@Path("project_id") projectId: Int): Response> + + @POST("projects/{project_id}/tasks/") + suspend fun createTask(@Path("project_id") projectId: Int, @Body taskBase: TaskBase): Response + +} diff --git a/app/src/main/java/com/campusaula/edbole/kanban_clone_android/network/AuthCookieJar.kt b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/network/AuthCookieJar.kt new file mode 100644 index 0000000..f9ee3f4 --- /dev/null +++ b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/network/AuthCookieJar.kt @@ -0,0 +1,186 @@ +package com.campusaula.edbole.kanban_clone_android.network + +import android.content.Context +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import com.google.gson.Gson +import androidx.core.content.edit +import java.util.concurrent.ConcurrentHashMap +import android.util.Base64 +import com.auth0.android.jwt.JWT + + +private data class StoredCookie( + val name: String, + val value: String, + val expiresAt: Long, + val domain: String, + val path: String, + val secure: Boolean, + val httpOnly: Boolean, + val hostOnly: Boolean, + val iat: Long? = null, + val exp: Long? = null +) + +class AuthCookieJar( + context: Context, + private val authCookieNames: Set = setOf("access_token", "session", "auth", "auth_token", "JSESSIONID") +) : CookieJar { + + private val prefs = context.applicationContext.getSharedPreferences("auth_cookie_prefs", Context.MODE_PRIVATE) + private val lock = Any() + private val gson = Gson() + private val knownHosts = ConcurrentHashMap.newKeySet() + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + if (cookies.isEmpty()) return + val hostKey = url.host + synchronized(lock) { + val existing = prefs.getStringSet(hostKey, emptySet())?.toMutableSet() ?: mutableSetOf() + val now = System.currentTimeMillis() + for (cookie in cookies) { + if (cookie.expiresAt <= now) continue + existing.removeIf { it.startsWith("${'$'}{cookie.name}|${'$'}{cookie.path}|") } + + // default stored expiresAt comes from cookie.expiresAt (already in ms) + var storedExpires = cookie.expiresAt + var iatVal: Long? = null + var expVal: Long? = null + + // if this is an auth cookie, try to decode JWT payload to obtain exp/iat + if (cookie.name in authCookieNames) { + try { + val (expSec, iatSec) = decodeJwtExpIat(cookie.value) + if (expSec != null) { + expVal = expSec + // convert to millis + storedExpires = expSec * 1000L + } + if (iatSec != null) { + iatVal = iatSec + } + } catch (_: Exception) { + // ignore, keep cookie.expiresAt + } + } + + val stored = StoredCookie( + name = cookie.name, + value = cookie.value, + expiresAt = storedExpires, + domain = cookie.domain, + path = cookie.path, + secure = cookie.secure, + httpOnly = cookie.httpOnly, + hostOnly = cookie.hostOnly, + iat = iatVal, + exp = expVal + ) + // serialize to json explicitly and use it + val json = gson.toJson(stored) + existing.add("${cookie.name}|${cookie.path}|${json}") + } + prefs.edit { putStringSet(hostKey, existing) } + knownHosts.add(hostKey) + } + } + + override fun loadForRequest(url: HttpUrl): List { + val hostKey = url.host + val result = ArrayList() + val now = System.currentTimeMillis() + synchronized(lock) { + val set = prefs.getStringSet(hostKey, emptySet()) ?: emptySet() + val newSet = mutableSetOf() + for (s in set) { + try { + val jsonStart = s.indexOf('{') + val json = if (jsonStart >= 0) s.substring(jsonStart) else s + val stored = gson.fromJson(json, StoredCookie::class.java) + if (stored.expiresAt <= now) continue + val builder = Cookie.Builder() + .name(stored.name) + .value(stored.value) + .expiresAt(stored.expiresAt) + .path(stored.path) + if (stored.hostOnly) builder.hostOnlyDomain(stored.domain) else builder.domain(stored.domain) + if (stored.secure) builder.secure() + if (stored.httpOnly) builder.httpOnly() + val cookie = builder.build() + if (cookie.matches(url)) { + result.add(cookie) + } + newSet.add(s) + } catch (_: Exception) { + // skip malformed + } + } + prefs.edit { putStringSet(hostKey, newSet) } + if (newSet.isNotEmpty()) knownHosts.add(hostKey) + } + return result + } + + fun saveFromSetCookieHeader(setCookieHeader: String, requestUrl: String) { + val url = requestUrl.toHttpUrlOrNull() ?: return + val lines = setCookieHeader.split('\n').map { it.trim() }.filter { it.isNotEmpty() } + val parsed = mutableListOf() + for (line in lines) { + Cookie.parse(url, line)?.let { parsed.add(it) } + } + if (parsed.isNotEmpty()) saveFromResponse(url, parsed) + } + + fun getCookieHeaderForUrl(urlString: String): String? { + val url = urlString.toHttpUrlOrNull() ?: return null + val cookies = loadForRequest(url) + if (cookies.isEmpty()) return null + return cookies.joinToString("; ") { "${'$'}{it.name}=${'$'}{it.value}" } + } + + fun getAuthCookieForUrl(urlString: String): String? { + val url = urlString.toHttpUrlOrNull() ?: return null + val host = url.host + val set = prefs.getStringSet(host, emptySet()) ?: return null + val now = System.currentTimeMillis() + for (s in set) { + try { + val jsonStart = s.indexOf('{') + val json = if (jsonStart >= 0) s.substring(jsonStart) else s + val stored = gson.fromJson(json, StoredCookie::class.java) + if (stored.expiresAt <= now) continue + if (stored.name in authCookieNames) return stored.value + } catch (_: Exception) { + } + } + return null + } + + /** Remove all cookies stored for the given host. */ + fun clearCookiesForHost(host: String) { + synchronized(lock) { + prefs.edit { remove(host) } + knownHosts.remove(host) + } + } + + private fun decodeJwtExpIat(token: String): Pair { + return try { + val jwt = JWT(token) + // expiresAt / issuedAt -> java.util.Date? + val expSec = jwt.expiresAt?.time?.div(1000) // segundos desde epoch + val iatSec = jwt.issuedAt?.time?.div(1000) + Pair(expSec, iatSec) + } catch (e: Exception) { + Pair(null, null) + } + } + + private fun padBase64(b64: String): String { + val rem = b64.length % 4 + return if (rem == 0) b64 else b64 + "=".repeat(4 - rem) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/campusaula/edbole/kanban_clone_android/network/RetrofitInstance.kt b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/network/RetrofitInstance.kt new file mode 100644 index 0000000..c0c778e --- /dev/null +++ b/app/src/main/java/com/campusaula/edbole/kanban_clone_android/network/RetrofitInstance.kt @@ -0,0 +1,58 @@ +package com.campusaula.edbole.kanban_clone_android.network + +import android.content.Context +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit + +object RetrofitInstance { + private const val BASE_URL = "http://10.0.2.2:8000/" + + @Volatile + private var retrofit: Retrofit? = null + private var cookieJar: AuthCookieJar? = null + + private val logging = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + + fun getRetrofit(context: Context): Retrofit { + return retrofit ?: synchronized(this) { + retrofit ?: buildRetrofit(context.applicationContext).also { retrofit = it } + } + } + + private fun buildRetrofit(context: Context): Retrofit { + cookieJar = AuthCookieJar(context) + + val client = OkHttpClient.Builder() + .cookieJar(cookieJar!!) + .addInterceptor(logging) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + return Retrofit.Builder() + .baseUrl(BASE_URL) + .client(client) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + /** Helper: obtiene el valor de la cookie de autenticación para una URL. */ + fun getAuthCookieForUrl(url: String): String? { + return cookieJar?.getAuthCookieForUrl(url) + } + + fun getCookieHeaderForUrl(url: String): String? { + return cookieJar?.getCookieHeaderForUrl(url) + } + + fun clearCookiesForHost(host: String) { + cookieJar?.clearCookiesForHost(host) + } + + fun cookieJarInstance(): AuthCookieJar? = cookieJar +}