Mercurial
comparison mrjunejune/auth_api.c @ 264:04fee26ecce0
add authenticated JRPG conversation platform
Add reusable auth/session storage, owned conversation recovery, guest quotas, admin workflows, URL-routed conversation UI, mobile frame support, and parallel browser acceptance.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Fri, 07 Aug 2026 07:34:12 -0700 |
| parents | |
| children |
comparison
equal
deleted
inserted
replaced
| 263:ee04e4e69fed | 264:04fee26ecce0 |
|---|---|
| 1 #include "mrjunejune/auth_api.h" | |
| 2 #include "mrjunejune/template_renderer.h" | |
| 3 | |
| 4 #include "auth/auth_crypto.h" | |
| 5 #include "auth/auth_store.h" | |
| 6 #include "seobeo/seobeo.h" | |
| 7 #include "dowa/dowa.h" | |
| 8 | |
| 9 #include <openssl/hmac.h> | |
| 10 #include <openssl/evp.h> | |
| 11 #include <openssl/rand.h> | |
| 12 #include <openssl/crypto.h> | |
| 13 | |
| 14 #include <arpa/inet.h> | |
| 15 #include <pthread.h> | |
| 16 #include <stdio.h> | |
| 17 #include <stdlib.h> | |
| 18 #include <string.h> | |
| 19 #include <strings.h> | |
| 20 #include <time.h> | |
| 21 | |
| 22 /* ------------------------------------------------------------------ */ | |
| 23 /* Constants */ | |
| 24 /* ------------------------------------------------------------------ */ | |
| 25 | |
| 26 #define BODY_MAX_BYTES 4096 | |
| 27 #define COOKIE_VALUE_MAX 512 | |
| 28 #define CSRF_SUFFIX ":csrf:v1" | |
| 29 #define RATE_TABLE_SIZE 512 /* must be power of 2 */ | |
| 30 #define RATE_PROBE_LIMIT 16 | |
| 31 #define RATE_LIMIT_MAX_FAILURES 5 | |
| 32 #define RATE_LIMIT_WINDOW_SECS (15 * 60) | |
| 33 #define SESSION_COOKIE_MAX 512 | |
| 34 #define GUEST_COOKIE_MAX (AUTH_CRYPTO_GUEST_COOKIE_SIZE + 256) | |
| 35 #define RATE_KEY_MAX 65 | |
| 36 #define BINDING_INPUT_MAX (AUTH_CRYPTO_TOKEN_DIGEST_SIZE + 16) | |
| 37 | |
| 38 /* | |
| 39 * Fixed precomputed scrypt hash of the constant string "dummy-zenbu-timing". | |
| 40 * Used only for timing-attack mitigation on nonexistent-username lookups. | |
| 41 * Never used as an account credential; salt+hash are intentionally public. | |
| 42 */ | |
| 43 #define AUTH_DUMMY_PASSWORD_HASH \ | |
| 44 "zenbu-scrypt$v=1$N=32768$r=8$p=1$" \ | |
| 45 "c4ff27cc756429b17991b80e4c2c5f77$" \ | |
| 46 "34e208b3a51796ebd3b39da1039611b1c7ffeb04090b07ef2bf4968ce8131ce3" | |
| 47 | |
| 48 /* ------------------------------------------------------------------ */ | |
| 49 /* Module state */ | |
| 50 /* ------------------------------------------------------------------ */ | |
| 51 | |
| 52 static Auth_Store *g_auth_store = NULL; | |
| 53 static uint8 g_cookie_secret[AUTH_CRYPTO_COOKIE_SECRET_MAX_BYTES]; | |
| 54 static size_t g_cookie_secret_length = 0; | |
| 55 static char g_trusted_proxy_ip[AUTH_CRYPTO_IP_MAX_BYTES]; | |
| 56 static boolean g_has_trusted_proxy = FALSE; | |
| 57 static int64 g_session_idle_ttl = AUTH_API_SESSION_IDLE_TTL_DEFAULT; | |
| 58 static int64 g_session_abs_ttl = AUTH_API_SESSION_ABS_TTL_DEFAULT; | |
| 59 static int64 g_guest_ttl = AUTH_API_GUEST_TTL_DEFAULT; | |
| 60 static boolean g_dev_insecure_cookie = FALSE; | |
| 61 static Auth_Guest_Transfer_Hook g_transfer_hook = NULL; | |
| 62 static void *g_transfer_hook_ctx = NULL; | |
| 63 static Auth_API_Guest_Quota_Cb g_guest_quota_cb = NULL; | |
| 64 #ifdef AUTH_API_TEST_HOOKS | |
| 65 static Auth_API_Test_Login_Pre_Create_Hook g_login_pre_create_hook = NULL; | |
| 66 static void *g_login_pre_create_context = NULL; | |
| 67 #endif | |
| 68 | |
| 69 /* ------------------------------------------------------------------ */ | |
| 70 /* Rate limiter (collision-safe open-addressing with LRU eviction) */ | |
| 71 /* ------------------------------------------------------------------ */ | |
| 72 | |
| 73 typedef struct { | |
| 74 char key[RATE_KEY_MAX]; /* HMAC hex binding; empty if slot unused */ | |
| 75 uint32 count; | |
| 76 int64 window_start; | |
| 77 } Auth_Rate_Entry; | |
| 78 | |
| 79 static Auth_Rate_Entry g_rate_table[RATE_TABLE_SIZE]; | |
| 80 static pthread_mutex_t g_rate_mutex = PTHREAD_MUTEX_INITIALIZER; | |
| 81 | |
| 82 /* ------------------------------------------------------------------ */ | |
| 83 /* Internal helpers */ | |
| 84 /* ------------------------------------------------------------------ */ | |
| 85 | |
| 86 static int64 auth_now(void) | |
| 87 { | |
| 88 return (int64)time(NULL); | |
| 89 } | |
| 90 | |
| 91 /* | |
| 92 * Generate a UUID v4 using RAND_bytes. | |
| 93 * buf must be at least 37 bytes. | |
| 94 */ | |
| 95 static boolean auth_uuid4(char *buf, size_t capacity) | |
| 96 { | |
| 97 if (capacity < 37) return FALSE; | |
| 98 uint8 rnd[16]; | |
| 99 if (RAND_bytes(rnd, sizeof(rnd)) != 1) return FALSE; | |
| 100 /* RFC 4122 version 4 */ | |
| 101 rnd[6] = (rnd[6] & 0x0f) | 0x40; | |
| 102 rnd[8] = (rnd[8] & 0x3f) | 0x80; | |
| 103 snprintf(buf, capacity, | |
| 104 "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-" | |
| 105 "%02x%02x%02x%02x%02x%02x", | |
| 106 rnd[0], rnd[1], rnd[2], rnd[3], | |
| 107 rnd[4], rnd[5], | |
| 108 rnd[6], rnd[7], | |
| 109 rnd[8], rnd[9], | |
| 110 rnd[10], rnd[11], rnd[12], rnd[13], rnd[14], rnd[15]); | |
| 111 OPENSSL_cleanse(rnd, sizeof(rnd)); | |
| 112 return TRUE; | |
| 113 } | |
| 114 | |
| 115 /* | |
| 116 * base64url encode src_len bytes from src into dst. | |
| 117 * dst must have capacity ceil(src_len * 4 / 3) + 1. | |
| 118 * Returns length of encoded string (without NUL). | |
| 119 * | |
| 120 * Delegates to Auth_Crypto_Base64url_Encode which uses the correct loop | |
| 121 * boundary (processes remaining bytes after full 3-byte groups). | |
| 122 */ | |
| 123 static size_t auth_base64url_encode( | |
| 124 const uint8 *src, | |
| 125 size_t src_len, | |
| 126 char *dst, | |
| 127 size_t dst_capacity) | |
| 128 { | |
| 129 return Auth_Crypto_Base64url_Encode(src, src_len, dst, dst_capacity); | |
| 130 } | |
| 131 | |
| 132 static void auth_hex_encode( | |
| 133 const uint8 *src, | |
| 134 size_t src_len, | |
| 135 char *dst, | |
| 136 size_t dst_capacity) | |
| 137 { | |
| 138 static const char kHex[] = "0123456789abcdef"; | |
| 139 size_t i = 0, o = 0; | |
| 140 while (i < src_len && o + 2 < dst_capacity) | |
| 141 { | |
| 142 dst[o++] = kHex[(src[i] >> 4) & 0xf]; | |
| 143 dst[o++] = kHex[src[i] & 0xf]; | |
| 144 i++; | |
| 145 } | |
| 146 if (o < dst_capacity) dst[o] = '\0'; | |
| 147 } | |
| 148 | |
| 149 /* | |
| 150 * Derive a CSRF token deterministically from a session binding. | |
| 151 * binding: session token_digest (user) or guest_id (guest). | |
| 152 * Output: base64url-encoded HMAC-SHA256, AUTH_CRYPTO_TOKEN_SIZE bytes. | |
| 153 */ | |
| 154 static boolean auth_derive_csrf( | |
| 155 const char *binding, | |
| 156 char *csrf_out, | |
| 157 size_t csrf_capacity) | |
| 158 { | |
| 159 if (!binding || !csrf_out || csrf_capacity < AUTH_CRYPTO_TOKEN_SIZE) | |
| 160 return FALSE; | |
| 161 | |
| 162 size_t binding_len = strlen(binding); | |
| 163 size_t suffix_len = strlen(CSRF_SUFFIX); | |
| 164 size_t input_len = binding_len + suffix_len; | |
| 165 | |
| 166 if (input_len >= BINDING_INPUT_MAX) | |
| 167 return FALSE; | |
| 168 | |
| 169 char input[BINDING_INPUT_MAX]; | |
| 170 memcpy(input, binding, binding_len); | |
| 171 memcpy(input + binding_len, CSRF_SUFFIX, suffix_len); | |
| 172 | |
| 173 uint8 digest[32]; | |
| 174 uint32 digest_len = 32; | |
| 175 if (!HMAC(EVP_sha256(), | |
| 176 g_cookie_secret, (int)g_cookie_secret_length, | |
| 177 (const uint8 *)input, input_len, | |
| 178 digest, &digest_len)) | |
| 179 { | |
| 180 OPENSSL_cleanse(input, sizeof(input)); | |
| 181 OPENSSL_cleanse(digest, sizeof(digest)); | |
| 182 return FALSE; | |
| 183 } | |
| 184 OPENSSL_cleanse(input, sizeof(input)); | |
| 185 | |
| 186 size_t encoded_length = | |
| 187 auth_base64url_encode(digest, 32, csrf_out, csrf_capacity); | |
| 188 OPENSSL_cleanse(digest, sizeof(digest)); | |
| 189 return encoded_length == AUTH_CRYPTO_TOKEN_SIZE - 1; | |
| 190 } | |
| 191 | |
| 192 /* | |
| 193 * Build the rate-limit key: HMAC(secret, peer_digest + ":" + norm_user). | |
| 194 * Output: 64-char hex string. | |
| 195 */ | |
| 196 static boolean auth_rate_key( | |
| 197 const char *peer_binding_digest, | |
| 198 const char *normalized_username, | |
| 199 char *key_out) | |
| 200 { | |
| 201 char input[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE + 1 + AUTH_STORE_USERNAME_MAX + 1]; | |
| 202 int n = snprintf(input, sizeof(input), "%s:%s", | |
| 203 peer_binding_digest, normalized_username); | |
| 204 if (n < 0 || (size_t)n >= sizeof(input)) | |
| 205 return FALSE; | |
| 206 | |
| 207 uint8 digest[32]; | |
| 208 uint32 digest_len = 32; | |
| 209 if (!HMAC(EVP_sha256(), | |
| 210 g_cookie_secret, (int)g_cookie_secret_length, | |
| 211 (const uint8 *)input, (size_t)n, | |
| 212 digest, &digest_len)) | |
| 213 { | |
| 214 OPENSSL_cleanse(input, sizeof(input)); | |
| 215 OPENSSL_cleanse(digest, sizeof(digest)); | |
| 216 return FALSE; | |
| 217 } | |
| 218 OPENSSL_cleanse(input, sizeof(input)); | |
| 219 | |
| 220 auth_hex_encode(digest, 32, key_out, RATE_KEY_MAX); | |
| 221 OPENSSL_cleanse(digest, sizeof(digest)); | |
| 222 return TRUE; | |
| 223 } | |
| 224 | |
| 225 static uint32 auth_rate_index(const char *key) | |
| 226 { | |
| 227 /* Use the first 8 hex chars of the HMAC key as a uint32 hash seed. */ | |
| 228 uint32 h = 0; | |
| 229 for (int i = 0; i < 8 && key[i] != '\0'; i++) | |
| 230 { | |
| 231 char c = key[i]; | |
| 232 uint32 nibble = (c >= '0' && c <= '9') ? (uint32)(c - '0') : | |
| 233 (c >= 'a' && c <= 'f') ? (uint32)(c - 'a' + 10) : 0; | |
| 234 h = (h << 4) | nibble; | |
| 235 } | |
| 236 return h & (RATE_TABLE_SIZE - 1); | |
| 237 } | |
| 238 | |
| 239 /* | |
| 240 * Find or insert an entry for key in the open-addressing rate table. | |
| 241 * Probes up to RATE_PROBE_LIMIT slots from the hash index. | |
| 242 * On a full probe window, evicts the oldest entry by window_start. | |
| 243 * Must be called with g_rate_mutex held. | |
| 244 * Returns a pointer to the entry on success, NULL if internal error. | |
| 245 */ | |
| 246 static Auth_Rate_Entry *auth_rate_find_or_insert(const char *key, int64 now) | |
| 247 { | |
| 248 uint32 start = auth_rate_index(key); | |
| 249 Auth_Rate_Entry *evict_candidate = NULL; | |
| 250 int64 evict_time = INT64_MAX; | |
| 251 | |
| 252 for (uint32 i = 0; i < (uint32)RATE_PROBE_LIMIT; i++) | |
| 253 { | |
| 254 uint32 idx = (start + i) & (RATE_TABLE_SIZE - 1); | |
| 255 Auth_Rate_Entry *e = &g_rate_table[idx]; | |
| 256 | |
| 257 /* Exact match */ | |
| 258 if (e->key[0] != '\0' && | |
| 259 memcmp(e->key, key, RATE_KEY_MAX) == 0) | |
| 260 return e; | |
| 261 | |
| 262 /* Empty slot — claim it */ | |
| 263 if (e->key[0] == '\0') | |
| 264 { | |
| 265 memcpy(e->key, key, RATE_KEY_MAX); | |
| 266 e->count = 0; | |
| 267 e->window_start = now; | |
| 268 return e; | |
| 269 } | |
| 270 | |
| 271 /* Expired entry — reuse it immediately */ | |
| 272 if (now - e->window_start >= RATE_LIMIT_WINDOW_SECS) | |
| 273 { | |
| 274 memcpy(e->key, key, RATE_KEY_MAX); | |
| 275 e->count = 0; | |
| 276 e->window_start = now; | |
| 277 return e; | |
| 278 } | |
| 279 | |
| 280 /* Track the oldest live entry for eviction */ | |
| 281 if (e->window_start < evict_time) | |
| 282 { | |
| 283 evict_time = e->window_start; | |
| 284 evict_candidate = e; | |
| 285 } | |
| 286 } | |
| 287 | |
| 288 /* All probe slots occupied by live, non-matching entries — evict oldest */ | |
| 289 if (evict_candidate) | |
| 290 { | |
| 291 memcpy(evict_candidate->key, key, RATE_KEY_MAX); | |
| 292 evict_candidate->count = 0; | |
| 293 evict_candidate->window_start = now; | |
| 294 return evict_candidate; | |
| 295 } | |
| 296 | |
| 297 return NULL; | |
| 298 } | |
| 299 | |
| 300 /* | |
| 301 * Returns TRUE if the caller should be rate-limited (too many failures). | |
| 302 * Caller must still call auth_rate_record_failure on a failed attempt. | |
| 303 */ | |
| 304 static boolean auth_rate_check(const char *key) | |
| 305 { | |
| 306 pthread_mutex_lock(&g_rate_mutex); | |
| 307 int64 now = auth_now(); | |
| 308 Auth_Rate_Entry *entry = auth_rate_find_or_insert(key, now); | |
| 309 boolean limited = FALSE; | |
| 310 if (entry && now - entry->window_start < RATE_LIMIT_WINDOW_SECS) | |
| 311 limited = (entry->count >= RATE_LIMIT_MAX_FAILURES); | |
| 312 pthread_mutex_unlock(&g_rate_mutex); | |
| 313 return limited; | |
| 314 } | |
| 315 | |
| 316 static void auth_rate_record_failure(const char *key) | |
| 317 { | |
| 318 pthread_mutex_lock(&g_rate_mutex); | |
| 319 int64 now = auth_now(); | |
| 320 Auth_Rate_Entry *entry = auth_rate_find_or_insert(key, now); | |
| 321 if (entry) | |
| 322 { | |
| 323 if (now - entry->window_start >= RATE_LIMIT_WINDOW_SECS) | |
| 324 { | |
| 325 /* Window expired; start fresh */ | |
| 326 entry->count = 1; | |
| 327 entry->window_start = now; | |
| 328 } | |
| 329 else | |
| 330 { | |
| 331 entry->count++; | |
| 332 } | |
| 333 } | |
| 334 pthread_mutex_unlock(&g_rate_mutex); | |
| 335 } | |
| 336 | |
| 337 static void auth_rate_reset(const char *key) | |
| 338 { | |
| 339 pthread_mutex_lock(&g_rate_mutex); | |
| 340 uint32 start = auth_rate_index(key); | |
| 341 for (uint32 i = 0; i < (uint32)RATE_PROBE_LIMIT; i++) | |
| 342 { | |
| 343 uint32 idx = (start + i) & (RATE_TABLE_SIZE - 1); | |
| 344 Auth_Rate_Entry *e = &g_rate_table[idx]; | |
| 345 if (e->key[0] != '\0' && memcmp(e->key, key, RATE_KEY_MAX) == 0) | |
| 346 { | |
| 347 memset(e, 0, sizeof(*e)); | |
| 348 break; | |
| 349 } | |
| 350 } | |
| 351 pthread_mutex_unlock(&g_rate_mutex); | |
| 352 } | |
| 353 | |
| 354 /* ------------------------------------------------------------------ */ | |
| 355 /* Request helpers */ | |
| 356 /* ------------------------------------------------------------------ */ | |
| 357 | |
| 358 static const char *auth_req_value( | |
| 359 Seobeo_Request_Entry *p_req, | |
| 360 const char *key) | |
| 361 { | |
| 362 void *p = Dowa_HashMap_Get_Ptr(p_req, (char *)key); | |
| 363 return p ? ((Seobeo_Request_Entry *)p)->value : NULL; | |
| 364 } | |
| 365 | |
| 366 static boolean auth_extract_secret_field( | |
| 367 Dowa_JSON_Entry *obj, | |
| 368 const char *key, | |
| 369 char *out_buf, | |
| 370 size_t max_len) | |
| 371 { | |
| 372 char *arena_ptr = Dowa_JSON_Get_String(obj, key); | |
| 373 if (!arena_ptr || arena_ptr[0] == '\0') | |
| 374 return FALSE; | |
| 375 | |
| 376 size_t field_len = strlen(arena_ptr); | |
| 377 if (field_len > max_len) | |
| 378 { | |
| 379 OPENSSL_cleanse(arena_ptr, field_len); | |
| 380 return FALSE; | |
| 381 } | |
| 382 | |
| 383 memcpy(out_buf, arena_ptr, field_len); | |
| 384 out_buf[field_len] = '\0'; | |
| 385 OPENSSL_cleanse(arena_ptr, field_len); | |
| 386 return TRUE; | |
| 387 } | |
| 388 | |
| 389 /* | |
| 390 * Parse a named cookie from the Cookie header. | |
| 391 * Returns TRUE and fills value_out on success. | |
| 392 */ | |
| 393 static boolean auth_parse_cookie( | |
| 394 const char *cookie_header, | |
| 395 const char *name, | |
| 396 char *value_out, | |
| 397 size_t capacity) | |
| 398 { | |
| 399 if (!cookie_header || !name || !value_out || capacity == 0) | |
| 400 return FALSE; | |
| 401 | |
| 402 size_t name_len = strlen(name); | |
| 403 const char *p = cookie_header; | |
| 404 | |
| 405 while (*p) | |
| 406 { | |
| 407 /* skip whitespace */ | |
| 408 while (*p == ' ' || *p == '\t') p++; | |
| 409 | |
| 410 /* check for name= */ | |
| 411 if (strncmp(p, name, name_len) == 0 && p[name_len] == '=') | |
| 412 { | |
| 413 p += name_len + 1; | |
| 414 const char *start = p; | |
| 415 while (*p && *p != ';') p++; | |
| 416 size_t vlen = (size_t)(p - start); | |
| 417 if (vlen >= capacity) return FALSE; | |
| 418 memcpy(value_out, start, vlen); | |
| 419 value_out[vlen] = '\0'; | |
| 420 return TRUE; | |
| 421 } | |
| 422 | |
| 423 /* skip to next ; */ | |
| 424 while (*p && *p != ';') p++; | |
| 425 if (*p == ';') p++; | |
| 426 } | |
| 427 return FALSE; | |
| 428 } | |
| 429 | |
| 430 /* | |
| 431 * Resolve effective peer IP. | |
| 432 * If Remote-Addr matches configured trusted proxy, accept X-Real-IP. | |
| 433 * Never logs raw IPs. | |
| 434 */ | |
| 435 static boolean auth_peer_ip( | |
| 436 Seobeo_Request_Entry *p_req, | |
| 437 char *ip_out, | |
| 438 size_t capacity) | |
| 439 { | |
| 440 const char *direct = auth_req_value(p_req, "Remote-Addr"); | |
| 441 if (!direct || direct[0] == '\0') | |
| 442 return FALSE; | |
| 443 | |
| 444 if (g_has_trusted_proxy && | |
| 445 strcmp(direct, g_trusted_proxy_ip) == 0) | |
| 446 { | |
| 447 const char *forwarded = auth_req_value(p_req, "X-Real-IP"); | |
| 448 if (forwarded && forwarded[0] != '\0' && strlen(forwarded) < capacity) | |
| 449 { | |
| 450 strncpy(ip_out, forwarded, capacity - 1); | |
| 451 ip_out[capacity - 1] = '\0'; | |
| 452 return TRUE; | |
| 453 } | |
| 454 } | |
| 455 | |
| 456 if (strlen(direct) >= capacity) | |
| 457 return FALSE; | |
| 458 strncpy(ip_out, direct, capacity - 1); | |
| 459 ip_out[capacity - 1] = '\0'; | |
| 460 return TRUE; | |
| 461 } | |
| 462 | |
| 463 static boolean auth_same_origin(Seobeo_Request_Entry *p_req) | |
| 464 { | |
| 465 const char *host = auth_req_value(p_req, "Host"); | |
| 466 const char *origin = auth_req_value(p_req, "Origin"); | |
| 467 if (!host || !origin) return FALSE; | |
| 468 | |
| 469 const char *host_in_origin = strstr(origin, "://"); | |
| 470 if (!host_in_origin) return FALSE; | |
| 471 host_in_origin += 3; | |
| 472 | |
| 473 const char *end = strchr(host_in_origin, '/'); | |
| 474 size_t len = end ? (size_t)(end - host_in_origin) : strlen(host_in_origin); | |
| 475 return strlen(host) == len && strncmp(host, host_in_origin, len) == 0; | |
| 476 } | |
| 477 | |
| 478 /* ------------------------------------------------------------------ */ | |
| 479 /* Response builders */ | |
| 480 /* ------------------------------------------------------------------ */ | |
| 481 | |
| 482 /* | |
| 483 * Build a Set-Cookie directive string. | |
| 484 * expires_max_age = 0 means no Max-Age (persistent); < 0 means Max-Age=0 | |
| 485 * (clear the cookie). | |
| 486 */ | |
| 487 static boolean auth_build_cookie_directive( | |
| 488 const char *name, | |
| 489 const char *value, | |
| 490 int32 max_age, | |
| 491 boolean http_only, | |
| 492 char *out, | |
| 493 size_t capacity) | |
| 494 { | |
| 495 int n; | |
| 496 if (max_age < 0) | |
| 497 { | |
| 498 n = snprintf(out, capacity, | |
| 499 "%s=; Path=/; %sSameSite=Lax; Max-Age=0%s", | |
| 500 name, | |
| 501 http_only ? "HttpOnly; " : "", | |
| 502 g_dev_insecure_cookie ? "" : "; Secure"); | |
| 503 } | |
| 504 else if (max_age == 0) | |
| 505 { | |
| 506 n = snprintf(out, capacity, | |
| 507 "%s=%s; Path=/; %sSameSite=Lax%s", | |
| 508 name, value, | |
| 509 http_only ? "HttpOnly; " : "", | |
| 510 g_dev_insecure_cookie ? "" : "; Secure"); | |
| 511 } | |
| 512 else | |
| 513 { | |
| 514 n = snprintf(out, capacity, | |
| 515 "%s=%s; Path=/; %sSameSite=Lax; Max-Age=%d%s", | |
| 516 name, value, | |
| 517 http_only ? "HttpOnly; " : "", | |
| 518 max_age, | |
| 519 g_dev_insecure_cookie ? "" : "; Secure"); | |
| 520 } | |
| 521 return n > 0 && (size_t)n < capacity; | |
| 522 } | |
| 523 | |
| 524 static Seobeo_Request_Entry *auth_json_response( | |
| 525 Dowa_Arena *p_arena, | |
| 526 const char *status, | |
| 527 const char *body) | |
| 528 { | |
| 529 Seobeo_Request_Entry *resp = NULL; | |
| 530 Dowa_HashMap_Push_Arena(resp, "status", (char *)status, p_arena); | |
| 531 Dowa_HashMap_Push_Arena( | |
| 532 resp, "content-type", "application/json; charset=utf-8", p_arena); | |
| 533 Dowa_HashMap_Push_Arena(resp, "cache-control", "no-store", p_arena); | |
| 534 Dowa_HashMap_Push_Arena(resp, "pragma", "no-cache", p_arena); | |
| 535 Dowa_HashMap_Push_Arena(resp, "x-content-type-options", "nosniff", p_arena); | |
| 536 Dowa_HashMap_Push_Arena(resp, "body", (char *)body, p_arena); | |
| 537 return resp; | |
| 538 } | |
| 539 | |
| 540 static Seobeo_Request_Entry *auth_json_response_with_cookies( | |
| 541 Dowa_Arena *p_arena, | |
| 542 const char *status, | |
| 543 const char *body, | |
| 544 const char *cookie1, /* value for "Set-Cookie"; NULL to skip */ | |
| 545 const char *cookie2) /* value for "set-cookie"; NULL to skip */ | |
| 546 { | |
| 547 Seobeo_Request_Entry *resp = auth_json_response(p_arena, status, body); | |
| 548 if (cookie1) | |
| 549 Dowa_HashMap_Push_Arena(resp, "Set-Cookie", (char *)cookie1, p_arena); | |
| 550 if (cookie2) | |
| 551 Dowa_HashMap_Push_Arena(resp, "set-cookie", (char *)cookie2, p_arena); | |
| 552 return resp; | |
| 553 } | |
| 554 | |
| 555 static Seobeo_Request_Entry *auth_error( | |
| 556 Dowa_Arena *p_arena, | |
| 557 const char *status, | |
| 558 const char *code, | |
| 559 const char *message) | |
| 560 { | |
| 561 char body[512]; | |
| 562 snprintf(body, sizeof(body), | |
| 563 "{\"error\":{\"code\":\"%s\",\"message\":\"%s\"}}", | |
| 564 code, message); | |
| 565 char *body_copy = Dowa_Arena_Allocate(p_arena, strlen(body) + 1); | |
| 566 if (body_copy) strcpy(body_copy, body); | |
| 567 return auth_json_response(p_arena, status, body_copy ? body_copy : "{}"); | |
| 568 } | |
| 569 | |
| 570 /* ------------------------------------------------------------------ */ | |
| 571 /* Principal resolution */ | |
| 572 /* ------------------------------------------------------------------ */ | |
| 573 | |
| 574 boolean Auth_API_Resolve_Principal( | |
| 575 Seobeo_Request_Entry *p_request, | |
| 576 Auth_Principal *p_principal, | |
| 577 Dowa_Arena *p_arena, | |
| 578 char *new_guest_cookie_out, | |
| 579 size_t new_guest_cookie_capacity) | |
| 580 { | |
| 581 if (!g_auth_store || !p_principal) return FALSE; | |
| 582 | |
| 583 memset(p_principal, 0, sizeof(*p_principal)); | |
| 584 if (new_guest_cookie_out && new_guest_cookie_capacity > 0) | |
| 585 new_guest_cookie_out[0] = '\0'; | |
| 586 | |
| 587 const char *cookie_header = auth_req_value(p_request, "Cookie"); | |
| 588 int64 now = auth_now(); | |
| 589 | |
| 590 /* --- Try authenticated session first --- */ | |
| 591 char session_token[COOKIE_VALUE_MAX] = {0}; | |
| 592 if (cookie_header && | |
| 593 auth_parse_cookie(cookie_header, AUTH_API_SESSION_COOKIE_NAME, | |
| 594 session_token, sizeof(session_token)) && | |
| 595 session_token[0] != '\0') | |
| 596 { | |
| 597 char token_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE]; | |
| 598 if (Auth_Crypto_Token_Digest(session_token, token_digest, | |
| 599 sizeof(token_digest)) == AUTH_CRYPTO_OK) | |
| 600 { | |
| 601 Auth_Session_Record session; | |
| 602 Auth_User_Record user; | |
| 603 Auth_Store_Result result = Auth_Store_Find_Session( | |
| 604 g_auth_store, token_digest, now, &session, &user); | |
| 605 | |
| 606 if (result == AUTH_STORE_OK) | |
| 607 { | |
| 608 Auth_Store_Touch_Session( | |
| 609 g_auth_store, token_digest, now, g_session_idle_ttl); | |
| 610 | |
| 611 p_principal->kind = AUTH_PRINCIPAL_USER; | |
| 612 strncpy(p_principal->user_id, user.id, sizeof(p_principal->user_id) - 1); | |
| 613 strncpy(p_principal->username, user.username, sizeof(p_principal->username) - 1); | |
| 614 strncpy(p_principal->role, user.role, sizeof(p_principal->role) - 1); | |
| 615 p_principal->must_change_password = user.must_change_password; | |
| 616 strncpy(p_principal->_binding, token_digest, sizeof(p_principal->_binding) - 1); | |
| 617 if (!auth_derive_csrf(token_digest, p_principal->csrf_token, | |
| 618 sizeof(p_principal->csrf_token))) | |
| 619 { | |
| 620 OPENSSL_cleanse(session_token, sizeof(session_token)); | |
| 621 OPENSSL_cleanse(token_digest, sizeof(token_digest)); | |
| 622 memset(p_principal, 0, sizeof(*p_principal)); | |
| 623 return FALSE; | |
| 624 } | |
| 625 OPENSSL_cleanse(session_token, sizeof(session_token)); | |
| 626 OPENSSL_cleanse(token_digest, sizeof(token_digest)); | |
| 627 return TRUE; | |
| 628 } | |
| 629 } | |
| 630 OPENSSL_cleanse(session_token, sizeof(session_token)); | |
| 631 } | |
| 632 | |
| 633 /* --- Try guest cookie --- */ | |
| 634 char peer_ip[AUTH_CRYPTO_IP_MAX_BYTES] = {0}; | |
| 635 boolean have_ip = auth_peer_ip(p_request, peer_ip, sizeof(peer_ip)); | |
| 636 | |
| 637 char ip_binding[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE] = {0}; | |
| 638 if (have_ip && | |
| 639 Auth_Crypto_IP_Binding_Digest( | |
| 640 g_cookie_secret, g_cookie_secret_length, | |
| 641 peer_ip, ip_binding, sizeof(ip_binding)) != AUTH_CRYPTO_OK) | |
| 642 { | |
| 643 OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); | |
| 644 return FALSE; | |
| 645 } | |
| 646 | |
| 647 char guest_cookie_val[COOKIE_VALUE_MAX] = {0}; | |
| 648 boolean guest_valid = FALSE; | |
| 649 Auth_Crypto_Guest_Cookie guest_parsed; | |
| 650 memset(&guest_parsed, 0, sizeof(guest_parsed)); | |
| 651 | |
| 652 if (cookie_header && | |
| 653 auth_parse_cookie(cookie_header, AUTH_API_GUEST_COOKIE_NAME, | |
| 654 guest_cookie_val, sizeof(guest_cookie_val)) && | |
| 655 guest_cookie_val[0] != '\0' && | |
| 656 have_ip) | |
| 657 { | |
| 658 Auth_Crypto_Result cr = Auth_Crypto_Guest_Cookie_Verify( | |
| 659 g_cookie_secret, g_cookie_secret_length, | |
| 660 guest_cookie_val, (uint64)now, | |
| 661 ip_binding, &guest_parsed); | |
| 662 guest_valid = (cr == AUTH_CRYPTO_OK); | |
| 663 } | |
| 664 | |
| 665 if (guest_valid) | |
| 666 { | |
| 667 Auth_Guest_Identity_Record identity; | |
| 668 Auth_Store_Result result = Auth_Store_Find_Guest_Identity( | |
| 669 g_auth_store, guest_parsed.guest_uuid, now, &identity); | |
| 670 | |
| 671 if (result == AUTH_STORE_OK) | |
| 672 { | |
| 673 Auth_Store_Upsert_Guest_Identity( | |
| 674 g_auth_store, guest_parsed.guest_uuid, | |
| 675 ip_binding, now + g_guest_ttl, &identity); | |
| 676 | |
| 677 p_principal->kind = AUTH_PRINCIPAL_GUEST; | |
| 678 strncpy(p_principal->guest_id, guest_parsed.guest_uuid, | |
| 679 sizeof(p_principal->guest_id) - 1); | |
| 680 strncpy(p_principal->_binding, guest_parsed.guest_uuid, | |
| 681 sizeof(p_principal->_binding) - 1); | |
| 682 if (!auth_derive_csrf(guest_parsed.guest_uuid, p_principal->csrf_token, | |
| 683 sizeof(p_principal->csrf_token))) | |
| 684 { | |
| 685 OPENSSL_cleanse(guest_cookie_val, sizeof(guest_cookie_val)); | |
| 686 OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); | |
| 687 memset(p_principal, 0, sizeof(*p_principal)); | |
| 688 return FALSE; | |
| 689 } | |
| 690 OPENSSL_cleanse(guest_cookie_val, sizeof(guest_cookie_val)); | |
| 691 OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); | |
| 692 return TRUE; | |
| 693 } | |
| 694 } | |
| 695 OPENSSL_cleanse(guest_cookie_val, sizeof(guest_cookie_val)); | |
| 696 | |
| 697 /* --- Create new guest identity --- */ | |
| 698 char guest_uuid[AUTH_CRYPTO_GUEST_UUID_SIZE]; | |
| 699 if (!auth_uuid4(guest_uuid, sizeof(guest_uuid))) | |
| 700 { | |
| 701 OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); | |
| 702 return FALSE; | |
| 703 } | |
| 704 | |
| 705 int64 guest_expires = now + g_guest_ttl; | |
| 706 Auth_Guest_Identity_Record new_identity; | |
| 707 if (Auth_Store_Upsert_Guest_Identity( | |
| 708 g_auth_store, guest_uuid, ip_binding, guest_expires, | |
| 709 &new_identity) != AUTH_STORE_OK) | |
| 710 { | |
| 711 OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); | |
| 712 return FALSE; | |
| 713 } | |
| 714 | |
| 715 p_principal->kind = AUTH_PRINCIPAL_GUEST; | |
| 716 strncpy(p_principal->guest_id, guest_uuid, | |
| 717 sizeof(p_principal->guest_id) - 1); | |
| 718 strncpy(p_principal->_binding, guest_uuid, | |
| 719 sizeof(p_principal->_binding) - 1); | |
| 720 if (!auth_derive_csrf(guest_uuid, p_principal->csrf_token, | |
| 721 sizeof(p_principal->csrf_token))) | |
| 722 { | |
| 723 OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); | |
| 724 memset(p_principal, 0, sizeof(*p_principal)); | |
| 725 return FALSE; | |
| 726 } | |
| 727 | |
| 728 /* Build new guest cookie for the response */ | |
| 729 if (new_guest_cookie_out && new_guest_cookie_capacity > 0 && have_ip) | |
| 730 { | |
| 731 char signed_cookie[AUTH_CRYPTO_GUEST_COOKIE_SIZE]; | |
| 732 if (Auth_Crypto_Guest_Cookie_Create( | |
| 733 g_cookie_secret, g_cookie_secret_length, | |
| 734 guest_uuid, (uint64)guest_expires, ip_binding, | |
| 735 signed_cookie, sizeof(signed_cookie)) == AUTH_CRYPTO_OK) | |
| 736 { | |
| 737 auth_build_cookie_directive( | |
| 738 AUTH_API_GUEST_COOKIE_NAME, signed_cookie, | |
| 739 (int32)g_guest_ttl, TRUE, | |
| 740 new_guest_cookie_out, new_guest_cookie_capacity); | |
| 741 OPENSSL_cleanse(signed_cookie, sizeof(signed_cookie)); | |
| 742 } | |
| 743 } | |
| 744 | |
| 745 OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); | |
| 746 return TRUE; | |
| 747 } | |
| 748 | |
| 749 boolean Auth_API_Resolve_Existing_Principal( | |
| 750 Seobeo_Request_Entry *p_request, | |
| 751 Auth_Principal *p_principal, | |
| 752 Dowa_Arena *p_arena, | |
| 753 boolean *p_found) | |
| 754 { | |
| 755 if (!g_auth_store || !p_principal || !p_found) return FALSE; | |
| 756 | |
| 757 memset(p_principal, 0, sizeof(*p_principal)); | |
| 758 *p_found = FALSE; | |
| 759 | |
| 760 const char *cookie_header = auth_req_value(p_request, "Cookie"); | |
| 761 int64 now = auth_now(); | |
| 762 | |
| 763 /* --- Try authenticated session first --- */ | |
| 764 char session_token[COOKIE_VALUE_MAX] = {0}; | |
| 765 if (cookie_header && | |
| 766 auth_parse_cookie(cookie_header, AUTH_API_SESSION_COOKIE_NAME, | |
| 767 session_token, sizeof(session_token)) && | |
| 768 session_token[0] != '\0') | |
| 769 { | |
| 770 char token_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE]; | |
| 771 if (Auth_Crypto_Token_Digest(session_token, token_digest, | |
| 772 sizeof(token_digest)) == AUTH_CRYPTO_OK) | |
| 773 { | |
| 774 Auth_Session_Record session; | |
| 775 Auth_User_Record user; | |
| 776 Auth_Store_Result result = Auth_Store_Find_Session( | |
| 777 g_auth_store, token_digest, now, &session, &user); | |
| 778 | |
| 779 if (result == AUTH_STORE_OK) | |
| 780 { | |
| 781 Auth_Store_Touch_Session( | |
| 782 g_auth_store, token_digest, now, g_session_idle_ttl); | |
| 783 | |
| 784 p_principal->kind = AUTH_PRINCIPAL_USER; | |
| 785 strncpy(p_principal->user_id, user.id, sizeof(p_principal->user_id) - 1); | |
| 786 strncpy(p_principal->username, user.username, sizeof(p_principal->username) - 1); | |
| 787 strncpy(p_principal->role, user.role, sizeof(p_principal->role) - 1); | |
| 788 p_principal->must_change_password = user.must_change_password; | |
| 789 strncpy(p_principal->_binding, token_digest, sizeof(p_principal->_binding) - 1); | |
| 790 if (!auth_derive_csrf(token_digest, p_principal->csrf_token, | |
| 791 sizeof(p_principal->csrf_token))) | |
| 792 { | |
| 793 OPENSSL_cleanse(session_token, sizeof(session_token)); | |
| 794 OPENSSL_cleanse(token_digest, sizeof(token_digest)); | |
| 795 memset(p_principal, 0, sizeof(*p_principal)); | |
| 796 return FALSE; | |
| 797 } | |
| 798 OPENSSL_cleanse(session_token, sizeof(session_token)); | |
| 799 OPENSSL_cleanse(token_digest, sizeof(token_digest)); | |
| 800 *p_found = TRUE; | |
| 801 return TRUE; | |
| 802 } | |
| 803 } | |
| 804 OPENSSL_cleanse(session_token, sizeof(session_token)); | |
| 805 } | |
| 806 | |
| 807 /* --- Try existing guest cookie (no new guest created) --- */ | |
| 808 char peer_ip[AUTH_CRYPTO_IP_MAX_BYTES] = {0}; | |
| 809 boolean have_ip = auth_peer_ip(p_request, peer_ip, sizeof(peer_ip)); | |
| 810 | |
| 811 char ip_binding[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE] = {0}; | |
| 812 if (have_ip && | |
| 813 Auth_Crypto_IP_Binding_Digest( | |
| 814 g_cookie_secret, g_cookie_secret_length, | |
| 815 peer_ip, ip_binding, sizeof(ip_binding)) != AUTH_CRYPTO_OK) | |
| 816 { | |
| 817 OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); | |
| 818 return FALSE; | |
| 819 } | |
| 820 | |
| 821 char guest_cookie_val[COOKIE_VALUE_MAX] = {0}; | |
| 822 Auth_Crypto_Guest_Cookie guest_parsed; | |
| 823 memset(&guest_parsed, 0, sizeof(guest_parsed)); | |
| 824 | |
| 825 if (cookie_header && | |
| 826 auth_parse_cookie(cookie_header, AUTH_API_GUEST_COOKIE_NAME, | |
| 827 guest_cookie_val, sizeof(guest_cookie_val)) && | |
| 828 guest_cookie_val[0] != '\0' && | |
| 829 have_ip) | |
| 830 { | |
| 831 Auth_Crypto_Result cr = Auth_Crypto_Guest_Cookie_Verify( | |
| 832 g_cookie_secret, g_cookie_secret_length, | |
| 833 guest_cookie_val, (uint64)now, | |
| 834 ip_binding, &guest_parsed); | |
| 835 if (cr == AUTH_CRYPTO_OK) | |
| 836 { | |
| 837 Auth_Guest_Identity_Record identity; | |
| 838 Auth_Store_Result result = Auth_Store_Find_Guest_Identity( | |
| 839 g_auth_store, guest_parsed.guest_uuid, now, &identity); | |
| 840 | |
| 841 if (result == AUTH_STORE_OK) | |
| 842 { | |
| 843 Auth_Store_Upsert_Guest_Identity( | |
| 844 g_auth_store, guest_parsed.guest_uuid, | |
| 845 ip_binding, now + g_guest_ttl, &identity); | |
| 846 | |
| 847 p_principal->kind = AUTH_PRINCIPAL_GUEST; | |
| 848 strncpy(p_principal->guest_id, guest_parsed.guest_uuid, | |
| 849 sizeof(p_principal->guest_id) - 1); | |
| 850 strncpy(p_principal->_binding, guest_parsed.guest_uuid, | |
| 851 sizeof(p_principal->_binding) - 1); | |
| 852 if (!auth_derive_csrf(guest_parsed.guest_uuid, p_principal->csrf_token, | |
| 853 sizeof(p_principal->csrf_token))) | |
| 854 { | |
| 855 OPENSSL_cleanse(guest_cookie_val, sizeof(guest_cookie_val)); | |
| 856 OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); | |
| 857 memset(p_principal, 0, sizeof(*p_principal)); | |
| 858 return FALSE; | |
| 859 } | |
| 860 OPENSSL_cleanse(guest_cookie_val, sizeof(guest_cookie_val)); | |
| 861 OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); | |
| 862 *p_found = TRUE; | |
| 863 return TRUE; | |
| 864 } | |
| 865 } | |
| 866 } | |
| 867 OPENSSL_cleanse(guest_cookie_val, sizeof(guest_cookie_val)); | |
| 868 OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); | |
| 869 | |
| 870 /* No existing identity found — caller should return 401. | |
| 871 * We do NOT create a guest row or generate a Set-Cookie directive. */ | |
| 872 *p_found = FALSE; | |
| 873 return TRUE; | |
| 874 } | |
| 875 | |
| 876 /* ------------------------------------------------------------------ */ | |
| 877 /* CSRF verification helper */ | |
| 878 /* ------------------------------------------------------------------ */ | |
| 879 | |
| 880 /* | |
| 881 * Verify a CSRF token provided by the client against the binding for | |
| 882 * the current session/guest. Comparison is by SHA-256 digest equality | |
| 883 * to avoid timing-oracle attacks on the base64url token directly. | |
| 884 */ | |
| 885 static boolean auth_verify_csrf( | |
| 886 const char *provided_token, | |
| 887 const char *binding) | |
| 888 { | |
| 889 if (!provided_token || !binding || provided_token[0] == '\0') | |
| 890 return FALSE; | |
| 891 | |
| 892 char expected[AUTH_CRYPTO_TOKEN_SIZE]; | |
| 893 if (!auth_derive_csrf(binding, expected, sizeof(expected))) | |
| 894 return FALSE; | |
| 895 | |
| 896 /* Compare SHA-256 digests of both tokens (constant-time length comparison) */ | |
| 897 char digest_provided[AUTH_CRYPTO_TOKEN_DIGEST_SIZE]; | |
| 898 char digest_expected[AUTH_CRYPTO_TOKEN_DIGEST_SIZE]; | |
| 899 | |
| 900 if (Auth_Crypto_Token_Digest(provided_token, digest_provided, | |
| 901 sizeof(digest_provided)) != AUTH_CRYPTO_OK || | |
| 902 Auth_Crypto_Token_Digest(expected, digest_expected, | |
| 903 sizeof(digest_expected)) != AUTH_CRYPTO_OK) | |
| 904 { | |
| 905 OPENSSL_cleanse(expected, sizeof(expected)); | |
| 906 OPENSSL_cleanse(digest_provided, sizeof(digest_provided)); | |
| 907 OPENSSL_cleanse(digest_expected, sizeof(digest_expected)); | |
| 908 return FALSE; | |
| 909 } | |
| 910 | |
| 911 int match = CRYPTO_memcmp(digest_provided, digest_expected, | |
| 912 sizeof(digest_provided)); | |
| 913 OPENSSL_cleanse(expected, sizeof(expected)); | |
| 914 OPENSSL_cleanse(digest_provided, sizeof(digest_provided)); | |
| 915 OPENSSL_cleanse(digest_expected, sizeof(digest_expected)); | |
| 916 return match == 0; | |
| 917 } | |
| 918 | |
| 919 /* ------------------------------------------------------------------ */ | |
| 920 /* Public: Auth_API_Verify_CSRF */ | |
| 921 /* ------------------------------------------------------------------ */ | |
| 922 | |
| 923 boolean Auth_API_Verify_CSRF( | |
| 924 Seobeo_Request_Entry *p_request, | |
| 925 const Auth_Principal *p_principal) | |
| 926 { | |
| 927 if (!p_request || !p_principal) | |
| 928 return FALSE; | |
| 929 if (!auth_same_origin(p_request)) | |
| 930 return FALSE; | |
| 931 const char *csrf = auth_req_value(p_request, "X-CSRF-Token"); | |
| 932 if (!csrf || csrf[0] == '\0') | |
| 933 return FALSE; | |
| 934 return auth_verify_csrf(csrf, p_principal->_binding); | |
| 935 } | |
| 936 | |
| 937 /* ------------------------------------------------------------------ */ | |
| 938 /* Route: GET /api/auth/session */ | |
| 939 /* ------------------------------------------------------------------ */ | |
| 940 | |
| 941 static Seobeo_Request_Entry *auth_session_handler( | |
| 942 Seobeo_Request_Entry *p_req, | |
| 943 Dowa_Arena *p_arena) | |
| 944 { | |
| 945 if (!g_auth_store) | |
| 946 return auth_error(p_arena, "503", "service_unavailable", | |
| 947 "Auth not initialised"); | |
| 948 | |
| 949 Auth_Principal principal; | |
| 950 char new_guest_cookie[GUEST_COOKIE_MAX] = {0}; | |
| 951 | |
| 952 if (!Auth_API_Resolve_Principal(p_req, &principal, p_arena, | |
| 953 new_guest_cookie, sizeof(new_guest_cookie))) | |
| 954 return auth_error(p_arena, "500", "internal_error", "Session error"); | |
| 955 | |
| 956 char body[2048]; | |
| 957 if (principal.kind == AUTH_PRINCIPAL_USER) | |
| 958 { | |
| 959 char *safe_username = | |
| 960 Dowa_JSON_Escape_String(principal.username, 0, p_arena); | |
| 961 char *safe_role = | |
| 962 Dowa_JSON_Escape_String(principal.role, 0, p_arena); | |
| 963 char *safe_csrf = | |
| 964 Dowa_JSON_Escape_String(principal.csrf_token, 0, p_arena); | |
| 965 if (!safe_username || !safe_role || !safe_csrf) | |
| 966 return auth_error(p_arena, "500", "internal_error", "Encode error"); | |
| 967 | |
| 968 snprintf(body, sizeof(body), | |
| 969 "{\"kind\":\"user\",\"username\":\"%s\",\"role\":\"%s\"," | |
| 970 "\"mustChangePassword\":%s,\"csrfToken\":\"%s\"," | |
| 971 "\"quota\":null}", | |
| 972 safe_username, safe_role, | |
| 973 principal.must_change_password ? "true" : "false", | |
| 974 safe_csrf); | |
| 975 } | |
| 976 else | |
| 977 { | |
| 978 char *safe_csrf = | |
| 979 Dowa_JSON_Escape_String(principal.csrf_token, 0, p_arena); | |
| 980 if (!safe_csrf) | |
| 981 return auth_error(p_arena, "500", "internal_error", "Encode error"); | |
| 982 | |
| 983 /* Ask conversation layer for quota JSON (null if not registered). */ | |
| 984 char quota_json[512] = "null"; | |
| 985 if (g_guest_quota_cb) | |
| 986 g_guest_quota_cb(principal.guest_id, auth_now(), quota_json, | |
| 987 sizeof(quota_json)); | |
| 988 | |
| 989 snprintf(body, sizeof(body), | |
| 990 "{\"kind\":\"guest\",\"csrfToken\":\"%s\",\"quota\":%s}", | |
| 991 safe_csrf, quota_json); | |
| 992 } | |
| 993 | |
| 994 char *body_copy = Dowa_Arena_Allocate(p_arena, strlen(body) + 1); | |
| 995 if (!body_copy) | |
| 996 return auth_error(p_arena, "500", "internal_error", "OOM"); | |
| 997 strcpy(body_copy, body); | |
| 998 | |
| 999 const char *cookie1 = (new_guest_cookie[0] != '\0') ? new_guest_cookie : NULL; | |
| 1000 return auth_json_response_with_cookies( | |
| 1001 p_arena, "200", body_copy, cookie1, NULL); | |
| 1002 } | |
| 1003 | |
| 1004 /* ------------------------------------------------------------------ */ | |
| 1005 /* Route: POST /api/auth/login */ | |
| 1006 /* ------------------------------------------------------------------ */ | |
| 1007 | |
| 1008 static Seobeo_Request_Entry *auth_login_handler( | |
| 1009 Seobeo_Request_Entry *p_req, | |
| 1010 Dowa_Arena *p_arena) | |
| 1011 { | |
| 1012 if (!g_auth_store) | |
| 1013 return auth_error(p_arena, "503", "service_unavailable", | |
| 1014 "Auth not initialised"); | |
| 1015 | |
| 1016 if (!auth_same_origin(p_req)) | |
| 1017 return auth_error(p_arena, "403", "forbidden", "Origin mismatch"); | |
| 1018 | |
| 1019 /* --- Parse body --- */ | |
| 1020 const char *body_str = auth_req_value(p_req, "Body"); | |
| 1021 if (!body_str) | |
| 1022 return auth_error(p_arena, "400", "bad_request", "Invalid body"); | |
| 1023 | |
| 1024 size_t body_len = strlen(body_str); | |
| 1025 if (body_len > BODY_MAX_BYTES) | |
| 1026 { | |
| 1027 OPENSSL_cleanse((char *)body_str, body_len); | |
| 1028 return auth_error(p_arena, "400", "bad_request", "Invalid body"); | |
| 1029 } | |
| 1030 Dowa_JSON_Value jv = | |
| 1031 Dowa_JSON_Parse(body_str, (int32)body_len, p_arena); | |
| 1032 OPENSSL_cleanse((char *)body_str, body_len); | |
| 1033 if (jv.type != DOWA_JSON_OBJECT) | |
| 1034 return auth_error(p_arena, "400", "bad_request", "Expected JSON object"); | |
| 1035 | |
| 1036 Dowa_JSON_Entry *obj = (Dowa_JSON_Entry *)jv.object_val; | |
| 1037 char *username_raw = Dowa_JSON_Get_String(obj, "username"); | |
| 1038 char *csrf_provided = Dowa_JSON_Get_String(obj, "csrfToken"); | |
| 1039 char password_buf[AUTH_CRYPTO_PASSWORD_MAX_BYTES + 1]; | |
| 1040 memset(password_buf, 0, sizeof(password_buf)); | |
| 1041 boolean have_password = auth_extract_secret_field( | |
| 1042 obj, "password", password_buf, AUTH_CRYPTO_PASSWORD_MAX_BYTES); | |
| 1043 char *password_raw = password_buf; | |
| 1044 | |
| 1045 if (!username_raw || !have_password || !csrf_provided || | |
| 1046 username_raw[0] == '\0' || | |
| 1047 csrf_provided[0] == '\0') | |
| 1048 { | |
| 1049 OPENSSL_cleanse(password_buf, sizeof(password_buf)); | |
| 1050 return auth_error(p_arena, "400", "bad_request", "Missing fields"); | |
| 1051 } | |
| 1052 | |
| 1053 /* --- Password length bounds --- */ | |
| 1054 size_t pw_len = strlen(password_raw); | |
| 1055 if (pw_len < 12 || pw_len > AUTH_CRYPTO_PASSWORD_MAX_BYTES) | |
| 1056 { | |
| 1057 OPENSSL_cleanse(password_raw, pw_len); | |
| 1058 return auth_error(p_arena, "401", "invalid_credentials", | |
| 1059 "Invalid credentials"); | |
| 1060 } | |
| 1061 | |
| 1062 /* --- Resolve existing principal for CSRF binding --- */ | |
| 1063 Auth_Principal principal; | |
| 1064 char ignored_cookie[GUEST_COOKIE_MAX] = {0}; | |
| 1065 if (!Auth_API_Resolve_Principal(p_req, &principal, p_arena, | |
| 1066 ignored_cookie, sizeof(ignored_cookie))) | |
| 1067 { | |
| 1068 OPENSSL_cleanse(password_raw, pw_len); | |
| 1069 return auth_error(p_arena, "500", "internal_error", "Session error"); | |
| 1070 } | |
| 1071 | |
| 1072 /* --- CSRF check --- */ | |
| 1073 if (!auth_verify_csrf(csrf_provided, principal._binding)) | |
| 1074 { | |
| 1075 OPENSSL_cleanse(password_raw, pw_len); | |
| 1076 return auth_error(p_arena, "403", "csrf_invalid", "CSRF token invalid"); | |
| 1077 } | |
| 1078 | |
| 1079 /* --- Peer IP and rate-limit key --- */ | |
| 1080 char peer_ip[AUTH_CRYPTO_IP_MAX_BYTES] = {0}; | |
| 1081 boolean have_ip = auth_peer_ip(p_req, peer_ip, sizeof(peer_ip)); | |
| 1082 | |
| 1083 char ip_binding[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE] = {0}; | |
| 1084 if (have_ip && | |
| 1085 Auth_Crypto_IP_Binding_Digest( | |
| 1086 g_cookie_secret, g_cookie_secret_length, | |
| 1087 peer_ip, ip_binding, sizeof(ip_binding)) != AUTH_CRYPTO_OK) | |
| 1088 { | |
| 1089 OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); | |
| 1090 OPENSSL_cleanse(password_raw, pw_len); | |
| 1091 return auth_error(p_arena, "500", "internal_error", "Binding error"); | |
| 1092 } | |
| 1093 OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); | |
| 1094 | |
| 1095 /* Normalize username */ | |
| 1096 char norm_username[AUTH_STORE_USERNAME_MAX + 1] = {0}; | |
| 1097 if (!Auth_Store_Normalize_Username( | |
| 1098 username_raw, norm_username, sizeof(norm_username))) | |
| 1099 { | |
| 1100 OPENSSL_cleanse(password_raw, pw_len); | |
| 1101 return auth_error(p_arena, "401", "invalid_credentials", | |
| 1102 "Invalid credentials"); | |
| 1103 } | |
| 1104 | |
| 1105 /* Rate limit check */ | |
| 1106 char rate_key[RATE_KEY_MAX] = {0}; | |
| 1107 boolean have_rate_key = | |
| 1108 auth_rate_key(ip_binding, norm_username, rate_key); | |
| 1109 | |
| 1110 if (have_rate_key && auth_rate_check(rate_key)) | |
| 1111 { | |
| 1112 OPENSSL_cleanse(password_raw, pw_len); | |
| 1113 OPENSSL_cleanse(norm_username, sizeof(norm_username)); | |
| 1114 return auth_error(p_arena, "429", "too_many_requests", | |
| 1115 "Too many login attempts"); | |
| 1116 } | |
| 1117 | |
| 1118 /* --- Fetch user record --- */ | |
| 1119 Auth_User_Auth_Record auth_record; | |
| 1120 memset(&auth_record, 0, sizeof(auth_record)); | |
| 1121 Auth_Store_Result find_result = | |
| 1122 Auth_Store_Find_User_By_Username( | |
| 1123 g_auth_store, norm_username, &auth_record); | |
| 1124 | |
| 1125 if (find_result != AUTH_STORE_OK) | |
| 1126 { | |
| 1127 /* | |
| 1128 * User not found; run exactly one scrypt verification against a fixed | |
| 1129 * precomputed hash to consume constant time, then return a generic error. | |
| 1130 * AUTH_DUMMY_PASSWORD_HASH is a valid zenbu-scrypt hash of a known | |
| 1131 * constant string — it is never an account credential. | |
| 1132 */ | |
| 1133 Auth_Crypto_Password_Verify(password_raw, AUTH_DUMMY_PASSWORD_HASH); | |
| 1134 OPENSSL_cleanse(password_raw, pw_len); | |
| 1135 OPENSSL_cleanse(auth_record.password_hash, | |
| 1136 sizeof(auth_record.password_hash)); | |
| 1137 if (have_rate_key) | |
| 1138 auth_rate_record_failure(rate_key); | |
| 1139 return auth_error(p_arena, "401", "invalid_credentials", | |
| 1140 "Invalid credentials"); | |
| 1141 } | |
| 1142 | |
| 1143 if (strcmp(auth_record.user.status, "active") != 0) | |
| 1144 { | |
| 1145 Auth_Crypto_Password_Verify(password_raw, auth_record.password_hash); | |
| 1146 OPENSSL_cleanse(password_raw, pw_len); | |
| 1147 OPENSSL_cleanse(auth_record.password_hash, | |
| 1148 sizeof(auth_record.password_hash)); | |
| 1149 if (have_rate_key) | |
| 1150 auth_rate_record_failure(rate_key); | |
| 1151 return auth_error(p_arena, "401", "invalid_credentials", | |
| 1152 "Invalid credentials"); | |
| 1153 } | |
| 1154 | |
| 1155 /* --- Verify password --- */ | |
| 1156 Auth_Crypto_Result verify = | |
| 1157 Auth_Crypto_Password_Verify(password_raw, auth_record.password_hash); | |
| 1158 OPENSSL_cleanse(password_raw, pw_len); | |
| 1159 | |
| 1160 if (verify != AUTH_CRYPTO_OK) | |
| 1161 { | |
| 1162 OPENSSL_cleanse(auth_record.password_hash, | |
| 1163 sizeof(auth_record.password_hash)); | |
| 1164 if (have_rate_key) | |
| 1165 auth_rate_record_failure(rate_key); | |
| 1166 return auth_error(p_arena, "401", "invalid_credentials", | |
| 1167 "Invalid credentials"); | |
| 1168 } | |
| 1169 | |
| 1170 /* Successful authentication: reset rate limit */ | |
| 1171 if (have_rate_key) | |
| 1172 auth_rate_reset(rate_key); | |
| 1173 | |
| 1174 /* --- Create new session (prevents session fixation) --- */ | |
| 1175 char new_token[AUTH_CRYPTO_TOKEN_SIZE]; | |
| 1176 if (Auth_Crypto_Token_Generate(new_token, sizeof(new_token)) != | |
| 1177 AUTH_CRYPTO_OK) | |
| 1178 { | |
| 1179 OPENSSL_cleanse(auth_record.password_hash, | |
| 1180 sizeof(auth_record.password_hash)); | |
| 1181 return auth_error(p_arena, "500", "internal_error", "Token error"); | |
| 1182 } | |
| 1183 | |
| 1184 char new_token_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE]; | |
| 1185 if (Auth_Crypto_Token_Digest(new_token, new_token_digest, | |
| 1186 sizeof(new_token_digest)) != AUTH_CRYPTO_OK) | |
| 1187 { | |
| 1188 OPENSSL_cleanse(auth_record.password_hash, | |
| 1189 sizeof(auth_record.password_hash)); | |
| 1190 OPENSSL_cleanse(new_token, sizeof(new_token)); | |
| 1191 return auth_error(p_arena, "500", "internal_error", "Digest error"); | |
| 1192 } | |
| 1193 | |
| 1194 /* CSRF for this new session */ | |
| 1195 char new_csrf[AUTH_CRYPTO_TOKEN_SIZE]; | |
| 1196 if (!auth_derive_csrf(new_token_digest, new_csrf, sizeof(new_csrf))) | |
| 1197 { | |
| 1198 OPENSSL_cleanse(auth_record.password_hash, | |
| 1199 sizeof(auth_record.password_hash)); | |
| 1200 OPENSSL_cleanse(new_token, sizeof(new_token)); | |
| 1201 OPENSSL_cleanse(new_token_digest, sizeof(new_token_digest)); | |
| 1202 return auth_error(p_arena, "500", "internal_error", "CSRF error"); | |
| 1203 } | |
| 1204 | |
| 1205 char csrf_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE]; | |
| 1206 if (Auth_Crypto_Token_Digest(new_csrf, csrf_digest, | |
| 1207 sizeof(csrf_digest)) != AUTH_CRYPTO_OK) | |
| 1208 { | |
| 1209 OPENSSL_cleanse(auth_record.password_hash, | |
| 1210 sizeof(auth_record.password_hash)); | |
| 1211 OPENSSL_cleanse(new_token, sizeof(new_token)); | |
| 1212 OPENSSL_cleanse(new_token_digest, sizeof(new_token_digest)); | |
| 1213 OPENSSL_cleanse(new_csrf, sizeof(new_csrf)); | |
| 1214 return auth_error(p_arena, "500", "internal_error", "CSRF digest error"); | |
| 1215 } | |
| 1216 | |
| 1217 int64 now = auth_now(); | |
| 1218 Auth_Session_Record session; | |
| 1219 #ifdef AUTH_API_TEST_HOOKS | |
| 1220 if (g_login_pre_create_hook) | |
| 1221 g_login_pre_create_hook(g_login_pre_create_context); | |
| 1222 #endif | |
| 1223 Auth_Store_Result create_result = Auth_Store_Create_Session_CAS( | |
| 1224 g_auth_store, | |
| 1225 auth_record.user.id, | |
| 1226 auth_record.password_hash, | |
| 1227 new_token_digest, | |
| 1228 csrf_digest, | |
| 1229 g_session_idle_ttl, | |
| 1230 g_session_abs_ttl, | |
| 1231 now, | |
| 1232 &session); | |
| 1233 | |
| 1234 OPENSSL_cleanse(auth_record.password_hash, | |
| 1235 sizeof(auth_record.password_hash)); | |
| 1236 OPENSSL_cleanse(csrf_digest, sizeof(csrf_digest)); | |
| 1237 OPENSSL_cleanse(new_token_digest, sizeof(new_token_digest)); | |
| 1238 | |
| 1239 if (create_result != AUTH_STORE_OK) | |
| 1240 { | |
| 1241 OPENSSL_cleanse(new_token, sizeof(new_token)); | |
| 1242 OPENSSL_cleanse(new_csrf, sizeof(new_csrf)); | |
| 1243 if (create_result == AUTH_STORE_STALE_PASSWORD) | |
| 1244 return auth_error(p_arena, "401", "invalid_credentials", | |
| 1245 "Invalid credentials"); | |
| 1246 return auth_error(p_arena, "500", "internal_error", "Session create failed"); | |
| 1247 } | |
| 1248 | |
| 1249 /* --- Call guest transfer hook (before clearing guest state) --- */ | |
| 1250 if (g_transfer_hook && | |
| 1251 principal.kind == AUTH_PRINCIPAL_GUEST && | |
| 1252 principal.guest_id[0] != '\0') | |
| 1253 { | |
| 1254 boolean transferred = g_transfer_hook( | |
| 1255 principal.guest_id, auth_record.user.id, g_transfer_hook_ctx); | |
| 1256 if (!transferred) | |
| 1257 { | |
| 1258 /* Transfer failed — revoke the new session and return 500. | |
| 1259 * Guest cookie/data are preserved (no clear_guest in response). */ | |
| 1260 char rev_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE]; | |
| 1261 if (Auth_Crypto_Token_Digest(new_token, rev_digest, | |
| 1262 sizeof(rev_digest)) == AUTH_CRYPTO_OK) | |
| 1263 { | |
| 1264 Auth_Store_Result revoke_result = | |
| 1265 Auth_Store_Revoke_Session(g_auth_store, rev_digest); | |
| 1266 if (revoke_result == AUTH_STORE_ERROR) | |
| 1267 Seobeo_Log(SEOBEO_ERROR, | |
| 1268 "[AUTH] Failed to revoke session after transfer failure\n"); | |
| 1269 OPENSSL_cleanse(rev_digest, sizeof(rev_digest)); | |
| 1270 } | |
| 1271 OPENSSL_cleanse(new_token, sizeof(new_token)); | |
| 1272 OPENSSL_cleanse(new_csrf, sizeof(new_csrf)); | |
| 1273 return auth_error(p_arena, "500", "transfer_failed", | |
| 1274 "Resource transfer failed"); | |
| 1275 } | |
| 1276 } | |
| 1277 | |
| 1278 /* --- Build response --- */ | |
| 1279 char *safe_username = | |
| 1280 Dowa_JSON_Escape_String(auth_record.user.username, 0, p_arena); | |
| 1281 char *safe_role = | |
| 1282 Dowa_JSON_Escape_String(auth_record.user.role, 0, p_arena); | |
| 1283 char *safe_csrf = | |
| 1284 Dowa_JSON_Escape_String(new_csrf, 0, p_arena); | |
| 1285 OPENSSL_cleanse(new_csrf, sizeof(new_csrf)); | |
| 1286 | |
| 1287 if (!safe_username || !safe_role || !safe_csrf) | |
| 1288 { | |
| 1289 OPENSSL_cleanse(new_token, sizeof(new_token)); | |
| 1290 return auth_error(p_arena, "500", "internal_error", "Encode error"); | |
| 1291 } | |
| 1292 | |
| 1293 char body_buf[512]; | |
| 1294 snprintf(body_buf, sizeof(body_buf), | |
| 1295 "{\"kind\":\"user\",\"username\":\"%s\",\"role\":\"%s\"," | |
| 1296 "\"mustChangePassword\":%s,\"csrfToken\":\"%s\"," | |
| 1297 "\"quota\":null}", | |
| 1298 safe_username, safe_role, | |
| 1299 auth_record.user.must_change_password ? "true" : "false", | |
| 1300 safe_csrf); | |
| 1301 | |
| 1302 char *body_copy = Dowa_Arena_Allocate(p_arena, strlen(body_buf) + 1); | |
| 1303 if (!body_copy) | |
| 1304 { | |
| 1305 OPENSSL_cleanse(new_token, sizeof(new_token)); | |
| 1306 return auth_error(p_arena, "500", "internal_error", "OOM"); | |
| 1307 } | |
| 1308 strcpy(body_copy, body_buf); | |
| 1309 | |
| 1310 /* Session cookie */ | |
| 1311 char session_cookie[SESSION_COOKIE_MAX]; | |
| 1312 auth_build_cookie_directive( | |
| 1313 AUTH_API_SESSION_COOKIE_NAME, new_token, 0, | |
| 1314 TRUE, session_cookie, sizeof(session_cookie)); | |
| 1315 OPENSSL_cleanse(new_token, sizeof(new_token)); | |
| 1316 | |
| 1317 char *session_cookie_copy = | |
| 1318 Dowa_Arena_Allocate(p_arena, strlen(session_cookie) + 1); | |
| 1319 if (session_cookie_copy) strcpy(session_cookie_copy, session_cookie); | |
| 1320 OPENSSL_cleanse(session_cookie, sizeof(session_cookie)); | |
| 1321 | |
| 1322 /* Clear guest cookie */ | |
| 1323 char clear_guest[256]; | |
| 1324 auth_build_cookie_directive( | |
| 1325 AUTH_API_GUEST_COOKIE_NAME, "", -1, | |
| 1326 TRUE, clear_guest, sizeof(clear_guest)); | |
| 1327 | |
| 1328 char *clear_guest_copy = | |
| 1329 Dowa_Arena_Allocate(p_arena, strlen(clear_guest) + 1); | |
| 1330 if (clear_guest_copy) strcpy(clear_guest_copy, clear_guest); | |
| 1331 | |
| 1332 return auth_json_response_with_cookies( | |
| 1333 p_arena, "200", body_copy, | |
| 1334 session_cookie_copy, clear_guest_copy); | |
| 1335 } | |
| 1336 | |
| 1337 /* ------------------------------------------------------------------ */ | |
| 1338 /* Route: POST /api/auth/logout */ | |
| 1339 /* ------------------------------------------------------------------ */ | |
| 1340 | |
| 1341 static Seobeo_Request_Entry *auth_logout_handler( | |
| 1342 Seobeo_Request_Entry *p_req, | |
| 1343 Dowa_Arena *p_arena) | |
| 1344 { | |
| 1345 if (!g_auth_store) | |
| 1346 return auth_error(p_arena, "503", "service_unavailable", | |
| 1347 "Auth not initialised"); | |
| 1348 | |
| 1349 if (!auth_same_origin(p_req)) | |
| 1350 return auth_error(p_arena, "403", "forbidden", "Origin mismatch"); | |
| 1351 | |
| 1352 /* --- Resolve current principal for CSRF binding --- */ | |
| 1353 Auth_Principal principal; | |
| 1354 char ignored_cookie[GUEST_COOKIE_MAX] = {0}; | |
| 1355 if (!Auth_API_Resolve_Principal(p_req, &principal, p_arena, | |
| 1356 ignored_cookie, sizeof(ignored_cookie))) | |
| 1357 return auth_error(p_arena, "500", "internal_error", "Session error"); | |
| 1358 | |
| 1359 /* --- CSRF check --- */ | |
| 1360 const char *body_str = auth_req_value(p_req, "Body"); | |
| 1361 char csrf_provided[AUTH_CRYPTO_TOKEN_SIZE] = {0}; | |
| 1362 if (body_str && strlen(body_str) <= BODY_MAX_BYTES) | |
| 1363 { | |
| 1364 Dowa_JSON_Value jv = | |
| 1365 Dowa_JSON_Parse(body_str, (int32)strlen(body_str), p_arena); | |
| 1366 if (jv.type == DOWA_JSON_OBJECT) | |
| 1367 { | |
| 1368 char *t = Dowa_JSON_Get_String((Dowa_JSON_Entry *)jv.object_val, | |
| 1369 "csrfToken"); | |
| 1370 if (t) | |
| 1371 strncpy(csrf_provided, t, | |
| 1372 sizeof(csrf_provided) - 1); | |
| 1373 } | |
| 1374 } | |
| 1375 | |
| 1376 /* Also accept CSRF from X-CSRF-Token header */ | |
| 1377 if (csrf_provided[0] == '\0') | |
| 1378 { | |
| 1379 const char *hdr = auth_req_value(p_req, "X-CSRF-Token"); | |
| 1380 if (hdr) | |
| 1381 strncpy(csrf_provided, hdr, sizeof(csrf_provided) - 1); | |
| 1382 } | |
| 1383 | |
| 1384 if (!auth_verify_csrf(csrf_provided, principal._binding)) | |
| 1385 return auth_error(p_arena, "403", "csrf_invalid", "CSRF token invalid"); | |
| 1386 | |
| 1387 /* --- Revoke session if authenticated --- */ | |
| 1388 const char *cookie_hdr = auth_req_value(p_req, "Cookie"); | |
| 1389 char session_token[COOKIE_VALUE_MAX] = {0}; | |
| 1390 if (cookie_hdr && | |
| 1391 auth_parse_cookie(cookie_hdr, AUTH_API_SESSION_COOKIE_NAME, | |
| 1392 session_token, sizeof(session_token)) && | |
| 1393 session_token[0] != '\0') | |
| 1394 { | |
| 1395 char token_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE] = {0}; | |
| 1396 Auth_Crypto_Result digest_result = | |
| 1397 Auth_Crypto_Token_Digest( | |
| 1398 session_token, token_digest, sizeof(token_digest)); | |
| 1399 if (digest_result == AUTH_CRYPTO_OK) | |
| 1400 { | |
| 1401 Auth_Store_Result revoke_result = | |
| 1402 Auth_Store_Revoke_Session(g_auth_store, token_digest); | |
| 1403 OPENSSL_cleanse(token_digest, sizeof(token_digest)); | |
| 1404 if (revoke_result == AUTH_STORE_ERROR) | |
| 1405 { | |
| 1406 OPENSSL_cleanse(session_token, sizeof(session_token)); | |
| 1407 return auth_error(p_arena, "500", "internal_error", "Logout failed"); | |
| 1408 } | |
| 1409 } | |
| 1410 else | |
| 1411 { | |
| 1412 OPENSSL_cleanse(token_digest, sizeof(token_digest)); | |
| 1413 OPENSSL_cleanse(session_token, sizeof(session_token)); | |
| 1414 return auth_error(p_arena, "500", "internal_error", "Logout failed"); | |
| 1415 } | |
| 1416 OPENSSL_cleanse(session_token, sizeof(session_token)); | |
| 1417 } | |
| 1418 | |
| 1419 /* --- Create fresh guest identity --- */ | |
| 1420 char peer_ip[AUTH_CRYPTO_IP_MAX_BYTES] = {0}; | |
| 1421 boolean have_ip = auth_peer_ip(p_req, peer_ip, sizeof(peer_ip)); | |
| 1422 | |
| 1423 char ip_binding[AUTH_CRYPTO_IP_BINDING_DIGEST_SIZE] = {0}; | |
| 1424 if (have_ip && | |
| 1425 Auth_Crypto_IP_Binding_Digest( | |
| 1426 g_cookie_secret, g_cookie_secret_length, | |
| 1427 peer_ip, ip_binding, sizeof(ip_binding)) != AUTH_CRYPTO_OK) | |
| 1428 { | |
| 1429 OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); | |
| 1430 return auth_error(p_arena, "500", "internal_error", "Binding error"); | |
| 1431 } | |
| 1432 OPENSSL_cleanse(peer_ip, sizeof(peer_ip)); | |
| 1433 | |
| 1434 char guest_uuid[AUTH_CRYPTO_GUEST_UUID_SIZE] = {0}; | |
| 1435 if (!auth_uuid4(guest_uuid, sizeof(guest_uuid))) | |
| 1436 return auth_error(p_arena, "500", "internal_error", "UUID error"); | |
| 1437 | |
| 1438 int64 now = auth_now(); | |
| 1439 int64 guest_expires = now + g_guest_ttl; | |
| 1440 Auth_Guest_Identity_Record new_identity; | |
| 1441 Auth_Store_Upsert_Guest_Identity( | |
| 1442 g_auth_store, guest_uuid, ip_binding, guest_expires, &new_identity); | |
| 1443 | |
| 1444 char new_csrf[AUTH_CRYPTO_TOKEN_SIZE] = {0}; | |
| 1445 if (!auth_derive_csrf(guest_uuid, new_csrf, sizeof(new_csrf))) | |
| 1446 return auth_error(p_arena, "500", "internal_error", "CSRF error"); | |
| 1447 | |
| 1448 char signed_cookie[AUTH_CRYPTO_GUEST_COOKIE_SIZE] = {0}; | |
| 1449 char new_guest_cookie[GUEST_COOKIE_MAX] = {0}; | |
| 1450 if (have_ip && | |
| 1451 Auth_Crypto_Guest_Cookie_Create( | |
| 1452 g_cookie_secret, g_cookie_secret_length, | |
| 1453 guest_uuid, (uint64)guest_expires, ip_binding, | |
| 1454 signed_cookie, sizeof(signed_cookie)) == AUTH_CRYPTO_OK) | |
| 1455 { | |
| 1456 auth_build_cookie_directive( | |
| 1457 AUTH_API_GUEST_COOKIE_NAME, signed_cookie, | |
| 1458 (int32)g_guest_ttl, TRUE, | |
| 1459 new_guest_cookie, sizeof(new_guest_cookie)); | |
| 1460 OPENSSL_cleanse(signed_cookie, sizeof(signed_cookie)); | |
| 1461 } | |
| 1462 | |
| 1463 /* --- Build response --- */ | |
| 1464 char *safe_csrf = Dowa_JSON_Escape_String(new_csrf, 0, p_arena); | |
| 1465 OPENSSL_cleanse(new_csrf, sizeof(new_csrf)); | |
| 1466 if (!safe_csrf) | |
| 1467 return auth_error(p_arena, "500", "internal_error", "Encode error"); | |
| 1468 | |
| 1469 char body_buf[256]; | |
| 1470 snprintf(body_buf, sizeof(body_buf), | |
| 1471 "{\"kind\":\"guest\",\"csrfToken\":\"%s\",\"quota\":null}", | |
| 1472 safe_csrf); | |
| 1473 | |
| 1474 char *body_copy = Dowa_Arena_Allocate(p_arena, strlen(body_buf) + 1); | |
| 1475 if (!body_copy) | |
| 1476 return auth_error(p_arena, "500", "internal_error", "OOM"); | |
| 1477 strcpy(body_copy, body_buf); | |
| 1478 | |
| 1479 /* Clear session cookie */ | |
| 1480 char clear_session[256]; | |
| 1481 auth_build_cookie_directive( | |
| 1482 AUTH_API_SESSION_COOKIE_NAME, "", -1, | |
| 1483 TRUE, clear_session, sizeof(clear_session)); | |
| 1484 | |
| 1485 char *clear_session_copy = | |
| 1486 Dowa_Arena_Allocate(p_arena, strlen(clear_session) + 1); | |
| 1487 if (!clear_session_copy) | |
| 1488 return auth_error(p_arena, "500", "internal_error", "OOM"); | |
| 1489 strcpy(clear_session_copy, clear_session); | |
| 1490 | |
| 1491 char *new_guest_copy = NULL; | |
| 1492 if (new_guest_cookie[0] != '\0') | |
| 1493 { | |
| 1494 new_guest_copy = | |
| 1495 Dowa_Arena_Allocate(p_arena, strlen(new_guest_cookie) + 1); | |
| 1496 if (!new_guest_copy) | |
| 1497 return auth_error(p_arena, "500", "internal_error", "OOM"); | |
| 1498 strcpy(new_guest_copy, new_guest_cookie); | |
| 1499 } | |
| 1500 | |
| 1501 return auth_json_response_with_cookies( | |
| 1502 p_arena, "200", body_copy, | |
| 1503 clear_session_copy, | |
| 1504 new_guest_copy); | |
| 1505 } | |
| 1506 | |
| 1507 /* ------------------------------------------------------------------ */ | |
| 1508 /* Route: POST /api/auth/password */ | |
| 1509 /* ------------------------------------------------------------------ */ | |
| 1510 | |
| 1511 static Seobeo_Request_Entry *auth_password_handler( | |
| 1512 Seobeo_Request_Entry *p_req, | |
| 1513 Dowa_Arena *p_arena) | |
| 1514 { | |
| 1515 if (!g_auth_store) | |
| 1516 return auth_error(p_arena, "503", "service_unavailable", | |
| 1517 "Auth not initialised"); | |
| 1518 | |
| 1519 if (!auth_same_origin(p_req)) | |
| 1520 return auth_error(p_arena, "403", "forbidden", "Origin mismatch"); | |
| 1521 | |
| 1522 /* --- Resolve principal — must be authenticated user --- */ | |
| 1523 Auth_Principal principal; | |
| 1524 char ignored_cookie[GUEST_COOKIE_MAX] = {0}; | |
| 1525 if (!Auth_API_Resolve_Principal(p_req, &principal, p_arena, | |
| 1526 ignored_cookie, sizeof(ignored_cookie))) | |
| 1527 return auth_error(p_arena, "500", "internal_error", "Session error"); | |
| 1528 | |
| 1529 if (principal.kind != AUTH_PRINCIPAL_USER) | |
| 1530 return auth_error(p_arena, "401", "unauthenticated", | |
| 1531 "Authentication required"); | |
| 1532 | |
| 1533 /* --- Parse body --- */ | |
| 1534 const char *body_str = auth_req_value(p_req, "Body"); | |
| 1535 if (!body_str) | |
| 1536 return auth_error(p_arena, "400", "bad_request", "Invalid body"); | |
| 1537 | |
| 1538 size_t body_len = strlen(body_str); | |
| 1539 if (body_len > BODY_MAX_BYTES) | |
| 1540 { | |
| 1541 OPENSSL_cleanse((char *)body_str, body_len); | |
| 1542 return auth_error(p_arena, "400", "bad_request", "Invalid body"); | |
| 1543 } | |
| 1544 Dowa_JSON_Value jv = | |
| 1545 Dowa_JSON_Parse(body_str, (int32)body_len, p_arena); | |
| 1546 OPENSSL_cleanse((char *)body_str, body_len); | |
| 1547 if (jv.type != DOWA_JSON_OBJECT) | |
| 1548 return auth_error(p_arena, "400", "bad_request", "Expected JSON object"); | |
| 1549 | |
| 1550 Dowa_JSON_Entry *obj = (Dowa_JSON_Entry *)jv.object_val; | |
| 1551 char *csrf_provided = Dowa_JSON_Get_String(obj, "csrfToken"); | |
| 1552 char current_pw_buf[AUTH_CRYPTO_PASSWORD_MAX_BYTES + 1]; | |
| 1553 char new_pw_buf[AUTH_CRYPTO_PASSWORD_MAX_BYTES + 1]; | |
| 1554 memset(current_pw_buf, 0, sizeof(current_pw_buf)); | |
| 1555 memset(new_pw_buf, 0, sizeof(new_pw_buf)); | |
| 1556 boolean have_current = auth_extract_secret_field( | |
| 1557 obj, "currentPassword", current_pw_buf, | |
| 1558 AUTH_CRYPTO_PASSWORD_MAX_BYTES); | |
| 1559 boolean have_new = auth_extract_secret_field( | |
| 1560 obj, "newPassword", new_pw_buf, AUTH_CRYPTO_PASSWORD_MAX_BYTES); | |
| 1561 char *current_pw_raw = current_pw_buf; | |
| 1562 char *new_pw_raw = new_pw_buf; | |
| 1563 | |
| 1564 /* Also accept CSRF from header */ | |
| 1565 if (!csrf_provided || csrf_provided[0] == '\0') | |
| 1566 csrf_provided = (char *)auth_req_value(p_req, "X-CSRF-Token"); | |
| 1567 | |
| 1568 if (!have_current || !have_new || !csrf_provided) | |
| 1569 { | |
| 1570 OPENSSL_cleanse(current_pw_buf, sizeof(current_pw_buf)); | |
| 1571 OPENSSL_cleanse(new_pw_buf, sizeof(new_pw_buf)); | |
| 1572 return auth_error(p_arena, "400", "bad_request", "Missing fields"); | |
| 1573 } | |
| 1574 | |
| 1575 /* --- CSRF check --- */ | |
| 1576 if (!auth_verify_csrf(csrf_provided, principal._binding)) | |
| 1577 { | |
| 1578 OPENSSL_cleanse(current_pw_buf, sizeof(current_pw_buf)); | |
| 1579 OPENSSL_cleanse(new_pw_buf, sizeof(new_pw_buf)); | |
| 1580 return auth_error(p_arena, "403", "csrf_invalid", "CSRF token invalid"); | |
| 1581 } | |
| 1582 | |
| 1583 /* --- Password policy --- */ | |
| 1584 size_t new_pw_len = strlen(new_pw_raw); | |
| 1585 size_t cur_pw_len = strlen(current_pw_raw); | |
| 1586 if (new_pw_len < 12 || new_pw_len > AUTH_CRYPTO_PASSWORD_MAX_BYTES || | |
| 1587 cur_pw_len < 1 || cur_pw_len > AUTH_CRYPTO_PASSWORD_MAX_BYTES) | |
| 1588 { | |
| 1589 OPENSSL_cleanse(current_pw_raw, cur_pw_len); | |
| 1590 OPENSSL_cleanse(new_pw_raw, new_pw_len); | |
| 1591 return auth_error(p_arena, "400", "password_policy", | |
| 1592 "New password must be at least 12 characters"); | |
| 1593 } | |
| 1594 | |
| 1595 /* --- Fetch user with password hash --- */ | |
| 1596 char norm_username[AUTH_STORE_USERNAME_MAX + 1]; | |
| 1597 if (!Auth_Store_Normalize_Username( | |
| 1598 principal.username, norm_username, sizeof(norm_username))) | |
| 1599 { | |
| 1600 OPENSSL_cleanse(current_pw_raw, cur_pw_len); | |
| 1601 OPENSSL_cleanse(new_pw_raw, new_pw_len); | |
| 1602 return auth_error(p_arena, "500", "internal_error", "Username error"); | |
| 1603 } | |
| 1604 | |
| 1605 Auth_User_Auth_Record auth_record; | |
| 1606 memset(&auth_record, 0, sizeof(auth_record)); | |
| 1607 if (Auth_Store_Find_User_By_Username( | |
| 1608 g_auth_store, norm_username, &auth_record) != AUTH_STORE_OK) | |
| 1609 { | |
| 1610 OPENSSL_cleanse(current_pw_raw, cur_pw_len); | |
| 1611 OPENSSL_cleanse(new_pw_raw, new_pw_len); | |
| 1612 OPENSSL_cleanse(auth_record.password_hash, | |
| 1613 sizeof(auth_record.password_hash)); | |
| 1614 return auth_error(p_arena, "401", "invalid_credentials", | |
| 1615 "Invalid credentials"); | |
| 1616 } | |
| 1617 | |
| 1618 /* --- Verify current password --- */ | |
| 1619 Auth_Crypto_Result verify = | |
| 1620 Auth_Crypto_Password_Verify(current_pw_raw, auth_record.password_hash); | |
| 1621 OPENSSL_cleanse(current_pw_raw, cur_pw_len); | |
| 1622 | |
| 1623 if (verify != AUTH_CRYPTO_OK) | |
| 1624 { | |
| 1625 OPENSSL_cleanse(auth_record.password_hash, | |
| 1626 sizeof(auth_record.password_hash)); | |
| 1627 OPENSSL_cleanse(new_pw_raw, new_pw_len); | |
| 1628 return auth_error(p_arena, "401", "invalid_credentials", | |
| 1629 "Invalid credentials"); | |
| 1630 } | |
| 1631 | |
| 1632 /* --- Hash new password --- */ | |
| 1633 char new_encoded_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE]; | |
| 1634 Auth_Crypto_Result hash_result = Auth_Crypto_Password_Hash( | |
| 1635 new_pw_raw, new_encoded_hash, sizeof(new_encoded_hash)); | |
| 1636 OPENSSL_cleanse(new_pw_raw, new_pw_len); | |
| 1637 | |
| 1638 if (hash_result != AUTH_CRYPTO_OK) | |
| 1639 { | |
| 1640 OPENSSL_cleanse(auth_record.password_hash, | |
| 1641 sizeof(auth_record.password_hash)); | |
| 1642 OPENSSL_cleanse(new_encoded_hash, sizeof(new_encoded_hash)); | |
| 1643 return auth_error(p_arena, "500", "internal_error", "Hash error"); | |
| 1644 } | |
| 1645 | |
| 1646 /* Generate the replacement session before entering the transaction. */ | |
| 1647 char new_token[AUTH_CRYPTO_TOKEN_SIZE]; | |
| 1648 if (Auth_Crypto_Token_Generate(new_token, sizeof(new_token)) != | |
| 1649 AUTH_CRYPTO_OK) | |
| 1650 { | |
| 1651 OPENSSL_cleanse(auth_record.password_hash, | |
| 1652 sizeof(auth_record.password_hash)); | |
| 1653 OPENSSL_cleanse(new_encoded_hash, sizeof(new_encoded_hash)); | |
| 1654 return auth_error(p_arena, "500", "internal_error", "Token error"); | |
| 1655 } | |
| 1656 | |
| 1657 char new_token_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE]; | |
| 1658 if (Auth_Crypto_Token_Digest(new_token, new_token_digest, | |
| 1659 sizeof(new_token_digest)) != AUTH_CRYPTO_OK) | |
| 1660 { | |
| 1661 OPENSSL_cleanse(auth_record.password_hash, | |
| 1662 sizeof(auth_record.password_hash)); | |
| 1663 OPENSSL_cleanse(new_encoded_hash, sizeof(new_encoded_hash)); | |
| 1664 OPENSSL_cleanse(new_token, sizeof(new_token)); | |
| 1665 return auth_error(p_arena, "500", "internal_error", "Digest error"); | |
| 1666 } | |
| 1667 | |
| 1668 char new_csrf[AUTH_CRYPTO_TOKEN_SIZE]; | |
| 1669 if (!auth_derive_csrf(new_token_digest, new_csrf, sizeof(new_csrf))) | |
| 1670 { | |
| 1671 OPENSSL_cleanse(auth_record.password_hash, | |
| 1672 sizeof(auth_record.password_hash)); | |
| 1673 OPENSSL_cleanse(new_encoded_hash, sizeof(new_encoded_hash)); | |
| 1674 OPENSSL_cleanse(new_token, sizeof(new_token)); | |
| 1675 OPENSSL_cleanse(new_token_digest, sizeof(new_token_digest)); | |
| 1676 return auth_error(p_arena, "500", "internal_error", "CSRF error"); | |
| 1677 } | |
| 1678 | |
| 1679 char new_csrf_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE]; | |
| 1680 if (Auth_Crypto_Token_Digest(new_csrf, new_csrf_digest, | |
| 1681 sizeof(new_csrf_digest)) != AUTH_CRYPTO_OK) | |
| 1682 { | |
| 1683 OPENSSL_cleanse(auth_record.password_hash, | |
| 1684 sizeof(auth_record.password_hash)); | |
| 1685 OPENSSL_cleanse(new_encoded_hash, sizeof(new_encoded_hash)); | |
| 1686 OPENSSL_cleanse(new_token, sizeof(new_token)); | |
| 1687 OPENSSL_cleanse(new_token_digest, sizeof(new_token_digest)); | |
| 1688 OPENSSL_cleanse(new_csrf, sizeof(new_csrf)); | |
| 1689 return auth_error(p_arena, "500", "internal_error", "CSRF digest error"); | |
| 1690 } | |
| 1691 | |
| 1692 int64 now = auth_now(); | |
| 1693 Auth_Session_Record new_session; | |
| 1694 Auth_Store_Result password_result = Auth_Store_Self_Change_Password( | |
| 1695 g_auth_store, | |
| 1696 principal.user_id, | |
| 1697 auth_record.password_hash, | |
| 1698 new_encoded_hash, | |
| 1699 new_token_digest, | |
| 1700 new_csrf_digest, | |
| 1701 g_session_idle_ttl, | |
| 1702 g_session_abs_ttl, | |
| 1703 now, | |
| 1704 &new_session); | |
| 1705 | |
| 1706 OPENSSL_cleanse(auth_record.password_hash, | |
| 1707 sizeof(auth_record.password_hash)); | |
| 1708 OPENSSL_cleanse(new_encoded_hash, sizeof(new_encoded_hash)); | |
| 1709 OPENSSL_cleanse(new_csrf_digest, sizeof(new_csrf_digest)); | |
| 1710 OPENSSL_cleanse(new_token_digest, sizeof(new_token_digest)); | |
| 1711 | |
| 1712 if (password_result != AUTH_STORE_OK) | |
| 1713 { | |
| 1714 OPENSSL_cleanse(new_token, sizeof(new_token)); | |
| 1715 OPENSSL_cleanse(new_csrf, sizeof(new_csrf)); | |
| 1716 return auth_error(p_arena, "500", "internal_error", | |
| 1717 "Password update failed"); | |
| 1718 } | |
| 1719 | |
| 1720 /* --- Build response --- */ | |
| 1721 char *safe_username = | |
| 1722 Dowa_JSON_Escape_String(principal.username, 0, p_arena); | |
| 1723 char *safe_role = | |
| 1724 Dowa_JSON_Escape_String(principal.role, 0, p_arena); | |
| 1725 char *safe_csrf = | |
| 1726 Dowa_JSON_Escape_String(new_csrf, 0, p_arena); | |
| 1727 OPENSSL_cleanse(new_csrf, sizeof(new_csrf)); | |
| 1728 | |
| 1729 if (!safe_username || !safe_role || !safe_csrf) | |
| 1730 { | |
| 1731 OPENSSL_cleanse(new_token, sizeof(new_token)); | |
| 1732 return auth_error(p_arena, "500", "internal_error", "Encode error"); | |
| 1733 } | |
| 1734 | |
| 1735 char body_buf[512]; | |
| 1736 snprintf(body_buf, sizeof(body_buf), | |
| 1737 "{\"kind\":\"user\",\"username\":\"%s\",\"role\":\"%s\"," | |
| 1738 "\"mustChangePassword\":false,\"csrfToken\":\"%s\"," | |
| 1739 "\"quota\":null}", | |
| 1740 safe_username, safe_role, safe_csrf); | |
| 1741 | |
| 1742 char *body_copy = Dowa_Arena_Allocate(p_arena, strlen(body_buf) + 1); | |
| 1743 if (!body_copy) | |
| 1744 { | |
| 1745 OPENSSL_cleanse(new_token, sizeof(new_token)); | |
| 1746 return auth_error(p_arena, "500", "internal_error", "OOM"); | |
| 1747 } | |
| 1748 strcpy(body_copy, body_buf); | |
| 1749 | |
| 1750 /* Updated session cookie */ | |
| 1751 char session_cookie[SESSION_COOKIE_MAX]; | |
| 1752 auth_build_cookie_directive( | |
| 1753 AUTH_API_SESSION_COOKIE_NAME, new_token, 0, | |
| 1754 TRUE, session_cookie, sizeof(session_cookie)); | |
| 1755 OPENSSL_cleanse(new_token, sizeof(new_token)); | |
| 1756 | |
| 1757 char *session_cookie_copy = | |
| 1758 Dowa_Arena_Allocate(p_arena, strlen(session_cookie) + 1); | |
| 1759 if (session_cookie_copy) strcpy(session_cookie_copy, session_cookie); | |
| 1760 OPENSSL_cleanse(session_cookie, sizeof(session_cookie)); | |
| 1761 | |
| 1762 return auth_json_response_with_cookies( | |
| 1763 p_arena, "200", body_copy, | |
| 1764 session_cookie_copy, NULL); | |
| 1765 } | |
| 1766 | |
| 1767 /* ------------------------------------------------------------------ */ | |
| 1768 /* Route: GET /account/password */ | |
| 1769 /* ------------------------------------------------------------------ */ | |
| 1770 | |
| 1771 static Seobeo_Request_Entry *auth_password_page_handler( | |
| 1772 Seobeo_Request_Entry *p_req, | |
| 1773 Dowa_Arena *p_arena) | |
| 1774 { | |
| 1775 (void)p_req; | |
| 1776 char *body = Dowa_Arena_Allocate(p_arena, 128 * 1024); | |
| 1777 if (!body || !Mjj_Template_Render_File(body, 128 * 1024, "/account/password.html", p_arena)) | |
| 1778 { | |
| 1779 Seobeo_Request_Entry *resp = NULL; | |
| 1780 Dowa_HashMap_Push_Arena(resp, "status", "500", p_arena); | |
| 1781 Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain; charset=utf-8", p_arena); | |
| 1782 Dowa_HashMap_Push_Arena(resp, "cache-control", "no-store", p_arena); | |
| 1783 Dowa_HashMap_Push_Arena(resp, "body", "Internal Server Error", p_arena); | |
| 1784 return resp; | |
| 1785 } | |
| 1786 | |
| 1787 Seobeo_Request_Entry *resp = NULL; | |
| 1788 Dowa_HashMap_Push_Arena(resp, "body", body, p_arena); | |
| 1789 Dowa_HashMap_Push_Arena( | |
| 1790 resp, "content-type", "text/html; charset=utf-8", p_arena); | |
| 1791 Dowa_HashMap_Push_Arena(resp, "cache-control", "no-store", p_arena); | |
| 1792 Dowa_HashMap_Push_Arena(resp, "pragma", "no-cache", p_arena); | |
| 1793 Dowa_HashMap_Push_Arena(resp, "x-content-type-options", "nosniff", p_arena); | |
| 1794 Dowa_HashMap_Push_Arena(resp, "referrer-policy", "no-referrer", p_arena); | |
| 1795 Dowa_HashMap_Push_Arena( | |
| 1796 resp, "content-security-policy", "frame-ancestors 'none'", p_arena); | |
| 1797 return resp; | |
| 1798 } | |
| 1799 | |
| 1800 /* ------------------------------------------------------------------ */ | |
| 1801 /* Public API */ | |
| 1802 /* ------------------------------------------------------------------ */ | |
| 1803 | |
| 1804 boolean Auth_API_Is_Forced_Password_Change_Only(const char *http_path) | |
| 1805 { | |
| 1806 if (!http_path) return FALSE; | |
| 1807 return strcmp(http_path, AUTH_API_PATH_SESSION) == 0 || | |
| 1808 strcmp(http_path, AUTH_API_PATH_LOGIN) == 0 || | |
| 1809 strcmp(http_path, AUTH_API_PATH_LOGOUT) == 0 || | |
| 1810 strcmp(http_path, AUTH_API_PATH_PASSWORD) == 0 || | |
| 1811 strcmp(http_path, AUTH_API_PATH_PASSWORD_PAGE) == 0; | |
| 1812 } | |
| 1813 | |
| 1814 void Auth_API_Register_Guest_Transfer_Hook( | |
| 1815 Auth_Guest_Transfer_Hook hook, | |
| 1816 void *context) | |
| 1817 { | |
| 1818 g_transfer_hook = hook; | |
| 1819 g_transfer_hook_ctx = context; | |
| 1820 } | |
| 1821 | |
| 1822 void Auth_API_Register_Guest_Quota_Cb(Auth_API_Guest_Quota_Cb cb) | |
| 1823 { | |
| 1824 g_guest_quota_cb = cb; | |
| 1825 } | |
| 1826 | |
| 1827 boolean Auth_API_Init( | |
| 1828 const char *database_path, | |
| 1829 const uint8 *cookie_secret, | |
| 1830 size_t cookie_secret_length, | |
| 1831 const char *bootstrap_username, | |
| 1832 const char *bootstrap_password_hash, | |
| 1833 const char *trusted_proxy_ip, | |
| 1834 int64 session_idle_ttl_secs, | |
| 1835 int64 session_absolute_ttl_secs, | |
| 1836 int64 guest_ttl_secs, | |
| 1837 boolean dev_insecure_cookie) | |
| 1838 { | |
| 1839 /* Fail closed: cookie secret required */ | |
| 1840 if (!cookie_secret || | |
| 1841 cookie_secret_length < AUTH_CRYPTO_COOKIE_SECRET_MIN_BYTES || | |
| 1842 cookie_secret_length > AUTH_CRYPTO_COOKIE_SECRET_MAX_BYTES) | |
| 1843 { | |
| 1844 Seobeo_Log(SEOBEO_ERROR, | |
| 1845 "[AUTH] Init failed: cookie secret missing or invalid length\n"); | |
| 1846 return FALSE; | |
| 1847 } | |
| 1848 | |
| 1849 if (!database_path || database_path[0] == '\0') | |
| 1850 { | |
| 1851 Seobeo_Log(SEOBEO_ERROR, | |
| 1852 "[AUTH] Init failed: database path required\n"); | |
| 1853 return FALSE; | |
| 1854 } | |
| 1855 | |
| 1856 g_auth_store = Auth_Store_Create(database_path); | |
| 1857 if (!g_auth_store) | |
| 1858 { | |
| 1859 Seobeo_Log(SEOBEO_ERROR, "[AUTH] Failed to open auth store\n"); | |
| 1860 return FALSE; | |
| 1861 } | |
| 1862 | |
| 1863 memcpy(g_cookie_secret, cookie_secret, cookie_secret_length); | |
| 1864 g_cookie_secret_length = cookie_secret_length; | |
| 1865 | |
| 1866 if (trusted_proxy_ip && trusted_proxy_ip[0] != '\0') | |
| 1867 { | |
| 1868 /* Canonicalize via inet_pton/inet_ntop; reject invalid addresses. */ | |
| 1869 struct in_addr addr4; | |
| 1870 struct in6_addr addr6; | |
| 1871 char canonical[AUTH_CRYPTO_IP_MAX_BYTES]; | |
| 1872 canonical[0] = '\0'; | |
| 1873 if (inet_pton(AF_INET, trusted_proxy_ip, &addr4) == 1) | |
| 1874 { | |
| 1875 if (!inet_ntop(AF_INET, &addr4, canonical, sizeof(canonical))) | |
| 1876 { | |
| 1877 Seobeo_Log(SEOBEO_ERROR, | |
| 1878 "[AUTH] Init failed: trusted proxy IPv4 canonicalization\n"); | |
| 1879 Auth_Store_Destroy(g_auth_store); | |
| 1880 g_auth_store = NULL; | |
| 1881 return FALSE; | |
| 1882 } | |
| 1883 } | |
| 1884 else if (inet_pton(AF_INET6, trusted_proxy_ip, &addr6) == 1) | |
| 1885 { | |
| 1886 if (!inet_ntop(AF_INET6, &addr6, canonical, sizeof(canonical))) | |
| 1887 { | |
| 1888 Seobeo_Log(SEOBEO_ERROR, | |
| 1889 "[AUTH] Init failed: trusted proxy IPv6 canonicalization\n"); | |
| 1890 Auth_Store_Destroy(g_auth_store); | |
| 1891 g_auth_store = NULL; | |
| 1892 return FALSE; | |
| 1893 } | |
| 1894 } | |
| 1895 else | |
| 1896 { | |
| 1897 Seobeo_Log(SEOBEO_ERROR, | |
| 1898 "[AUTH] Init failed: trusted proxy is not a valid IP address\n"); | |
| 1899 Auth_Store_Destroy(g_auth_store); | |
| 1900 g_auth_store = NULL; | |
| 1901 return FALSE; | |
| 1902 } | |
| 1903 strncpy(g_trusted_proxy_ip, canonical, sizeof(g_trusted_proxy_ip) - 1); | |
| 1904 g_trusted_proxy_ip[sizeof(g_trusted_proxy_ip) - 1] = '\0'; | |
| 1905 g_has_trusted_proxy = TRUE; | |
| 1906 } | |
| 1907 | |
| 1908 if (session_idle_ttl_secs > 0) | |
| 1909 g_session_idle_ttl = session_idle_ttl_secs; | |
| 1910 if (session_absolute_ttl_secs > 0) | |
| 1911 g_session_abs_ttl = session_absolute_ttl_secs; | |
| 1912 if (guest_ttl_secs > 0) | |
| 1913 g_guest_ttl = guest_ttl_secs; | |
| 1914 | |
| 1915 g_dev_insecure_cookie = dev_insecure_cookie; | |
| 1916 | |
| 1917 /* Bootstrap admin — fail closed on any store error. */ | |
| 1918 if (bootstrap_username && bootstrap_username[0] != '\0' && | |
| 1919 bootstrap_password_hash && bootstrap_password_hash[0] != '\0') | |
| 1920 { | |
| 1921 Auth_Store_Bootstrap_Result bootstrap_result; | |
| 1922 char bootstrap_id[37]; | |
| 1923 Auth_Store_Result r = Auth_Store_Bootstrap_Admin( | |
| 1924 g_auth_store, | |
| 1925 bootstrap_username, | |
| 1926 bootstrap_password_hash, | |
| 1927 &bootstrap_result, | |
| 1928 bootstrap_id); | |
| 1929 | |
| 1930 if (r == AUTH_STORE_OK && | |
| 1931 bootstrap_result == AUTH_STORE_BOOTSTRAP_CREATED) | |
| 1932 { | |
| 1933 Seobeo_Log(SEOBEO_INFO, "[AUTH] Bootstrap admin created\n"); | |
| 1934 } | |
| 1935 else if (r == AUTH_STORE_OK && | |
| 1936 bootstrap_result == AUTH_STORE_BOOTSTRAP_ALREADY_PRESENT) | |
| 1937 { | |
| 1938 Seobeo_Log(SEOBEO_INFO, "[AUTH] Bootstrap admin already present\n"); | |
| 1939 } | |
| 1940 else | |
| 1941 { | |
| 1942 Seobeo_Log(SEOBEO_ERROR, | |
| 1943 "[AUTH] Bootstrap admin failed: store error %d — refusing to start\n", r); | |
| 1944 OPENSSL_cleanse(g_cookie_secret, sizeof(g_cookie_secret)); | |
| 1945 g_cookie_secret_length = 0; | |
| 1946 g_has_trusted_proxy = FALSE; | |
| 1947 Auth_Store_Destroy(g_auth_store); | |
| 1948 g_auth_store = NULL; | |
| 1949 return FALSE; | |
| 1950 } | |
| 1951 } | |
| 1952 | |
| 1953 Seobeo_Log(SEOBEO_INFO, "[AUTH] Initialised (dev_insecure=%s)\n", | |
| 1954 dev_insecure_cookie ? "yes" : "no"); | |
| 1955 return TRUE; | |
| 1956 } | |
| 1957 | |
| 1958 void Auth_API_Destroy(void) | |
| 1959 { | |
| 1960 if (g_auth_store) | |
| 1961 { | |
| 1962 Auth_Store_Destroy(g_auth_store); | |
| 1963 g_auth_store = NULL; | |
| 1964 } | |
| 1965 OPENSSL_cleanse(g_cookie_secret, sizeof(g_cookie_secret)); | |
| 1966 g_cookie_secret_length = 0; | |
| 1967 g_has_trusted_proxy = FALSE; | |
| 1968 g_transfer_hook = NULL; | |
| 1969 g_transfer_hook_ctx = NULL; | |
| 1970 g_guest_quota_cb = NULL; | |
| 1971 #ifdef AUTH_API_TEST_HOOKS | |
| 1972 g_login_pre_create_hook = NULL; | |
| 1973 g_login_pre_create_context = NULL; | |
| 1974 #endif | |
| 1975 } | |
| 1976 | |
| 1977 void Auth_API_Register_Routes(void) | |
| 1978 { | |
| 1979 Seobeo_Router_Register("GET", "/api/auth/session", auth_session_handler); | |
| 1980 Seobeo_Router_Register("POST", "/api/auth/login", auth_login_handler); | |
| 1981 Seobeo_Router_Register("POST", "/api/auth/logout", auth_logout_handler); | |
| 1982 Seobeo_Router_Register("POST", "/api/auth/password", auth_password_handler); | |
| 1983 Seobeo_Router_Register("GET", "/account/password", auth_password_page_handler); | |
| 1984 } | |
| 1985 | |
| 1986 Auth_Store *Auth_API_Get_Store(void) | |
| 1987 { | |
| 1988 return g_auth_store; | |
| 1989 } | |
| 1990 | |
| 1991 /* ------------------------------------------------------------------ */ | |
| 1992 /* Test hooks (compiled in only for test builds) */ | |
| 1993 /* ------------------------------------------------------------------ */ | |
| 1994 | |
| 1995 #ifdef AUTH_API_TEST_HOOKS | |
| 1996 void Auth_API_Test_Set_Login_Pre_Create_Hook( | |
| 1997 Auth_API_Test_Login_Pre_Create_Hook hook, | |
| 1998 void *p_context) | |
| 1999 { | |
| 2000 g_login_pre_create_hook = hook; | |
| 2001 g_login_pre_create_context = p_context; | |
| 2002 } | |
| 2003 | |
| 2004 Seobeo_Request_Entry *Auth_API_Test_Session_Handler( | |
| 2005 Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena) | |
| 2006 { return auth_session_handler(p_req, p_arena); } | |
| 2007 | |
| 2008 Seobeo_Request_Entry *Auth_API_Test_Login_Handler( | |
| 2009 Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena) | |
| 2010 { return auth_login_handler(p_req, p_arena); } | |
| 2011 | |
| 2012 Seobeo_Request_Entry *Auth_API_Test_Logout_Handler( | |
| 2013 Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena) | |
| 2014 { return auth_logout_handler(p_req, p_arena); } | |
| 2015 | |
| 2016 Seobeo_Request_Entry *Auth_API_Test_Password_Handler( | |
| 2017 Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena) | |
| 2018 { return auth_password_handler(p_req, p_arena); } | |
| 2019 #endif /* AUTH_API_TEST_HOOKS */ |