From f925009977beafa11a46cf26326cd4eed261b95f Mon Sep 17 00:00:00 2001 From: nessi Date: Sun, 28 Jun 2026 09:37:51 +0200 Subject: [PATCH] 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 --- .env.example | 30 +++ .gitea/workflows/ci.yml | 81 +++++++ README.md | 116 ++++++++++ SECURITY.md | 47 ++++ android/app/build.gradle.kts | 70 ++++++ android/app/proguard-rules.pro | 1 + android/app/src/main/AndroidManifest.xml | 29 +++ .../main/java/com/nexamfa/app/MainActivity.kt | 217 ++++++++++++++++++ .../main/java/com/nexamfa/app/data/Models.kt | 63 +++++ .../main/java/com/nexamfa/app/data/NexaApi.kt | 55 +++++ .../java/com/nexamfa/app/data/SecureStore.kt | 31 +++ .../app/push/NexaFirebaseMessagingService.kt | 31 +++ .../nexamfa/app/security/DeviceKeyStore.kt | 41 ++++ android/app/src/main/res/values/strings.xml | 3 + android/app/src/main/res/values/styles.xml | 3 + .../nexamfa/app/data/ChallengePayloadTest.kt | 24 ++ android/build.gradle.kts | 5 + android/settings.gradle.kts | 16 ++ backend/Dockerfile | 22 ++ backend/app/__init__.py | 1 + backend/app/api/__init__.py | 1 + backend/app/api/admin.py | 57 +++++ backend/app/api/oidc.py | 193 ++++++++++++++++ backend/app/api/public.py | 150 ++++++++++++ backend/app/core/config.py | 49 ++++ backend/app/core/database.py | 32 +++ backend/app/core/deps.py | 25 ++ backend/app/core/security.py | 98 ++++++++ backend/app/main.py | 67 ++++++ backend/app/models/__init__.py | 8 + backend/app/models/audit.py | 20 ++ backend/app/models/challenge.py | 36 +++ backend/app/models/device.py | 27 +++ backend/app/models/enrollment.py | 18 ++ backend/app/models/oidc.py | 24 ++ backend/app/models/user.py | 20 ++ backend/app/schemas/api.py | 106 +++++++++ backend/app/services/audit.py | 25 ++ backend/app/services/challenges.py | 138 +++++++++++ backend/app/services/enrollment.py | 94 ++++++++ backend/app/services/oidc.py | 132 +++++++++++ backend/app/services/push.py | 44 ++++ backend/pyproject.toml | 39 ++++ backend/tests/test_api.py | 14 ++ backend/tests/test_challenges.py | 80 +++++++ backend/tests/test_crypto.py | 22 ++ docker-compose.yml | 47 ++++ docs/authentik.md | 47 ++++ docs/gitea-actions.md | 30 +++ frontend/Dockerfile | 12 + frontend/index.html | 2 + frontend/nginx.conf | 10 + frontend/package.json | 23 ++ frontend/src/main.tsx | 133 +++++++++++ frontend/src/styles.css | 45 ++++ frontend/src/vite-env.d.ts | 1 + frontend/tsconfig.json | 21 ++ 57 files changed, 2776 insertions(+) create mode 100644 .env.example create mode 100644 .gitea/workflows/ci.yml create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/com/nexamfa/app/MainActivity.kt create mode 100644 android/app/src/main/java/com/nexamfa/app/data/Models.kt create mode 100644 android/app/src/main/java/com/nexamfa/app/data/NexaApi.kt create mode 100644 android/app/src/main/java/com/nexamfa/app/data/SecureStore.kt create mode 100644 android/app/src/main/java/com/nexamfa/app/push/NexaFirebaseMessagingService.kt create mode 100644 android/app/src/main/java/com/nexamfa/app/security/DeviceKeyStore.kt create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/styles.xml create mode 100644 android/app/src/test/java/com/nexamfa/app/data/ChallengePayloadTest.kt create mode 100644 android/build.gradle.kts create mode 100644 android/settings.gradle.kts create mode 100644 backend/Dockerfile create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/admin.py create mode 100644 backend/app/api/oidc.py create mode 100644 backend/app/api/public.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/database.py create mode 100644 backend/app/core/deps.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/audit.py create mode 100644 backend/app/models/challenge.py create mode 100644 backend/app/models/device.py create mode 100644 backend/app/models/enrollment.py create mode 100644 backend/app/models/oidc.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/schemas/api.py create mode 100644 backend/app/services/audit.py create mode 100644 backend/app/services/challenges.py create mode 100644 backend/app/services/enrollment.py create mode 100644 backend/app/services/oidc.py create mode 100644 backend/app/services/push.py create mode 100644 backend/pyproject.toml create mode 100644 backend/tests/test_api.py create mode 100644 backend/tests/test_challenges.py create mode 100644 backend/tests/test_crypto.py create mode 100644 docker-compose.yml create mode 100644 docs/authentik.md create mode 100644 docs/gitea-actions.md create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/styles.css create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f953699 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..2eb9f5e --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..03dd159 --- /dev/null +++ b/README.md @@ -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). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..fe098e2 --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..f474fab --- /dev/null +++ b/android/app/build.gradle.kts @@ -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") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..e136fd3 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1 @@ +-keep class com.nexamfa.app.** { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4cfa3ba --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/nexamfa/app/MainActivity.kt b/android/app/src/main/java/com/nexamfa/app/MainActivity.kt new file mode 100644 index 0000000..8bef9f6 --- /dev/null +++ b/android/app/src/main/java/com/nexamfa/app/MainActivity.kt @@ -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(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) +} diff --git a/android/app/src/main/java/com/nexamfa/app/data/Models.kt b/android/app/src/main/java/com/nexamfa/app/data/Models.kt new file mode 100644 index 0000000..37fb561 --- /dev/null +++ b/android/app/src/main/java/com/nexamfa/app/data/Models.kt @@ -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"), + ) + } + } +} diff --git a/android/app/src/main/java/com/nexamfa/app/data/NexaApi.kt b/android/app/src/main/java/com/nexamfa/app/data/NexaApi.kt new file mode 100644 index 0000000..ff26345 --- /dev/null +++ b/android/app/src/main/java/com/nexamfa/app/data/NexaApi.kt @@ -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" } } + } +} diff --git a/android/app/src/main/java/com/nexamfa/app/data/SecureStore.kt b/android/app/src/main/java/com/nexamfa/app/data/SecureStore.kt new file mode 100644 index 0000000..7616103 --- /dev/null +++ b/android/app/src/main/java/com/nexamfa/app/data/SecureStore.kt @@ -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")) + } +} diff --git a/android/app/src/main/java/com/nexamfa/app/push/NexaFirebaseMessagingService.kt b/android/app/src/main/java/com/nexamfa/app/push/NexaFirebaseMessagingService.kt new file mode 100644 index 0000000..e9ac2ae --- /dev/null +++ b/android/app/src/main/java/com/nexamfa/app/push/NexaFirebaseMessagingService.kt @@ -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) + } +} diff --git a/android/app/src/main/java/com/nexamfa/app/security/DeviceKeyStore.kt b/android/app/src/main/java/com/nexamfa/app/security/DeviceKeyStore.kt new file mode 100644 index 0000000..1c1b71a --- /dev/null +++ b/android/app/src/main/java/com/nexamfa/app/security/DeviceKeyStore.kt @@ -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) + } +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..1899612 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + NexaMFA + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..0e8ecce --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,3 @@ + + + + +
+

