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

Add complete NexaMFA push MFA system with:
- FastAPI backend with PostgreSQL, Redis, OIDC provider, and Prometheus metrics
- React TypeScript admin console
- Android Kotlin/Jetpack Compose app with biometric authentication
- Docker Compose deployment configuration
- Gitea CI workflow for backend, frontend, and Android builds
- Environment configuration template with security settings
- Documentation for security model, deployment
This commit is contained in:
2026-06-28 09:37:51 +02:00
commit f925009977
57 changed files with 2776 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application
android:allowBackup="false"
android:label="NexaMFA"
android:supportsRtl="true"
android:theme="@style/Theme.NexaMFA">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".push.NexaFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>
@@ -0,0 +1,217 @@
package com.nexamfa.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.google.firebase.messaging.FirebaseMessaging
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import com.nexamfa.app.data.ChallengePayload
import com.nexamfa.app.data.DeviceRecord
import com.nexamfa.app.data.EnrollmentPayload
import com.nexamfa.app.data.NexaApi
import com.nexamfa.app.data.SecureStore
import com.nexamfa.app.security.DeviceKeyStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val initialChallengeId = intent.getStringExtra("challenge_id")
setContent {
MaterialTheme {
NexaApp(activity = this, initialChallengeId = initialChallengeId)
}
}
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun NexaApp(activity: ComponentActivity, initialChallengeId: String?) {
val context = LocalContext.current
val store = remember { SecureStore(context) }
val api = remember { NexaApi() }
val keyStore = remember { DeviceKeyStore() }
var device by remember { mutableStateOf(store.getDevice()) }
var challenge by remember { mutableStateOf<ChallengePayload?>(null) }
var challengeId by remember { mutableStateOf(initialChallengeId.orEmpty()) }
var status by remember { mutableStateOf("") }
var screen by remember { mutableStateOf(if (device == null) "welcome" else "settings") }
val scanner = rememberLauncherForActivityResult(ScanContract()) { result ->
val contents = result.contents ?: return@rememberLauncherForActivityResult
val obj = JSONObject(contents)
val payload = EnrollmentPayload(
serverUrl = obj.getString("server_url").trimEnd('/'),
enrollmentToken = obj.getString("enrollment_token"),
username = obj.getString("username"),
)
status = "Finishing enrollment..."
FirebaseMessaging.getInstance().token.addOnSuccessListener { fcmToken ->
Thread {
try {
val record = api.finishEnrollment(payload, android.os.Build.MODEL, keyStore.publicKeyPem(), fcmToken)
store.saveDevice(record)
device = record
screen = "enrolled"
status = "Device enrolled"
} catch (e: Exception) {
status = e.message ?: "Enrollment failed"
}
}.start()
}
}
LaunchedEffect(challengeId, device) {
val record = device
if (challengeId.isNotBlank() && record != null) {
status = "Loading login request..."
challenge = withContext(Dispatchers.IO) { api.fetchChallenge(record.serverUrl, challengeId) }
screen = "challenge"
status = ""
}
}
Scaffold(topBar = { TopAppBar(title = { Text("NexaMFA") }) }) { padding ->
Column(Modifier.fillMaxSize().padding(padding).padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
when (screen) {
"welcome" -> WelcomeScreen(
status = status,
onEnroll = {
scanner.launch(ScanOptions().setPrompt("Scan NexaMFA enrollment QR").setBeepEnabled(false))
},
)
"enrolled" -> DeviceEnrolledScreen(device = device, onContinue = { screen = "settings" })
"challenge" -> ChallengeScreen(
payload = challenge,
status = status,
onApprove = {
val record = device ?: return@ChallengeScreen
authenticate(activity) {
Thread {
try {
val payload = challenge ?: return@Thread
val signature = keyStore.signCanonicalJson(payload.canonicalJson())
api.approve(record.serverUrl, payload.challengeId, record.deviceId, payload, signature)
status = "Approved"
screen = "history"
} catch (e: Exception) {
status = e.message ?: "Approval failed"
}
}.start()
}
},
onDeny = {
val record = device ?: return@ChallengeScreen
Thread {
api.deny(record.serverUrl, challenge?.challengeId ?: challengeId, record.deviceId)
status = "Denied"
screen = "history"
}.start()
},
)
"history" -> HistoryScreen(status = status, onSettings = { screen = "settings" })
else -> SettingsScreen(device = device, challengeId = challengeId, onChallengeId = { challengeId = it }, onOpen = { screen = "challenge" })
}
}
}
}
@Composable
fun WelcomeScreen(status: String, onEnroll: () -> Unit) {
Text("Welcome", style = MaterialTheme.typography.headlineMedium)
Text("Enroll this Android device by scanning a NexaMFA QR code.")
Button(onClick = onEnroll) { Text("Scan enrollment QR") }
if (status.isNotBlank()) Text(status)
}
@Composable
fun DeviceEnrolledScreen(device: DeviceRecord?, onContinue: () -> Unit) {
Text("Device enrolled", style = MaterialTheme.typography.headlineMedium)
Text(device?.username.orEmpty())
Button(onClick = onContinue) { Text("Continue") }
}
@Composable
fun ChallengeScreen(payload: ChallengePayload?, status: String, onApprove: () -> Unit, onDeny: () -> Unit) {
Text("Incoming login request", style = MaterialTheme.typography.headlineMedium)
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Service: ${payload?.relyingParty.orEmpty()}")
Text("Username: ${payload?.username.orEmpty()}")
Text("IP address: ${payload?.requesterIp.orEmpty()}")
Text("Timestamp: ${payload?.issuedAt.orEmpty()}")
Text("Location: ${payload?.location ?: "Not provided"}")
}
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onApprove, enabled = payload != null) { Text("Approve") }
OutlinedButton(onClick = onDeny) { Text("Deny") }
}
if (status.isNotBlank()) Text(status)
}
@Composable
fun SettingsScreen(device: DeviceRecord?, challengeId: String, onChallengeId: (String) -> Unit, onOpen: () -> Unit) {
Text("Settings", style = MaterialTheme.typography.headlineMedium)
Text("Server: ${device?.serverUrl ?: "Not enrolled"}")
Text("Device ID: ${device?.deviceId ?: ""}")
Spacer(Modifier.height(8.dp))
OutlinedTextField(value = challengeId, onValueChange = onChallengeId, label = { Text("Challenge ID") }, modifier = Modifier.fillMaxWidth())
Button(onClick = onOpen, enabled = challengeId.isNotBlank() && device != null) { Text("Open request") }
}
@Composable
fun HistoryScreen(status: String, onSettings: () -> Unit) {
Text("Audit/history", style = MaterialTheme.typography.headlineMedium)
Text(status)
Button(onClick = onSettings) { Text("Settings") }
}
fun authenticate(activity: ComponentActivity, onSuccess: () -> Unit) {
val executor = ContextCompat.getMainExecutor(activity)
val prompt = BiometricPrompt(activity, executor, object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
onSuccess()
}
})
val info = BiometricPrompt.PromptInfo.Builder()
.setTitle("Approve NexaMFA request")
.setSubtitle("Confirm with fingerprint, face unlock, or device credential")
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL)
.build()
prompt.authenticate(info)
}
@@ -0,0 +1,63 @@
package com.nexamfa.app.data
import org.json.JSONObject
data class EnrollmentPayload(
val serverUrl: String,
val enrollmentToken: String,
val username: String,
)
data class DeviceRecord(
val serverUrl: String,
val deviceId: String,
val userId: String,
val username: String,
)
data class ChallengePayload(
val challengeId: String,
val userId: String,
val username: String,
val relyingParty: String,
val requesterIp: String,
val location: String?,
val issuedAt: String,
val expiresAt: String,
val nonce: String,
) {
fun canonicalJson(): String {
val map = sortedMapOf(
"challenge_id" to challengeId,
"expires_at" to expiresAt,
"issued_at" to issuedAt,
"location" to location,
"nonce" to nonce,
"relying_party" to relyingParty,
"requester_ip" to requesterIp,
"user_id" to userId,
"username" to username,
)
return map.entries.joinToString(prefix = "{", postfix = "}") { (key, value) ->
val rendered = value?.let { JSONObject.quote(it) } ?: "null"
"${JSONObject.quote(key)}:$rendered"
}
}
companion object {
fun fromJson(json: String): ChallengePayload {
val obj = JSONObject(json)
return ChallengePayload(
challengeId = obj.getString("challenge_id"),
userId = obj.getString("user_id"),
username = obj.optString("username"),
relyingParty = obj.getString("relying_party"),
requesterIp = obj.getString("requester_ip"),
location = if (obj.isNull("location")) null else obj.optString("location").ifBlank { null },
issuedAt = obj.getString("issued_at"),
expiresAt = obj.getString("expires_at"),
nonce = obj.getString("nonce"),
)
}
}
}
@@ -0,0 +1,55 @@
package com.nexamfa.app.data
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
class NexaApi(private val client: OkHttpClient = OkHttpClient()) {
private val jsonType = "application/json".toMediaType()
fun finishEnrollment(payload: EnrollmentPayload, deviceName: String, publicKeyPem: String, fcmToken: String?): DeviceRecord {
val body = JSONObject()
.put("enrollment_token", payload.enrollmentToken)
.put("device_name", deviceName)
.put("public_key_pem", publicKeyPem)
.put("public_key_alg", "ES256")
.put("fcm_token", fcmToken)
.put("app_version", "0.1.0")
.toString()
.toRequestBody(jsonType)
val req = Request.Builder().url("${payload.serverUrl}/api/enroll/finish").post(body).build()
client.newCall(req).execute().use { response ->
require(response.isSuccessful) { response.body?.string() ?: "Enrollment failed" }
val obj = JSONObject(response.body!!.string())
return DeviceRecord(payload.serverUrl, obj.getString("device_id"), obj.getString("user_id"), payload.username)
}
}
fun fetchChallenge(serverUrl: String, challengeId: String): ChallengePayload {
val req = Request.Builder().url("$serverUrl/api/challenges/$challengeId").get().build()
client.newCall(req).execute().use { response ->
require(response.isSuccessful) { response.body?.string() ?: "Challenge fetch failed" }
val obj = JSONObject(response.body!!.string())
return ChallengePayload.fromJson(obj.getJSONObject("payload").toString())
}
}
fun approve(serverUrl: String, challengeId: String, deviceId: String, payload: ChallengePayload, signature: String) {
val body = JSONObject()
.put("device_id", deviceId)
.put("signature", signature)
.put("payload", JSONObject(payload.canonicalJson()))
.toString()
.toRequestBody(jsonType)
val req = Request.Builder().url("$serverUrl/api/challenges/$challengeId/approve").post(body).build()
client.newCall(req).execute().use { response -> require(response.isSuccessful) { response.body?.string() ?: "Approval failed" } }
}
fun deny(serverUrl: String, challengeId: String, deviceId: String?) {
val body = JSONObject().put("device_id", deviceId).put("reason", "Denied on Android").toString().toRequestBody(jsonType)
val req = Request.Builder().url("$serverUrl/api/challenges/$challengeId/deny").post(body).build()
client.newCall(req).execute().use { response -> require(response.isSuccessful) { response.body?.string() ?: "Deny failed" } }
}
}
@@ -0,0 +1,31 @@
package com.nexamfa.app.data
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import org.json.JSONObject
class SecureStore(context: Context) {
private val prefs = EncryptedSharedPreferences.create(
context,
"nexamfa_secure_store",
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
fun saveDevice(record: DeviceRecord) {
prefs.edit().putString("device", JSONObject()
.put("serverUrl", record.serverUrl)
.put("deviceId", record.deviceId)
.put("userId", record.userId)
.put("username", record.username)
.toString()).apply()
}
fun getDevice(): DeviceRecord? {
val raw = prefs.getString("device", null) ?: return null
val obj = JSONObject(raw)
return DeviceRecord(obj.getString("serverUrl"), obj.getString("deviceId"), obj.getString("userId"), obj.getString("username"))
}
}
@@ -0,0 +1,31 @@
package com.nexamfa.app.push
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.nexamfa.app.MainActivity
class NexaFirebaseMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
val challengeId = message.data["challenge_id"] ?: return
val intent = Intent(this, MainActivity::class.java).putExtra("challenge_id", challengeId)
val pendingIntent = PendingIntent.getActivity(this, 100, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val manager = getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
manager.createNotificationChannel(NotificationChannel("challenges", "Login requests", NotificationManager.IMPORTANCE_HIGH))
}
val notification = NotificationCompat.Builder(this, "challenges")
.setContentTitle("NexaMFA login request")
.setContentText("Open to review and approve")
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
manager.notify(challengeId.hashCode(), notification)
}
}
@@ -0,0 +1,41 @@
package com.nexamfa.app.security
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.Signature
import java.security.spec.ECGenParameterSpec
class DeviceKeyStore(private val alias: String = "nexamfa_device_key") {
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
fun ensureKeyPair() {
if (keyStore.containsAlias(alias)) return
val generator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore")
val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN)
.setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
.setDigests(KeyProperties.DIGEST_SHA256)
.setUserAuthenticationRequired(false)
.build()
generator.initialize(spec)
generator.generateKeyPair()
}
fun publicKeyPem(): String {
ensureKeyPair()
val cert = keyStore.getCertificate(alias)
val encoded = Base64.encodeToString(cert.publicKey.encoded, Base64.NO_WRAP)
return "-----BEGIN PUBLIC KEY-----\n${encoded.chunked(64).joinToString("\n")}\n-----END PUBLIC KEY-----\n"
}
fun signCanonicalJson(canonicalJson: String): String {
ensureKeyPair()
val privateKey = keyStore.getKey(alias, null)
val signature = Signature.getInstance("SHA256withECDSA")
signature.initSign(privateKey as java.security.PrivateKey)
signature.update(canonicalJson.toByteArray(Charsets.UTF_8))
return Base64.encodeToString(signature.sign(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
}
}
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">NexaMFA</string>
</resources>
@@ -0,0 +1,3 @@
<resources>
<style name="Theme.NexaMFA" parent="android:style/Theme.Material.Light.NoActionBar" />
</resources>
@@ -0,0 +1,24 @@
package com.nexamfa.app.data
import org.junit.Assert.assertEquals
import org.junit.Test
class ChallengePayloadTest {
@Test
fun parsesChallengePayload() {
val payload = ChallengePayload.fromJson(
"""{"challenge_id":"c1","user_id":"u1","username":"alice","relying_party":"authentik","requester_ip":"10.0.0.1","location":"Lab","issued_at":"2026-01-01T00:00:00Z","expires_at":"2026-01-01T00:01:00Z","nonce":"n"}"""
)
assertEquals("c1", payload.challengeId)
assertEquals("authentik", payload.relyingParty)
}
@Test
fun canonicalJsonIsStableAndSorted() {
val payload = ChallengePayload("c1", "u1", "alice", "authentik", "10.0.0.1", null, "i", "e", "n")
assertEquals(
"""{"challenge_id":"c1","expires_at":"e","issued_at":"i","location":null,"nonce":"n","relying_party":"authentik","requester_ip":"10.0.0.1","user_id":"u1","username":"alice"}""",
payload.canonicalJson(),
)
}
}