-
-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy pathHttpExtensions.kt
More file actions
190 lines (169 loc) · 7.08 KB
/
Copy pathHttpExtensions.kt
File metadata and controls
190 lines (169 loc) · 7.08 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
package eu.darken.octi.server.common
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
import eu.darken.octi.server.device.DeviceCredentials
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.util.*
import java.time.Instant
import java.util.*
val IpDeviceTrackerKey = AttributeKey<IpDeviceTracker>("IpDeviceTracker")
val DeviceClientIdentityTrackerKey = AttributeKey<DeviceClientIdentityTracker>("DeviceClientIdentityTracker")
private val TAG = logTag("Auth")
fun parseDeviceId(header: String?): DeviceId? {
if (header.isNullOrBlank()) return null
return try {
UUID.fromString(header)
} catch (e: IllegalArgumentException) {
log(TAG, WARN) { "Invalid device ID" }
null
}
}
val RoutingCall.headerDeviceId: DeviceId?
get() = parseDeviceId(request.header("X-Device-ID"))
sealed interface AuthResult {
data class Success(val deviceId: DeviceId, val device: Device) : AuthResult
data class Failure(val reason: String, val tag: String, val status: HttpStatusCode) : AuthResult
}
data class DeviceMetadataPatch(
val version: String? = null,
val platform: String? = null,
val label: String? = null,
)
fun normalizeLabel(raw: String?): String? = raw?.trim()?.take(128)?.ifBlank { null }
/**
* Validates the auth headers and returns the device on success — no side effects.
* Use [touchAuthenticatedDevice] to record lastSeen/IP after the per-account rate
* limit gate has accepted the call. Splitting validate from touch keeps over-limit
* requests from updating device metadata.
*/
suspend fun authenticateDevice(
deviceIdHeader: String?,
authHeader: String?,
deviceRepo: DeviceRepo,
): AuthResult {
val deviceId = parseDeviceId(deviceIdHeader)
?: 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(
reason = "Device credentials are missing",
tag = "missing-credentials",
status = HttpStatusCode.BadRequest,
)
val device = deviceRepo.getDevice(DeviceKey(creds.accountId, deviceId))
?: return AuthResult.Failure(
reason = "Unknown device: $deviceId",
tag = "unknown-device",
status = HttpStatusCode.NotFound,
)
if (!device.isAuthorized(creds)) {
return AuthResult.Failure(
reason = "Device credentials not found or insufficient",
tag = "bad-credentials",
status = HttpStatusCode.Unauthorized,
)
}
return AuthResult.Success(deviceId, device)
}
/**
* Records lastSeen + optional metadata + IP-device association for an already-authenticated
* device. Called only after the per-account rate-limit gate accepts the request, so over-limit
* traffic doesn't churn device metadata.
*/
suspend fun touchAuthenticatedDevice(
device: Device,
deviceRepo: DeviceRepo,
clientIp: String? = null,
ipTracker: IpDeviceTracker? = null,
metadata: DeviceMetadataPatch? = null,
): Device {
deviceRepo.updateDevice(device.key) {
var updated = it.copy(lastSeen = Instant.now())
metadata?.version?.let { v -> updated = updated.copy(version = v) }
metadata?.platform?.let { p -> updated = updated.copy(platform = p) }
metadata?.label?.let { l -> updated = updated.copy(label = l) }
updated
}
if (clientIp != null && ipTracker != null) {
try {
ipTracker.record(clientIp, device.accountId, device.id)
} catch (e: Exception) {
log(TAG, WARN) { "Failed to record IP device tracking: ${e.message}" }
}
}
return deviceRepo.getDevice(device.key) ?: device
}
/**
* Parses an entity-tag for `If-Match` / `If-None-Match`.
* Accepts `*`, `"opaque"`, and bare `opaque` (legacy clients).
* Rejects weak (`W/"..."`) — If-Match requires strong comparison (RFC 7232 §3.1).
* Returns null for malformed input so the caller can respond 400.
*/
fun parseStrongEtag(raw: String): String? {
val trimmed = raw.trim()
if (trimmed.isEmpty()) return null
if (trimmed == "*") return "*"
if (trimmed.startsWith("W/", ignoreCase = true)) return null
if (trimmed.startsWith("\"") && trimmed.endsWith("\"") && trimmed.length >= 2) {
return trimmed.substring(1, trimmed.length - 1)
}
return trimmed
}
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(
deviceIdHeader = call.request.header("X-Device-ID"),
authHeader = call.request.header("Authorization"),
deviceRepo = deviceRepo,
)
val device = when (result) {
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)) {
AccountRateLimiter.Decision.Accepted -> Unit
is AccountRateLimiter.Decision.Rejected -> {
call.response.header(HttpHeaders.RetryAfter, decision.retryAfterSeconds.toString())
call.respond(HttpStatusCode.TooManyRequests, "Account rate limit exceeded")
return null
}
}
}
val trustedProxyIps = call.application.attributes.getOrNull(TrustedProxyIpsKey)
?: IpHelper.DEFAULT_TRUSTED_PROXY_IPS
// 3. Record metadata only for accepted calls.
return touchAuthenticatedDevice(
device = device,
deviceRepo = deviceRepo,
clientIp = call.request.clientIp(trustedProxyIps),
ipTracker = ipTracker,
metadata = DeviceMetadataPatch(
version = call.request.header("Octi-Device-Version"),
platform = call.request.header("Octi-Device-Platform"),
label = normalizeLabel(call.request.header("Octi-Device-Label")),
),
)
}