mirror of
https://github.com/a-mayb3/KanbanCloneAndroid.git
synced 2026-03-21 18:15:38 +01:00
Started integrating JWT, cookies, sessions and auth
This commit is contained in:
parent
d81a613a2c
commit
9c27038313
5 changed files with 343 additions and 2 deletions
|
|
@ -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){
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
)
|
||||
|
|
@ -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>
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue