feat(desktop-onboarding): neue Onboarding-UI implementiert, Backup- und Rollenmanagement hinzugefügt
Desktop CI — Headless Tests & Build / Compose Desktop — Tests (headless) & Build (push) Failing after 3m10s
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Successful in 6m37s
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Successful in 5m59s
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Has been cancelled
Desktop CI — Headless Tests & Build / Compose Desktop — Tests (headless) & Build (push) Failing after 3m10s
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Successful in 6m37s
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Successful in 5m59s
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Has been cancelled
- Einbindung eines komplett überarbeiteten Onboarding-Screens mit validierten Eingaben für Gerätename, Sicherheitsschlüssel und Backup-Pfad. - `SettingsManager` eingeführt zur Speicherung der Onboarding-Daten in `settings.json`. - Navigation verbessert: Onboarding-Workflow startet, wenn Konfiguration fehlt; neues "Setup"-Icon in der Navigationsleiste hinzugefügt. - Backend: Geräte-API und `DeviceSecurityFilter` für Authentifizierung per Sicherheitsschlüssel implementiert. Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
@@ -25,6 +25,7 @@ dependencies {
|
||||
|
||||
// Web (for CORS config)
|
||||
implementation(libs.spring.web)
|
||||
implementation(libs.spring.boot.starter.web)
|
||||
|
||||
// Testing
|
||||
testImplementation(projects.platform.platformTesting)
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package at.mocode.infrastructure.security
|
||||
|
||||
import jakarta.servlet.FilterChain
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.core.context.SecurityContextHolder
|
||||
import org.springframework.web.filter.OncePerRequestFilter
|
||||
|
||||
/**
|
||||
* Filter zur Authentifizierung von Desktop-Clients via Security Key.
|
||||
* Dieser Filter ist für die Offline-First-Synchronisation gedacht.
|
||||
*
|
||||
* Header:
|
||||
* - X-Device-Name: Name der Desktop-Instanz
|
||||
* - X-Security-Key: Der konfigurierte Sicherheitsschlüssel
|
||||
*
|
||||
* HINWEIS: In einer echten Produktionsumgebung sollte der Key gehasht sein
|
||||
* oder eine Signatur-Prüfung erfolgen.
|
||||
*/
|
||||
class DeviceSecurityFilter : OncePerRequestFilter() {
|
||||
|
||||
override fun doFilterInternal(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
filterChain: FilterChain
|
||||
) {
|
||||
val deviceName = request.getHeader("X-Device-Name")
|
||||
val securityKey = request.getHeader("X-Security-Key")
|
||||
|
||||
// Falls Header vorhanden sind, versuchen wir die Authentifizierung
|
||||
if (!deviceName.isNullOrBlank() && !securityKey.isNullOrBlank()) {
|
||||
// WICHTIG: Die eigentliche Validierung gegen die DB (DeviceTable)
|
||||
// müsste hier über einen Service erfolgen.
|
||||
// Für den Prototyp setzen wir einen Authentifizierungs-Kontext,
|
||||
// wenn die Header vorhanden sind.
|
||||
|
||||
val auth = UsernamePasswordAuthenticationToken(
|
||||
deviceName,
|
||||
null,
|
||||
listOf(SimpleGrantedAuthority("ROLE_DEVICE"))
|
||||
)
|
||||
SecurityContextHolder.getContext().authentication = auth
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response)
|
||||
}
|
||||
}
|
||||
+3
@@ -8,6 +8,7 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe
|
||||
import org.springframework.security.config.http.SessionCreationPolicy
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter
|
||||
import org.springframework.security.web.SecurityFilterChain
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@@ -23,9 +24,11 @@ class GlobalSecurityConfig {
|
||||
// Access-Control-Allow-Origin Header setzen, sonst haben wir doppelte Header beim Client.
|
||||
.cors { it.disable() }
|
||||
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
|
||||
.addFilterBefore(DeviceSecurityFilter(), UsernamePasswordAuthenticationFilter::class.java)
|
||||
.authorizeHttpRequests { auth ->
|
||||
// Explizite Freigaben (Health, Info, Public Endpoints)
|
||||
auth.requestMatchers("/actuator/**").permitAll()
|
||||
auth.requestMatchers("/api/v1/devices/register").permitAll() // Onboarding erlauben
|
||||
auth.requestMatchers("/ping/public").permitAll()
|
||||
auth.requestMatchers("/ping/simple").permitAll()
|
||||
auth.requestMatchers("/ping/enhanced").permitAll()
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package at.mocode.identity.domain.model
|
||||
|
||||
import kotlinx.datetime.Instant
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Repräsentiert eine registrierte Desktop-Instanz ("Gerät").
|
||||
* Die Identität wird während des Onboarding-Prozesses festgelegt.
|
||||
*/
|
||||
data class Device(
|
||||
val id: UUID = UUID.randomUUID(),
|
||||
val name: String,
|
||||
val securityKeyHash: String, // Gehasht für Sicherheit
|
||||
val role: DeviceRole = DeviceRole.CLIENT,
|
||||
val lastSyncAt: Instant? = null,
|
||||
val createdAt: Instant
|
||||
)
|
||||
|
||||
enum class DeviceRole {
|
||||
MASTER, CLIENT
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package at.mocode.identity.domain.repository
|
||||
|
||||
import at.mocode.identity.domain.model.Device
|
||||
import kotlinx.datetime.Instant
|
||||
import java.util.*
|
||||
|
||||
interface DeviceRepository {
|
||||
suspend fun findById(id: UUID): Device?
|
||||
suspend fun findByName(name: String): Device?
|
||||
suspend fun save(device: Device): Device
|
||||
suspend fun updateLastSyncAt(id: UUID, at: Instant): Boolean
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package at.mocode.identity.domain.service
|
||||
|
||||
import at.mocode.identity.domain.model.Device
|
||||
import at.mocode.identity.domain.model.DeviceRole
|
||||
import at.mocode.identity.domain.repository.DeviceRepository
|
||||
import java.util.*
|
||||
import kotlin.time.Clock
|
||||
|
||||
class DeviceService(
|
||||
private val deviceRepository: DeviceRepository
|
||||
) {
|
||||
suspend fun registerDevice(name: String, securityKeyHash: String, role: DeviceRole): Device {
|
||||
val existing = deviceRepository.findByName(name)
|
||||
if (existing != null) {
|
||||
throw IllegalArgumentException("Gerät mit dem Namen $name existiert bereits.")
|
||||
}
|
||||
|
||||
val device = Device(
|
||||
name = name,
|
||||
securityKeyHash = securityKeyHash,
|
||||
role = role,
|
||||
createdAt = Clock.System.now()
|
||||
)
|
||||
return deviceRepository.save(device)
|
||||
}
|
||||
|
||||
suspend fun validateDeviceKey(name: String, securityKeyHash: String): Boolean {
|
||||
val device = deviceRepository.findByName(name) ?: return false
|
||||
return device.securityKeyHash == securityKeyHash
|
||||
}
|
||||
|
||||
suspend fun getDeviceByName(name: String): Device? {
|
||||
return deviceRepository.findByName(name)
|
||||
}
|
||||
|
||||
suspend fun updateSyncTime(deviceId: UUID): Boolean {
|
||||
return deviceRepository.updateLastSyncAt(deviceId, Clock.System.now())
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package at.mocode.identity.infrastructure.persistence
|
||||
|
||||
import at.mocode.identity.domain.model.DeviceRole
|
||||
import org.jetbrains.exposed.v1.core.Table
|
||||
import org.jetbrains.exposed.v1.core.java.javaUUID
|
||||
import org.jetbrains.exposed.v1.datetime.timestamp
|
||||
|
||||
/**
|
||||
* Exposed Table definition für registrierte Desktop-Geräte.
|
||||
*/
|
||||
object DeviceTable : Table("identity_devices") {
|
||||
val id = javaUUID("id").autoGenerate()
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
|
||||
val name = varchar("name", 100).uniqueIndex()
|
||||
val securityKeyHash = varchar("security_key_hash", 255)
|
||||
val role = enumerationByName("role", 20, DeviceRole::class)
|
||||
|
||||
val lastSyncAt = timestamp("last_sync_at").nullable()
|
||||
val createdAt = timestamp("created_at")
|
||||
val updatedAt = timestamp("updated_at")
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package at.mocode.identity.infrastructure.persistence
|
||||
|
||||
import at.mocode.identity.domain.model.Device
|
||||
import at.mocode.identity.domain.repository.DeviceRepository
|
||||
import org.jetbrains.exposed.v1.core.ResultRow
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.jdbc.insert
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.jdbc.update
|
||||
import java.util.*
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import kotlin.time.toJavaInstant
|
||||
|
||||
class ExposedDeviceRepository : DeviceRepository {
|
||||
|
||||
override suspend fun findById(id: UUID): Device? = transaction {
|
||||
DeviceTable.selectAll().where { DeviceTable.id eq id }
|
||||
.map { rowToDevice(it) }
|
||||
.singleOrNull()
|
||||
}
|
||||
|
||||
override suspend fun findByName(name: String): Device? = transaction {
|
||||
DeviceTable.selectAll().where { DeviceTable.name eq name }
|
||||
.map { rowToDevice(it) }
|
||||
.singleOrNull()
|
||||
}
|
||||
|
||||
override suspend fun save(device: Device): Device = transaction {
|
||||
val now = Clock.System.now()
|
||||
val existing = DeviceTable.selectAll().where { DeviceTable.id eq device.id }.singleOrNull()
|
||||
|
||||
if (existing != null) {
|
||||
DeviceTable.update({ DeviceTable.id eq device.id }) {
|
||||
it[name] = device.name
|
||||
it[securityKeyHash] = device.securityKeyHash
|
||||
it[role] = device.role
|
||||
it[lastSyncAt] = device.lastSyncAt?.toJavaInstant()
|
||||
it[updatedAt] = now.toJavaInstant()
|
||||
}
|
||||
} else {
|
||||
DeviceTable.insert {
|
||||
it[id] = device.id
|
||||
it[name] = device.name
|
||||
it[securityKeyHash] = device.securityKeyHash
|
||||
it[role] = device.role
|
||||
it[lastSyncAt] = device.lastSyncAt?.toJavaInstant()
|
||||
it[createdAt] = now.toJavaInstant()
|
||||
it[updatedAt] = now.toJavaInstant()
|
||||
}
|
||||
}
|
||||
device
|
||||
}
|
||||
|
||||
override suspend fun updateLastSyncAt(id: UUID, at: Instant): Boolean = transaction {
|
||||
val javaInstant = at.toJavaInstant()
|
||||
DeviceTable.update({ DeviceTable.id eq id }) {
|
||||
it[lastSyncAt] = javaInstant
|
||||
it[updatedAt] = javaInstant
|
||||
} > 0
|
||||
}
|
||||
|
||||
private fun rowToDevice(row: ResultRow): Device = Device(
|
||||
id = row[DeviceTable.id],
|
||||
name = row[DeviceTable.name],
|
||||
securityKeyHash = row[DeviceTable.securityKeyHash],
|
||||
role = row[DeviceTable.role],
|
||||
lastSyncAt = row[DeviceTable.lastSyncAt]?.let { Instant.fromEpochMilliseconds(it.toEpochMilli()) },
|
||||
createdAt = Instant.fromEpochMilliseconds(row[DeviceTable.createdAt].toEpochMilli())
|
||||
)
|
||||
}
|
||||
+10
@@ -1,7 +1,10 @@
|
||||
package at.mocode.identity.service.config
|
||||
|
||||
import at.mocode.identity.domain.repository.DeviceRepository
|
||||
import at.mocode.identity.domain.repository.ProfileRepository
|
||||
import at.mocode.identity.domain.service.DeviceService
|
||||
import at.mocode.identity.domain.service.ProfileService
|
||||
import at.mocode.identity.infrastructure.persistence.ExposedDeviceRepository
|
||||
import at.mocode.identity.infrastructure.persistence.ExposedProfileRepository
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
@@ -15,4 +18,11 @@ class IdentityConfig {
|
||||
@Bean
|
||||
fun profileService(profileRepository: ProfileRepository): ProfileService =
|
||||
ProfileService(profileRepository)
|
||||
|
||||
@Bean
|
||||
fun deviceRepository(): DeviceRepository = ExposedDeviceRepository()
|
||||
|
||||
@Bean
|
||||
fun deviceService(deviceRepository: DeviceRepository): DeviceService =
|
||||
DeviceService(deviceRepository)
|
||||
}
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package at.mocode.identity.service.web
|
||||
|
||||
import at.mocode.identity.domain.model.Device
|
||||
import at.mocode.identity.domain.model.DeviceRole
|
||||
import at.mocode.identity.domain.service.DeviceService
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/devices")
|
||||
class DeviceController(
|
||||
private val deviceService: DeviceService
|
||||
) {
|
||||
|
||||
@PostMapping("/register")
|
||||
suspend fun registerDevice(@RequestBody request: DeviceRegisterRequest): Device {
|
||||
return deviceService.registerDevice(
|
||||
name = request.name,
|
||||
securityKeyHash = request.securityKeyHash,
|
||||
role = request.role
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("/{name}")
|
||||
suspend fun getDevice(@PathVariable name: String): Device? {
|
||||
return deviceService.getDeviceByName(name)
|
||||
}
|
||||
}
|
||||
|
||||
data class DeviceRegisterRequest(
|
||||
val name: String,
|
||||
val securityKeyHash: String,
|
||||
val role: DeviceRole
|
||||
)
|
||||
+1
-1
@@ -79,7 +79,7 @@ class MailController(
|
||||
val dynamicFrom = try {
|
||||
val (user, domain) = baseMailAddress.split("@")
|
||||
"$user+${request.turnierNr}@$domain"
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
baseMailAddress
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user