fix: derive JWT iat from server clock
Shaarli rejects tokens whose iat falls outside its clock tolerance, so device/server skew caused HTTP 401 logins even with a correct API secret. Estimate server time from the Date response header, retry a 401 once with a recalibrated token, trim credentials on login, and explain the API-secret vs password mismatch in French error messages. Bump version to 2.14.1 (code 42).
This commit is contained in:
@@ -4,32 +4,58 @@ import com.shaarit.core.storage.TokenManager
|
||||
import com.shaarit.core.util.JwtGenerator
|
||||
import javax.inject.Inject
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
||||
class AuthInterceptor @Inject constructor(
|
||||
private val tokenManager: TokenManager,
|
||||
private val sessionManager: SessionManager
|
||||
private val sessionManager: SessionManager,
|
||||
private val serverClock: ServerClock
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val original = chain.request()
|
||||
val builder = original.newBuilder()
|
||||
|
||||
// Shaarli requires a fresh JWT token generated from the API secret for each request
|
||||
// The token is valid for 9 minutes, so we generate a new one for each request
|
||||
val apiSecret = tokenManager.getApiSecret()
|
||||
if (!apiSecret.isNullOrBlank()) {
|
||||
val jwtToken = JwtGenerator.generateToken(apiSecret)
|
||||
builder.header("Authorization", "Bearer $jwtToken")
|
||||
companion object {
|
||||
// Shaarli tokens carry an expiry; staying a minute "old" is safer than risking a
|
||||
// future-dated iat if our clock estimate is slightly off.
|
||||
private const val IAT_GRACE_SECONDS = 60L
|
||||
}
|
||||
|
||||
val response = chain.proceed(builder.build())
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val original = chain.request()
|
||||
|
||||
// A 401 means our API secret no longer matches the one on the Shaarli server.
|
||||
// Broadcast the event so the UI can clear credentials and return to the login screen.
|
||||
// Shaarli requires a fresh JWT token generated from the API secret for each request.
|
||||
val apiSecret = tokenManager.getApiSecret()
|
||||
if (apiSecret.isNullOrBlank()) {
|
||||
return chain.proceed(original)
|
||||
}
|
||||
|
||||
// Remember whether the first token was generated before we knew the server time. If
|
||||
// so, a 401 is ambiguous: bad secret, or device clock skew pushed `iat` outside
|
||||
// Shaarli's tolerance. The Date header of the failed response calibrates our clock,
|
||||
// so retry exactly once with a regenerated token.
|
||||
val clockWasUnknown = !serverClock.isSynced
|
||||
|
||||
var response = chain.proceed(original.withAuth(apiSecret))
|
||||
serverClock.syncFromResponse(response)
|
||||
|
||||
if (response.code == 401 && clockWasUnknown) {
|
||||
response.close()
|
||||
response = chain.proceed(original.withAuth(apiSecret))
|
||||
serverClock.syncFromResponse(response)
|
||||
}
|
||||
|
||||
// Still 401 after a clock-corrected retry: the secret is genuinely wrong or was
|
||||
// regenerated server-side. Broadcast so the UI can clear credentials and return
|
||||
// to the login screen.
|
||||
if (response.code == 401) {
|
||||
sessionManager.onUnauthorized()
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
private fun Request.withAuth(apiSecret: String): Request {
|
||||
val jwtToken =
|
||||
JwtGenerator.generateToken(apiSecret, serverClock.nowSeconds() - IAT_GRACE_SECONDS)
|
||||
return newBuilder().header("Authorization", "Bearer $jwtToken").build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.shaarit.core.network
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import okhttp3.Response
|
||||
|
||||
/**
|
||||
* Estimates the server clock so JWT timestamps survive device/server clock skew.
|
||||
*
|
||||
* Shaarli rejects tokens whose `iat` is in the future or older than its lifetime. The app
|
||||
* previously used the raw device clock, so a phone running a few seconds fast (or a server
|
||||
* running slow) produced "HTTP 401 Not authorized" even with the correct API secret. We derive
|
||||
* the offset from the `Date` header present in every HTTP response and extrapolate with
|
||||
* SystemClock.elapsedRealtime(), which keeps ticking correctly through doze/suspend.
|
||||
*/
|
||||
@Singleton
|
||||
class ServerClock @Inject constructor() {
|
||||
|
||||
@Volatile
|
||||
private var serverBaseMs: Long = 0L
|
||||
|
||||
@Volatile
|
||||
private var localBaseElapsed: Long = 0L
|
||||
|
||||
@Volatile
|
||||
var isSynced: Boolean = false
|
||||
private set
|
||||
|
||||
/** Update the estimate from a response's `Date` header (server epoch millis). */
|
||||
fun syncFromResponse(response: Response) {
|
||||
val serverDate = parseHttpDate(response.header("Date")) ?: return
|
||||
serverBaseMs = serverDate
|
||||
localBaseElapsed = android.os.SystemClock.elapsedRealtime()
|
||||
isSynced = true
|
||||
}
|
||||
|
||||
/** Current time as the server sees it, in epoch seconds (device clock if never synced). */
|
||||
fun nowSeconds(): Long {
|
||||
val base = serverBaseMs
|
||||
if (base == 0L || !isSynced) {
|
||||
return System.currentTimeMillis() / 1000
|
||||
}
|
||||
val elapsedSinceSync = android.os.SystemClock.elapsedRealtime() - localBaseElapsed
|
||||
return (base + elapsedSinceSync) / 1000
|
||||
}
|
||||
|
||||
private fun parseHttpDate(value: String?): Long? {
|
||||
if (value.isNullOrBlank()) return null
|
||||
return try {
|
||||
val format = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US)
|
||||
format.timeZone = TimeZone.getTimeZone("GMT")
|
||||
format.parse(value)?.time
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,15 +17,18 @@ object JwtGenerator {
|
||||
/**
|
||||
* Generates a JWT token for Shaarli API.
|
||||
* @param apiSecret The API secret from Shaarli settings
|
||||
* @param issuedAt The `iat` claim (epoch seconds). Callers should pass the *server*
|
||||
* clock (see ServerClock) so device clock skew never pushes the token outside
|
||||
* Shaarli's tolerance window.
|
||||
* @return The JWT token string
|
||||
*/
|
||||
fun generateToken(apiSecret: String): String {
|
||||
@JvmOverloads
|
||||
fun generateToken(apiSecret: String, issuedAt: Long = System.currentTimeMillis() / 1000): String {
|
||||
// Header: {"typ":"JWT","alg":"HS512"}
|
||||
val header = """{"typ":"JWT","alg":"HS512"}"""
|
||||
|
||||
// Payload: {"iat": unix_timestamp}
|
||||
// Use timestamp 60 seconds in the past to avoid clock skew issues
|
||||
val iat = (System.currentTimeMillis() / 1000) - 60
|
||||
val iat = issuedAt
|
||||
val payload = """{"iat":$iat}"""
|
||||
|
||||
// Base64URL encode header and payload
|
||||
|
||||
@@ -4,7 +4,9 @@ import com.shaarit.core.storage.TokenManager
|
||||
import com.shaarit.data.api.ShaarliApi
|
||||
import com.shaarit.domain.model.Credentials
|
||||
import com.shaarit.domain.repository.AuthRepository
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import retrofit2.HttpException
|
||||
|
||||
class AuthRepositoryImpl
|
||||
@Inject
|
||||
@@ -24,14 +26,47 @@ constructor(private val api: ShaarliApi, private val tokenManager: TokenManager)
|
||||
// If we get here, authentication worked
|
||||
// The info contains Shaarli version and settings
|
||||
Result.success(true)
|
||||
} catch (e: Exception) {
|
||||
} catch (e: HttpException) {
|
||||
// Authentication failed - clear the secret
|
||||
tokenManager.clearApiSecret()
|
||||
e.printStackTrace()
|
||||
Result.failure(describeHttpFailure(e))
|
||||
} catch (e: IOException) {
|
||||
// Network/DNS/TLS error: keep the stored credentials untouched by the new URL,
|
||||
// just report clearly. Base URL was already overwritten above.
|
||||
e.printStackTrace()
|
||||
Result.failure(
|
||||
Exception(
|
||||
"Serveur injoignable (${e.message}). Verifie l'URL et la connexion réseau."
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
tokenManager.clearApiSecret()
|
||||
e.printStackTrace()
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun describeHttpFailure(e: HttpException): Exception {
|
||||
return when (e.code()) {
|
||||
401,
|
||||
403 ->
|
||||
Exception(
|
||||
"HTTP ${e.code()} - Secret API refuse. Copie le champ « Secret API » de " +
|
||||
"Shaarli (Configuration -> Securite) : ce n'est PAS ton mot de passe " +
|
||||
"de connexion. Si tu as utilise « Deconnexion de tous les appareils », " +
|
||||
"le secret a ete regenere et il faut ressaisir le nouveau."
|
||||
)
|
||||
404 ->
|
||||
Exception(
|
||||
"HTTP 404 - API introuvable a cette URL. Verifie le chemin d'installation " +
|
||||
"(ex: https://serveur.com/shaarli) et que l'API est activee dans " +
|
||||
"Shaarli (Configuration -> Securite -> Activer l'API)."
|
||||
)
|
||||
else -> Exception("HTTP ${e.code()} - ${e.message() ?: "erreur serveur"}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun isLoggedIn(): Boolean {
|
||||
// We're logged in if we have both base URL and API secret
|
||||
return !tokenManager.getApiSecret().isNullOrBlank() &&
|
||||
|
||||
@@ -6,9 +6,13 @@ import javax.inject.Inject
|
||||
|
||||
class LoginUseCase @Inject constructor(private val repository: AuthRepository) {
|
||||
suspend operator fun invoke(url: String, secret: String): Result<Boolean> {
|
||||
if (url.isBlank() || secret.isBlank()) {
|
||||
// Trim so a trailing space from copy/paste or autocomplete does not silently
|
||||
// corrupt the URL or the HMAC key (which would surface as an obscure HTTP 401).
|
||||
val cleanUrl = url.trim()
|
||||
val cleanSecret = secret.trim()
|
||||
if (cleanUrl.isBlank() || cleanSecret.isBlank()) {
|
||||
return Result.failure(IllegalArgumentException("URL and Secret cannot be empty"))
|
||||
}
|
||||
return repository.login(Credentials(url, secret), url)
|
||||
return repository.login(Credentials(cleanUrl, cleanSecret), cleanUrl)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
#Thu Apr 23 19:46:44 2026
|
||||
VERSION_NAME=2.14.0
|
||||
VERSION_CODE=41
|
||||
VERSION_NAME=2.14.1
|
||||
VERSION_CODE=42
|
||||
Reference in New Issue
Block a user