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 */