From 336893ffa6842fe9c33772cc39f6deed2a8d0676 Mon Sep 17 00:00:00 2001 From: Amir Date: Sun, 21 Jun 2026 02:44:27 +0000 Subject: [PATCH] feat: login-context approval screen, one-tap Deny, stale-request expiry - Approve now opens an in-app review screen showing app/user/IP/geo of the request (optional deep-link params: app, user, ip, geo, ts) before biometrics - Add a Deny button on the approval screen (no biometric needed to reject) - Reject requests older than 2 min via the ts param (replay safety net) - README: context params table, new Features bullets, approve-context screenshot - Bump to v1.2 (versionCode 3) --- README.md | 26 ++++++- app/build.gradle.kts | 4 +- .../main/kotlin/me/khodak/mfa/MainActivity.kt | 72 +++++++++++++++--- app/src/main/res/layout/activity_main.xml | 24 ++++++ docs/screenshots/approve-context.png | Bin 0 -> 78514 bytes docs/screenshots/ready.png | Bin 0 -> 68776 bytes 6 files changed, 109 insertions(+), 17 deletions(-) create mode 100644 docs/screenshots/approve-context.png create mode 100644 docs/screenshots/ready.png diff --git a/README.md b/README.md index 4fdb5be..1d2371f 100644 --- a/README.md +++ b/README.md @@ -9,16 +9,34 @@ Authy push, with **no third-party cloud** in the loop. 1. You log in via **Authentik**. A flow step fires an **ntfy** notification to your phone with two action buttons: **Approve** and **Deny**. 2. The buttons are `homemfa://approve?token=โ€ฆ` / `homemfa://deny?token=โ€ฆ` deep links that open this app. -3. **Approve** triggers a biometric prompt (fingerprint / face / device PIN). On success the app - POSTs `approve:` to `ntfy.khodak.me/mfa-approve`; your Authentik flow is listening on that - topic and completes the login. +3. **Approve** opens an in-app review screen showing *who is asking* (see context params below), then + triggers a biometric prompt (fingerprint / face / device PIN). On success the app POSTs + `approve:` to `ntfy.khodak.me/mfa-approve`; your Authentik flow is listening on that topic and + completes the login. You can also **Deny** right from that screen. 4. **Deny** POSTs `deny:` to `mfa-deny` and blocks the login โ€” no biometric needed to reject. +### Optional context params + +The approve link may carry extra query params so you can see what you're approving before you do: + +| Param | Shown as | Example | +|--------|---------------------|------------------------| +| `app` | ๐Ÿ–ฅ application name | `app=Grafana` | +| `user` | ๐Ÿ‘ค username | `user=amir` | +| `ip` | ๐Ÿ“ source IP | `ip=203.0.113.44` | +| `geo` | appended to the IP | `geo=Berlin, DE` | +| `ts` | request timestamp (epoch ms) โ€” used to **expire** stale prompts after 2 min | `ts=1750000000000` | + +All are optional and URL-encoded; with none present the app just shows a plain Approve / Deny prompt. + The activity is `showWhenLocked` + `turnScreenOn`, so an approval works straight from the lock screen. ## Features - **Biometric gate** โ€” `BIOMETRIC_STRONG` or device credential required to approve (deny is instant) +- **Login context** โ€” see the app, user, source IP and geo of the request before approving +- **One-tap Deny** โ€” reject a suspicious login straight from the approval screen, no biometric needed +- **Stale-request expiry** โ€” prompts older than 2 minutes can't be approved (replay safety net) - **Lock-screen ready** โ€” wakes the screen and shows over the keyguard - **Self-hosted** โ€” talks only to your own ntfy instance; no Firebase, no vendor cloud - **Zero stored secrets** โ€” stateless; each request carries a one-time token @@ -29,6 +47,8 @@ The activity is `showWhenLocked` + `turnScreenOn`, so an approval works straight

Setup / ready screen   + Approval prompt with login context +   Login approved

diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 240e845..29a954d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "me.khodak.mfa" minSdk = 28 targetSdk = 34 - versionCode = 2 - versionName = "1.1" + versionCode = 3 + versionName = "1.2" } signingConfigs { diff --git a/app/src/main/kotlin/me/khodak/mfa/MainActivity.kt b/app/src/main/kotlin/me/khodak/mfa/MainActivity.kt index 68c4fe0..eec291f 100644 --- a/app/src/main/kotlin/me/khodak/mfa/MainActivity.kt +++ b/app/src/main/kotlin/me/khodak/mfa/MainActivity.kt @@ -28,7 +28,12 @@ class MainActivity : AppCompatActivity() { private lateinit var icon: ImageView private lateinit var statusText: TextView private lateinit var subText: TextView + private lateinit var contextText: TextView private lateinit var actionButton: Button + private lateinit var denyButton: Button + + // A login request older than this is considered stale and can't be approved. + private val maxRequestAgeMs = 120_000L override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -37,7 +42,9 @@ class MainActivity : AppCompatActivity() { icon = findViewById(R.id.icon) statusText = findViewById(R.id.statusText) subText = findViewById(R.id.subText) + contextText = findViewById(R.id.contextText) actionButton = findViewById(R.id.actionButton) + denyButton = findViewById(R.id.denyButton) val uri = intent?.data if (uri?.scheme == "homemfa") { @@ -70,21 +77,60 @@ class MainActivity : AppCompatActivity() { return } - // action == "approve" โ€” require biometrics - val canAuth = BiometricManager.from(this) - .canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) - - if (canAuth != BiometricManager.BIOMETRIC_SUCCESS) { - showResult(false, "No biometrics enrolled", "Go to phone Settings โ†’ Security โ†’ Fingerprint to enroll, then try again.") - actionButton.text = "Open Security Settings" - actionButton.visibility = View.VISIBLE - actionButton.setOnClickListener { - startActivity(Intent(Settings.ACTION_SECURITY_SETTINGS)) - } + // Reject stale requests โ€” a notification can't be approved minutes later. + val ts = uri.getQueryParameter("ts")?.toLongOrNull() + if (ts != null && System.currentTimeMillis() - ts > maxRequestAgeMs) { + showResult(false, "Request expired", "This login prompt is too old to approve. Sign in again to get a fresh request.") + scheduleClose(3000) return } - showBiometricPrompt(token) + // action == "approve" โ€” show the request with its context and let the user decide. + showApprovalRequest(uri, token) + } + + private fun showApprovalRequest(uri: Uri, token: String) { + icon.setImageResource(R.drawable.ic_fingerprint) + icon.setColorFilter(ContextCompat.getColor(this, R.color.accent)) + statusText.text = "Approve login?" + subText.text = "A login is requesting access. Review the details, then approve with your fingerprint or deny." + subText.visibility = View.VISIBLE + + val details = buildContext(uri) + if (details.isNotEmpty()) { + contextText.text = details + contextText.visibility = View.VISIBLE + } + + denyButton.visibility = View.VISIBLE + denyButton.setOnClickListener { sendDecision(token, "deny") } + + val canAuth = BiometricManager.from(this) + .canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) + + actionButton.visibility = View.VISIBLE + if (canAuth == BiometricManager.BIOMETRIC_SUCCESS) { + actionButton.text = "Approve" + actionButton.setOnClickListener { showBiometricPrompt(token) } + } else { + actionButton.text = "Open Security Settings" + subText.text = "No biometrics enrolled. Enroll a fingerprint to approve, or deny this request." + actionButton.setOnClickListener { + startActivity(Intent(Settings.ACTION_SECURITY_SETTINGS)) + } + } + } + + // Formats whatever login context the deep link carried (all params optional). + private fun buildContext(uri: Uri): String { + val lines = mutableListOf() + uri.getQueryParameter("app")?.takeIf { it.isNotBlank() }?.let { lines.add("๐Ÿ–ฅ $it") } + uri.getQueryParameter("user")?.takeIf { it.isNotBlank() }?.let { lines.add("๐Ÿ‘ค $it") } + uri.getQueryParameter("ip")?.takeIf { it.isNotBlank() }?.let { ip -> + val geo = uri.getQueryParameter("geo")?.takeIf { it.isNotBlank() } + lines.add(if (geo != null) "๐Ÿ“ $ip ยท $geo" else "๐Ÿ“ $ip") + } + return lines.joinToString("\n") } private fun showSetupScreen() { @@ -211,7 +257,9 @@ class MainActivity : AppCompatActivity() { statusText.text = title subText.text = subtitle subText.visibility = if (subtitle.isEmpty()) View.GONE else View.VISIBLE + contextText.visibility = View.GONE actionButton.visibility = View.GONE + denyButton.visibility = View.GONE when (approved) { true -> { diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 1f62134..e11c1be 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -36,6 +36,19 @@ android:lineSpacingMultiplier="1.4" android:visibility="gone" /> + +