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:
@@ -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=
|
||||
@@ -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
|
||||
@@ -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
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
-keep class com.nexamfa.app.** { *; }
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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()
|
||||
@@ -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."}
|
||||
@@ -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"}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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))
|
||||
@@ -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:
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
@@ -0,0 +1,10 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 />);
|
||||
@@ -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; }
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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": []
|
||||
}
|
||||
Reference in New Issue
Block a user