chore: initial project setup with backend, frontend, Android app, and CI/CD

Add complete NexaMFA push MFA system with:
- FastAPI backend with PostgreSQL, Redis, OIDC provider, and Prometheus metrics
- React TypeScript admin console
- Android Kotlin/Jetpack Compose app with biometric authentication
- Docker Compose deployment configuration
- Gitea CI workflow for backend, frontend, and Android builds
- Environment configuration template with security settings
- Documentation for security model, deployment
This commit is contained in:
2026-06-28 09:37:51 +02:00
commit f925009977
57 changed files with 2776 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
APP_NAME=NexaMFA
ENVIRONMENT=prod
PUBLIC_BASE_URL=https://mfa.example.com
CORS_ORIGINS=https://mfa-admin.example.com
POSTGRES_DB=nexamfa
POSTGRES_USER=nexamfa
POSTGRES_PASSWORD=replace-with-a-long-random-password
DATABASE_URL=postgresql+asyncpg://nexamfa:replace-with-a-long-random-password@postgres:5432/nexamfa
REDIS_URL=redis://redis:6379/0
ADMIN_TOKEN=replace-with-a-long-random-admin-token
OIDC_ISSUER=https://mfa.example.com
OIDC_CLIENT_ID=authentik
OIDC_CLIENT_SECRET=replace-with-a-long-random-oidc-secret
OIDC_REDIRECT_URIS=https://authentik.example.com/application/o/nexamfa/callback/
# Generate a persistent RSA key and paste the PEM as a single-line escaped value or Docker secret.
# OIDC_SIGNING_KEY_PEM=
CHALLENGE_TTL_SECONDS=60
ENROLLMENT_TTL_SECONDS=600
ACCESS_TOKEN_TTL_SECONDS=300
AUTH_CODE_TTL_SECONDS=120
RATE_LIMIT_DEFAULT=120/minute
RATE_LIMIT_APPROVE=12/minute
FCM_PROJECT_ID=your-firebase-project-id
# Store the Firebase service account JSON as a secret in production.
FCM_SERVICE_ACCOUNT_JSON=
+81
View File
@@ -0,0 +1,81 @@
name: NexaMFA CI
on:
push:
branches: ["main", "master"]
pull_request:
jobs:
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install backend dependencies
working-directory: backend
run: |
pip install --upgrade pip
pip install ".[test]"
- name: Run backend tests
working-directory: backend
env:
DATABASE_URL: sqlite+aiosqlite:///:memory:
ENVIRONMENT: test
run: pytest
- name: Build backend Docker image
run: docker build -t nexamfa-backend:${{ github.sha }} backend
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Build frontend
working-directory: frontend
run: |
npm install
npm run build
- name: Build frontend Docker image
run: docker build -t nexamfa-frontend:${{ github.sha }} frontend
android:
runs-on: ubuntu-latest
container:
image: ghcr.io/cirruslabs/android-sdk:35
steps:
- uses: actions/checkout@v4
- name: Prepare Firebase configuration
if: ${{ secrets.GOOGLE_SERVICES_JSON_BASE64 != '' }}
run: echo "${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }}" | base64 -d > android/app/google-services.json
- name: Prepare release keystore
if: ${{ secrets.ANDROID_KEYSTORE_BASE64 != '' }}
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > android/release.keystore
- name: Build Android artifacts
working-directory: android
env:
ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/android/release.keystore
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
gradle testDebugUnitTest assembleDebug assembleRelease bundleRelease
- name: Upload debug APK
uses: actions/upload-artifact@v3
with:
name: nexamfa-debug-apk
path: android/app/build/outputs/apk/debug/*.apk
- name: Upload release APK
uses: actions/upload-artifact@v3
with:
name: nexamfa-release-apk
path: android/app/build/outputs/apk/release/*.apk
- name: Upload release AAB
uses: actions/upload-artifact@v3
with:
name: nexamfa-release-aab
path: android/app/build/outputs/bundle/release/*.aab
+116
View File
@@ -0,0 +1,116 @@
# NexaMFA
NexaMFA is an open-source, self-hosted Push MFA system similar to Duo Push. It provides a FastAPI backend, PostgreSQL persistence, Redis-ready queue/cache integration, a React TypeScript admin console, an Android Kotlin/Jetpack Compose app, Firebase Cloud Messaging push notifications, Docker Compose deployment, Prometheus metrics, and OIDC provider mode for authentik.
## Security Model
Push delivery is only a wake-up signal. NexaMFA never treats a push notification as approval. Each Android device generates an asymmetric keypair in Android Keystore, stores only the public key on the server, and signs the exact challenge payload after local BiometricPrompt confirmation. The backend verifies the signature, checks challenge expiry, blocks replay by allowing only one terminal state, and rejects revoked devices immediately.
Push notifications contain only `challenge_id`.
## Quick Start
1. Copy the environment file:
```bash
cp .env.example .env
```
2. Replace all secrets in `.env`, set `PUBLIC_BASE_URL`, `OIDC_ISSUER`, `OIDC_REDIRECT_URIS`, and Firebase settings.
3. Start the stack:
```bash
docker compose up --build
```
4. Open the admin UI at `http://localhost:8080` and sign in with `ADMIN_TOKEN`.
5. Create an enrollment from the backend API:
```bash
curl -H 'Content-Type: application/json' \
-d '{"username":"alice","display_name":"Alice","email":"alice@example.com"}' \
http://localhost:8000/api/enroll/start
```
Scan the returned QR code with the Android app.
## Backend API
Public/device endpoints:
- `POST /api/enroll/start`
- `POST /api/enroll/finish`
- `POST /api/challenges`
- `GET /api/challenges/{id}`
- `POST /api/challenges/{id}/approve`
- `POST /api/challenges/{id}/deny`
- `GET /health`
- `GET /metrics`
Admin endpoints require `Authorization: Bearer $ADMIN_TOKEN`:
- `GET /api/admin/users`
- `GET /api/admin/devices`
- `POST /api/admin/devices/{id}/revoke`
- `GET /api/admin/challenges`
- `GET /api/admin/audit`
OIDC endpoints:
- `/.well-known/openid-configuration`
- `/oauth/authorize`
- `/oauth/token`
- `/oauth/userinfo`
- `/oauth/jwks`
## Zoraxy Reverse Proxy
Expose the backend public hostname, for example `https://mfa.example.com`, to container `backend:8000`. Enable HTTPS in Zoraxy and forward:
- `/.well-known/openid-configuration`
- `/oauth/*`
- `/api/*`
- `/health`
- `/metrics` if Prometheus is remote and authorized by your network policy
Expose the admin frontend separately, for example `https://mfa-admin.example.com`, to container `frontend:80`. Set `CORS_ORIGINS=https://mfa-admin.example.com`.
## Development
Backend:
```bash
cd backend
pip install -e '.[test]'
pytest
uvicorn app.main:app --reload
```
Frontend:
```bash
cd frontend
npm install
npm run dev
```
Android:
```bash
cd android
./gradlew testDebugUnitTest assembleDebug
```
Add your Firebase `google-services.json` at `android/app/google-services.json`.
## Production Notes
- Use long random values for `ADMIN_TOKEN`, `POSTGRES_PASSWORD`, and `OIDC_CLIENT_SECRET`.
- Persist `OIDC_SIGNING_KEY_PEM`; changing it invalidates token verification until clients refresh JWKS.
- Restrict admin UI and metrics at the reverse proxy or network layer.
- Use HTTPS only. Android enrollment and challenge approval should never be sent over cleartext.
- Configure Firebase service account credentials for real push delivery.
See [SECURITY.md](SECURITY.md) and [docs/authentik.md](docs/authentik.md).
+47
View File
@@ -0,0 +1,47 @@
# NexaMFA Security
## Threat Model
NexaMFA assumes the network, push transport, and user-facing login prompts can be attacked. Security depends on server-issued challenge payloads, local Android confirmation, asymmetric signatures, server-side verification, expiry, and single-use state transitions.
## Challenge Payload
Each challenge contains:
- `challenge_id`
- `user_id`
- `username`
- `relying_party`
- `requester_ip`
- `location`
- `issued_at`
- `expires_at`
- `nonce`
The Android app displays these details before approval. After BiometricPrompt succeeds using fingerprint, face unlock, or device credential, the app signs the canonical JSON payload. The server verifies the signature against the enrolled public key.
## Controls
- Private keys are generated in Android Keystore and never leave the device.
- Push notifications contain only `challenge_id`.
- Challenges expire after `CHALLENGE_TTL_SECONDS`, default 60 seconds.
- Approved, denied, and expired challenges cannot be reused.
- Revoked devices cannot approve challenges.
- Deny, timeout, invalid signature, replay attempts, enrollment, and revocation are audit logged.
- Admin APIs require bearer-token authentication and should be reverse-proxy restricted.
- Prometheus metrics should be network restricted.
## Operational Guidance
- Rotate admin and OIDC secrets on suspected disclosure.
- Revoke lost devices immediately.
- Keep Firebase credentials outside Git.
- Use HTTPS end to end from clients to Zoraxy.
- Back up PostgreSQL and the OIDC signing key.
## Known Hardening Backlog
- Add Android hardware attestation enforcement.
- Replace the FCM service stub with google-auth OAuth2 token minting in `PushService`.
- Add admin SSO instead of static bearer token for large deployments.
- Add per-user device selection and policy rules.
+70
View File
@@ -0,0 +1,70 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
if (file("google-services.json").exists()) {
apply(plugin = "com.google.gms.google-services")
}
android {
namespace = "com.nexamfa.app"
compileSdk = 35
defaultConfig {
applicationId = "com.nexamfa.app"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
create("release") {
val keystorePath = System.getenv("ANDROID_KEYSTORE_PATH")
if (keystorePath != null) {
storeFile = file(keystorePath)
storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
}
}
}
buildTypes {
getByName("release") {
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
if (System.getenv("ANDROID_KEYSTORE_PATH") != null) {
signingConfig = signingConfigs.getByName("release")
}
}
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.14"
}
}
dependencies {
implementation(platform("androidx.compose:compose-bom:2024.06.00"))
implementation("androidx.activity:activity-compose:1.9.0")
implementation("androidx.biometric:biometric:1.1.0")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("com.google.firebase:firebase-messaging-ktx:24.0.0")
implementation("com.google.mlkit:barcode-scanning:17.2.0")
implementation("com.journeyapps:zxing-android-embedded:4.3.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
implementation("org.json:json:20240303")
testImplementation("junit:junit:4.13.2")
}
+1
View File
@@ -0,0 +1 @@
-keep class com.nexamfa.app.** { *; }
+29
View File
@@ -0,0 +1,29 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application
android:allowBackup="false"
android:label="NexaMFA"
android:supportsRtl="true"
android:theme="@style/Theme.NexaMFA">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".push.NexaFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>
@@ -0,0 +1,217 @@
package com.nexamfa.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.google.firebase.messaging.FirebaseMessaging
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import com.nexamfa.app.data.ChallengePayload
import com.nexamfa.app.data.DeviceRecord
import com.nexamfa.app.data.EnrollmentPayload
import com.nexamfa.app.data.NexaApi
import com.nexamfa.app.data.SecureStore
import com.nexamfa.app.security.DeviceKeyStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val initialChallengeId = intent.getStringExtra("challenge_id")
setContent {
MaterialTheme {
NexaApp(activity = this, initialChallengeId = initialChallengeId)
}
}
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun NexaApp(activity: ComponentActivity, initialChallengeId: String?) {
val context = LocalContext.current
val store = remember { SecureStore(context) }
val api = remember { NexaApi() }
val keyStore = remember { DeviceKeyStore() }
var device by remember { mutableStateOf(store.getDevice()) }
var challenge by remember { mutableStateOf<ChallengePayload?>(null) }
var challengeId by remember { mutableStateOf(initialChallengeId.orEmpty()) }
var status by remember { mutableStateOf("") }
var screen by remember { mutableStateOf(if (device == null) "welcome" else "settings") }
val scanner = rememberLauncherForActivityResult(ScanContract()) { result ->
val contents = result.contents ?: return@rememberLauncherForActivityResult
val obj = JSONObject(contents)
val payload = EnrollmentPayload(
serverUrl = obj.getString("server_url").trimEnd('/'),
enrollmentToken = obj.getString("enrollment_token"),
username = obj.getString("username"),
)
status = "Finishing enrollment..."
FirebaseMessaging.getInstance().token.addOnSuccessListener { fcmToken ->
Thread {
try {
val record = api.finishEnrollment(payload, android.os.Build.MODEL, keyStore.publicKeyPem(), fcmToken)
store.saveDevice(record)
device = record
screen = "enrolled"
status = "Device enrolled"
} catch (e: Exception) {
status = e.message ?: "Enrollment failed"
}
}.start()
}
}
LaunchedEffect(challengeId, device) {
val record = device
if (challengeId.isNotBlank() && record != null) {
status = "Loading login request..."
challenge = withContext(Dispatchers.IO) { api.fetchChallenge(record.serverUrl, challengeId) }
screen = "challenge"
status = ""
}
}
Scaffold(topBar = { TopAppBar(title = { Text("NexaMFA") }) }) { padding ->
Column(Modifier.fillMaxSize().padding(padding).padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
when (screen) {
"welcome" -> WelcomeScreen(
status = status,
onEnroll = {
scanner.launch(ScanOptions().setPrompt("Scan NexaMFA enrollment QR").setBeepEnabled(false))
},
)
"enrolled" -> DeviceEnrolledScreen(device = device, onContinue = { screen = "settings" })
"challenge" -> ChallengeScreen(
payload = challenge,
status = status,
onApprove = {
val record = device ?: return@ChallengeScreen
authenticate(activity) {
Thread {
try {
val payload = challenge ?: return@Thread
val signature = keyStore.signCanonicalJson(payload.canonicalJson())
api.approve(record.serverUrl, payload.challengeId, record.deviceId, payload, signature)
status = "Approved"
screen = "history"
} catch (e: Exception) {
status = e.message ?: "Approval failed"
}
}.start()
}
},
onDeny = {
val record = device ?: return@ChallengeScreen
Thread {
api.deny(record.serverUrl, challenge?.challengeId ?: challengeId, record.deviceId)
status = "Denied"
screen = "history"
}.start()
},
)
"history" -> HistoryScreen(status = status, onSettings = { screen = "settings" })
else -> SettingsScreen(device = device, challengeId = challengeId, onChallengeId = { challengeId = it }, onOpen = { screen = "challenge" })
}
}
}
}
@Composable
fun WelcomeScreen(status: String, onEnroll: () -> Unit) {
Text("Welcome", style = MaterialTheme.typography.headlineMedium)
Text("Enroll this Android device by scanning a NexaMFA QR code.")
Button(onClick = onEnroll) { Text("Scan enrollment QR") }
if (status.isNotBlank()) Text(status)
}
@Composable
fun DeviceEnrolledScreen(device: DeviceRecord?, onContinue: () -> Unit) {
Text("Device enrolled", style = MaterialTheme.typography.headlineMedium)
Text(device?.username.orEmpty())
Button(onClick = onContinue) { Text("Continue") }
}
@Composable
fun ChallengeScreen(payload: ChallengePayload?, status: String, onApprove: () -> Unit, onDeny: () -> Unit) {
Text("Incoming login request", style = MaterialTheme.typography.headlineMedium)
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Service: ${payload?.relyingParty.orEmpty()}")
Text("Username: ${payload?.username.orEmpty()}")
Text("IP address: ${payload?.requesterIp.orEmpty()}")
Text("Timestamp: ${payload?.issuedAt.orEmpty()}")
Text("Location: ${payload?.location ?: "Not provided"}")
}
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onApprove, enabled = payload != null) { Text("Approve") }
OutlinedButton(onClick = onDeny) { Text("Deny") }
}
if (status.isNotBlank()) Text(status)
}
@Composable
fun SettingsScreen(device: DeviceRecord?, challengeId: String, onChallengeId: (String) -> Unit, onOpen: () -> Unit) {
Text("Settings", style = MaterialTheme.typography.headlineMedium)
Text("Server: ${device?.serverUrl ?: "Not enrolled"}")
Text("Device ID: ${device?.deviceId ?: ""}")
Spacer(Modifier.height(8.dp))
OutlinedTextField(value = challengeId, onValueChange = onChallengeId, label = { Text("Challenge ID") }, modifier = Modifier.fillMaxWidth())
Button(onClick = onOpen, enabled = challengeId.isNotBlank() && device != null) { Text("Open request") }
}
@Composable
fun HistoryScreen(status: String, onSettings: () -> Unit) {
Text("Audit/history", style = MaterialTheme.typography.headlineMedium)
Text(status)
Button(onClick = onSettings) { Text("Settings") }
}
fun authenticate(activity: ComponentActivity, onSuccess: () -> Unit) {
val executor = ContextCompat.getMainExecutor(activity)
val prompt = BiometricPrompt(activity, executor, object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
onSuccess()
}
})
val info = BiometricPrompt.PromptInfo.Builder()
.setTitle("Approve NexaMFA request")
.setSubtitle("Confirm with fingerprint, face unlock, or device credential")
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL)
.build()
prompt.authenticate(info)
}
@@ -0,0 +1,63 @@
package com.nexamfa.app.data
import org.json.JSONObject
data class EnrollmentPayload(
val serverUrl: String,
val enrollmentToken: String,
val username: String,
)
data class DeviceRecord(
val serverUrl: String,
val deviceId: String,
val userId: String,
val username: String,
)
data class ChallengePayload(
val challengeId: String,
val userId: String,
val username: String,
val relyingParty: String,
val requesterIp: String,
val location: String?,
val issuedAt: String,
val expiresAt: String,
val nonce: String,
) {
fun canonicalJson(): String {
val map = sortedMapOf(
"challenge_id" to challengeId,
"expires_at" to expiresAt,
"issued_at" to issuedAt,
"location" to location,
"nonce" to nonce,
"relying_party" to relyingParty,
"requester_ip" to requesterIp,
"user_id" to userId,
"username" to username,
)
return map.entries.joinToString(prefix = "{", postfix = "}") { (key, value) ->
val rendered = value?.let { JSONObject.quote(it) } ?: "null"
"${JSONObject.quote(key)}:$rendered"
}
}
companion object {
fun fromJson(json: String): ChallengePayload {
val obj = JSONObject(json)
return ChallengePayload(
challengeId = obj.getString("challenge_id"),
userId = obj.getString("user_id"),
username = obj.optString("username"),
relyingParty = obj.getString("relying_party"),
requesterIp = obj.getString("requester_ip"),
location = if (obj.isNull("location")) null else obj.optString("location").ifBlank { null },
issuedAt = obj.getString("issued_at"),
expiresAt = obj.getString("expires_at"),
nonce = obj.getString("nonce"),
)
}
}
}
@@ -0,0 +1,55 @@
package com.nexamfa.app.data
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
class NexaApi(private val client: OkHttpClient = OkHttpClient()) {
private val jsonType = "application/json".toMediaType()
fun finishEnrollment(payload: EnrollmentPayload, deviceName: String, publicKeyPem: String, fcmToken: String?): DeviceRecord {
val body = JSONObject()
.put("enrollment_token", payload.enrollmentToken)
.put("device_name", deviceName)
.put("public_key_pem", publicKeyPem)
.put("public_key_alg", "ES256")
.put("fcm_token", fcmToken)
.put("app_version", "0.1.0")
.toString()
.toRequestBody(jsonType)
val req = Request.Builder().url("${payload.serverUrl}/api/enroll/finish").post(body).build()
client.newCall(req).execute().use { response ->
require(response.isSuccessful) { response.body?.string() ?: "Enrollment failed" }
val obj = JSONObject(response.body!!.string())
return DeviceRecord(payload.serverUrl, obj.getString("device_id"), obj.getString("user_id"), payload.username)
}
}
fun fetchChallenge(serverUrl: String, challengeId: String): ChallengePayload {
val req = Request.Builder().url("$serverUrl/api/challenges/$challengeId").get().build()
client.newCall(req).execute().use { response ->
require(response.isSuccessful) { response.body?.string() ?: "Challenge fetch failed" }
val obj = JSONObject(response.body!!.string())
return ChallengePayload.fromJson(obj.getJSONObject("payload").toString())
}
}
fun approve(serverUrl: String, challengeId: String, deviceId: String, payload: ChallengePayload, signature: String) {
val body = JSONObject()
.put("device_id", deviceId)
.put("signature", signature)
.put("payload", JSONObject(payload.canonicalJson()))
.toString()
.toRequestBody(jsonType)
val req = Request.Builder().url("$serverUrl/api/challenges/$challengeId/approve").post(body).build()
client.newCall(req).execute().use { response -> require(response.isSuccessful) { response.body?.string() ?: "Approval failed" } }
}
fun deny(serverUrl: String, challengeId: String, deviceId: String?) {
val body = JSONObject().put("device_id", deviceId).put("reason", "Denied on Android").toString().toRequestBody(jsonType)
val req = Request.Builder().url("$serverUrl/api/challenges/$challengeId/deny").post(body).build()
client.newCall(req).execute().use { response -> require(response.isSuccessful) { response.body?.string() ?: "Deny failed" } }
}
}
@@ -0,0 +1,31 @@
package com.nexamfa.app.data
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import org.json.JSONObject
class SecureStore(context: Context) {
private val prefs = EncryptedSharedPreferences.create(
context,
"nexamfa_secure_store",
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
fun saveDevice(record: DeviceRecord) {
prefs.edit().putString("device", JSONObject()
.put("serverUrl", record.serverUrl)
.put("deviceId", record.deviceId)
.put("userId", record.userId)
.put("username", record.username)
.toString()).apply()
}
fun getDevice(): DeviceRecord? {
val raw = prefs.getString("device", null) ?: return null
val obj = JSONObject(raw)
return DeviceRecord(obj.getString("serverUrl"), obj.getString("deviceId"), obj.getString("userId"), obj.getString("username"))
}
}
@@ -0,0 +1,31 @@
package com.nexamfa.app.push
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.nexamfa.app.MainActivity
class NexaFirebaseMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
val challengeId = message.data["challenge_id"] ?: return
val intent = Intent(this, MainActivity::class.java).putExtra("challenge_id", challengeId)
val pendingIntent = PendingIntent.getActivity(this, 100, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val manager = getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
manager.createNotificationChannel(NotificationChannel("challenges", "Login requests", NotificationManager.IMPORTANCE_HIGH))
}
val notification = NotificationCompat.Builder(this, "challenges")
.setContentTitle("NexaMFA login request")
.setContentText("Open to review and approve")
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
manager.notify(challengeId.hashCode(), notification)
}
}
@@ -0,0 +1,41 @@
package com.nexamfa.app.security
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.Signature
import java.security.spec.ECGenParameterSpec
class DeviceKeyStore(private val alias: String = "nexamfa_device_key") {
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
fun ensureKeyPair() {
if (keyStore.containsAlias(alias)) return
val generator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore")
val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN)
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
.setDigests(KeyProperties.DIGEST_SHA256)
.setUserAuthenticationRequired(false)
.build()
generator.initialize(spec)
generator.generateKeyPair()
}
fun publicKeyPem(): String {
ensureKeyPair()
val cert = keyStore.getCertificate(alias)
val encoded = Base64.encodeToString(cert.publicKey.encoded, Base64.NO_WRAP)
return "-----BEGIN PUBLIC KEY-----\n${encoded.chunked(64).joinToString("\n")}\n-----END PUBLIC KEY-----\n"
}
fun signCanonicalJson(canonicalJson: String): String {
ensureKeyPair()
val privateKey = keyStore.getKey(alias, null)
val signature = Signature.getInstance("SHA256withECDSA")
signature.initSign(privateKey as java.security.PrivateKey)
signature.update(canonicalJson.toByteArray(Charsets.UTF_8))
return Base64.encodeToString(signature.sign(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
}
}
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">NexaMFA</string>
</resources>
@@ -0,0 +1,3 @@
<resources>
<style name="Theme.NexaMFA" parent="android:style/Theme.Material.Light.NoActionBar" />
</resources>
@@ -0,0 +1,24 @@
package com.nexamfa.app.data
import org.junit.Assert.assertEquals
import org.junit.Test
class ChallengePayloadTest {
@Test
fun parsesChallengePayload() {
val payload = ChallengePayload.fromJson(
"""{"challenge_id":"c1","user_id":"u1","username":"alice","relying_party":"authentik","requester_ip":"10.0.0.1","location":"Lab","issued_at":"2026-01-01T00:00:00Z","expires_at":"2026-01-01T00:01:00Z","nonce":"n"}"""
)
assertEquals("c1", payload.challengeId)
assertEquals("authentik", payload.relyingParty)
}
@Test
fun canonicalJsonIsStableAndSorted() {
val payload = ChallengePayload("c1", "u1", "alice", "authentik", "10.0.0.1", null, "i", "e", "n")
assertEquals(
"""{"challenge_id":"c1","expires_at":"e","issued_at":"i","location":null,"nonce":"n","relying_party":"authentik","requester_ip":"10.0.0.1","user_id":"u1","username":"alice"}""",
payload.canonicalJson(),
)
}
}
+5
View File
@@ -0,0 +1,5 @@
plugins {
id("com.android.application") version "8.5.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
id("com.google.gms.google-services") version "4.4.2" apply false
}
+16
View File
@@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "NexaMFA"
include(":app")
+22
View File
@@ -0,0 +1,22 @@
FROM python:3.12-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential curl \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml ./
RUN pip install --upgrade pip \
&& pip install ".[test]"
COPY app ./app
COPY tests ./tests
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD curl -fsS http://localhost:8000/health || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+57
View File
@@ -0,0 +1,57 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db, require_admin
from app.core.security import now_utc
from app.models.audit import AuditLog
from app.models.challenge import Challenge
from app.models.device import Device
from app.models.user import User
from app.schemas.api import AuditOut, ChallengeOut, DeviceOut, UserOut
from app.services.audit import audit
router = APIRouter(prefix="/api/admin", tags=["admin"], dependencies=[Depends(require_admin)])
@router.get("/users", response_model=list[UserOut])
async def users(session: AsyncSession = Depends(get_db)):
return (await session.scalars(select(User).order_by(User.username))).all()
@router.get("/devices", response_model=list[DeviceOut])
async def devices(session: AsyncSession = Depends(get_db)):
return (await session.scalars(select(Device).order_by(desc(Device.created_at)))).all()
@router.post("/devices/{device_id}/revoke")
async def revoke_device(device_id: UUID, request: Request, session: AsyncSession = Depends(get_db)):
device = await session.get(Device, device_id)
if not device:
raise HTTPException(status_code=404, detail="Device not found")
device.is_revoked = True
device.revoked_at = now_utc()
await audit(
session,
"device.revoked",
actor="admin",
target_type="device",
target_id=str(device.id),
ip_address=request.client.host if request.client else None,
)
await session.commit()
return {"status": "revoked"}
@router.get("/audit", response_model=list[AuditOut])
async def audit_logs(limit: int = 100, session: AsyncSession = Depends(get_db)):
limit = min(max(limit, 1), 500)
return (await session.scalars(select(AuditLog).order_by(desc(AuditLog.created_at)).limit(limit))).all()
@router.get("/challenges", response_model=list[ChallengeOut])
async def challenges(limit: int = 100, session: AsyncSession = Depends(get_db)):
limit = min(max(limit, 1), 500)
return (await session.scalars(select(Challenge).order_by(desc(Challenge.created_at)).limit(limit))).all()
+193
View File
@@ -0,0 +1,193 @@
import json
from uuid import UUID
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import Settings
from app.core.deps import get_app_settings, get_db
from app.models.challenge import Challenge
from app.models.oidc import AuthorizationCode
from app.services.challenges import create_challenge, expire_if_needed
from app.services.oidc import (
build_token_response,
consume_authorization_code,
is_approved,
issue_authorization_code,
jwks,
redirect_with_code,
redirect_with_error,
validate_authorize_request,
)
from app.services.push import PushService
router = APIRouter(tags=["oidc"])
OIDC_PRIVATE_KEY = None
def set_oidc_private_key(key) -> None:
global OIDC_PRIVATE_KEY
OIDC_PRIVATE_KEY = key
@router.get("/.well-known/openid-configuration")
async def openid_configuration(settings: Settings = Depends(get_app_settings)):
issuer = settings.oidc_issuer.rstrip("/")
return {
"issuer": issuer,
"authorization_endpoint": f"{issuer}/oauth/authorize",
"token_endpoint": f"{issuer}/oauth/token",
"userinfo_endpoint": f"{issuer}/oauth/userinfo",
"jwks_uri": f"{issuer}/oauth/jwks",
"response_types_supported": ["code"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["openid", "profile", "email"],
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
"claims_supported": ["sub", "name", "preferred_username", "email", "amr"],
}
@router.get("/oauth/jwks")
async def jwks_endpoint():
return jwks(OIDC_PRIVATE_KEY)
@router.get("/oauth/authorize")
async def authorize(
request: Request,
response_type: str,
client_id: str,
redirect_uri: str,
scope: str = "openid profile email",
state: str | None = None,
nonce: str | None = None,
login_hint: str | None = None,
session: AsyncSession = Depends(get_db),
settings: Settings = Depends(get_app_settings),
):
error = validate_authorize_request(settings, client_id, redirect_uri, response_type)
if error:
return RedirectResponse(redirect_with_error(redirect_uri, error, state))
if not login_hint:
return HTMLResponse("<h1>NexaMFA</h1><p>authentik must send login_hint with the username.</p>", status_code=400)
challenge = await create_challenge(
session,
settings,
PushService(settings),
username=login_hint,
relying_party="authentik",
requester_ip=request.client.host if request.client else "unknown",
location=None,
ttl_seconds=settings.challenge_ttl_seconds,
oidc_state=state,
)
if not challenge:
return RedirectResponse(redirect_with_error(redirect_uri, "access_denied", state))
await session.commit()
html = f"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NexaMFA Approval</title>
<style>
body {{ font-family: system-ui, sans-serif; margin: 0; min-height: 100vh; display: grid; place-items: center; background: #0f172a; color: #e5e7eb; }}
main {{ max-width: 560px; padding: 32px; }}
.dot {{ display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: #22c55e; animation: pulse 1s infinite alternate; }}
@keyframes pulse {{ from {{ opacity: .35 }} to {{ opacity: 1 }} }}
</style>
</head>
<body>
<main>
<h1>Approve sign-in</h1>
<p><span class="dot"></span> A NexaMFA push request was sent to your enrolled Android device.</p>
<p>This request expires in {settings.challenge_ttl_seconds} seconds.</p>
</main>
<script>
const challengeId = {json.dumps(str(challenge.id))};
const params = new URLSearchParams({json.dumps({
"client_id": client_id,
"redirect_uri": redirect_uri,
"scope": scope,
"state": state or "",
"nonce": nonce or "",
})});
async function poll() {{
const res = await fetch(`/oauth/status/${{challengeId}}?${{params.toString()}}`);
const data = await res.json();
if (data.redirect) window.location = data.redirect;
else if (data.done) document.body.innerHTML = "<main><h1>Request ended</h1><p>" + data.status + "</p></main>";
}}
setInterval(poll, 2000);
poll();
</script>
</body>
</html>
"""
return HTMLResponse(html)
@router.get("/oauth/status/{challenge_id}")
async def oauth_status(
challenge_id: UUID,
client_id: str,
redirect_uri: str,
scope: str = "openid profile email",
state: str | None = None,
nonce: str | None = None,
session: AsyncSession = Depends(get_db),
settings: Settings = Depends(get_app_settings),
):
challenge = await session.get(Challenge, challenge_id)
if not challenge:
raise HTTPException(status_code=404, detail="Challenge not found")
await expire_if_needed(session, challenge)
if is_approved(challenge):
code = await issue_authorization_code(
session,
settings,
challenge=challenge,
client_id=client_id,
redirect_uri=redirect_uri,
scope=scope,
state=state,
nonce=nonce,
)
await session.commit()
return {"done": True, "redirect": redirect_with_code(redirect_uri, code, state)}
if challenge.status.value in {"denied", "expired"}:
await session.commit()
return {"done": True, "status": challenge.status.value, "redirect": redirect_with_error(redirect_uri, "access_denied", state)}
await session.commit()
return {"done": False, "status": challenge.status.value}
@router.post("/oauth/token")
async def token(
grant_type: str = Form(...),
code: str = Form(...),
redirect_uri: str = Form(...),
client_id: str = Form(...),
client_secret: str = Form(...),
session: AsyncSession = Depends(get_db),
settings: Settings = Depends(get_app_settings),
):
if grant_type != "authorization_code":
raise HTTPException(status_code=400, detail="unsupported_grant_type")
if client_id != settings.oidc_client_id or client_secret != settings.oidc_client_secret:
raise HTTPException(status_code=401, detail="invalid_client")
auth_code = await consume_authorization_code(session, code=code, client_id=client_id, redirect_uri=redirect_uri)
if not auth_code:
raise HTTPException(status_code=400, detail="invalid_grant")
response = await build_token_response(session, settings, OIDC_PRIVATE_KEY, auth_code)
await session.commit()
return JSONResponse(response)
@router.get("/oauth/userinfo")
async def userinfo():
return {"service": "NexaMFA", "note": "Use ID token claims for authenticated user details."}
+150
View File
@@ -0,0 +1,150 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import Settings
from app.core.deps import get_app_settings, get_db
from app.models.challenge import Challenge
from app.schemas.api import (
ChallengeApprovalRequest,
ChallengeCreateRequest,
ChallengeOut,
DenyRequest,
EnrollmentFinishRequest,
EnrollmentFinishResponse,
EnrollmentStartRequest,
EnrollmentStartResponse,
)
from app.services.challenges import approve_challenge, create_challenge, deny_challenge, expire_if_needed
from app.services.enrollment import finish_enrollment, start_enrollment
from app.services.push import PushService
router = APIRouter(prefix="/api", tags=["api"])
@router.post("/enroll/start", response_model=EnrollmentStartResponse)
async def enroll_start(
body: EnrollmentStartRequest,
request: Request,
session: AsyncSession = Depends(get_db),
settings: Settings = Depends(get_app_settings),
):
enrollment, qr_payload, qr_b64 = await start_enrollment(
session,
settings,
username=body.username,
display_name=body.display_name,
email=body.email,
ip_address=request.client.host if request.client else None,
)
await session.commit()
return EnrollmentStartResponse(
enrollment_id=enrollment.id,
qr_payload=qr_payload,
qr_png_base64=qr_b64,
expires_at=enrollment.expires_at,
)
@router.post("/enroll/finish", response_model=EnrollmentFinishResponse)
async def enroll_finish(
body: EnrollmentFinishRequest,
request: Request,
session: AsyncSession = Depends(get_db),
):
device = await finish_enrollment(
session,
enrollment_token=body.enrollment_token,
device_name=body.device_name,
public_key_pem=body.public_key_pem,
public_key_alg=body.public_key_alg,
fcm_token=body.fcm_token,
app_version=body.app_version,
attestation=body.attestation,
ip_address=request.client.host if request.client else None,
)
if not device:
raise HTTPException(status_code=400, detail="Invalid or expired enrollment")
await session.commit()
return EnrollmentFinishResponse(device_id=device.id, user_id=device.user_id)
@router.post("/challenges", response_model=ChallengeOut)
async def challenges_create(
body: ChallengeCreateRequest,
session: AsyncSession = Depends(get_db),
settings: Settings = Depends(get_app_settings),
):
challenge = await create_challenge(
session,
settings,
PushService(settings),
username=body.username,
relying_party=body.relying_party,
requester_ip=body.requester_ip,
location=body.location,
ttl_seconds=body.ttl_seconds,
oidc_state=body.oidc_state,
)
if not challenge:
raise HTTPException(status_code=404, detail="No active enrolled device for user")
await session.commit()
return challenge
@router.get("/challenges/{challenge_id}", response_model=ChallengeOut)
async def challenges_get(challenge_id: UUID, session: AsyncSession = Depends(get_db)):
challenge = await session.get(Challenge, challenge_id)
if not challenge:
raise HTTPException(status_code=404, detail="Challenge not found")
await expire_if_needed(session, challenge)
await session.commit()
return challenge
@router.post("/challenges/{challenge_id}/approve")
async def challenges_approve(
challenge_id: UUID,
body: ChallengeApprovalRequest,
request: Request,
session: AsyncSession = Depends(get_db),
):
challenge = await session.get(Challenge, challenge_id)
if not challenge:
raise HTTPException(status_code=404, detail="Challenge not found")
ok, reason = await approve_challenge(
session,
challenge=challenge,
device_id=body.device_id,
payload=body.payload,
signature=body.signature,
ip_address=request.client.host if request.client else None,
)
await session.commit()
if not ok:
raise HTTPException(status_code=400, detail=reason)
return {"status": "approved"}
@router.post("/challenges/{challenge_id}/deny")
async def challenges_deny(
challenge_id: UUID,
body: DenyRequest,
request: Request,
session: AsyncSession = Depends(get_db),
):
challenge = await session.get(Challenge, challenge_id)
if not challenge:
raise HTTPException(status_code=404, detail="Challenge not found")
ok, reason = await deny_challenge(
session,
challenge=challenge,
device_id=body.device_id,
reason=body.reason,
ip_address=request.client.host if request.client else None,
)
await session.commit()
if not ok:
raise HTTPException(status_code=400, detail=reason)
return {"status": "denied"}
+49
View File
@@ -0,0 +1,49 @@
from functools import lru_cache
from typing import Literal
from pydantic import AnyHttpUrl, Field, computed_field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
app_name: str = "NexaMFA"
environment: Literal["dev", "test", "prod"] = "dev"
public_base_url: AnyHttpUrl = "https://mfa.example.com"
cors_origins: str = "http://localhost:5173"
database_url: str = "postgresql+asyncpg://nexamfa:nexamfa@postgres:5432/nexamfa"
redis_url: str = "redis://redis:6379/0"
admin_token: str = Field(default="change-me-admin-token")
oidc_issuer: str = "https://mfa.example.com"
oidc_client_id: str = "authentik"
oidc_client_secret: str = "change-me-oidc-secret"
oidc_redirect_uris: str = "https://authentik.example.com/application/o/nexamfa/callback/"
oidc_signing_key_pem: str | None = None
challenge_ttl_seconds: int = 60
enrollment_ttl_seconds: int = 600
access_token_ttl_seconds: int = 300
auth_code_ttl_seconds: int = 120
rate_limit_default: str = "120/minute"
rate_limit_approve: str = "12/minute"
fcm_project_id: str | None = None
fcm_service_account_json: str | None = None
@computed_field
@property
def cors_origin_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
@computed_field
@property
def oidc_redirect_uri_list(self) -> list[str]:
return [uri.strip() for uri in self.oidc_redirect_uris.split(",") if uri.strip()]
@lru_cache
def get_settings() -> Settings:
return Settings()
+32
View File
@@ -0,0 +1,32 @@
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.core.config import get_settings
class Base(DeclarativeBase):
pass
settings = get_settings()
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with SessionLocal() as session:
yield session
async def create_all() -> None:
import app.models.audit # noqa: F401
import app.models.challenge # noqa: F401
import app.models.device # noqa: F401
import app.models.enrollment # noqa: F401
import app.models.oidc # noqa: F401
import app.models.user # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+25
View File
@@ -0,0 +1,25 @@
from fastapi import Depends, Header, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import Settings, get_settings
from app.core.database import get_session
async def get_db(session: AsyncSession = Depends(get_session)) -> AsyncSession:
return session
def get_app_settings() -> Settings:
return get_settings()
async def require_admin(
request: Request,
authorization: str | None = Header(default=None),
settings: Settings = Depends(get_app_settings),
) -> None:
token = None
if authorization and authorization.lower().startswith("bearer "):
token = authorization.split(" ", 1)[1]
if token != settings.admin_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin token")
+98
View File
@@ -0,0 +1,98 @@
from datetime import UTC, datetime, timedelta
import base64
import json
import secrets
from typing import Any
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature, encode_dss_signature
from cryptography.hazmat.primitives.hashes import SHA256
import jwt
def now_utc() -> datetime:
return datetime.now(UTC)
def random_token(bytes_len: int = 32) -> str:
return secrets.token_urlsafe(bytes_len)
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def b64url_decode(value: str) -> bytes:
pad = "=" * (-len(value) % 4)
return base64.urlsafe_b64decode(value + pad)
def canonical_json(payload: dict[str, Any]) -> bytes:
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
def load_public_key(public_key_pem: str):
return serialization.load_pem_public_key(public_key_pem.encode("utf-8"))
def verify_signature(public_key_pem: str, payload: dict[str, Any], signature_b64: str) -> bool:
key = load_public_key(public_key_pem)
signature = b64url_decode(signature_b64)
message = canonical_json(payload)
try:
if isinstance(key, ec.EllipticCurvePublicKey):
if len(signature) == 64:
signature = encode_dss_signature(
int.from_bytes(signature[:32], "big"),
int.from_bytes(signature[32:], "big"),
)
key.verify(signature, message, ec.ECDSA(SHA256()))
return True
if isinstance(key, rsa.RSAPublicKey):
key.verify(signature, message, padding.PKCS1v15(), SHA256())
return True
except Exception:
return False
return False
def generate_oidc_private_key() -> rsa.RSAPrivateKey:
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
def serialize_private_key(key: rsa.RSAPrivateKey) -> str:
return key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
).decode("utf-8")
def load_or_create_oidc_key(key_pem: str | None) -> rsa.RSAPrivateKey:
if key_pem:
return serialization.load_pem_private_key(key_pem.encode("utf-8"), password=None)
return generate_oidc_private_key()
def public_jwk(private_key: rsa.RSAPrivateKey, kid: str) -> dict[str, str]:
numbers = private_key.public_key().public_numbers()
return {
"kty": "RSA",
"use": "sig",
"kid": kid,
"alg": "RS256",
"n": b64url(numbers.n.to_bytes((numbers.n.bit_length() + 7) // 8, "big")),
"e": b64url(numbers.e.to_bytes((numbers.e.bit_length() + 7) // 8, "big")),
}
def sign_jwt(claims: dict[str, Any], private_key: rsa.RSAPrivateKey, kid: str) -> str:
claims = claims.copy()
claims.setdefault("iat", int(now_utc().timestamp()))
return jwt.encode(claims, private_key, algorithm="RS256", headers={"kid": kid})
def expires_in(seconds: int) -> datetime:
return now_utc() + timedelta(seconds=seconds)
+67
View File
@@ -0,0 +1,67 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest
from slowapi import Limiter
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from slowapi.util import get_remote_address
from starlette.responses import JSONResponse
from app.api import admin, oidc, public
from app.core.config import get_settings
from app.core.database import create_all
from app.core.security import load_or_create_oidc_key
settings = get_settings()
REQUESTS = Counter("nexamfa_http_requests_total", "HTTP requests", ["method", "path", "status"])
LATENCY = Histogram("nexamfa_http_request_duration_seconds", "HTTP request latency", ["method", "path"])
limiter = Limiter(key_func=get_remote_address, default_limits=[settings.rate_limit_default])
@asynccontextmanager
async def lifespan(app: FastAPI):
oidc.set_oidc_private_key(load_or_create_oidc_key(settings.oidc_signing_key_pem))
await create_all()
yield
app = FastAPI(title="NexaMFA", version="0.1.0", lifespan=lifespan)
app.state.limiter = limiter
app.add_middleware(SlowAPIMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.exception_handler(RateLimitExceeded)
async def rate_limit_handler(_: Request, exc: RateLimitExceeded):
return JSONResponse({"detail": "Rate limit exceeded"}, status_code=429)
@app.middleware("http")
async def metrics_middleware(request: Request, call_next):
with LATENCY.labels(request.method, request.url.path).time():
response = await call_next(request)
REQUESTS.labels(request.method, request.url.path, str(response.status_code)).inc()
return response
@app.get("/health")
async def health():
return {"status": "ok", "service": "NexaMFA"}
@app.get("/metrics")
async def metrics():
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
app.include_router(public.router)
app.include_router(admin.router)
app.include_router(oidc.router)
+8
View File
@@ -0,0 +1,8 @@
from app.models.audit import AuditLog
from app.models.challenge import Challenge, ChallengeStatus
from app.models.device import Device
from app.models.enrollment import Enrollment
from app.models.oidc import AuthorizationCode
from app.models.user import User
__all__ = ["AuditLog", "AuthorizationCode", "Challenge", "ChallengeStatus", "Device", "Enrollment", "User"]
+20
View File
@@ -0,0 +1,20 @@
from datetime import datetime
import uuid
from sqlalchemy import JSON, DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
actor: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
action: Mapped[str] = mapped_column(String(128), index=True)
target_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
target_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
+36
View File
@@ -0,0 +1,36 @@
from datetime import datetime
from enum import StrEnum
import uuid
from sqlalchemy import JSON, DateTime, Enum, ForeignKey, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class ChallengeStatus(StrEnum):
pending = "pending"
approved = "approved"
denied = "denied"
expired = "expired"
class Challenge(Base):
__tablename__ = "challenges"
__table_args__ = (UniqueConstraint("nonce", name="uq_challenges_nonce"),)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
device_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("devices.id", ondelete="SET NULL"), nullable=True, index=True)
status: Mapped[ChallengeStatus] = mapped_column(Enum(ChallengeStatus), default=ChallengeStatus.pending, index=True)
relying_party: Mapped[str] = mapped_column(String(255))
requester_ip: Mapped[str] = mapped_column(String(64))
location: Mapped[str | None] = mapped_column(String(255), nullable=True)
nonce: Mapped[str] = mapped_column(String(128), index=True)
payload: Mapped[dict] = mapped_column(JSON)
signature: Mapped[str | None] = mapped_column(Text, nullable=True)
issued_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
responded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
oidc_state: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+27
View File
@@ -0,0 +1,27 @@
from datetime import datetime
import uuid
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class Device(Base):
__tablename__ = "devices"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(255))
platform: Mapped[str] = mapped_column(String(64), default="android")
public_key_pem: Mapped[str] = mapped_column(Text)
public_key_alg: Mapped[str] = mapped_column(String(64), default="ES256")
fcm_token: Mapped[str | None] = mapped_column(Text, nullable=True)
app_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
attestation: Mapped[dict | None] = mapped_column(JSON, nullable=True)
is_revoked: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user = relationship("User", back_populates="devices")
+18
View File
@@ -0,0 +1,18 @@
from datetime import datetime
import uuid
from sqlalchemy import Boolean, DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class Enrollment(Base):
__tablename__ = "enrollments"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
token_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
used: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+24
View File
@@ -0,0 +1,24 @@
from datetime import datetime
import uuid
from sqlalchemy import Boolean, DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class AuthorizationCode(Base):
__tablename__ = "authorization_codes"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
code_hash: Mapped[str] = mapped_column(String(128), unique=True, index=True)
client_id: Mapped[str] = mapped_column(String(255), index=True)
redirect_uri: Mapped[str] = mapped_column(String(1024))
scope: Mapped[str] = mapped_column(String(1024), default="openid profile email")
state: Mapped[str | None] = mapped_column(String(1024), nullable=True)
nonce: Mapped[str | None] = mapped_column(String(1024), nullable=True)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
challenge_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE"), index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
used: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+20
View File
@@ -0,0 +1,20 @@
from datetime import datetime
import uuid
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
username: Mapped[str] = mapped_column(String(255), unique=True, index=True)
display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
devices = relationship("Device", back_populates="user")
+106
View File
@@ -0,0 +1,106 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from pydantic import BaseModel, Field
class UserOut(BaseModel):
id: UUID
username: str
display_name: str | None = None
email: str | None = None
created_at: datetime
model_config = {"from_attributes": True}
class DeviceOut(BaseModel):
id: UUID
user_id: UUID
name: str
platform: str
public_key_alg: str
is_revoked: bool
revoked_at: datetime | None = None
last_seen_at: datetime | None = None
created_at: datetime
model_config = {"from_attributes": True}
class EnrollmentStartRequest(BaseModel):
username: str = Field(min_length=1, max_length=255)
display_name: str | None = None
email: str | None = None
class EnrollmentStartResponse(BaseModel):
enrollment_id: UUID
qr_payload: dict[str, Any]
qr_png_base64: str
expires_at: datetime
class EnrollmentFinishRequest(BaseModel):
enrollment_token: str
device_name: str = Field(min_length=1, max_length=255)
public_key_pem: str
public_key_alg: str = "ES256"
fcm_token: str | None = None
app_version: str | None = None
attestation: dict[str, Any] | None = None
class EnrollmentFinishResponse(BaseModel):
device_id: UUID
user_id: UUID
class ChallengeCreateRequest(BaseModel):
username: str
relying_party: str
requester_ip: str
location: str | None = None
ttl_seconds: int | None = Field(default=None, ge=10, le=300)
oidc_state: str | None = None
class ChallengeOut(BaseModel):
id: UUID
user_id: UUID
device_id: UUID | None
status: str
relying_party: str
requester_ip: str
location: str | None
payload: dict[str, Any]
issued_at: datetime
expires_at: datetime
responded_at: datetime | None
model_config = {"from_attributes": True}
class ChallengeApprovalRequest(BaseModel):
device_id: UUID
signature: str
payload: dict[str, Any]
class DenyRequest(BaseModel):
device_id: UUID | None = None
reason: str | None = Field(default=None, max_length=255)
class AuditOut(BaseModel):
id: UUID
actor: str | None
action: str
target_type: str | None
target_id: str | None
ip_address: str | None
metadata_json: dict[str, Any] | None
created_at: datetime
model_config = {"from_attributes": True}
+25
View File
@@ -0,0 +1,25 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audit import AuditLog
async def audit(
session: AsyncSession,
action: str,
*,
actor: str | None = None,
target_type: str | None = None,
target_id: str | None = None,
ip_address: str | None = None,
metadata: dict | None = None,
) -> None:
session.add(
AuditLog(
actor=actor,
action=action,
target_type=target_type,
target_id=target_id,
ip_address=ip_address,
metadata_json=metadata,
)
)
+138
View File
@@ -0,0 +1,138 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import Settings
from app.core.security import expires_in, now_utc, random_token, verify_signature
from app.models.challenge import Challenge, ChallengeStatus
from app.models.device import Device
from app.models.user import User
from app.services.audit import audit
from app.services.push import PushService
def build_challenge_payload(challenge: Challenge, username: str) -> dict:
return {
"challenge_id": str(challenge.id),
"user_id": str(challenge.user_id),
"username": username,
"relying_party": challenge.relying_party,
"requester_ip": challenge.requester_ip,
"location": challenge.location,
"issued_at": challenge.issued_at.isoformat(),
"expires_at": challenge.expires_at.isoformat(),
"nonce": challenge.nonce,
}
async def create_challenge(
session: AsyncSession,
settings: Settings,
push: PushService,
*,
username: str,
relying_party: str,
requester_ip: str,
location: str | None,
ttl_seconds: int | None,
oidc_state: str | None = None,
) -> Challenge | None:
user = await session.scalar(select(User).where(User.username == username))
if not user:
return None
device = await session.scalar(
select(Device)
.where(Device.user_id == user.id, Device.is_revoked.is_(False))
.order_by(Device.last_seen_at.desc().nullslast(), Device.created_at.desc())
)
if not device:
return None
issued = now_utc()
challenge = Challenge(
user_id=user.id,
device_id=device.id,
relying_party=relying_party,
requester_ip=requester_ip,
location=location,
nonce=random_token(32),
issued_at=issued,
expires_at=expires_in(ttl_seconds or settings.challenge_ttl_seconds),
oidc_state=oidc_state,
payload={},
)
session.add(challenge)
await session.flush()
challenge.payload = build_challenge_payload(challenge, user.username)
await audit(
session,
"challenge.created",
actor=user.username,
target_type="challenge",
target_id=str(challenge.id),
ip_address=requester_ip,
metadata={"device_id": str(device.id), "oidc": bool(oidc_state)},
)
await push.send_challenge(device.fcm_token, str(challenge.id))
return challenge
async def expire_if_needed(session: AsyncSession, challenge: Challenge) -> bool:
if challenge.status == ChallengeStatus.pending and challenge.expires_at <= now_utc():
challenge.status = ChallengeStatus.expired
challenge.responded_at = now_utc()
await audit(session, "challenge.expired", target_type="challenge", target_id=str(challenge.id))
return True
return False
async def approve_challenge(
session: AsyncSession,
*,
challenge: Challenge,
device_id,
payload: dict,
signature: str,
ip_address: str | None,
) -> tuple[bool, str]:
await expire_if_needed(session, challenge)
if challenge.status != ChallengeStatus.pending:
await audit(session, "challenge.replay_blocked", target_type="challenge", target_id=str(challenge.id), ip_address=ip_address)
return False, "challenge_not_pending"
if str(device_id) != str(challenge.device_id):
return False, "wrong_device"
device = await session.get(Device, device_id)
if not device or device.is_revoked:
await audit(session, "challenge.revoked_device_blocked", target_type="challenge", target_id=str(challenge.id), ip_address=ip_address)
return False, "device_revoked"
if payload != challenge.payload:
return False, "payload_mismatch"
if not verify_signature(device.public_key_pem, payload, signature):
await audit(session, "challenge.signature_invalid", target_type="challenge", target_id=str(challenge.id), ip_address=ip_address)
return False, "invalid_signature"
challenge.status = ChallengeStatus.approved
challenge.signature = signature
challenge.responded_at = now_utc()
device.last_seen_at = now_utc()
await audit(session, "challenge.approved", actor=str(device.user_id), target_type="challenge", target_id=str(challenge.id), ip_address=ip_address, metadata={"device_id": str(device.id)})
return True, "approved"
async def deny_challenge(
session: AsyncSession,
*,
challenge: Challenge,
device_id,
reason: str | None,
ip_address: str | None,
) -> tuple[bool, str]:
await expire_if_needed(session, challenge)
if challenge.status != ChallengeStatus.pending:
return False, "challenge_not_pending"
if device_id and str(device_id) != str(challenge.device_id):
return False, "wrong_device"
challenge.status = ChallengeStatus.denied
challenge.responded_at = now_utc()
await audit(session, "challenge.denied", target_type="challenge", target_id=str(challenge.id), ip_address=ip_address, metadata={"reason": reason})
return True, "denied"
+94
View File
@@ -0,0 +1,94 @@
import base64
import hashlib
import json
from io import BytesIO
import qrcode
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import Settings
from app.core.security import expires_in, random_token, now_utc
from app.models.device import Device
from app.models.enrollment import Enrollment
from app.models.user import User
from app.services.audit import audit
def token_hash(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
async def start_enrollment(
session: AsyncSession,
settings: Settings,
*,
username: str,
display_name: str | None,
email: str | None,
ip_address: str | None,
) -> tuple[Enrollment, dict, str]:
user = await session.scalar(select(User).where(User.username == username))
if not user:
user = User(username=username, display_name=display_name, email=email)
session.add(user)
await session.flush()
raw_token = random_token(32)
enrollment = Enrollment(
user_id=user.id,
token_hash=token_hash(raw_token),
expires_at=expires_in(settings.enrollment_ttl_seconds),
)
session.add(enrollment)
await session.flush()
qr_payload = {
"type": "nexamfa-enrollment",
"server_url": str(settings.public_base_url).rstrip("/"),
"enrollment_id": str(enrollment.id),
"enrollment_token": raw_token,
"username": user.username,
"expires_at": enrollment.expires_at.isoformat(),
}
qr = qrcode.make(json.dumps(qr_payload, separators=(",", ":")))
buf = BytesIO()
qr.save(buf, format="PNG")
qr_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
await audit(session, "enrollment.started", actor=username, target_type="user", target_id=str(user.id), ip_address=ip_address)
return enrollment, qr_payload, qr_b64
async def finish_enrollment(
session: AsyncSession,
*,
enrollment_token: str,
device_name: str,
public_key_pem: str,
public_key_alg: str,
fcm_token: str | None,
app_version: str | None,
attestation: dict | None,
ip_address: str | None,
) -> Device | None:
enrollment = await session.scalar(
select(Enrollment).where(Enrollment.token_hash == token_hash(enrollment_token))
)
if not enrollment or enrollment.used or enrollment.expires_at <= now_utc():
return None
device = Device(
user_id=enrollment.user_id,
name=device_name,
public_key_pem=public_key_pem,
public_key_alg=public_key_alg,
fcm_token=fcm_token,
app_version=app_version,
attestation=attestation,
last_seen_at=now_utc(),
)
enrollment.used = True
session.add(device)
await session.flush()
await audit(session, "device.enrolled", actor=str(enrollment.user_id), target_type="device", target_id=str(device.id), ip_address=ip_address)
return device
+132
View File
@@ -0,0 +1,132 @@
import hashlib
from urllib.parse import urlencode
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import Settings
from app.core.security import expires_in, now_utc, public_jwk, random_token, sign_jwt
from app.models.challenge import Challenge, ChallengeStatus
from app.models.oidc import AuthorizationCode
from app.models.user import User
OIDC_KEY_ID = "nexamfa-oidc-1"
def hash_code(code: str) -> str:
return hashlib.sha256(code.encode("utf-8")).hexdigest()
def validate_authorize_request(settings: Settings, client_id: str, redirect_uri: str, response_type: str) -> str | None:
if client_id != settings.oidc_client_id:
return "invalid_client"
if redirect_uri not in settings.oidc_redirect_uri_list:
return "invalid_redirect_uri"
if response_type != "code":
return "unsupported_response_type"
return None
async def issue_authorization_code(
session: AsyncSession,
settings: Settings,
*,
challenge: Challenge,
client_id: str,
redirect_uri: str,
scope: str,
state: str | None,
nonce: str | None,
) -> str:
code = random_token(32)
session.add(
AuthorizationCode(
code_hash=hash_code(code),
client_id=client_id,
redirect_uri=redirect_uri,
scope=scope,
state=state,
nonce=nonce,
user_id=challenge.user_id,
challenge_id=challenge.id,
expires_at=expires_in(settings.auth_code_ttl_seconds),
)
)
return code
async def consume_authorization_code(
session: AsyncSession,
*,
code: str,
client_id: str,
redirect_uri: str,
) -> AuthorizationCode | None:
auth_code = await session.scalar(select(AuthorizationCode).where(AuthorizationCode.code_hash == hash_code(code)))
if not auth_code or auth_code.used or auth_code.expires_at <= now_utc():
return None
if auth_code.client_id != client_id or auth_code.redirect_uri != redirect_uri:
return None
auth_code.used = True
return auth_code
def redirect_with_code(redirect_uri: str, code: str, state: str | None) -> str:
query = {"code": code}
if state:
query["state"] = state
return f"{redirect_uri}?{urlencode(query)}"
def redirect_with_error(redirect_uri: str, error: str, state: str | None = None) -> str:
query = {"error": error}
if state:
query["state"] = state
return f"{redirect_uri}?{urlencode(query)}"
async def build_token_response(
session: AsyncSession,
settings: Settings,
private_key,
auth_code: AuthorizationCode,
) -> dict:
user = await session.get(User, auth_code.user_id)
now = int(now_utc().timestamp())
exp = now + settings.access_token_ttl_seconds
claims = {
"iss": settings.oidc_issuer,
"sub": str(user.id),
"aud": auth_code.client_id,
"exp": exp,
"iat": now,
"auth_time": now,
"amr": ["push", "biometric"],
"name": user.display_name or user.username,
"preferred_username": user.username,
"email": user.email,
}
if auth_code.nonce:
claims["nonce"] = auth_code.nonce
id_token = sign_jwt(claims, private_key, OIDC_KEY_ID)
access_token = sign_jwt(
{"iss": settings.oidc_issuer, "sub": str(user.id), "aud": "nexamfa-api", "exp": exp, "scope": auth_code.scope},
private_key,
OIDC_KEY_ID,
)
return {
"access_token": access_token,
"id_token": id_token,
"token_type": "Bearer",
"expires_in": settings.access_token_ttl_seconds,
"scope": auth_code.scope,
}
def jwks(private_key) -> dict:
return {"keys": [public_jwk(private_key, OIDC_KEY_ID)]}
def is_approved(challenge: Challenge) -> bool:
return challenge.status == ChallengeStatus.approved
+44
View File
@@ -0,0 +1,44 @@
import json
import logging
from google.oauth2 import service_account
from google.auth.transport.requests import Request as GoogleAuthRequest
import httpx
from app.core.config import Settings
logger = logging.getLogger(__name__)
class PushService:
def __init__(self, settings: Settings):
self.settings = settings
async def send_challenge(self, fcm_token: str | None, challenge_id: str) -> None:
if not fcm_token:
logger.info("Skipping push: device has no FCM token for challenge %s", challenge_id)
return
if not self.settings.fcm_project_id or not self.settings.fcm_service_account_json:
logger.info("Skipping push: FCM credentials not configured for challenge %s", challenge_id)
return
credentials = service_account.Credentials.from_service_account_info(
json.loads(self.settings.fcm_service_account_json),
scopes=["https://www.googleapis.com/auth/firebase.messaging"],
)
credentials.refresh(GoogleAuthRequest())
payload = {
"message": {
"token": fcm_token,
"data": {"challenge_id": challenge_id},
"android": {"priority": "high"},
}
}
logger.debug("Prepared FCM payload: %s", json.dumps(payload))
async with httpx.AsyncClient(timeout=10) as client:
response = await client.post(
f"https://fcm.googleapis.com/v1/projects/{self.settings.fcm_project_id}/messages:send",
json=payload,
headers={"Authorization": f"Bearer {credentials.token}"},
)
response.raise_for_status()
+39
View File
@@ -0,0 +1,39 @@
[project]
name = "nexamfa-backend"
version = "0.1.0"
description = "Self-hosted Push MFA service with OIDC provider support"
requires-python = ">=3.12"
dependencies = [
"alembic==1.13.2",
"asyncpg==0.29.0",
"cryptography==42.0.8",
"fastapi==0.111.0",
"google-auth==2.30.0",
"httpx==0.27.0",
"prometheus-client==0.20.0",
"pydantic-settings==2.3.4",
"pyjwt[crypto]==2.8.0",
"python-jose[cryptography]==3.3.0",
"python-multipart==0.0.9",
"qrcode[pil]==7.4.2",
"redis==5.0.7",
"slowapi==0.1.9",
"sqlalchemy[asyncio]==2.0.31",
"uvicorn[standard]==0.30.1"
]
[project.optional-dependencies]
test = [
"aiosqlite==0.20.0",
"pytest==8.2.2",
"pytest-asyncio==0.23.7",
"pytest-cov==5.0.0"
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py312"
+14
View File
@@ -0,0 +1,14 @@
import os
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
os.environ["ENVIRONMENT"] = "test"
from app.main import app
from fastapi.testclient import TestClient
def test_health_endpoint():
with TestClient(app) as client:
response = client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"
+80
View File
@@ -0,0 +1,80 @@
from datetime import timedelta
from uuid import uuid4
import pytest
from app.core.security import now_utc
from app.models.challenge import Challenge, ChallengeStatus
from app.services.challenges import approve_challenge, expire_if_needed
class DummySession:
def __init__(self, device=None):
self.device = device
self.added = []
def add(self, item):
self.added.append(item)
async def get(self, model, ident):
return self.device
class DummyDevice:
def __init__(self, device_id, user_id):
self.id = device_id
self.user_id = user_id
self.is_revoked = False
self.public_key_pem = "invalid"
self.last_seen_at = None
@pytest.mark.asyncio
async def test_expired_challenge_transitions_to_expired():
challenge = Challenge(
id=uuid4(),
user_id=uuid4(),
device_id=uuid4(),
status=ChallengeStatus.pending,
relying_party="app",
requester_ip="127.0.0.1",
nonce="nonce",
payload={},
issued_at=now_utc() - timedelta(seconds=120),
expires_at=now_utc() - timedelta(seconds=1),
)
changed = await expire_if_needed(DummySession(), challenge)
assert changed
assert challenge.status == ChallengeStatus.expired
assert challenge.responded_at is not None
@pytest.mark.asyncio
async def test_replay_is_blocked_after_approval_state():
device_id = uuid4()
challenge = Challenge(
id=uuid4(),
user_id=uuid4(),
device_id=device_id,
status=ChallengeStatus.approved,
relying_party="app",
requester_ip="127.0.0.1",
nonce="nonce",
payload={"challenge_id": "x"},
issued_at=now_utc(),
expires_at=now_utc() + timedelta(seconds=60),
)
ok, reason = await approve_challenge(
DummySession(DummyDevice(device_id, challenge.user_id)),
challenge=challenge,
device_id=device_id,
payload=challenge.payload,
signature="anything",
ip_address="127.0.0.1",
)
assert not ok
assert reason == "challenge_not_pending"
+22
View File
@@ -0,0 +1,22 @@
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
from cryptography.hazmat.primitives.hashes import SHA256
from app.core.security import b64url, canonical_json, verify_signature
def test_verify_es256_signature_der_and_raw():
private_key = ec.generate_private_key(ec.SECP256R1())
public_pem = private_key.public_key().public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
).decode("utf-8")
payload = {"challenge_id": "abc", "nonce": "n", "user_id": "u"}
der_sig = private_key.sign(canonical_json(payload), ec.ECDSA(SHA256()))
r, s = decode_dss_signature(der_sig)
raw_sig = r.to_bytes(32, "big") + s.to_bytes(32, "big")
assert verify_signature(public_pem, payload, b64url(der_sig))
assert verify_signature(public_pem, payload, b64url(raw_sig))
assert not verify_signature(public_pem, payload | {"nonce": "changed"}, b64url(der_sig))
+47
View File
@@ -0,0 +1,47 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-nexamfa}
POSTGRES_USER: ${POSTGRES_USER:-nexamfa}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-nexamfa}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-nexamfa} -d ${POSTGRES_DB:-nexamfa}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
backend:
build: ./backend
env_file: .env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "8000:8000"
frontend:
build: ./frontend
depends_on:
- backend
ports:
- "8080:80"
volumes:
postgres_data:
redis_data:
+47
View File
@@ -0,0 +1,47 @@
# authentik Integration
NexaMFA acts as an OIDC provider that authentik can call as an external authentication source.
## NexaMFA Settings
Set:
```env
PUBLIC_BASE_URL=https://mfa.example.com
OIDC_ISSUER=https://mfa.example.com
OIDC_CLIENT_ID=authentik
OIDC_CLIENT_SECRET=generate-a-long-secret
OIDC_REDIRECT_URIS=https://authentik.example.com/application/o/nexamfa/callback/
```
The discovery URL is:
```text
https://mfa.example.com/.well-known/openid-configuration
```
## authentik Setup
1. In authentik, create an OAuth2/OIDC source or provider entry for NexaMFA.
2. Use the discovery URL above if your authentik flow supports discovery.
3. Set client ID to `authentik`.
4. Set client secret to `OIDC_CLIENT_SECRET`.
5. Set scopes to `openid profile email`.
6. Configure the redirect URI in NexaMFA and authentik to match exactly.
7. Ensure authentik sends the username as `login_hint` during `/oauth/authorize`.
## Flow
1. authentik redirects the browser to `/oauth/authorize`.
2. NexaMFA validates the client and redirect URI.
3. NexaMFA creates a challenge for the `login_hint` user and sends FCM push containing only `challenge_id`.
4. The browser waits on the NexaMFA approval page.
5. The Android app fetches challenge details, shows service, username, IP, timestamp, and location if present.
6. The user approves with biometric or device credential.
7. The Android app signs the canonical payload and posts approval.
8. NexaMFA issues an authorization code and redirects back to authentik.
9. authentik exchanges the code at `/oauth/token`.
## Zoraxy
Route `https://mfa.example.com` to `backend:8000` and keep HTTPS enabled. The OIDC issuer must exactly match the public HTTPS origin.
+30
View File
@@ -0,0 +1,30 @@
# Gitea Actions CI/CD
The workflow at `.gitea/workflows/ci.yml` builds and tests:
- Backend Python tests
- Backend Docker image
- Frontend TypeScript build
- Frontend Docker image
- Android debug APK
- Android release APK
- Android release AAB
## Required Secrets
Set these repository secrets in Gitea for signed Android release builds:
- `ANDROID_KEYSTORE_BASE64`
- `ANDROID_KEYSTORE_PASSWORD`
- `ANDROID_KEY_ALIAS`
- `ANDROID_KEY_PASSWORD`
- `GOOGLE_SERVICES_JSON_BASE64`
Create base64 values with:
```bash
base64 -w0 release.keystore
base64 -w0 google-services.json
```
The Gradle Google Services plugin is applied only when `android/app/google-services.json` exists. The debug APK and unit tests can build without Firebase configuration, but real push delivery requires it.
+12
View File
@@ -0,0 +1,12 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD wget -qO- http://localhost/ || exit 1
+2
View File
@@ -0,0 +1,2 @@
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
+10
View File
@@ -0,0 +1,10 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri /index.html;
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "nexamfa-admin",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc && vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"@vitejs/plugin-react": "latest",
"lucide-react": "latest",
"vite": "latest",
"react": "latest",
"react-dom": "latest"
},
"devDependencies": {
"typescript": "latest",
"@types/react": "latest",
"@types/react-dom": "latest"
}
}
+133
View File
@@ -0,0 +1,133 @@
import React, { useEffect, useMemo, useState } from "react";
import { createRoot } from "react-dom/client";
import { Activity, Ban, History, KeyRound, RefreshCw, Settings, ShieldCheck, Smartphone, Users } from "lucide-react";
import "./styles.css";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
const tabs = [
["users", Users],
["devices", Smartphone],
["challenges", Activity],
["audit", History],
["settings", Settings],
] as const;
type User = { id: string; username: string; display_name?: string; email?: string; created_at: string };
type Device = { id: string; user_id: string; name: string; platform: string; public_key_alg: string; is_revoked: boolean; last_seen_at?: string; created_at: string };
type Challenge = { id: string; user_id: string; device_id?: string; status: string; relying_party: string; requester_ip: string; location?: string; issued_at: string; expires_at: string; responded_at?: string };
type Audit = { id: string; actor?: string; action: string; target_type?: string; target_id?: string; ip_address?: string; created_at: string };
function useAdminToken() {
const [token, setToken] = useState(localStorage.getItem("nexamfa_admin_token") ?? "");
const save = (value: string) => {
localStorage.setItem("nexamfa_admin_token", value);
setToken(value);
};
return { token, save };
}
async function api<T>(path: string, token: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, ...(init?.headers ?? {}) },
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
function Login({ onSave }: { onSave: (token: string) => void }) {
const [value, setValue] = useState("");
return (
<main className="login">
<section className="loginPanel">
<ShieldCheck size={32} />
<h1>NexaMFA Admin</h1>
<input type="password" placeholder="Admin bearer token" value={value} onChange={(e) => setValue(e.target.value)} />
<button onClick={() => onSave(value)}><KeyRound size={16} /> Sign in</button>
</section>
</main>
);
}
function App() {
const { token, save } = useAdminToken();
const [tab, setTab] = useState("users");
const [users, setUsers] = useState<User[]>([]);
const [devices, setDevices] = useState<Device[]>([]);
const [challenges, setChallenges] = useState<Challenge[]>([]);
const [audit, setAudit] = useState<Audit[]>([]);
const [error, setError] = useState("");
const userById = useMemo(() => Object.fromEntries(users.map((u) => [u.id, u.username])), [users]);
async function load() {
if (!token) return;
setError("");
try {
const [u, d, c, a] = await Promise.all([
api<User[]>("/api/admin/users", token),
api<Device[]>("/api/admin/devices", token),
api<Challenge[]>("/api/admin/challenges", token),
api<Audit[]>("/api/admin/audit", token),
]);
setUsers(u); setDevices(d); setChallenges(c); setAudit(a);
} catch (e) {
setError(e instanceof Error ? e.message : "Request failed");
}
}
async function revoke(id: string) {
await api(`/api/admin/devices/${id}/revoke`, token, { method: "POST" });
await load();
}
useEffect(() => { load(); }, [token]);
if (!token) return <Login onSave={save} />;
return (
<div className="shell">
<aside>
<h1>NexaMFA</h1>
{tabs.map(([name, Icon]) => (
<button key={name} className={tab === name ? "active" : ""} onClick={() => setTab(name as string)}>
<Icon size={18} /> {name}
</button>
))}
</aside>
<main>
<header>
<div>
<h2>{tab}</h2>
<p>{users.length} users · {devices.filter((d) => !d.is_revoked).length} active devices · {challenges.length} recent challenges</p>
</div>
<button className="iconBtn" onClick={load} title="Refresh"><RefreshCw size={18} /></button>
</header>
{error && <pre className="error">{error}</pre>}
{tab === "users" && <Table headers={["Username", "Display", "Email", "Created"]} rows={users.map((u) => [u.username, u.display_name ?? "", u.email ?? "", fmt(u.created_at)])} />}
{tab === "devices" && (
<table>
<thead><tr><th>Name</th><th>User</th><th>Platform</th><th>Key</th><th>Status</th><th>Last seen</th><th /></tr></thead>
<tbody>{devices.map((d) => <tr key={d.id}>
<td>{d.name}</td><td>{userById[d.user_id] ?? d.user_id}</td><td>{d.platform}</td><td>{d.public_key_alg}</td>
<td><span className={d.is_revoked ? "bad" : "good"}>{d.is_revoked ? "revoked" : "active"}</span></td><td>{fmt(d.last_seen_at)}</td>
<td>{!d.is_revoked && <button className="danger" onClick={() => revoke(d.id)}><Ban size={16} /> Revoke</button>}</td>
</tr>)}</tbody>
</table>
)}
{tab === "challenges" && <Table headers={["User", "Service", "IP", "Status", "Issued", "Expires"]} rows={challenges.map((c) => [userById[c.user_id] ?? c.user_id, c.relying_party, c.requester_ip, c.status, fmt(c.issued_at), fmt(c.expires_at)])} />}
{tab === "audit" && <Table headers={["Action", "Actor", "Target", "IP", "Time"]} rows={audit.map((a) => [a.action, a.actor ?? "", `${a.target_type ?? ""} ${a.target_id ?? ""}`, a.ip_address ?? "", fmt(a.created_at)])} />}
{tab === "settings" && <section className="settings"><label>API base URL<input value={API_BASE || "same origin"} readOnly /></label><label>Admin token<input type="password" value={token} onChange={(e) => save(e.target.value)} /></label></section>}
</main>
</div>
);
}
function Table({ headers, rows }: { headers: string[]; rows: string[][] }) {
return <table><thead><tr>{headers.map((h) => <th key={h}>{h}</th>)}</tr></thead><tbody>{rows.map((r, i) => <tr key={i}>{r.map((c, j) => <td key={j}>{c}</td>)}</tr>)}</tbody></table>;
}
function fmt(value?: string) {
return value ? new Date(value).toLocaleString() : "";
}
createRoot(document.getElementById("root")!).render(<App />);
+45
View File
@@ -0,0 +1,45 @@
:root {
color: #18212f;
background: #eef2f5;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; }
button, input { font: inherit; }
button { cursor: pointer; border: 1px solid #c7d2da; background: #fff; color: #18212f; min-height: 36px; border-radius: 6px; display: inline-flex; align-items: center; gap: 8px; padding: 0 12px; }
button:hover { border-color: #2563eb; }
.shell { display: grid; grid-template-columns: 220px 1fr; min-height: 100vh; }
aside { background: #111827; color: #f8fafc; padding: 20px 12px; }
aside h1 { font-size: 20px; margin: 0 8px 24px; }
aside button { width: 100%; justify-content: flex-start; margin-bottom: 6px; color: #cbd5e1; background: transparent; border-color: transparent; text-transform: capitalize; }
aside button.active, aside button:hover { color: #fff; background: #243244; border-color: #3b4b61; }
main { padding: 24px; overflow: auto; }
header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
h2 { margin: 0; text-transform: capitalize; }
p { margin: 6px 0 0; color: #64748b; }
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #dbe3ea; border-radius: 8px; overflow: hidden; }
th, td { text-align: left; padding: 12px; border-bottom: 1px solid #edf2f7; font-size: 14px; white-space: nowrap; }
th { background: #f8fafc; color: #475569; font-weight: 650; }
tr:last-child td { border-bottom: 0; }
.good, .bad { display: inline-flex; align-items: center; border-radius: 999px; padding: 3px 8px; font-size: 12px; font-weight: 700; }
.good { background: #dcfce7; color: #166534; }
.bad { background: #fee2e2; color: #991b1b; }
.danger { color: #991b1b; border-color: #fecaca; }
.iconBtn { width: 40px; padding: 0; justify-content: center; }
.error { background: #fff1f2; color: #9f1239; padding: 12px; border-radius: 8px; overflow: auto; }
.settings { display: grid; gap: 16px; max-width: 640px; }
label { display: grid; gap: 6px; color: #475569; font-weight: 650; }
input { min-height: 40px; border: 1px solid #cbd5e1; border-radius: 6px; padding: 0 10px; }
.login { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
.loginPanel { width: min(420px, 100%); background: #fff; border: 1px solid #dbe3ea; border-radius: 8px; padding: 24px; display: grid; gap: 14px; }
.loginPanel h1 { margin: 0; font-size: 24px; }
@media (max-width: 800px) {
.shell { grid-template-columns: 1fr; }
aside { position: sticky; top: 0; z-index: 1; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; padding: 10px; }
aside h1 { display: none; }
aside button { justify-content: center; margin: 0; padding: 0 8px; }
main { padding: 16px; }
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": []
}