Approve sign-in

+

A NexaMFA push request was sent to your enrolled Android device.

+

This request expires in {settings.challenge_ttl_seconds} seconds.

+
+ + + +""" + 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."} diff --git a/backend/app/api/public.py b/backend/app/api/public.py new file mode 100644 index 0000000..ba950f7 --- /dev/null +++ b/backend/app/api/public.py @@ -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"} diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..a643e4a --- /dev/null +++ b/backend/app/core/config.py @@ -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() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..0bcaeb9 --- /dev/null +++ b/backend/app/core/database.py @@ -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) diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py new file mode 100644 index 0000000..f169714 --- /dev/null +++ b/backend/app/core/deps.py @@ -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") diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..46c501d --- /dev/null +++ b/backend/app/core/security.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..7fd6ba6 --- /dev/null +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..51050ec --- /dev/null +++ b/backend/app/models/__init__.py @@ -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"] diff --git a/backend/app/models/audit.py b/backend/app/models/audit.py new file mode 100644 index 0000000..98e4fd2 --- /dev/null +++ b/backend/app/models/audit.py @@ -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) diff --git a/backend/app/models/challenge.py b/backend/app/models/challenge.py new file mode 100644 index 0000000..e50d60b --- /dev/null +++ b/backend/app/models/challenge.py @@ -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()) diff --git a/backend/app/models/device.py b/backend/app/models/device.py new file mode 100644 index 0000000..2c615e4 --- /dev/null +++ b/backend/app/models/device.py @@ -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") diff --git a/backend/app/models/enrollment.py b/backend/app/models/enrollment.py new file mode 100644 index 0000000..ba45788 --- /dev/null +++ b/backend/app/models/enrollment.py @@ -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()) diff --git a/backend/app/models/oidc.py b/backend/app/models/oidc.py new file mode 100644 index 0000000..58a274c --- /dev/null +++ b/backend/app/models/oidc.py @@ -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()) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..cd56303 --- /dev/null +++ b/backend/app/models/user.py @@ -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") diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py new file mode 100644 index 0000000..2a269c3 --- /dev/null +++ b/backend/app/schemas/api.py @@ -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} diff --git a/backend/app/services/audit.py b/backend/app/services/audit.py new file mode 100644 index 0000000..bcce9e3 --- /dev/null +++ b/backend/app/services/audit.py @@ -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, + ) + ) diff --git a/backend/app/services/challenges.py b/backend/app/services/challenges.py new file mode 100644 index 0000000..5721d3a --- /dev/null +++ b/backend/app/services/challenges.py @@ -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" diff --git a/backend/app/services/enrollment.py b/backend/app/services/enrollment.py new file mode 100644 index 0000000..9393564 --- /dev/null +++ b/backend/app/services/enrollment.py @@ -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 diff --git a/backend/app/services/oidc.py b/backend/app/services/oidc.py new file mode 100644 index 0000000..3e41305 --- /dev/null +++ b/backend/app/services/oidc.py @@ -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 diff --git a/backend/app/services/push.py b/backend/app/services/push.py new file mode 100644 index 0000000..b4c950a --- /dev/null +++ b/backend/app/services/push.py @@ -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() diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..3ed69f2 --- /dev/null +++ b/backend/pyproject.toml @@ -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" diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..c244411 --- /dev/null +++ b/backend/tests/test_api.py @@ -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" diff --git a/backend/tests/test_challenges.py b/backend/tests/test_challenges.py new file mode 100644 index 0000000..9774d1b --- /dev/null +++ b/backend/tests/test_challenges.py @@ -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" diff --git a/backend/tests/test_crypto.py b/backend/tests/test_crypto.py new file mode 100644 index 0000000..0f80dae --- /dev/null +++ b/backend/tests/test_crypto.py @@ -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)) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7b7f88a --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/authentik.md b/docs/authentik.md new file mode 100644 index 0000000..0fc0dc0 --- /dev/null +++ b/docs/authentik.md @@ -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. diff --git a/docs/gitea-actions.md b/docs/gitea-actions.md new file mode 100644 index 0000000..b868582 --- /dev/null +++ b/docs/gitea-actions.md @@ -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. diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..6b542d9 --- /dev/null +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..39688cc --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,2 @@ +
+ diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..84fa1c0 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,10 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri /index.html; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..3751157 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..f77246c --- /dev/null +++ b/frontend/src/main.tsx @@ -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(path: string, token: string, init?: RequestInit): Promise { + 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 ( +
+
+ +

NexaMFA Admin

+ setValue(e.target.value)} /> + +
+
+ ); +} + +function App() { + const { token, save } = useAdminToken(); + const [tab, setTab] = useState("users"); + const [users, setUsers] = useState([]); + const [devices, setDevices] = useState([]); + const [challenges, setChallenges] = useState([]); + const [audit, setAudit] = useState([]); + 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("/api/admin/users", token), + api("/api/admin/devices", token), + api("/api/admin/challenges", token), + api("/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 ; + + return ( +
+ +
+
+
+

{tab}

+

{users.length} users · {devices.filter((d) => !d.is_revoked).length} active devices · {challenges.length} recent challenges

+
+ +
+ {error &&
{error}
} + {tab === "users" && [u.username, u.display_name ?? "", u.email ?? "", fmt(u.created_at)])} />} + {tab === "devices" && ( +
+ + {devices.map((d) => + + + + )} +
NameUserPlatformKeyStatusLast seen
{d.name}{userById[d.user_id] ?? d.user_id}{d.platform}{d.public_key_alg}{d.is_revoked ? "revoked" : "active"}{fmt(d.last_seen_at)}{!d.is_revoked && }
+ )} + {tab === "challenges" && [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" &&
[a.action, a.actor ?? "", `${a.target_type ?? ""} ${a.target_id ?? ""}`, a.ip_address ?? "", fmt(a.created_at)])} />} + {tab === "settings" &&
} + + + ); +} + +function Table({ headers, rows }: { headers: string[]; rows: string[][] }) { + return
{headers.map((h) => )}{rows.map((r, i) => {r.map((c, j) => )})}
{h}
{c}
; +} + +function fmt(value?: string) { + return value ? new Date(value).toLocaleString() : ""; +} + +createRoot(document.getElementById("root")!).render(); diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..cae832e --- /dev/null +++ b/frontend/src/styles.css @@ -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; } +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..8c69ba7 --- /dev/null +++ b/frontend/tsconfig.json @@ -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": [] +}