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 androidx.lifecycle.lifecycleScope
|
||||||
import com.campusaula.edbole.kanban_clone_android.network.ApiService
|
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.network.RetrofitInstance
|
||||||
|
import com.campusaula.edbole.kanban_clone_android.kanban.ErrorResponse
|
||||||
|
import com.google.gson.Gson
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
|
|
||||||
|
|
@ -58,9 +60,11 @@ class LoginActivity : AppCompatActivity() {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val baseUrl = retrofit.baseUrl().toString()
|
||||||
|
val baseHost = retrofit.baseUrl().host
|
||||||
|
|
||||||
if (loginResponse.isSuccessful) {
|
if (loginResponse.isSuccessful) {
|
||||||
// Después del login exitoso OkHttp/CookieJar habrá guardado las cookies.
|
// Después del login exitoso OkHttp/CookieJar habrá guardado las cookies.
|
||||||
val baseUrl = retrofit.baseUrl().toString()
|
|
||||||
val authValue = RetrofitInstance.getAuthCookieForUrl(baseUrl)
|
val authValue = RetrofitInstance.getAuthCookieForUrl(baseUrl)
|
||||||
if (authValue != null) {
|
if (authValue != null) {
|
||||||
android.widget.Toast.makeText(this@LoginActivity, "Auth cookie guardada", android.widget.Toast.LENGTH_SHORT).show()
|
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()
|
android.widget.Toast.makeText(this@LoginActivity, "Login OK pero no se encontró cookie de auth", android.widget.Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
} else {
|
} 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){
|
} 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