Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/main/kotlin/eu/darken/octi/server/AppComponent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import dagger.Component
import eu.darken.octi.server.account.AccountRepo
import eu.darken.octi.server.account.AccountStorageTracker
import eu.darken.octi.server.common.serialization.SerializationModule
import eu.darken.octi.server.device.DeviceClientIdentityTracker
import eu.darken.octi.server.device.DeviceRepo
import eu.darken.octi.server.module.ModuleLifecycleService
import eu.darken.octi.server.module.ModuleRepo
Expand All @@ -29,6 +30,7 @@ interface AppComponent {
fun lifecycleService(): ModuleLifecycleService
fun accountRepo(): AccountRepo
fun deviceRepo(): DeviceRepo
fun deviceClientIdentityTracker(): DeviceClientIdentityTracker
fun json(): Json

@Component.Builder
Expand All @@ -38,4 +40,4 @@ interface AppComponent {

fun build(): AppComponent
}
}
}
4 changes: 4 additions & 0 deletions src/main/kotlin/eu/darken/octi/server/Server.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ import eu.darken.octi.server.common.debug.logging.log
import eu.darken.octi.server.common.debug.logging.logTag
import eu.darken.octi.server.common.AccountRateLimiter
import eu.darken.octi.server.common.AccountRateLimiterKey
import eu.darken.octi.server.common.DeviceClientIdentityTrackerKey
import eu.darken.octi.server.common.IpDeviceTracker
import eu.darken.octi.server.common.IpDeviceTrackerKey
import eu.darken.octi.server.common.TrustedProxyIpsKey
import eu.darken.octi.server.common.installCallLogging
import eu.darken.octi.server.common.installRateLimit
import io.ktor.server.plugins.bodylimit.*
import eu.darken.octi.server.device.DeviceClientIdentityTracker
import eu.darken.octi.server.device.DeviceRoute
import eu.darken.octi.server.module.BlobRoute
import eu.darken.octi.server.module.ModuleRoute
Expand Down Expand Up @@ -56,6 +58,7 @@ class Server @Inject constructor(
private val serializers: SerializersModule,
private val ipDeviceTracker: IpDeviceTracker,
private val accountRateLimiter: AccountRateLimiter,
private val deviceClientIdentityTracker: DeviceClientIdentityTracker,
) {

@Suppress("ExtractKtorModule")
Expand Down Expand Up @@ -111,6 +114,7 @@ class Server @Inject constructor(
attributes.put(IpDeviceTrackerKey, ipDeviceTracker)
attributes.put(AccountRateLimiterKey, accountRateLimiter)
attributes.put(TrustedProxyIpsKey, config.trustedProxyIps)
attributes.put(DeviceClientIdentityTrackerKey, deviceClientIdentityTracker)

config.rateLimit
?.let { installRateLimit(it, ipDeviceTracker, config.trustedProxyIps) }
Expand Down
5 changes: 5 additions & 0 deletions src/main/kotlin/eu/darken/octi/server/account/AccountRoute.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ import eu.darken.octi.server.common.debug.logging.shortId
import eu.darken.octi.server.common.headerDeviceId
import eu.darken.octi.server.common.normalizeLabel
import eu.darken.octi.server.common.verifyCaller
import eu.darken.octi.server.device.DeviceClientIdentityTracker
import eu.darken.octi.server.device.DeviceLimitExceededException
import eu.darken.octi.server.device.DeviceRepo
import eu.darken.octi.server.device.deviceCredentials
import eu.darken.octi.server.module.ModuleLifecycleService
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.coroutines.NonCancellable
Expand All @@ -29,6 +31,7 @@ class AccountRoute @Inject constructor(
private val deviceRepo: DeviceRepo,
private val shareRepo: ShareRepo,
private val lifecycleService: ModuleLifecycleService,
private val deviceClientIdentityTracker: DeviceClientIdentityTracker,
) {

fun setup(rootRoute: Routing) {
Expand Down Expand Up @@ -109,6 +112,8 @@ class AccountRoute @Inject constructor(
throw e
}

deviceClientIdentityTracker.recordUserAgent(device.key, call.request.userAgent())

val response = RegisterResponse(
accountID = device.accountId,
password = device.password,
Expand Down
32 changes: 27 additions & 5 deletions src/main/kotlin/eu/darken/octi/server/common/HttpExtensions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import eu.darken.octi.server.common.debug.logging.Logging.Priority.WARN
import eu.darken.octi.server.common.debug.logging.log
import eu.darken.octi.server.common.debug.logging.logTag
import eu.darken.octi.server.device.Device
import eu.darken.octi.server.device.DeviceClientIdentityTracker
import eu.darken.octi.server.device.DeviceId
import eu.darken.octi.server.device.DeviceKey
import eu.darken.octi.server.device.DeviceRepo
Expand All @@ -17,6 +18,7 @@ import java.time.Instant
import java.util.*

val IpDeviceTrackerKey = AttributeKey<IpDeviceTracker>("IpDeviceTracker")
val DeviceClientIdentityTrackerKey = AttributeKey<DeviceClientIdentityTracker>("DeviceClientIdentityTracker")

private val TAG = logTag("Auth")

Expand All @@ -35,7 +37,7 @@ val RoutingCall.headerDeviceId: DeviceId?

sealed interface AuthResult {
data class Success(val deviceId: DeviceId, val device: Device) : AuthResult
data class Failure(val reason: String, val status: HttpStatusCode) : AuthResult
data class Failure(val reason: String, val tag: String, val status: HttpStatusCode) : AuthResult
}

data class DeviceMetadataPatch(
Expand All @@ -58,16 +60,32 @@ suspend fun authenticateDevice(
deviceRepo: DeviceRepo,
): AuthResult {
val deviceId = parseDeviceId(deviceIdHeader)
?: return AuthResult.Failure("X-Device-ID header is missing", HttpStatusCode.BadRequest)
?: return AuthResult.Failure(
reason = "X-Device-ID header is missing",
tag = "missing-device-id",
status = HttpStatusCode.BadRequest,
)

val creds = DeviceCredentials.parseFromHeader(authHeader)
?: return AuthResult.Failure("Device credentials are missing", HttpStatusCode.BadRequest)
?: return AuthResult.Failure(
reason = "Device credentials are missing",
tag = "missing-credentials",
status = HttpStatusCode.BadRequest,
)

val device = deviceRepo.getDevice(DeviceKey(creds.accountId, deviceId))
?: return AuthResult.Failure("Unknown device: $deviceId", HttpStatusCode.NotFound)
?: return AuthResult.Failure(
reason = "Unknown device: $deviceId",
tag = "unknown-device",
status = HttpStatusCode.NotFound,
)

if (!device.isAuthorized(creds)) {
return AuthResult.Failure("Device credentials not found or insufficient", HttpStatusCode.Unauthorized)
return AuthResult.Failure(
reason = "Device credentials not found or insufficient",
tag = "bad-credentials",
status = HttpStatusCode.Unauthorized,
)
}

return AuthResult.Success(deviceId, device)
Expand Down Expand Up @@ -122,6 +140,7 @@ fun parseStrongEtag(raw: String): String? {
suspend fun RoutingContext.verifyCaller(tag: String, deviceRepo: DeviceRepo): Device? {
val ipTracker = call.application.attributes.getOrNull(IpDeviceTrackerKey)
val accountRateLimiter = call.application.attributes.getOrNull(AccountRateLimiterKey)
val deviceClientIdentityTracker = call.application.attributes.getOrNull(DeviceClientIdentityTrackerKey)

// 1. Validate credentials (no side effects).
val result = authenticateDevice(
Expand All @@ -133,11 +152,14 @@ suspend fun RoutingContext.verifyCaller(tag: String, deviceRepo: DeviceRepo): De
is AuthResult.Success -> result.device
is AuthResult.Failure -> {
log(tag, WARN) { "verifyAuth(): ${result.reason}" }
deviceClientIdentityTracker?.recordAuthFailure(result.tag, call.request.userAgent())
call.respond(result.status, result.reason)
return null
}
}

deviceClientIdentityTracker?.recordUserAgent(device.key, call.request.userAgent())

// 2. Per-account rate-limit gate. Over-limit calls don't get to update lastSeen.
if (accountRateLimiter != null) {
when (val decision = accountRateLimiter.acquire(device.accountId)) {
Expand Down
Loading