feat: login-context approval screen, one-tap Deny, stale-request expiry
Release APK / build (push) Successful in 1m59s
Release APK / build (push) Successful in 1m59s
- 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)
This commit is contained in:
@@ -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
|
1. You log in via **Authentik**. A flow step fires an **ntfy** notification to your phone with two
|
||||||
action buttons: **Approve** and **Deny**.
|
action buttons: **Approve** and **Deny**.
|
||||||
2. The buttons are `homemfa://approve?token=…` / `homemfa://deny?token=…` deep links that open this app.
|
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
|
3. **Approve** opens an in-app review screen showing *who is asking* (see context params below), then
|
||||||
POSTs `approve:<token>` to `ntfy.khodak.me/mfa-approve`; your Authentik flow is listening on that
|
triggers a biometric prompt (fingerprint / face / device PIN). On success the app POSTs
|
||||||
topic and completes the login.
|
`approve:<token>` 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:<token>` to `mfa-deny` and blocks the login — no biometric needed to reject.
|
4. **Deny** POSTs `deny:<token>` 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.
|
The activity is `showWhenLocked` + `turnScreenOn`, so an approval works straight from the lock screen.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Biometric gate** — `BIOMETRIC_STRONG` or device credential required to approve (deny is instant)
|
- **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
|
- **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
|
- **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
|
- **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
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/screenshots/setup.png" width="240" alt="Setup / ready screen">
|
<img src="docs/screenshots/setup.png" width="240" alt="Setup / ready screen">
|
||||||
|
|
||||||
|
<img src="docs/screenshots/approve-context.png" width="240" alt="Approval prompt with login context">
|
||||||
|
|
||||||
<img src="docs/screenshots/approved.png" width="240" alt="Login approved">
|
<img src="docs/screenshots/approved.png" width="240" alt="Login approved">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "me.khodak.mfa"
|
applicationId = "me.khodak.mfa"
|
||||||
minSdk = 28
|
minSdk = 28
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 2
|
versionCode = 3
|
||||||
versionName = "1.1"
|
versionName = "1.2"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|||||||
@@ -28,7 +28,12 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private lateinit var icon: ImageView
|
private lateinit var icon: ImageView
|
||||||
private lateinit var statusText: TextView
|
private lateinit var statusText: TextView
|
||||||
private lateinit var subText: TextView
|
private lateinit var subText: TextView
|
||||||
|
private lateinit var contextText: TextView
|
||||||
private lateinit var actionButton: Button
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -37,7 +42,9 @@ class MainActivity : AppCompatActivity() {
|
|||||||
icon = findViewById(R.id.icon)
|
icon = findViewById(R.id.icon)
|
||||||
statusText = findViewById(R.id.statusText)
|
statusText = findViewById(R.id.statusText)
|
||||||
subText = findViewById(R.id.subText)
|
subText = findViewById(R.id.subText)
|
||||||
|
contextText = findViewById(R.id.contextText)
|
||||||
actionButton = findViewById(R.id.actionButton)
|
actionButton = findViewById(R.id.actionButton)
|
||||||
|
denyButton = findViewById(R.id.denyButton)
|
||||||
|
|
||||||
val uri = intent?.data
|
val uri = intent?.data
|
||||||
if (uri?.scheme == "homemfa") {
|
if (uri?.scheme == "homemfa") {
|
||||||
@@ -70,21 +77,60 @@ class MainActivity : AppCompatActivity() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// action == "approve" — require biometrics
|
// Reject stale requests — a notification can't be approved minutes later.
|
||||||
val canAuth = BiometricManager.from(this)
|
val ts = uri.getQueryParameter("ts")?.toLongOrNull()
|
||||||
.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
|
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.")
|
||||||
if (canAuth != BiometricManager.BIOMETRIC_SUCCESS) {
|
scheduleClose(3000)
|
||||||
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))
|
|
||||||
}
|
|
||||||
return
|
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<String>()
|
||||||
|
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() {
|
private fun showSetupScreen() {
|
||||||
@@ -211,7 +257,9 @@ class MainActivity : AppCompatActivity() {
|
|||||||
statusText.text = title
|
statusText.text = title
|
||||||
subText.text = subtitle
|
subText.text = subtitle
|
||||||
subText.visibility = if (subtitle.isEmpty()) View.GONE else View.VISIBLE
|
subText.visibility = if (subtitle.isEmpty()) View.GONE else View.VISIBLE
|
||||||
|
contextText.visibility = View.GONE
|
||||||
actionButton.visibility = View.GONE
|
actionButton.visibility = View.GONE
|
||||||
|
denyButton.visibility = View.GONE
|
||||||
|
|
||||||
when (approved) {
|
when (approved) {
|
||||||
true -> {
|
true -> {
|
||||||
|
|||||||
@@ -36,6 +36,19 @@
|
|||||||
android:lineSpacingMultiplier="1.4"
|
android:lineSpacingMultiplier="1.4"
|
||||||
android:visibility="gone" />
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/contextText"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="20dp"
|
||||||
|
android:text=""
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:gravity="center"
|
||||||
|
android:lineSpacingMultiplier="1.5"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/actionButton"
|
android:id="@+id/actionButton"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
@@ -47,4 +60,15 @@
|
|||||||
android:textColor="@color/text_primary"
|
android:textColor="@color/text_primary"
|
||||||
android:paddingHorizontal="32dp" />
|
android:paddingHorizontal="32dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/denyButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:text="Deny"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:backgroundTint="@color/red"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:paddingHorizontal="32dp" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
Reference in New Issue
Block a user