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.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -69,6 +67,7 @@ data class StoredSession(
val displayName: String, val displayName: String,
val accessToken: String, val accessToken: String,
val refreshToken: String, val refreshToken: String,
val mustChangePassword: Boolean = false,
) )
// ---------- Inspections ---------- // ---------- Inspections ----------
@@ -292,9 +291,6 @@ interface DhApi {
@POST("auth/mobile/login") @POST("auth/mobile/login")
suspend fun login(@Body request: LoginRequest): MobileSessionResponse suspend fun login(@Body request: LoginRequest): MobileSessionResponse
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
@POST("auth/mobile/logout") @POST("auth/mobile/logout")
suspend fun logout(@Header("Authorization") authorization: String): Map<String, Any?> 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")), displayName = json.optString("displayName", json.getString("username")),
accessToken = json.getString("accessToken"), accessToken = json.getString("accessToken"),
refreshToken = json.getString("refreshToken"), refreshToken = json.getString("refreshToken"),
mustChangePassword = json.optBoolean("mustChangePassword", false),
) )
}.getOrElse { }.getOrElse {
clear() clear()
@@ -403,19 +400,26 @@ class SecureSessionStore(context: Context) {
fun save(response: MobileSessionResponse): StoredSession { fun save(response: MobileSessionResponse): StoredSession {
val displayName = listOfNotNull(response.user.firstName, response.user.lastName) val displayName = listOfNotNull(response.user.firstName, response.user.lastName)
.joinToString(" ").trim().ifBlank { response.user.username } .joinToString(" ").trim().ifBlank { response.user.username }
val stored = StoredSession( return save(
userId = response.user.id, StoredSession(
username = response.user.username, userId = response.user.id,
displayName = displayName, username = response.user.username,
accessToken = response.accessToken, displayName = displayName,
refreshToken = response.refreshToken, accessToken = response.accessToken,
refreshToken = response.refreshToken,
mustChangePassword = response.user.mustChangePassword,
),
) )
}
fun save(stored: StoredSession): StoredSession {
val json = JSONObject() val json = JSONObject()
.put("userId", stored.userId) .put("userId", stored.userId)
.put("username", stored.username) .put("username", stored.username)
.put("displayName", stored.displayName) .put("displayName", stored.displayName)
.put("accessToken", stored.accessToken) .put("accessToken", stored.accessToken)
.put("refreshToken", stored.refreshToken) .put("refreshToken", stored.refreshToken)
.put("mustChangePassword", stored.mustChangePassword)
.toString() .toString()
val cipher = Cipher.getInstance("AES/GCM/NoPadding") val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key()) cipher.init(Cipher.ENCRYPT_MODE, key())
@@ -449,8 +453,7 @@ class SecureSessionStore(context: Context) {
} }
class DhRepository(context: Context) { class DhRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext) private val sessions = MobileSessionCoordinator.get(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: DhApi = Retrofit.Builder() private val api: DhApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL) .baseUrl(BuildConfig.API_BASE_URL)
@@ -459,42 +462,45 @@ class DhRepository(context: Context) {
.build() .build()
.create(DhApi::class.java) .create(DhApi::class.java)
fun currentSession(): StoredSession? = store.load() fun currentSession(): StoredSession? = sessions.currentSession()
suspend fun login(identifier: String, password: String): StoredSession = 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() { suspend fun logout() {
val session = store.load() val session = sessions.currentSession()
if (session != null) runCatching { api.logout("Bearer ${session.accessToken}") } 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) 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) 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) 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) 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) 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) 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) api.createFieldAsset("Bearer ${session.accessToken}", visitId, request)
} }
@@ -503,7 +509,7 @@ class DhRepository(context: Context) {
assetId: String, assetId: String,
canonicalAssetId: String, canonicalAssetId: String,
reason: String, reason: String,
): FieldInventoryMergeResult = authorized { session -> ): FieldInventoryMergeResult = sessions.authorized { session ->
api.mergeFieldAsset( api.mergeFieldAsset(
"Bearer ${session.accessToken}", "Bearer ${session.accessToken}",
visitId, visitId,
@@ -520,46 +526,28 @@ class DhRepository(context: Context) {
longitude: Double, longitude: Double,
accuracyM: Double?, accuracyM: Double?,
capturedAt: String = Instant.now().toString(), capturedAt: String = Instant.now().toString(),
): FieldPhotoResponse = authorized { session -> ): FieldPhotoResponse {
val text = "text/plain".toMediaType() val response = sessions.authorized { session ->
val body = file.asRequestBody("image/jpeg".toMediaType()) val text = "text/plain".toMediaType()
val part = MultipartBody.Part.createFormData("file", file.name, body) val body = file.asRequestBody("image/jpeg".toMediaType())
api.uploadFieldPhoto( val part = MultipartBody.Part.createFormData("file", file.name, body)
authorization = "Bearer ${session.accessToken}", api.uploadFieldPhoto(
visitId = visitId, authorization = "Bearer ${session.accessToken}",
assetId = assetId, visitId = visitId,
file = part, assetId = assetId,
latitude = latitude.toString().toRequestBody(text), file = part,
longitude = longitude.toString().toRequestBody(text), latitude = latitude.toString().toRequestBody(text),
accuracy = accuracyM?.toString()?.toRequestBody(text), longitude = longitude.toString().toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text), accuracy = accuracyM?.toString()?.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text), capturedAt = capturedAt.toRequestBody(text),
exifLatitude = latitude.toString().toRequestBody(text), deviceLabel = "DH Android".toRequestBody(text),
exifLongitude = longitude.toString().toRequestBody(text), exifLatitude = latitude.toString().toRequestBody(text),
exifCapturedAt = capturedAt.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
} }
file.delete()
return response
} }
companion object { companion object {
@@ -4,15 +4,12 @@ import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.HttpException
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body import retrofit2.http.Body
@@ -165,18 +162,14 @@ private interface FieldFindingsApi {
@Part("accuracyM") accuracyM: RequestBody?, @Part("accuracyM") accuracyM: RequestBody?,
@Part("deviceLabel") deviceLabel: RequestBody, @Part("deviceLabel") deviceLabel: RequestBody,
): FieldFindingEvidence ): FieldFindingEvidence
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
} }
/** /**
* Cliente de campo para Hallazgos y sus evidencias append-only. * 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) { class FieldFindingsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext) private val sessions = MobileSessionCoordinator.get(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: FieldFindingsApi = Retrofit.Builder() private val api: FieldFindingsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL) .baseUrl(BuildConfig.API_BASE_URL)
@@ -186,7 +179,7 @@ class FieldFindingsRepository(context: Context) {
.create(FieldFindingsApi::class.java) .create(FieldFindingsApi::class.java)
suspend fun options(visitId: String, assetId: String, actId: String): FieldFindingOptionsResponse = suspend fun options(visitId: String, assetId: String, actId: String): FieldFindingOptionsResponse =
authorized { session -> sessions.authorized { session ->
api.options("Bearer ${session.accessToken}", visitId, assetId, actId) api.options("Bearer ${session.accessToken}", visitId, assetId, actId)
} }
@@ -194,11 +187,11 @@ class FieldFindingsRepository(context: Context) {
visitId: String, visitId: String,
assetId: String, assetId: String,
request: CreateFieldFindingRequest, request: CreateFieldFindingRequest,
): FieldFindingCreateResponse = authorized { session -> ): FieldFindingCreateResponse = sessions.authorized { session ->
api.create("Bearer ${session.accessToken}", visitId, assetId, request) 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) api.evidence("Bearer ${session.accessToken}", findingId)
} }
@@ -211,48 +204,30 @@ class FieldFindingsRepository(context: Context) {
title: String? = null, title: String? = null,
description: String? = null, description: String? = null,
capturedAt: String = Instant.now().toString(), capturedAt: String = Instant.now().toString(),
): FieldFindingEvidence = authorized { session -> ): FieldFindingEvidence {
val text = "text/plain".toMediaType() val response = sessions.authorized { session ->
val part = MultipartBody.Part.createFormData( val text = "text/plain".toMediaType()
"file", val part = MultipartBody.Part.createFormData(
file.name, "file",
file.asRequestBody("image/jpeg".toMediaType()), file.name,
) file.asRequestBody("image/jpeg".toMediaType()),
api.uploadEvidence( )
authorization = "Bearer ${session.accessToken}", api.uploadEvidence(
findingId = findingId, authorization = "Bearer ${session.accessToken}",
file = part, findingId = findingId,
kind = "PHOTO".toRequestBody(text), file = part,
purpose = "OBSERVATION".toRequestBody(text), kind = "PHOTO".toRequestBody(text),
title = title?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text), purpose = "OBSERVATION".toRequestBody(text),
description = description?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text), title = title?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text), description = description?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
latitude = latitude.toString().toRequestBody(text), capturedAt = capturedAt.toRequestBody(text),
longitude = longitude.toString().toRequestBody(text), latitude = latitude.toString().toRequestBody(text),
accuracyM = accuracyM?.toString()?.toRequestBody(text), longitude = longitude.toString().toRequestBody(text),
deviceLabel = "DH Android".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
} }
file.delete()
return response
} }
} }
@@ -4,15 +4,12 @@ import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.HttpException
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body import retrofit2.http.Body
@@ -303,14 +300,10 @@ private interface MobileActsApi {
@Path("visitId") visitId: String, @Path("visitId") visitId: String,
@Body request: MobileCloseVisitRequest, @Body request: MobileCloseVisitRequest,
): VisitDetail ): VisitDetail
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
} }
class MobileActsRepository(context: Context) { class MobileActsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext) private val sessions = MobileSessionCoordinator.get(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: MobileActsApi = Retrofit.Builder() private val api: MobileActsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL) .baseUrl(BuildConfig.API_BASE_URL)
@@ -319,11 +312,11 @@ class MobileActsRepository(context: Context) {
.build() .build()
.create(MobileActsApi::class.java) .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) 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) api.act("Bearer ${session.accessToken}", actId)
} }
@@ -332,7 +325,7 @@ class MobileActsRepository(context: Context) {
assetId: String, assetId: String,
visitCode: String, visitCode: String,
urgency: String = "NON_URGENT", urgency: String = "NON_URGENT",
): MobileActDetail = authorized { session -> ): MobileActDetail = sessions.authorized { session ->
api.createAct( api.createAct(
"Bearer ${session.accessToken}", "Bearer ${session.accessToken}",
visitId, visitId,
@@ -350,20 +343,20 @@ class MobileActsRepository(context: Context) {
val detail = get(actId) val detail = get(actId)
if (detail.assets.any { it.id == assetId }) return detail if (detail.assets.any { it.id == assetId }) return detail
val ids = (detail.assets.map { it.id } + assetId).distinct() val ids = (detail.assets.map { it.id } + assetId).distinct()
return authorized { session -> return sessions.authorized { session ->
api.updateAct("Bearer ${session.accessToken}", actId, UpdateMobileActRequest(ids)) 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) 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) 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) api.lock("Bearer ${session.accessToken}", actId)
} }
@@ -394,7 +387,7 @@ class MobileActsRepository(context: Context) {
statement = statement, 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( api.companyOutcome(
"Bearer ${session.accessToken}", "Bearer ${session.accessToken}",
actId, 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()) 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()) api.closeVisit("Bearer ${session.accessToken}", visitId, MobileCloseVisitRequest())
} }
@@ -419,53 +412,35 @@ class MobileActsRepository(context: Context) {
company: Boolean, company: Boolean,
manifestation: String? = null, manifestation: String? = null,
statement: String? = null, statement: String? = null,
): MobileActClosure = authorized { session -> ): MobileActClosure {
val text = "text/plain".toMediaType() val response = sessions.authorized { session ->
val file = MultipartBody.Part.createFormData( val text = "text/plain".toMediaType()
"file", val filePart = MultipartBody.Part.createFormData(
png.name, "file",
png.asRequestBody("image/png".toMediaType()), 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,
) )
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,
)
}
} }
} png.delete()
return response
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
}
} }
} }
@@ -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 { CurrentAuth } from './decorators/current-auth.decorator';
import { Public } from './decorators/public.decorator'; import { Public } from './decorators/public.decorator';
import { SkipCsrf } from './decorators/skip-csrf.decorator'; import { SkipCsrf } from './decorators/skip-csrf.decorator';
import { ChangePasswordDto } from './dto/change-password.dto';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { MobileRefreshDto } from './dto/mobile-refresh.dto'; import { MobileRefreshDto } from './dto/mobile-refresh.dto';
import { MobileAuthService } from './mobile-auth.service'; import { MobileAuthService } from './mobile-auth.service';
@@ -53,4 +54,15 @@ export class MobileAuthController {
) { ) {
return this.mobileAuth.logout(principal, request); 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);
}
} }