Compare commits

...
7 changed files with 324 additions and 182 deletions
@@ -7,8 +7,6 @@ import android.util.Base64
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
@@ -69,6 +67,7 @@ data class StoredSession(
val displayName: String,
val accessToken: String,
val refreshToken: String,
val mustChangePassword: Boolean = false,
)
// ---------- Inspections ----------
@@ -292,9 +291,6 @@ interface DhApi {
@POST("auth/mobile/login")
suspend fun login(@Body request: LoginRequest): MobileSessionResponse
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
@POST("auth/mobile/logout")
suspend fun logout(@Header("Authorization") authorization: String): Map<String, Any?>
@@ -393,6 +389,7 @@ class SecureSessionStore(context: Context) {
displayName = json.optString("displayName", json.getString("username")),
accessToken = json.getString("accessToken"),
refreshToken = json.getString("refreshToken"),
mustChangePassword = json.optBoolean("mustChangePassword", false),
)
}.getOrElse {
clear()
@@ -403,19 +400,26 @@ class SecureSessionStore(context: Context) {
fun save(response: MobileSessionResponse): StoredSession {
val displayName = listOfNotNull(response.user.firstName, response.user.lastName)
.joinToString(" ").trim().ifBlank { response.user.username }
val stored = StoredSession(
userId = response.user.id,
username = response.user.username,
displayName = displayName,
accessToken = response.accessToken,
refreshToken = response.refreshToken,
return save(
StoredSession(
userId = response.user.id,
username = response.user.username,
displayName = displayName,
accessToken = response.accessToken,
refreshToken = response.refreshToken,
mustChangePassword = response.user.mustChangePassword,
),
)
}
fun save(stored: StoredSession): StoredSession {
val json = JSONObject()
.put("userId", stored.userId)
.put("username", stored.username)
.put("displayName", stored.displayName)
.put("accessToken", stored.accessToken)
.put("refreshToken", stored.refreshToken)
.put("mustChangePassword", stored.mustChangePassword)
.toString()
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key())
@@ -449,8 +453,7 @@ class SecureSessionStore(context: Context) {
}
class DhRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val sessions = MobileSessionCoordinator.get(context.applicationContext)
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: DhApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
@@ -459,42 +462,45 @@ class DhRepository(context: Context) {
.build()
.create(DhApi::class.java)
fun currentSession(): StoredSession? = store.load()
fun currentSession(): StoredSession? = sessions.currentSession()
suspend fun login(identifier: String, password: String): StoredSession =
store.save(api.login(LoginRequest(identifier.trim(), password)))
sessions.save(api.login(LoginRequest(identifier.trim(), password)))
suspend fun changePassword(currentPassword: String, newPassword: String): StoredSession =
sessions.changePassword(currentPassword, newPassword)
suspend fun logout() {
val session = store.load()
val session = sessions.currentSession()
if (session != null) runCatching { api.logout("Bearer ${session.accessToken}") }
store.clear()
sessions.clear()
}
suspend fun visits(): VisitListResponse = authorized { session ->
suspend fun visits(): VisitListResponse = sessions.authorized { session ->
api.visits("Bearer ${session.accessToken}", session.userId)
}
suspend fun visit(id: String): VisitDetail = authorized { session ->
suspend fun visit(id: String): VisitDetail = sessions.authorized { session ->
api.visit("Bearer ${session.accessToken}", id)
}
suspend fun startVisit(id: String): VisitDetail = authorized { session ->
suspend fun startVisit(id: String): VisitDetail = sessions.authorized { session ->
api.startVisit("Bearer ${session.accessToken}", id)
}
suspend fun fieldInventory(visitId: String, search: String?, parentId: String? = null) = authorized { session ->
suspend fun fieldInventory(visitId: String, search: String?, parentId: String? = null) = sessions.authorized { session ->
api.fieldInventory("Bearer ${session.accessToken}", visitId, search?.takeIf { it.isNotBlank() }, parentId)
}
suspend fun fieldTypes(visitId: String, parentId: String?) = authorized { session ->
suspend fun fieldTypes(visitId: String, parentId: String?) = sessions.authorized { session ->
api.fieldTypes("Bearer ${session.accessToken}", visitId, parentId)
}
suspend fun selectFieldAsset(visitId: String, assetId: String) = authorized { session ->
suspend fun selectFieldAsset(visitId: String, assetId: String) = sessions.authorized { session ->
api.selectFieldAsset("Bearer ${session.accessToken}", visitId, assetId)
}
suspend fun createFieldAsset(visitId: String, request: CreateFieldInventoryRequest) = authorized { session ->
suspend fun createFieldAsset(visitId: String, request: CreateFieldInventoryRequest) = sessions.authorized { session ->
api.createFieldAsset("Bearer ${session.accessToken}", visitId, request)
}
@@ -503,7 +509,7 @@ class DhRepository(context: Context) {
assetId: String,
canonicalAssetId: String,
reason: String,
): FieldInventoryMergeResult = authorized { session ->
): FieldInventoryMergeResult = sessions.authorized { session ->
api.mergeFieldAsset(
"Bearer ${session.accessToken}",
visitId,
@@ -520,46 +526,28 @@ class DhRepository(context: Context) {
longitude: Double,
accuracyM: Double?,
capturedAt: String = Instant.now().toString(),
): FieldPhotoResponse = authorized { session ->
val text = "text/plain".toMediaType()
val body = file.asRequestBody("image/jpeg".toMediaType())
val part = MultipartBody.Part.createFormData("file", file.name, body)
api.uploadFieldPhoto(
authorization = "Bearer ${session.accessToken}",
visitId = visitId,
assetId = assetId,
file = part,
latitude = latitude.toString().toRequestBody(text),
longitude = longitude.toString().toRequestBody(text),
accuracy = accuracyM?.toString()?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text),
exifLatitude = latitude.toString().toRequestBody(text),
exifLongitude = longitude.toString().toRequestBody(text),
exifCapturedAt = capturedAt.toRequestBody(text),
)
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try {
return block(session)
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session.refreshToken)
return block(session)
}
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
): FieldPhotoResponse {
val response = sessions.authorized { session ->
val text = "text/plain".toMediaType()
val body = file.asRequestBody("image/jpeg".toMediaType())
val part = MultipartBody.Part.createFormData("file", file.name, body)
api.uploadFieldPhoto(
authorization = "Bearer ${session.accessToken}",
visitId = visitId,
assetId = assetId,
file = part,
latitude = latitude.toString().toRequestBody(text),
longitude = longitude.toString().toRequestBody(text),
accuracy = accuracyM?.toString()?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text),
exifLatitude = latitude.toString().toRequestBody(text),
exifLongitude = longitude.toString().toRequestBody(text),
exifCapturedAt = capturedAt.toRequestBody(text),
)
}
file.delete()
return response
}
companion object {
@@ -4,15 +4,12 @@ import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body
@@ -165,18 +162,14 @@ private interface FieldFindingsApi {
@Part("accuracyM") accuracyM: RequestBody?,
@Part("deviceLabel") deviceLabel: RequestBody,
): FieldFindingEvidence
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
}
/**
* Cliente de campo para Hallazgos y sus evidencias append-only.
* F3.2 exige que la APK identifique explícitamente el Acta activa.
* F5 comparte autenticación con Inventario y Actas para serializar refresh tokens rotativos.
*/
class FieldFindingsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val sessions = MobileSessionCoordinator.get(context.applicationContext)
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: FieldFindingsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
@@ -186,7 +179,7 @@ class FieldFindingsRepository(context: Context) {
.create(FieldFindingsApi::class.java)
suspend fun options(visitId: String, assetId: String, actId: String): FieldFindingOptionsResponse =
authorized { session ->
sessions.authorized { session ->
api.options("Bearer ${session.accessToken}", visitId, assetId, actId)
}
@@ -194,11 +187,11 @@ class FieldFindingsRepository(context: Context) {
visitId: String,
assetId: String,
request: CreateFieldFindingRequest,
): FieldFindingCreateResponse = authorized { session ->
): FieldFindingCreateResponse = sessions.authorized { session ->
api.create("Bearer ${session.accessToken}", visitId, assetId, request)
}
suspend fun evidence(findingId: String): FieldFindingEvidenceListResponse = authorized { session ->
suspend fun evidence(findingId: String): FieldFindingEvidenceListResponse = sessions.authorized { session ->
api.evidence("Bearer ${session.accessToken}", findingId)
}
@@ -211,48 +204,30 @@ class FieldFindingsRepository(context: Context) {
title: String? = null,
description: String? = null,
capturedAt: String = Instant.now().toString(),
): FieldFindingEvidence = authorized { session ->
val text = "text/plain".toMediaType()
val part = MultipartBody.Part.createFormData(
"file",
file.name,
file.asRequestBody("image/jpeg".toMediaType()),
)
api.uploadEvidence(
authorization = "Bearer ${session.accessToken}",
findingId = findingId,
file = part,
kind = "PHOTO".toRequestBody(text),
purpose = "OBSERVATION".toRequestBody(text),
title = title?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
description = description?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text),
latitude = latitude.toString().toRequestBody(text),
longitude = longitude.toString().toRequestBody(text),
accuracyM = accuracyM?.toString()?.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text),
)
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try {
return block(session)
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session.refreshToken)
return block(session)
}
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
): FieldFindingEvidence {
val response = sessions.authorized { session ->
val text = "text/plain".toMediaType()
val part = MultipartBody.Part.createFormData(
"file",
file.name,
file.asRequestBody("image/jpeg".toMediaType()),
)
api.uploadEvidence(
authorization = "Bearer ${session.accessToken}",
findingId = findingId,
file = part,
kind = "PHOTO".toRequestBody(text),
purpose = "OBSERVATION".toRequestBody(text),
title = title?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
description = description?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text),
latitude = latitude.toString().toRequestBody(text),
longitude = longitude.toString().toRequestBody(text),
accuracyM = accuracyM?.toString()?.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text),
)
}
file.delete()
return response
}
}
@@ -4,15 +4,12 @@ import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body
@@ -303,14 +300,10 @@ private interface MobileActsApi {
@Path("visitId") visitId: String,
@Body request: MobileCloseVisitRequest,
): VisitDetail
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
}
class MobileActsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val sessions = MobileSessionCoordinator.get(context.applicationContext)
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: MobileActsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
@@ -319,11 +312,11 @@ class MobileActsRepository(context: Context) {
.build()
.create(MobileActsApi::class.java)
suspend fun list(visitId: String): MobileActListResponse = authorized { session ->
suspend fun list(visitId: String): MobileActListResponse = sessions.authorized { session ->
api.listActs("Bearer ${session.accessToken}", visitId)
}
suspend fun get(actId: String): MobileActDetail = authorized { session ->
suspend fun get(actId: String): MobileActDetail = sessions.authorized { session ->
api.act("Bearer ${session.accessToken}", actId)
}
@@ -332,7 +325,7 @@ class MobileActsRepository(context: Context) {
assetId: String,
visitCode: String,
urgency: String = "NON_URGENT",
): MobileActDetail = authorized { session ->
): MobileActDetail = sessions.authorized { session ->
api.createAct(
"Bearer ${session.accessToken}",
visitId,
@@ -350,20 +343,20 @@ class MobileActsRepository(context: Context) {
val detail = get(actId)
if (detail.assets.any { it.id == assetId }) return detail
val ids = (detail.assets.map { it.id } + assetId).distinct()
return authorized { session ->
return sessions.authorized { session ->
api.updateAct("Bearer ${session.accessToken}", actId, UpdateMobileActRequest(ids))
}
}
suspend fun closure(actId: String): MobileActClosure = authorized { session ->
suspend fun closure(actId: String): MobileActClosure = sessions.authorized { session ->
api.closure("Bearer ${session.accessToken}", actId)
}
suspend fun setResponsible(actId: String, request: MobileResponsibleRequest): MobileActClosure = authorized { session ->
suspend fun setResponsible(actId: String, request: MobileResponsibleRequest): MobileActClosure = sessions.authorized { session ->
api.responsible("Bearer ${session.accessToken}", actId, request)
}
suspend fun lock(actId: String): MobileActClosure = authorized { session ->
suspend fun lock(actId: String): MobileActClosure = sessions.authorized { session ->
api.lock("Bearer ${session.accessToken}", actId)
}
@@ -394,7 +387,7 @@ class MobileActsRepository(context: Context) {
statement = statement,
)
suspend fun companyOutcome(actId: String, status: String, reason: String): MobileActClosure = authorized { session ->
suspend fun companyOutcome(actId: String, status: String, reason: String): MobileActClosure = sessions.authorized { session ->
api.companyOutcome(
"Bearer ${session.accessToken}",
actId,
@@ -402,11 +395,11 @@ class MobileActsRepository(context: Context) {
)
}
suspend fun seal(actId: String): MobileActClosure = authorized { session ->
suspend fun seal(actId: String): MobileActClosure = sessions.authorized { session ->
api.sealAct("Bearer ${session.accessToken}", actId, MobileSealActRequest())
}
suspend fun closeVisit(visitId: String): VisitDetail = authorized { session ->
suspend fun closeVisit(visitId: String): VisitDetail = sessions.authorized { session ->
api.closeVisit("Bearer ${session.accessToken}", visitId, MobileCloseVisitRequest())
}
@@ -419,53 +412,35 @@ class MobileActsRepository(context: Context) {
company: Boolean,
manifestation: String? = null,
statement: String? = null,
): MobileActClosure = authorized { session ->
val text = "text/plain".toMediaType()
val file = MultipartBody.Part.createFormData(
"file",
png.name,
png.asRequestBody("image/png".toMediaType()),
)
val consent = "true".toRequestBody(text)
val signedAt = Instant.now().toString().toRequestBody(text)
val device = "DH Android".toRequestBody(text)
val lat = latitude?.toString()?.toRequestBody(text)
val lon = longitude?.toString()?.toRequestBody(text)
val accuracy = accuracyM?.toString()?.toRequestBody(text)
if (company) {
api.signCompany(
"Bearer ${session.accessToken}", actId, file, consent, signedAt,
lat, lon, accuracy, device,
manifestation?.toRequestBody(text),
statement?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
)
} else {
api.signInspector(
"Bearer ${session.accessToken}", actId, file, consent, signedAt,
lat, lon, accuracy, device,
): MobileActClosure {
val response = sessions.authorized { session ->
val text = "text/plain".toMediaType()
val filePart = MultipartBody.Part.createFormData(
"file",
png.name,
png.asRequestBody("image/png".toMediaType()),
)
val consent = "true".toRequestBody(text)
val signedAt = Instant.now().toString().toRequestBody(text)
val device = "DH Android".toRequestBody(text)
val lat = latitude?.toString()?.toRequestBody(text)
val lon = longitude?.toString()?.toRequestBody(text)
val accuracy = accuracyM?.toString()?.toRequestBody(text)
if (company) {
api.signCompany(
"Bearer ${session.accessToken}", actId, filePart, consent, signedAt,
lat, lon, accuracy, device,
manifestation?.toRequestBody(text),
statement?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
)
} else {
api.signInspector(
"Bearer ${session.accessToken}", actId, filePart, consent, signedAt,
lat, lon, accuracy, device,
)
}
}
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try {
return block(session)
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session.refreshToken)
return block(session)
}
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
png.delete()
return response
}
}
@@ -0,0 +1,98 @@
package com.korexlabs.dhinspeccion.data
import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okhttp3.OkHttpClient
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body
import retrofit2.http.Header
import retrofit2.http.POST
data class ChangeMobilePasswordRequest(
val currentPassword: String,
val newPassword: String,
)
data class ChangeMobilePasswordResponse(
val status: String,
val mustChangePassword: Boolean,
)
private interface MobileSessionApi {
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
@POST("auth/change-password")
suspend fun changePassword(
@Header("Authorization") authorization: String,
@Body request: ChangeMobilePasswordRequest,
): ChangeMobilePasswordResponse
}
/**
* Único coordinador de sesión del proceso Android.
*
* Todos los repositorios comparten el mismo gate de refresh para que un token rotativo no sea
* consumido en paralelo por Inventario, Hallazgos y Actas.
*/
class MobileSessionCoordinator private constructor(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val gate = SessionRefreshGate()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: MobileSessionApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(OkHttpClient.Builder().build())
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
.create(MobileSessionApi::class.java)
fun currentSession(): StoredSession? = store.load()
fun save(response: MobileSessionResponse): StoredSession = store.save(response)
fun clear() = store.clear()
suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try {
return block(session)
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = gate.refreshIfNeeded(
previousRefreshToken = session.refreshToken,
load = store::load,
save = store::save,
clear = store::clear,
refresh = { token -> api.refresh(RefreshRequest(token)) },
)
return block(session)
}
suspend fun changePassword(currentPassword: String, newPassword: String): StoredSession {
val sessionUsed = authorized { session ->
api.changePassword(
authorization = "Bearer ${session.accessToken}",
request = ChangeMobilePasswordRequest(currentPassword, newPassword),
)
session
}
val latest = store.load() ?: sessionUsed
return store.save(latest.copy(mustChangePassword = false))
}
companion object {
@Volatile
private var instance: MobileSessionCoordinator? = null
fun get(context: Context): MobileSessionCoordinator =
instance ?: synchronized(this) {
instance ?: MobileSessionCoordinator(context.applicationContext).also { instance = it }
}
}
}
@@ -0,0 +1,32 @@
package com.korexlabs.dhinspeccion.data
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Serializa la rotación del refresh token a nivel de proceso.
*
* El backend rota el token en cada refresh. Si dos repositorios reciben 401 al mismo tiempo,
* sólo el primero debe consumir el token anterior; el segundo reutiliza la sesión ya renovada.
*/
class SessionRefreshGate {
private val mutex = Mutex()
suspend fun refreshIfNeeded(
previousRefreshToken: String,
load: () -> StoredSession?,
save: (MobileSessionResponse) -> StoredSession,
clear: () -> Unit,
refresh: suspend (String) -> MobileSessionResponse,
): StoredSession = mutex.withLock {
val latest = load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
save(refresh(previousRefreshToken))
} catch (error: Throwable) {
clear()
throw error
}
}
}
@@ -0,0 +1,62 @@
package com.korexlabs.dhinspeccion.data
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
class SessionRefreshGateTest {
@Test
fun simultaneous401sConsumeTheRotatingRefreshTokenOnlyOnce() = runBlocking {
val gate = SessionRefreshGate()
val calls = AtomicInteger(0)
var stored: StoredSession? = StoredSession(
userId = "u-1",
username = "inspector",
displayName = "Inspector",
accessToken = "access-old",
refreshToken = "refresh-old",
)
fun response() = MobileSessionResponse(
user = MobileUser(id = "u-1", username = "inspector"),
accessToken = "access-new",
refreshToken = "refresh-new",
accessExpiresInSeconds = 900,
)
val results = listOf(1, 2).map {
async(Dispatchers.Default) {
gate.refreshIfNeeded(
previousRefreshToken = "refresh-old",
load = { stored },
save = { refreshed ->
StoredSession(
userId = refreshed.user.id,
username = refreshed.user.username,
displayName = refreshed.user.username,
accessToken = refreshed.accessToken,
refreshToken = refreshed.refreshToken,
).also { stored = it }
},
clear = { stored = null },
refresh = {
calls.incrementAndGet()
delay(40)
response()
},
)
}
}.awaitAll()
assertEquals(1, calls.get())
assertEquals(listOf("refresh-new", "refresh-new"), results.map { it.refreshToken })
assertNotNull(stored)
assertEquals("access-new", stored?.accessToken)
}
}
+12
View File
@@ -13,6 +13,7 @@ import type {
import { CurrentAuth } from './decorators/current-auth.decorator';
import { Public } from './decorators/public.decorator';
import { SkipCsrf } from './decorators/skip-csrf.decorator';
import { ChangePasswordDto } from './dto/change-password.dto';
import { LoginDto } from './dto/login.dto';
import { MobileRefreshDto } from './dto/mobile-refresh.dto';
import { MobileAuthService } from './mobile-auth.service';
@@ -53,4 +54,15 @@ export class MobileAuthController {
) {
return this.mobileAuth.logout(principal, request);
}
@Post('change-password')
@HttpCode(200)
@Throttle({ default: { limit: 10, ttl: 60_000 } })
changePassword(
@CurrentAuth() principal: AuthPrincipal,
@Body() dto: ChangePasswordDto,
@Req() request: RequestWithContext,
) {
return this.mobileAuth.changePassword(principal, dto, request);
}
}