Initial commit
This commit is contained in:
45
core/auth/build.gradle.kts
Normal file
45
core/auth/build.gradle.kts
Normal file
@@ -0,0 +1,45 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("kotlin-kapt")
|
||||
id("dagger.hilt.android.plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.smoa.core.auth"
|
||||
compileSdk = AppConfig.compileSdk
|
||||
|
||||
defaultConfig {
|
||||
minSdk = AppConfig.minSdk
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:common"))
|
||||
implementation(project(":core:security"))
|
||||
|
||||
implementation(Dependencies.androidxCoreKtx)
|
||||
implementation(Dependencies.androidxLifecycleRuntimeKtx)
|
||||
implementation(Dependencies.securityCrypto)
|
||||
implementation(Dependencies.biometric)
|
||||
implementation(Dependencies.coroutinesCore)
|
||||
implementation(Dependencies.coroutinesAndroid)
|
||||
|
||||
implementation(Dependencies.hiltAndroid)
|
||||
kapt(Dependencies.hiltAndroidCompiler)
|
||||
|
||||
// Testing
|
||||
testImplementation(Dependencies.junit)
|
||||
testImplementation(Dependencies.mockk)
|
||||
testImplementation(Dependencies.coroutinesTest)
|
||||
testImplementation(Dependencies.truth)
|
||||
}
|
||||
134
core/auth/src/main/java/com/smoa/core/auth/AuthCoordinator.kt
Normal file
134
core/auth/src/main/java/com/smoa/core/auth/AuthCoordinator.kt
Normal file
@@ -0,0 +1,134 @@
|
||||
package com.smoa.core.auth
|
||||
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AuthCoordinator @Inject constructor(
|
||||
private val pinManager: PinManager,
|
||||
private val biometricManager: BiometricManager,
|
||||
private val dualBiometricManager: DualBiometricManager,
|
||||
private val sessionManager: SessionManager
|
||||
) {
|
||||
private val _authState = MutableStateFlow<AuthState>(AuthState.Unauthenticated)
|
||||
val authState: StateFlow<AuthState> = _authState.asStateFlow()
|
||||
|
||||
/**
|
||||
* Initiate three-factor authentication flow.
|
||||
* Requires: PIN + Fingerprint + Facial Recognition
|
||||
*/
|
||||
suspend fun authenticate(
|
||||
pin: String,
|
||||
activity: FragmentActivity,
|
||||
onBiometricSuccess: () -> Unit,
|
||||
onError: (String) -> Unit
|
||||
): AuthResult {
|
||||
// Factor 1: Verify PIN
|
||||
val pinResult = pinManager.verifyPin(pin)
|
||||
when (pinResult) {
|
||||
is PinManager.PinVerificationResult.Success -> {
|
||||
// PIN verified, proceed to biometrics
|
||||
}
|
||||
is PinManager.PinVerificationResult.Failed -> {
|
||||
return AuthResult.Failure(
|
||||
"PIN incorrect. ${pinResult.remainingAttempts} attempts remaining."
|
||||
)
|
||||
}
|
||||
PinManager.PinVerificationResult.Locked -> {
|
||||
return AuthResult.Failure("Account locked due to too many failed attempts.")
|
||||
}
|
||||
PinManager.PinVerificationResult.NotSet -> {
|
||||
return AuthResult.Failure("PIN not set. Please set up authentication.")
|
||||
}
|
||||
}
|
||||
|
||||
// Factor 2 & 3: Dual biometric authentication (fingerprint + facial recognition)
|
||||
// Both must pass sequentially for true three-factor authentication
|
||||
|
||||
if (!dualBiometricManager.areBothBiometricsAvailable()) {
|
||||
return AuthResult.Failure("Biometric authentication not available. Please enroll fingerprint and face.")
|
||||
}
|
||||
|
||||
// Perform dual biometric authentication (fingerprint then face)
|
||||
val dualBiometricResult = dualBiometricManager.authenticateDualBiometric(
|
||||
activity = activity,
|
||||
onProgress = { message ->
|
||||
// Progress updates can be shown to user
|
||||
}
|
||||
)
|
||||
|
||||
return when (dualBiometricResult) {
|
||||
is DualBiometricResult.Success -> {
|
||||
// All three factors verified - create session
|
||||
sessionManager.startSession()
|
||||
_authState.value = AuthState.Authenticated
|
||||
onBiometricSuccess()
|
||||
AuthResult.Success
|
||||
}
|
||||
is DualBiometricResult.Failure -> {
|
||||
AuthResult.Failure("Biometric authentication failed: ${dualBiometricResult.message}")
|
||||
}
|
||||
is DualBiometricResult.Cancelled -> {
|
||||
AuthResult.Cancelled
|
||||
}
|
||||
is DualBiometricResult.NotAvailable -> {
|
||||
AuthResult.Failure("Biometric authentication not available")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step-up authentication for sensitive operations.
|
||||
*/
|
||||
suspend fun stepUpAuthentication(
|
||||
pin: String,
|
||||
activity: FragmentActivity,
|
||||
onSuccess: () -> Unit,
|
||||
onError: (String) -> Unit
|
||||
): AuthResult {
|
||||
// For step-up, we require PIN + biometric again
|
||||
return authenticate(pin, activity, onSuccess, onError)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is currently authenticated.
|
||||
*/
|
||||
fun isAuthenticated(): Boolean {
|
||||
return sessionManager.isSessionActive() && _authState.value is AuthState.Authenticated
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock the session (manual lock).
|
||||
*/
|
||||
fun lock() {
|
||||
sessionManager.endSession()
|
||||
_authState.value = AuthState.Locked
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout and clear session.
|
||||
*/
|
||||
fun logout() {
|
||||
sessionManager.endSession()
|
||||
_authState.value = AuthState.Unauthenticated
|
||||
}
|
||||
|
||||
sealed class AuthState {
|
||||
object Unauthenticated : AuthState()
|
||||
object Authenticated : AuthState()
|
||||
object Locked : AuthState()
|
||||
data class Authenticating(val factorsCompleted: Int) : AuthState()
|
||||
}
|
||||
|
||||
sealed class AuthResult {
|
||||
object Success : AuthResult()
|
||||
data class Failure(val message: String) : AuthResult()
|
||||
object Cancelled : AuthResult()
|
||||
object Pending : AuthResult()
|
||||
}
|
||||
}
|
||||
|
||||
109
core/auth/src/main/java/com/smoa/core/auth/BiometricManager.kt
Normal file
109
core/auth/src/main/java/com/smoa/core/auth/BiometricManager.kt
Normal file
@@ -0,0 +1,109 @@
|
||||
package com.smoa.core.auth
|
||||
|
||||
import android.content.Context
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class BiometricManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
/**
|
||||
* Check if biometric authentication is available on the device.
|
||||
*/
|
||||
fun isBiometricAvailable(): BiometricAvailability {
|
||||
val biometricManager = BiometricManager.from(context)
|
||||
return when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) {
|
||||
BiometricManager.BIOMETRIC_SUCCESS -> BiometricAvailability.Available
|
||||
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> BiometricAvailability.NoHardware
|
||||
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> BiometricAvailability.HardwareUnavailable
|
||||
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> BiometricAvailability.NotEnrolled
|
||||
else -> BiometricAvailability.Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if fingerprint authentication is available.
|
||||
*/
|
||||
fun isFingerprintAvailable(): Boolean {
|
||||
val biometricManager = BiometricManager.from(context)
|
||||
return biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
|
||||
BiometricManager.BIOMETRIC_SUCCESS
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if facial recognition is available.
|
||||
*/
|
||||
fun isFacialRecognitionAvailable(): Boolean {
|
||||
val biometricManager = BiometricManager.from(context)
|
||||
return biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
|
||||
BiometricManager.BIOMETRIC_SUCCESS
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a biometric prompt for authentication.
|
||||
* Requires both fingerprint and facial recognition (both factors must pass).
|
||||
*/
|
||||
fun createBiometricPrompt(
|
||||
activity: FragmentActivity,
|
||||
onSuccess: () -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
onCancel: () -> Unit
|
||||
): BiometricPrompt {
|
||||
val executor = ContextCompat.getMainExecutor(context)
|
||||
|
||||
val callback = object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
onSuccess()
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
when (errorCode) {
|
||||
BiometricPrompt.ERROR_USER_CANCELED,
|
||||
BiometricPrompt.ERROR_NEGATIVE_BUTTON -> onCancel()
|
||||
else -> onError(errString.toString())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
onError("Biometric authentication failed")
|
||||
}
|
||||
}
|
||||
|
||||
return BiometricPrompt(activity, executor, callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt for biometric authentication (fingerprint or face).
|
||||
*/
|
||||
fun authenticate(
|
||||
activity: FragmentActivity,
|
||||
onSuccess: () -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
val prompt = createBiometricPrompt(activity, onSuccess, onError, onCancel)
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle("Biometric Authentication")
|
||||
.setSubtitle("Use your fingerprint or face to authenticate")
|
||||
.setNegativeButtonText("Cancel")
|
||||
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
|
||||
.build()
|
||||
|
||||
prompt.authenticate(promptInfo)
|
||||
}
|
||||
|
||||
enum class BiometricAvailability {
|
||||
Available,
|
||||
NoHardware,
|
||||
HardwareUnavailable,
|
||||
NotEnrolled,
|
||||
Unknown
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.smoa.core.auth
|
||||
|
||||
import android.content.Context
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* Dual Biometric Manager for true three-factor authentication.
|
||||
* Requires: PIN + Fingerprint + Facial Recognition (both biometrics must pass).
|
||||
*
|
||||
* Note: Android's BiometricPrompt API doesn't support requiring both
|
||||
* fingerprint AND face separately in a single prompt. This implementation
|
||||
* uses sequential prompts to require both factors.
|
||||
*/
|
||||
@Singleton
|
||||
class DualBiometricManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val biometricManager: BiometricManager
|
||||
) {
|
||||
/**
|
||||
* Authenticate with both fingerprint and facial recognition sequentially.
|
||||
* Both must succeed for authentication to pass.
|
||||
*/
|
||||
suspend fun authenticateDualBiometric(
|
||||
activity: FragmentActivity,
|
||||
onProgress: (String) -> Unit = {}
|
||||
): DualBiometricResult {
|
||||
// Step 1: Fingerprint authentication
|
||||
onProgress("Please authenticate with your fingerprint")
|
||||
val fingerprintResult = authenticateFingerprint(activity)
|
||||
|
||||
when (fingerprintResult) {
|
||||
is DualBiometricResult.Success -> {
|
||||
// Fingerprint passed, proceed to face
|
||||
}
|
||||
is DualBiometricResult.Failure -> {
|
||||
return DualBiometricResult.Failure("Fingerprint authentication failed: ${fingerprintResult.message}")
|
||||
}
|
||||
is DualBiometricResult.Cancelled -> {
|
||||
return DualBiometricResult.Cancelled
|
||||
}
|
||||
else -> return fingerprintResult
|
||||
}
|
||||
|
||||
// Step 2: Facial recognition authentication
|
||||
onProgress("Please authenticate with your face")
|
||||
val faceResult = authenticateFacialRecognition(activity)
|
||||
|
||||
return when (faceResult) {
|
||||
is DualBiometricResult.Success -> {
|
||||
DualBiometricResult.Success("Both biometric factors verified")
|
||||
}
|
||||
is DualBiometricResult.Failure -> {
|
||||
DualBiometricResult.Failure("Facial recognition failed: ${faceResult.message}")
|
||||
}
|
||||
is DualBiometricResult.Cancelled -> {
|
||||
DualBiometricResult.Cancelled
|
||||
}
|
||||
else -> faceResult
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with fingerprint only.
|
||||
*/
|
||||
private suspend fun authenticateFingerprint(
|
||||
activity: FragmentActivity
|
||||
): DualBiometricResult = suspendCancellableCoroutine { continuation ->
|
||||
val executor = ContextCompat.getMainExecutor(context)
|
||||
|
||||
val callback = object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
continuation.resume(DualBiometricResult.Success("Fingerprint verified"))
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
when (errorCode) {
|
||||
BiometricPrompt.ERROR_USER_CANCELED,
|
||||
BiometricPrompt.ERROR_NEGATIVE_BUTTON -> {
|
||||
continuation.resume(DualBiometricResult.Cancelled)
|
||||
}
|
||||
else -> {
|
||||
continuation.resume(DualBiometricResult.Failure(errString.toString()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
continuation.resume(DualBiometricResult.Failure("Fingerprint authentication failed"))
|
||||
}
|
||||
}
|
||||
|
||||
val prompt = BiometricPrompt(activity, executor, callback)
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle("Fingerprint Authentication")
|
||||
.setSubtitle("Use your fingerprint to continue")
|
||||
.setNegativeButtonText("Cancel")
|
||||
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
|
||||
.build()
|
||||
|
||||
prompt.authenticate(promptInfo)
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with facial recognition only.
|
||||
*/
|
||||
private suspend fun authenticateFacialRecognition(
|
||||
activity: FragmentActivity
|
||||
): DualBiometricResult = suspendCancellableCoroutine { continuation ->
|
||||
val executor = ContextCompat.getMainExecutor(context)
|
||||
|
||||
val callback = object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
continuation.resume(DualBiometricResult.Success("Face verified"))
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
when (errorCode) {
|
||||
BiometricPrompt.ERROR_USER_CANCELED,
|
||||
BiometricPrompt.ERROR_NEGATIVE_BUTTON -> {
|
||||
continuation.resume(DualBiometricResult.Cancelled)
|
||||
}
|
||||
else -> {
|
||||
continuation.resume(DualBiometricResult.Failure(errString.toString()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
continuation.resume(DualBiometricResult.Failure("Facial recognition failed"))
|
||||
}
|
||||
}
|
||||
|
||||
val prompt = BiometricPrompt(activity, executor, callback)
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle("Facial Recognition")
|
||||
.setSubtitle("Use your face to continue")
|
||||
.setNegativeButtonText("Cancel")
|
||||
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
|
||||
.build()
|
||||
|
||||
prompt.authenticate(promptInfo)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if both fingerprint and facial recognition are available.
|
||||
*/
|
||||
fun areBothBiometricsAvailable(): Boolean {
|
||||
val biometricManager = BiometricManager.from(context)
|
||||
return biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
|
||||
BiometricManager.BIOMETRIC_SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of dual biometric authentication.
|
||||
*/
|
||||
sealed class DualBiometricResult {
|
||||
data class Success(val message: String) : DualBiometricResult()
|
||||
data class Failure(val message: String) : DualBiometricResult()
|
||||
object Cancelled : DualBiometricResult()
|
||||
object NotAvailable : DualBiometricResult()
|
||||
}
|
||||
|
||||
148
core/auth/src/main/java/com/smoa/core/auth/PinManager.kt
Normal file
148
core/auth/src/main/java/com/smoa/core/auth/PinManager.kt
Normal file
@@ -0,0 +1,148 @@
|
||||
package com.smoa.core.auth
|
||||
|
||||
import com.smoa.core.common.Result
|
||||
import com.smoa.core.security.KeyManager
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class PinManager @Inject constructor(
|
||||
private val keyManager: KeyManager
|
||||
) {
|
||||
companion object {
|
||||
private const val PIN_KEY = "user_pin_hash"
|
||||
private const val PIN_RETRY_COUNT_KEY = "pin_retry_count"
|
||||
private const val MAX_RETRY_ATTEMPTS = 5
|
||||
private const val MIN_PIN_LENGTH = 6
|
||||
private const val MAX_PIN_LENGTH = 12
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a new PIN after validating complexity requirements.
|
||||
*/
|
||||
fun setPin(pin: String): Result<Unit> {
|
||||
return try {
|
||||
validatePinComplexity(pin)
|
||||
val pinHash = hashPin(pin)
|
||||
keyManager.putSecureString(PIN_KEY, pinHash)
|
||||
keyManager.putSecureString(PIN_RETRY_COUNT_KEY, "0")
|
||||
Result.Success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a PIN against the stored hash.
|
||||
*/
|
||||
fun verifyPin(pin: String): PinVerificationResult {
|
||||
val currentRetries = getRetryCount()
|
||||
|
||||
if (currentRetries >= MAX_RETRY_ATTEMPTS) {
|
||||
return PinVerificationResult.Locked
|
||||
}
|
||||
|
||||
val storedHash = keyManager.getSecureString(PIN_KEY)
|
||||
if (storedHash == null) {
|
||||
return PinVerificationResult.NotSet
|
||||
}
|
||||
|
||||
val inputHash = hashPin(pin)
|
||||
val isValid = storedHash == inputHash
|
||||
|
||||
if (isValid) {
|
||||
keyManager.putSecureString(PIN_RETRY_COUNT_KEY, "0")
|
||||
return PinVerificationResult.Success
|
||||
} else {
|
||||
val newRetryCount = currentRetries + 1
|
||||
keyManager.putSecureString(PIN_RETRY_COUNT_KEY, newRetryCount.toString())
|
||||
|
||||
return if (newRetryCount >= MAX_RETRY_ATTEMPTS) {
|
||||
PinVerificationResult.Locked
|
||||
} else {
|
||||
PinVerificationResult.Failed(remainingAttempts = MAX_RETRY_ATTEMPTS - newRetryCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a PIN is set.
|
||||
*/
|
||||
fun isPinSet(): Boolean {
|
||||
return keyManager.getSecureString(PIN_KEY) != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current retry count.
|
||||
*/
|
||||
fun getRetryCount(): Int {
|
||||
return keyManager.getSecureString(PIN_RETRY_COUNT_KEY)?.toIntOrNull() ?: 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset retry count (used after successful authentication).
|
||||
*/
|
||||
fun resetRetryCount() {
|
||||
keyManager.putSecureString(PIN_RETRY_COUNT_KEY, "0")
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if account is locked due to too many failed attempts.
|
||||
*/
|
||||
fun isLocked(): Boolean {
|
||||
return getRetryCount() >= MAX_RETRY_ATTEMPTS
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate PIN complexity requirements.
|
||||
*/
|
||||
private fun validatePinComplexity(pin: String) {
|
||||
if (pin.length < MIN_PIN_LENGTH || pin.length > MAX_PIN_LENGTH) {
|
||||
throw IllegalArgumentException("PIN must be between $MIN_PIN_LENGTH and $MAX_PIN_LENGTH characters")
|
||||
}
|
||||
|
||||
if (!pin.all { it.isDigit() }) {
|
||||
throw IllegalArgumentException("PIN must contain only digits")
|
||||
}
|
||||
|
||||
// Check for simple patterns (e.g., 111111, 123456)
|
||||
if (pin.all { it == pin[0] } || isSequential(pin)) {
|
||||
throw IllegalArgumentException("PIN cannot be a simple pattern")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash PIN using SHA-256 (in production, use a proper password hashing algorithm like bcrypt).
|
||||
*/
|
||||
private fun hashPin(pin: String): String {
|
||||
val digest = java.security.MessageDigest.getInstance("SHA-256")
|
||||
val hashBytes = digest.digest(pin.toByteArray())
|
||||
return hashBytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if PIN is sequential (e.g., 123456, 654321).
|
||||
*/
|
||||
private fun isSequential(pin: String): Boolean {
|
||||
var isAscending = true
|
||||
var isDescending = true
|
||||
|
||||
for (i in 1 until pin.length) {
|
||||
val current = pin[i].digitToInt()
|
||||
val previous = pin[i - 1].digitToInt()
|
||||
|
||||
if (current != previous + 1) isAscending = false
|
||||
if (current != previous - 1) isDescending = false
|
||||
}
|
||||
|
||||
return isAscending || isDescending
|
||||
}
|
||||
|
||||
sealed class PinVerificationResult {
|
||||
object Success : PinVerificationResult()
|
||||
data class Failed(val remainingAttempts: Int) : PinVerificationResult()
|
||||
object Locked : PinVerificationResult()
|
||||
object NotSet : PinVerificationResult()
|
||||
}
|
||||
}
|
||||
|
||||
86
core/auth/src/main/java/com/smoa/core/auth/PolicyManager.kt
Normal file
86
core/auth/src/main/java/com/smoa/core/auth/PolicyManager.kt
Normal file
@@ -0,0 +1,86 @@
|
||||
package com.smoa.core.auth
|
||||
|
||||
import com.smoa.core.security.KeyManager
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Policy Manager for dynamic policy enforcement and updates.
|
||||
*/
|
||||
@Singleton
|
||||
class PolicyManager @Inject constructor(
|
||||
private val keyManager: KeyManager
|
||||
) {
|
||||
companion object {
|
||||
private const val POLICY_VERSION_KEY = "policy_version"
|
||||
private const val POLICY_DATA_KEY = "policy_data"
|
||||
private const val SESSION_TIMEOUT_KEY = "session_timeout_ms"
|
||||
private const val OFFLINE_TIMEOUT_KEY = "offline_timeout_ms"
|
||||
private const val LOCK_ON_FOLD_KEY = "lock_on_fold"
|
||||
private const val LOCK_ON_BACKGROUND_KEY = "lock_on_background"
|
||||
}
|
||||
|
||||
/**
|
||||
* Policy data structure.
|
||||
*/
|
||||
data class Policy(
|
||||
val version: Int,
|
||||
val sessionTimeoutMs: Long,
|
||||
val offlineTimeoutMs: Long,
|
||||
val lockOnFold: Boolean,
|
||||
val lockOnBackground: Boolean,
|
||||
val allowedModules: Set<String>,
|
||||
val allowedUrls: Set<String>
|
||||
)
|
||||
|
||||
/**
|
||||
* Get current policy version.
|
||||
*/
|
||||
fun getPolicyVersion(): Int {
|
||||
return keyManager.getSecureString(POLICY_VERSION_KEY)?.toIntOrNull() ?: 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Update policy from server (should be called on trusted connectivity).
|
||||
*/
|
||||
fun updatePolicy(policy: Policy) {
|
||||
keyManager.putSecureString(POLICY_VERSION_KEY, policy.version.toString())
|
||||
keyManager.putSecureString(SESSION_TIMEOUT_KEY, policy.sessionTimeoutMs.toString())
|
||||
keyManager.putSecureString(OFFLINE_TIMEOUT_KEY, policy.offlineTimeoutMs.toString())
|
||||
keyManager.putSecureString(LOCK_ON_FOLD_KEY, policy.lockOnFold.toString())
|
||||
keyManager.putSecureString(LOCK_ON_BACKGROUND_KEY, policy.lockOnBackground.toString())
|
||||
// Store policy data as JSON (simplified - use proper serialization in production)
|
||||
keyManager.putSecureString(POLICY_DATA_KEY, policy.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session timeout from policy.
|
||||
*/
|
||||
fun getSessionTimeoutMs(): Long {
|
||||
return keyManager.getSecureString(SESSION_TIMEOUT_KEY)?.toLongOrNull()
|
||||
?: 30 * 60 * 1000L // Default 30 minutes
|
||||
}
|
||||
|
||||
/**
|
||||
* Get offline timeout from policy.
|
||||
*/
|
||||
fun getOfflineTimeoutMs(): Long {
|
||||
return keyManager.getSecureString(OFFLINE_TIMEOUT_KEY)?.toLongOrNull()
|
||||
?: 7 * 24 * 60 * 60 * 1000L // Default 7 days
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if lock on fold is enabled.
|
||||
*/
|
||||
fun shouldLockOnFold(): Boolean {
|
||||
return keyManager.getSecureString(LOCK_ON_FOLD_KEY)?.toBoolean() ?: false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if lock on background is enabled.
|
||||
*/
|
||||
fun shouldLockOnBackground(): Boolean {
|
||||
return keyManager.getSecureString(LOCK_ON_BACKGROUND_KEY)?.toBoolean() ?: true
|
||||
}
|
||||
}
|
||||
|
||||
150
core/auth/src/main/java/com/smoa/core/auth/RBACFramework.kt
Normal file
150
core/auth/src/main/java/com/smoa/core/auth/RBACFramework.kt
Normal file
@@ -0,0 +1,150 @@
|
||||
package com.smoa.core.auth
|
||||
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Role-Based Access Control framework for SMOA.
|
||||
* Enforces access control at module, feature, and data levels.
|
||||
*/
|
||||
@Singleton
|
||||
class RBACFramework @Inject constructor() {
|
||||
|
||||
/**
|
||||
* User role definitions.
|
||||
*/
|
||||
enum class Role {
|
||||
ADMIN,
|
||||
OPERATOR,
|
||||
VIEWER,
|
||||
GUEST
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission definitions for modules and features.
|
||||
*/
|
||||
enum class Permission {
|
||||
// Credentials module
|
||||
VIEW_CREDENTIALS,
|
||||
DISPLAY_CREDENTIALS,
|
||||
|
||||
// Directory module
|
||||
VIEW_DIRECTORY,
|
||||
SEARCH_DIRECTORY,
|
||||
VIEW_UNIT_DIRECTORY,
|
||||
|
||||
// Communications module
|
||||
USE_RADIO,
|
||||
JOIN_CHANNEL,
|
||||
CREATE_CHANNEL,
|
||||
|
||||
// Meetings module
|
||||
JOIN_MEETING,
|
||||
HOST_MEETING,
|
||||
SCREEN_SHARE,
|
||||
|
||||
// Browser module
|
||||
ACCESS_BROWSER,
|
||||
NAVIGATE_URL
|
||||
}
|
||||
|
||||
/**
|
||||
* Module access definitions.
|
||||
*/
|
||||
enum class Module {
|
||||
CREDENTIALS,
|
||||
DIRECTORY,
|
||||
COMMUNICATIONS,
|
||||
MEETINGS,
|
||||
BROWSER
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a role has a specific permission.
|
||||
*/
|
||||
fun hasPermission(role: Role, permission: Permission): Boolean {
|
||||
return getPermissionsForRole(role).contains(permission)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a role can access a module.
|
||||
*/
|
||||
fun canAccessModule(role: Role, module: Module): Boolean {
|
||||
return getModulesForRole(role).contains(module)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all permissions for a role.
|
||||
*/
|
||||
private fun getPermissionsForRole(role: Role): Set<Permission> {
|
||||
return when (role) {
|
||||
Role.ADMIN -> setOf(
|
||||
Permission.VIEW_CREDENTIALS,
|
||||
Permission.DISPLAY_CREDENTIALS,
|
||||
Permission.VIEW_DIRECTORY,
|
||||
Permission.SEARCH_DIRECTORY,
|
||||
Permission.VIEW_UNIT_DIRECTORY,
|
||||
Permission.USE_RADIO,
|
||||
Permission.JOIN_CHANNEL,
|
||||
Permission.CREATE_CHANNEL,
|
||||
Permission.JOIN_MEETING,
|
||||
Permission.HOST_MEETING,
|
||||
Permission.SCREEN_SHARE,
|
||||
Permission.ACCESS_BROWSER,
|
||||
Permission.NAVIGATE_URL
|
||||
)
|
||||
Role.OPERATOR -> setOf(
|
||||
Permission.VIEW_CREDENTIALS,
|
||||
Permission.DISPLAY_CREDENTIALS,
|
||||
Permission.VIEW_DIRECTORY,
|
||||
Permission.SEARCH_DIRECTORY,
|
||||
Permission.VIEW_UNIT_DIRECTORY,
|
||||
Permission.USE_RADIO,
|
||||
Permission.JOIN_CHANNEL,
|
||||
Permission.JOIN_MEETING,
|
||||
Permission.SCREEN_SHARE,
|
||||
Permission.ACCESS_BROWSER
|
||||
)
|
||||
Role.VIEWER -> setOf(
|
||||
Permission.VIEW_CREDENTIALS,
|
||||
Permission.VIEW_DIRECTORY,
|
||||
Permission.SEARCH_DIRECTORY,
|
||||
Permission.JOIN_MEETING
|
||||
)
|
||||
Role.GUEST -> setOf(
|
||||
Permission.VIEW_CREDENTIALS
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all modules accessible by a role.
|
||||
*/
|
||||
private fun getModulesForRole(role: Role): Set<Module> {
|
||||
return when (role) {
|
||||
Role.ADMIN -> setOf(
|
||||
Module.CREDENTIALS,
|
||||
Module.DIRECTORY,
|
||||
Module.COMMUNICATIONS,
|
||||
Module.MEETINGS,
|
||||
Module.BROWSER
|
||||
)
|
||||
Role.OPERATOR -> setOf(
|
||||
Module.CREDENTIALS,
|
||||
Module.DIRECTORY,
|
||||
Module.COMMUNICATIONS,
|
||||
Module.MEETINGS,
|
||||
Module.BROWSER
|
||||
)
|
||||
Role.VIEWER -> setOf(
|
||||
Module.CREDENTIALS,
|
||||
Module.DIRECTORY,
|
||||
Module.MEETINGS
|
||||
)
|
||||
Role.GUEST -> setOf(
|
||||
Module.CREDENTIALS
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
112
core/auth/src/main/java/com/smoa/core/auth/SessionManager.kt
Normal file
112
core/auth/src/main/java/com/smoa/core/auth/SessionManager.kt
Normal file
@@ -0,0 +1,112 @@
|
||||
package com.smoa.core.auth
|
||||
|
||||
import com.smoa.core.security.KeyManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SessionManager @Inject constructor(
|
||||
private val keyManager: KeyManager
|
||||
) {
|
||||
companion object {
|
||||
private const val SESSION_START_TIME_KEY = "session_start_time"
|
||||
private const val SESSION_ACTIVE_KEY = "session_active"
|
||||
private const val DEFAULT_SESSION_TIMEOUT_MS = 30 * 60 * 1000L // 30 minutes
|
||||
}
|
||||
|
||||
private val _sessionState = MutableStateFlow<SessionState>(SessionState.Inactive)
|
||||
val sessionState: StateFlow<SessionState> = _sessionState.asStateFlow()
|
||||
|
||||
private var sessionStartTime: Long = 0
|
||||
var sessionTimeoutMs: Long = DEFAULT_SESSION_TIMEOUT_MS
|
||||
|
||||
/**
|
||||
* Start a new session.
|
||||
*/
|
||||
fun startSession() {
|
||||
sessionStartTime = System.currentTimeMillis()
|
||||
keyManager.putSecureString(SESSION_START_TIME_KEY, sessionStartTime.toString())
|
||||
keyManager.putSecureString(SESSION_ACTIVE_KEY, "true")
|
||||
_sessionState.value = SessionState.Active(sessionStartTime)
|
||||
}
|
||||
|
||||
/**
|
||||
* End the current session.
|
||||
*/
|
||||
fun endSession() {
|
||||
sessionStartTime = 0
|
||||
keyManager.putSecureString(SESSION_START_TIME_KEY, "0")
|
||||
keyManager.putSecureString(SESSION_ACTIVE_KEY, "false")
|
||||
_sessionState.value = SessionState.Inactive
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session is currently active.
|
||||
*/
|
||||
fun isSessionActive(): Boolean {
|
||||
val storedActive = keyManager.getSecureString(SESSION_ACTIVE_KEY) == "true"
|
||||
val storedStartTime = keyManager.getSecureString(SESSION_START_TIME_KEY)?.toLongOrNull() ?: 0
|
||||
|
||||
if (!storedActive || storedStartTime == 0L) {
|
||||
return false
|
||||
}
|
||||
|
||||
val elapsed = System.currentTimeMillis() - storedStartTime
|
||||
if (elapsed > sessionTimeoutMs) {
|
||||
// Session expired
|
||||
endSession()
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session has expired.
|
||||
*/
|
||||
fun isSessionExpired(): Boolean {
|
||||
if (sessionStartTime == 0L) return true
|
||||
val elapsed = System.currentTimeMillis() - sessionStartTime
|
||||
return elapsed > sessionTimeoutMs
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining session time in milliseconds.
|
||||
*/
|
||||
fun getRemainingSessionTime(): Long {
|
||||
if (sessionStartTime == 0L) return 0
|
||||
val elapsed = System.currentTimeMillis() - sessionStartTime
|
||||
return maxOf(0, sessionTimeoutMs - elapsed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore session state from storage.
|
||||
*/
|
||||
fun restoreSession(): Boolean {
|
||||
val storedActive = keyManager.getSecureString(SESSION_ACTIVE_KEY) == "true"
|
||||
val storedStartTime = keyManager.getSecureString(SESSION_START_TIME_KEY)?.toLongOrNull() ?: 0
|
||||
|
||||
if (storedActive && storedStartTime > 0) {
|
||||
val elapsed = System.currentTimeMillis() - storedStartTime
|
||||
if (elapsed <= sessionTimeoutMs) {
|
||||
sessionStartTime = storedStartTime
|
||||
_sessionState.value = SessionState.Active(storedStartTime)
|
||||
return true
|
||||
} else {
|
||||
// Session expired, clear it
|
||||
endSession()
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
sealed class SessionState {
|
||||
object Inactive : SessionState()
|
||||
data class Active(val startTime: Long) : SessionState()
|
||||
object Expired : SessionState()
|
||||
}
|
||||
}
|
||||
|
||||
65
core/auth/src/main/java/com/smoa/core/auth/UserSession.kt
Normal file
65
core/auth/src/main/java/com/smoa/core/auth/UserSession.kt
Normal file
@@ -0,0 +1,65 @@
|
||||
package com.smoa.core.auth
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* User session manager.
|
||||
* Tracks current user's role, unit, and session information.
|
||||
*/
|
||||
@Singleton
|
||||
class UserSession @Inject constructor() {
|
||||
private val _currentUser = MutableStateFlow<UserInfo?>(null)
|
||||
val currentUser: StateFlow<UserInfo?> = _currentUser.asStateFlow()
|
||||
|
||||
/**
|
||||
* Set current user session.
|
||||
*/
|
||||
fun setUser(userInfo: UserInfo) {
|
||||
_currentUser.value = userInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear user session.
|
||||
*/
|
||||
fun clearUser() {
|
||||
_currentUser.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user role.
|
||||
*/
|
||||
fun getCurrentRole(): RBACFramework.Role {
|
||||
return _currentUser.value?.role ?: RBACFramework.Role.GUEST
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user unit.
|
||||
*/
|
||||
fun getCurrentUnit(): String? {
|
||||
return _currentUser.value?.unit
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user ID.
|
||||
*/
|
||||
fun getCurrentUserId(): String? {
|
||||
return _currentUser.value?.userId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User information.
|
||||
*/
|
||||
data class UserInfo(
|
||||
val userId: String,
|
||||
val userName: String,
|
||||
val role: RBACFramework.Role,
|
||||
val unit: String?,
|
||||
val clearanceLevel: String?,
|
||||
val missionAssignment: String?
|
||||
)
|
||||
|
||||
67
core/auth/src/main/java/com/smoa/core/auth/di/AuthModule.kt
Normal file
67
core/auth/src/main/java/com/smoa/core/auth/di/AuthModule.kt
Normal file
@@ -0,0 +1,67 @@
|
||||
package com.smoa.core.auth.di
|
||||
|
||||
import android.content.Context
|
||||
import com.smoa.core.auth.BiometricManager
|
||||
import com.smoa.core.auth.DualBiometricManager
|
||||
import com.smoa.core.auth.PinManager
|
||||
import com.smoa.core.auth.SessionManager
|
||||
import com.smoa.core.auth.UserSession
|
||||
import com.smoa.core.auth.RBACFramework
|
||||
import com.smoa.core.security.KeyManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AuthModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePinManager(
|
||||
keyManager: KeyManager
|
||||
): PinManager {
|
||||
return PinManager(keyManager)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideBiometricManager(
|
||||
@ApplicationContext context: Context
|
||||
): BiometricManager {
|
||||
return BiometricManager(context)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDualBiometricManager(
|
||||
@ApplicationContext context: Context
|
||||
): DualBiometricManager {
|
||||
// DualBiometricManager uses androidx.biometric.BiometricManager directly
|
||||
val biometricManager = androidx.biometric.BiometricManager.from(context)
|
||||
return DualBiometricManager(context, biometricManager)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSessionManager(
|
||||
keyManager: KeyManager
|
||||
): SessionManager {
|
||||
return SessionManager(keyManager)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideUserSession(): UserSession {
|
||||
return UserSession()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRBACFramework(): RBACFramework {
|
||||
return RBACFramework()
|
||||
}
|
||||
}
|
||||
|
||||
111
core/auth/src/test/java/com/smoa/core/auth/PinManagerTest.kt
Normal file
111
core/auth/src/test/java/com/smoa/core/auth/PinManagerTest.kt
Normal file
@@ -0,0 +1,111 @@
|
||||
package com.smoa.core.auth
|
||||
|
||||
import com.smoa.core.common.TestCoroutineRule
|
||||
import com.smoa.core.security.KeyManager
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for PinManager.
|
||||
*/
|
||||
class PinManagerTest {
|
||||
@get:Rule
|
||||
val testCoroutineRule = TestCoroutineRule()
|
||||
|
||||
private val keyManager = mockk<KeyManager>(relaxed = true)
|
||||
private val pinManager = PinManager(keyManager)
|
||||
|
||||
@Test
|
||||
fun `setPin should store encrypted PIN`() = runTest {
|
||||
// Given
|
||||
val pin = "123456"
|
||||
every { keyManager.putSecureString(any(), any()) } returns Unit
|
||||
|
||||
// When
|
||||
val result = pinManager.setPin(pin)
|
||||
|
||||
// Then
|
||||
assertTrue(result.isSuccess)
|
||||
verify { keyManager.putSecureString("user_pin", any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setPin should fail for invalid PIN length`() = runTest {
|
||||
// Given
|
||||
val shortPin = "12345" // Too short
|
||||
val longPin = "1234567890123" // Too long
|
||||
|
||||
// When
|
||||
val shortResult = pinManager.setPin(shortPin)
|
||||
val longResult = pinManager.setPin(longPin)
|
||||
|
||||
// Then
|
||||
assertTrue(shortResult.isFailure)
|
||||
assertTrue(longResult.isFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verifyPin should return success for correct PIN`() = runTest {
|
||||
// Given
|
||||
val pin = "123456"
|
||||
val hashedPin = "hashed_pin_value"
|
||||
every { keyManager.getSecureString("user_pin") } returns hashedPin
|
||||
every { keyManager.putSecureString(any(), any()) } returns Unit
|
||||
|
||||
// Set PIN first
|
||||
pinManager.setPin(pin)
|
||||
|
||||
// When
|
||||
val result = pinManager.verifyPin(pin)
|
||||
|
||||
// Then
|
||||
assertTrue(result is PinManager.PinVerificationResult.Success)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verifyPin should return failed for incorrect PIN`() = runTest {
|
||||
// Given
|
||||
val correctPin = "123456"
|
||||
val wrongPin = "654321"
|
||||
every { keyManager.getSecureString("user_pin") } returns "hashed_pin"
|
||||
every { keyManager.putSecureString(any(), any()) } returns Unit
|
||||
|
||||
// Set PIN first
|
||||
pinManager.setPin(correctPin)
|
||||
|
||||
// When
|
||||
val result = pinManager.verifyPin(wrongPin)
|
||||
|
||||
// Then
|
||||
assertTrue(result is PinManager.PinVerificationResult.Failed)
|
||||
if (result is PinManager.PinVerificationResult.Failed) {
|
||||
assertTrue(result.remainingAttempts < 5)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verifyPin should lock after max attempts`() = runTest {
|
||||
// Given
|
||||
val correctPin = "123456"
|
||||
val wrongPin = "654321"
|
||||
every { keyManager.getSecureString("user_pin") } returns "hashed_pin"
|
||||
every { keyManager.putSecureString(any(), any()) } returns Unit
|
||||
|
||||
pinManager.setPin(correctPin)
|
||||
|
||||
// When - attempt wrong PIN multiple times
|
||||
repeat(5) {
|
||||
pinManager.verifyPin(wrongPin)
|
||||
}
|
||||
|
||||
// Then
|
||||
val result = pinManager.verifyPin(wrongPin)
|
||||
assertTrue(result is PinManager.PinVerificationResult.Locked)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user