Started integrating JWT, cookies, sessions and auth

This commit is contained in:
Marta Borgia Leiva 2026-02-04 13:52:29 +01:00
parent d81a613a2c
commit 9c27038313
5 changed files with 343 additions and 2 deletions

View file

@ -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,18 +60,36 @@ 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()
} else {
android.widget.Toast.makeText(this@LoginActivity, "Login OK pero no se encontró cookie de auth", android.widget.Toast.LENGTH_SHORT).show()
}
} else {
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){
android.widget.Toast.makeText(this@LoginActivity, "Login failed: ${ex.message}", android.widget.Toast.LENGTH_SHORT).show()

View file

@ -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?
)

View file

@ -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<Unit>
@POST("auth/login/")
suspend fun login(@Body userLogin: UserLogin): Response<LoginResponse>
@POST("me/logout/")
suspend fun logout(): Response<Unit>
@DELETE("me/delete-me/")
suspend fun deleteMe(): Response<Unit>
@GET("me/")
suspend fun getMe(): Response<ProjectUser>
@GET("users/{user_id}/")
suspend fun getUserById(@Path("user_id") userId: Int): Response<UserBase>
@GET("users/{user_id}/projects/")
suspend fun getUserProjectsByUserId(@Path("user_id") userId: Int): Response<List<ProjectBase>>
@POST("users/")
suspend fun createUser(@Body userLogin: UserCreate): Response<UserBase>
// Projects endpoints
@GET("projects/")
suspend fun getAllProjects(): Response<List<Project>>
@GET("projects/{project_id}/")
suspend fun getProjectById(@Path("project_id") projectId: Int): Response<Project>
@GET("projects/{project_id}/users/")
suspend fun getProjectUsers(@Path("project_id") projectId: Int): Response<List<UserBase>>
@POST("projects/")
suspend fun createProject(@Body projectCreate: ProjectCreate): Response<ProjectBase>
@PUT("projects/{project_id}/")
suspend fun updateProject(@Path("project_id") projectId: Int, @Body projectCreate: ProjectCreate): Response<ProjectBase>
@DELETE("projects/{project_id}/")
suspend fun deleteProject(@Path("project_id") projectId: Int): Response<Unit>
// Tasks endpoints
@GET("projects/{project_id}/tasks/")
suspend fun getProjectTasks(@Path("project_id") projectId: Int): Response<List<Task>>
@POST("projects/{project_id}/tasks/")
suspend fun createTask(@Path("project_id") projectId: Int, @Body taskBase: TaskBase): Response<TaskBase>
}

View file

@ -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<String> = 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<String>()
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
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<Cookie> {
val hostKey = url.host
val result = ArrayList<Cookie>()
val now = System.currentTimeMillis()
synchronized(lock) {
val set = prefs.getStringSet(hostKey, emptySet()) ?: emptySet()
val newSet = mutableSetOf<String>()
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<Cookie>()
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<Long?, Long?> {
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)
}
}

View file

@ -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
}