comparison mrjunejune/admin_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/admin_api.h"
2 #include "mrjunejune/auth_api.h"
3 #include "mrjunejune/template_renderer.h"
4
5 #include "auth/auth_store.h"
6 #include "auth/auth_crypto.h"
7 #include "seobeo/seobeo.h"
8 #include "dowa/dowa.h"
9
10 #include <openssl/crypto.h>
11
12 #include <stdio.h>
13 #include <stdlib.h>
14 #include <string.h>
15 #include <time.h>
16
17 /* ------------------------------------------------------------------ */
18 /* Constants */
19 /* ------------------------------------------------------------------ */
20
21 #define ADMIN_BODY_MAX_BYTES 4096
22 #define ADMIN_UUID_SIZE 37
23
24 /* ------------------------------------------------------------------ */
25 /* Internal helpers */
26 /* ------------------------------------------------------------------ */
27
28 static const char *admin_req_value(
29 Seobeo_Request_Entry *p_req,
30 const char *key)
31 {
32 void *p = Dowa_HashMap_Get_Ptr(p_req, (char *)key);
33 return p ? ((Seobeo_Request_Entry *)p)->value : NULL;
34 }
35
36 static boolean admin_extract_secret_field(
37 Dowa_JSON_Entry *obj,
38 const char *key,
39 char *out_buf,
40 size_t max_len)
41 {
42 char *arena_ptr = Dowa_JSON_Get_String(obj, key);
43 if (!arena_ptr || arena_ptr[0] == '\0')
44 return FALSE;
45
46 size_t field_len = strlen(arena_ptr);
47 if (field_len > max_len)
48 {
49 OPENSSL_cleanse(arena_ptr, field_len);
50 return FALSE;
51 }
52
53 memcpy(out_buf, arena_ptr, field_len);
54 out_buf[field_len] = '\0';
55 OPENSSL_cleanse(arena_ptr, field_len);
56 return TRUE;
57 }
58
59 static Seobeo_Request_Entry *admin_json_response(
60 Dowa_Arena *p_arena,
61 const char *status,
62 const char *body)
63 {
64 Seobeo_Request_Entry *resp = NULL;
65 Dowa_HashMap_Push_Arena(resp, "status", (char *)status, p_arena);
66 Dowa_HashMap_Push_Arena(
67 resp, "content-type", "application/json; charset=utf-8", p_arena);
68 Dowa_HashMap_Push_Arena(resp, "cache-control", "no-store", p_arena);
69 Dowa_HashMap_Push_Arena(resp, "pragma", "no-cache", p_arena);
70 Dowa_HashMap_Push_Arena(resp, "x-content-type-options", "nosniff", p_arena);
71 Dowa_HashMap_Push_Arena(resp, "body", (char *)body, p_arena);
72 return resp;
73 }
74
75 static Seobeo_Request_Entry *admin_error(
76 Dowa_Arena *p_arena,
77 const char *status,
78 const char *code,
79 const char *message)
80 {
81 char buf[512];
82 snprintf(buf, sizeof(buf),
83 "{\"error\":{\"code\":\"%s\",\"message\":\"%s\"}}",
84 code, message);
85 char *body = Dowa_Arena_Allocate(p_arena, strlen(buf) + 1);
86 if (body) strcpy(body, buf);
87 return admin_json_response(p_arena, status, body ? body : "{}");
88 }
89
90 static Seobeo_Request_Entry *admin_html_redirect(
91 Dowa_Arena *p_arena,
92 const char *location)
93 {
94 Seobeo_Request_Entry *resp = NULL;
95 Dowa_HashMap_Push_Arena(resp, "status", "302", p_arena);
96 Dowa_HashMap_Push_Arena(resp, "Location", (char *)location, p_arena);
97 Dowa_HashMap_Push_Arena(resp, "body", "", p_arena);
98 return resp;
99 }
100
101 /*
102 * Gate: resolve principal, enforce admin+active, reject forced-password.
103 * On failure sets *pp_err_resp and returns FALSE.
104 * On success fills *p_principal and returns TRUE.
105 *
106 * For page requests (is_page==TRUE), unauthenticated → 302 redirect.
107 * For API requests (is_page==FALSE), unauthenticated → 401 JSON.
108 */
109 static boolean admin_require_admin(
110 Seobeo_Request_Entry *p_req,
111 Auth_Principal *p_principal,
112 Dowa_Arena *p_arena,
113 boolean is_page,
114 Seobeo_Request_Entry **pp_err_resp)
115 {
116 Auth_Store *store = Auth_API_Get_Store();
117 if (!store)
118 {
119 *pp_err_resp = admin_error(p_arena, "503", "service_unavailable",
120 "Auth not initialised");
121 return FALSE;
122 }
123
124 boolean found = FALSE;
125 if (!Auth_API_Resolve_Existing_Principal(p_req, p_principal, p_arena, &found))
126 {
127 *pp_err_resp = admin_error(p_arena, "500", "internal_error",
128 "Session error");
129 return FALSE;
130 }
131
132 if (!found || p_principal->kind != AUTH_PRINCIPAL_USER)
133 {
134 *pp_err_resp = is_page
135 ? admin_html_redirect(p_arena, "/login")
136 : admin_error(p_arena, "401", "unauthenticated",
137 "Authentication required");
138 return FALSE;
139 }
140
141 if (p_principal->must_change_password)
142 {
143 *pp_err_resp = is_page
144 ? admin_html_redirect(p_arena, "/account/password")
145 : admin_error(p_arena, "403", "password_change_required",
146 "Password change required");
147 return FALSE;
148 }
149
150 if (strcmp(p_principal->role, "admin") != 0)
151 {
152 *pp_err_resp = admin_error(p_arena, "403", "forbidden",
153 "Admin access required");
154 return FALSE;
155 }
156
157 *pp_err_resp = NULL;
158 return TRUE;
159 }
160
161 /*
162 * Validate that `:id` param is a non-empty UUID-shaped string.
163 * Fills id_out (capacity >= 37). Returns FALSE on invalid/missing.
164 */
165 static boolean admin_get_id_param(
166 Seobeo_Request_Entry *p_req,
167 char *id_out,
168 size_t capacity)
169 {
170 void *kv = Dowa_HashMap_Get_Ptr(p_req, ":id");
171 if (!kv) return FALSE;
172 const char *val = ((Seobeo_Request_Entry *)kv)->value;
173 if (!val || val[0] == '\0') return FALSE;
174 size_t vlen = strlen(val);
175 if (vlen != 36) return FALSE; /* UUID is 36 chars + NUL */
176 if (vlen >= capacity) return FALSE;
177 memcpy(id_out, val, vlen);
178 id_out[vlen] = '\0';
179 return TRUE;
180 }
181
182 /*
183 * Append one JSON user object to buf (returns new offset, -1 on error).
184 * Never includes password_hash, session digests, or guest IDs.
185 * p_arena is used for temporary string escaping.
186 */
187 static int admin_append_user_json(
188 char *buf,
189 size_t capacity,
190 size_t offset,
191 boolean is_first,
192 const Auth_User_Record *u,
193 Dowa_Arena *p_arena)
194 {
195 char *safe_id = Dowa_JSON_Escape_String(u->id, 0, p_arena);
196 char *safe_user = Dowa_JSON_Escape_String(u->username, 0, p_arena);
197 char *safe_role = Dowa_JSON_Escape_String(u->role, 0, p_arena);
198 char *safe_stat = Dowa_JSON_Escape_String(u->status, 0, p_arena);
199 if (!safe_id || !safe_user || !safe_role || !safe_stat)
200 return -1;
201
202 int n = snprintf(buf + offset, capacity - offset,
203 "%s{"
204 "\"id\":\"%s\","
205 "\"username\":\"%s\","
206 "\"role\":\"%s\","
207 "\"status\":\"%s\","
208 "\"mustChangePassword\":%s,"
209 "\"createdAt\":%lld,"
210 "\"updatedAt\":%lld,"
211 "\"passwordChangedAt\":%lld"
212 "}",
213 is_first ? "" : ",",
214 safe_id, safe_user, safe_role, safe_stat,
215 u->must_change_password ? "true" : "false",
216 (long long)u->created_at,
217 (long long)u->updated_at,
218 (long long)u->password_changed_at);
219
220 if (n < 0 || (size_t)n >= capacity - offset)
221 return -1;
222 return (int)(offset + (size_t)n);
223 }
224
225 /* ------------------------------------------------------------------ */
226 /* Route: GET /admin/users (page) */
227 /* ------------------------------------------------------------------ */
228
229 static Seobeo_Request_Entry *admin_page_handler(
230 Seobeo_Request_Entry *p_req,
231 Dowa_Arena *p_arena)
232 {
233 Auth_Principal principal;
234 Seobeo_Request_Entry *err = NULL;
235 if (!admin_require_admin(p_req, &principal, p_arena, TRUE, &err))
236 return err;
237
238 char *body = Dowa_Arena_Allocate(p_arena, 128 * 1024);
239 if (!body || !Mjj_Template_Render_File(body, 128 * 1024, "/admin/users/index.html", p_arena))
240 {
241 return admin_error(p_arena, "500", "internal_error", "Render failed");
242 }
243
244 Seobeo_Request_Entry *resp = NULL;
245 Dowa_HashMap_Push_Arena(resp, "status", "200", p_arena);
246 Dowa_HashMap_Push_Arena(resp, "content-type", "text/html; charset=utf-8", p_arena);
247 Dowa_HashMap_Push_Arena(resp, "cache-control", "no-store", p_arena);
248 Dowa_HashMap_Push_Arena(resp, "pragma", "no-cache", p_arena);
249 Dowa_HashMap_Push_Arena(resp, "x-content-type-options", "nosniff", p_arena);
250 Dowa_HashMap_Push_Arena(resp, "referrer-policy", "no-referrer", p_arena);
251 Dowa_HashMap_Push_Arena(
252 resp, "content-security-policy", "frame-ancestors 'none'", p_arena);
253 Dowa_HashMap_Push_Arena(resp, "body", body, p_arena);
254 return resp;
255 }
256
257 static Seobeo_Request_Entry *admin_list_handler(
258 Seobeo_Request_Entry *p_req,
259 Dowa_Arena *p_arena)
260 {
261 Auth_Principal principal;
262 Seobeo_Request_Entry *err = NULL;
263 if (!admin_require_admin(p_req, &principal, p_arena, FALSE, &err))
264 return err;
265
266 Auth_Store *store = Auth_API_Get_Store();
267
268 /* Pagination params from query string; defaults applied. */
269 int64 page = 1;
270 int64 limit = ADMIN_API_DEFAULT_LIMIT;
271 const char *qpage = admin_req_value(p_req, "Query-page");
272 const char *qlimit = admin_req_value(p_req, "Query-limit");
273 if (qpage && qpage[0] != '\0') page = atol(qpage);
274 if (qlimit && qlimit[0] != '\0') limit = atol(qlimit);
275 if (page < 1) page = 1;
276 if (limit < 1) limit = 1;
277 if (limit > ADMIN_API_MAX_LIMIT) limit = ADMIN_API_MAX_LIMIT;
278
279 Dowa_Arena *list_arena = Dowa_Arena_Create(128 * 1024);
280 if (!list_arena)
281 return admin_error(p_arena, "500", "internal_error", "OOM");
282
283 Auth_User_Record *records = NULL;
284 Auth_Store_Result res = Auth_Store_List_Users(store, &records, list_arena);
285 if (res != AUTH_STORE_OK)
286 {
287 Dowa_Arena_Free(list_arena);
288 return admin_error(p_arena, "500", "internal_error", "List users failed");
289 }
290
291 int64 total = (int64)Dowa_Array_Length(records);
292 int64 offset_start = total;
293 if (page - 1 <= total / limit)
294 offset_start = (page - 1) * limit;
295 if (offset_start > total)
296 offset_start = total;
297 int64 offset_end = total - offset_start < limit
298 ? total
299 : offset_start + limit;
300
301 /* Build JSON response in the request arena. */
302 size_t resp_size = 64 + (size_t)(total > 0 ? total : 1) * 400;
303 char *body = Dowa_Arena_Allocate(p_arena, resp_size);
304 if (!body)
305 {
306 Dowa_Arena_Free(list_arena);
307 return admin_error(p_arena, "500", "internal_error", "OOM");
308 }
309
310 int n = snprintf(body, resp_size,
311 "{\"total\":%lld,\"page\":%lld,\"limit\":%lld,\"users\":[",
312 (long long)total, (long long)page, (long long)limit);
313 if (n < 0)
314 {
315 Dowa_Arena_Free(list_arena);
316 return admin_error(p_arena, "500", "internal_error", "Encode error");
317 }
318 size_t pos = (size_t)n;
319 boolean first = TRUE;
320
321 for (int64 i = offset_start; i < offset_end && pos < resp_size - 2; i++)
322 {
323 int nw = admin_append_user_json(body, resp_size, pos, first, &records[i], p_arena);
324 if (nw < 0)
325 {
326 Dowa_Arena_Free(list_arena);
327 return admin_error(p_arena, "500", "internal_error", "Encode error");
328 }
329 pos = (size_t)nw;
330 first = FALSE;
331 }
332
333 if (pos + 2 >= resp_size)
334 {
335 Dowa_Arena_Free(list_arena);
336 return admin_error(p_arena, "500", "internal_error", "Buffer too small");
337 }
338 body[pos++] = ']';
339 body[pos++] = '}';
340 body[pos] = '\0';
341
342 Dowa_Arena_Free(list_arena);
343 return admin_json_response(p_arena, "200", body);
344 }
345
346 /* ------------------------------------------------------------------ */
347 /* Route: POST /api/admin/users (create) */
348 /* ------------------------------------------------------------------ */
349
350 static Seobeo_Request_Entry *admin_create_handler(
351 Seobeo_Request_Entry *p_req,
352 Dowa_Arena *p_arena)
353 {
354 Auth_Principal principal;
355 Seobeo_Request_Entry *err = NULL;
356 if (!admin_require_admin(p_req, &principal, p_arena, FALSE, &err))
357 return err;
358
359 if (!Auth_API_Verify_CSRF(p_req, &principal))
360 return admin_error(p_arena, "403", "csrf_invalid", "CSRF check failed");
361
362 const char *body_str = admin_req_value(p_req, "Body");
363 if (!body_str)
364 return admin_error(p_arena, "400", "bad_request", "Invalid request body");
365
366 size_t body_len = strlen(body_str);
367 if (body_len > ADMIN_BODY_MAX_BYTES)
368 {
369 OPENSSL_cleanse((char *)body_str, body_len);
370 return admin_error(p_arena, "400", "bad_request", "Invalid request body");
371 }
372 Dowa_JSON_Value jv =
373 Dowa_JSON_Parse(body_str, (int32)body_len, p_arena);
374 OPENSSL_cleanse((char *)body_str, body_len);
375 if (jv.type != DOWA_JSON_OBJECT)
376 return admin_error(p_arena, "400", "bad_request", "Expected JSON object");
377
378 Dowa_JSON_Entry *obj = (Dowa_JSON_Entry *)jv.object_val;
379 char *username_raw = Dowa_JSON_Get_String(obj, "username");
380 char *role_raw = Dowa_JSON_Get_String(obj, "role");
381 char password_buf[AUTH_CRYPTO_PASSWORD_MAX_BYTES + 1];
382 memset(password_buf, 0, sizeof(password_buf));
383 boolean have_password = admin_extract_secret_field(
384 obj, "temporaryPassword", password_buf,
385 AUTH_CRYPTO_PASSWORD_MAX_BYTES);
386 char *password_raw = password_buf;
387
388 if (!username_raw || username_raw[0] == '\0' ||
389 !have_password)
390 {
391 OPENSSL_cleanse(password_buf, sizeof(password_buf));
392 return admin_error(p_arena, "400", "bad_request",
393 "username and temporaryPassword required");
394 }
395
396 const char *role = "member";
397 if (role_raw && role_raw[0] != '\0')
398 {
399 if (strcmp(role_raw, "admin") != 0 && strcmp(role_raw, "member") != 0)
400 {
401 OPENSSL_cleanse(password_raw, strlen(password_raw));
402 return admin_error(p_arena, "400", "invalid_role",
403 "role must be admin or member");
404 }
405 role = role_raw;
406 }
407
408 size_t pw_len = strlen(password_raw);
409 if (pw_len < 12 || pw_len > AUTH_CRYPTO_PASSWORD_MAX_BYTES)
410 {
411 OPENSSL_cleanse(password_raw, pw_len);
412 return admin_error(p_arena, "400", "password_policy",
413 "Password must be 12 to 1024 characters");
414 }
415
416 char encoded_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
417 Auth_Crypto_Result hr =
418 Auth_Crypto_Password_Hash(password_raw, encoded_hash, sizeof(encoded_hash));
419 OPENSSL_cleanse(password_raw, pw_len);
420
421 if (hr != AUTH_CRYPTO_OK)
422 {
423 OPENSSL_cleanse(encoded_hash, sizeof(encoded_hash));
424 return admin_error(p_arena, "500", "internal_error", "Hash error");
425 }
426
427 char new_id[ADMIN_UUID_SIZE];
428 Auth_Store *store = Auth_API_Get_Store();
429 Auth_Store_Result cr = Auth_Store_Create_User_Audited(
430 store,
431 username_raw,
432 encoded_hash,
433 role,
434 TRUE, /* must_change_password */
435 principal.user_id,
436 new_id);
437 OPENSSL_cleanse(encoded_hash, sizeof(encoded_hash));
438
439 if (cr == AUTH_STORE_CONFLICT)
440 return admin_error(p_arena, "409", "conflict", "Username already exists");
441 if (cr != AUTH_STORE_OK)
442 return admin_error(p_arena, "500", "internal_error", "Create user failed");
443
444 /* Fetch the created record for response. */
445 Auth_User_Record rec;
446 memset(&rec, 0, sizeof(rec));
447 if (Auth_Store_Get_User(store, new_id, &rec) != AUTH_STORE_OK)
448 return admin_error(p_arena, "500", "internal_error", "Fetch failed");
449
450 char *safe_id = Dowa_JSON_Escape_String(rec.id, 0, p_arena);
451 char *safe_user = Dowa_JSON_Escape_String(rec.username, 0, p_arena);
452 char *safe_role = Dowa_JSON_Escape_String(rec.role, 0, p_arena);
453 char *safe_stat = Dowa_JSON_Escape_String(rec.status, 0, p_arena);
454 if (!safe_id || !safe_user || !safe_role || !safe_stat)
455 return admin_error(p_arena, "500", "internal_error", "Encode error");
456
457 char body_buf[512];
458 snprintf(body_buf, sizeof(body_buf),
459 "{\"id\":\"%s\",\"username\":\"%s\",\"role\":\"%s\","
460 "\"status\":\"%s\",\"mustChangePassword\":true,"
461 "\"createdAt\":%lld}",
462 safe_id, safe_user, safe_role, safe_stat,
463 (long long)rec.created_at);
464
465 char *body_copy = Dowa_Arena_Allocate(p_arena, strlen(body_buf) + 1);
466 if (!body_copy)
467 return admin_error(p_arena, "500", "internal_error", "OOM");
468 strcpy(body_copy, body_buf);
469
470 return admin_json_response(p_arena, "201", body_copy);
471 }
472
473 /* ------------------------------------------------------------------ */
474 /* Route: PATCH /api/admin/users/:id (update) */
475 /* ------------------------------------------------------------------ */
476
477 static Seobeo_Request_Entry *admin_update_handler(
478 Seobeo_Request_Entry *p_req,
479 Dowa_Arena *p_arena)
480 {
481 Auth_Principal principal;
482 Seobeo_Request_Entry *err = NULL;
483 if (!admin_require_admin(p_req, &principal, p_arena, FALSE, &err))
484 return err;
485
486 if (!Auth_API_Verify_CSRF(p_req, &principal))
487 return admin_error(p_arena, "403", "csrf_invalid", "CSRF check failed");
488
489 char target_id[ADMIN_UUID_SIZE];
490 if (!admin_get_id_param(p_req, target_id, sizeof(target_id)))
491 return admin_error(p_arena, "400", "bad_request", "Invalid or missing id");
492
493 const char *body_str = admin_req_value(p_req, "Body");
494 if (!body_str)
495 return admin_error(p_arena, "400", "bad_request", "Invalid request body");
496
497 size_t body_len = strlen(body_str);
498 if (body_len > ADMIN_BODY_MAX_BYTES)
499 {
500 OPENSSL_cleanse((char *)body_str, body_len);
501 return admin_error(p_arena, "400", "bad_request", "Invalid request body");
502 }
503 Dowa_JSON_Value jv =
504 Dowa_JSON_Parse(body_str, (int32)body_len, p_arena);
505 OPENSSL_cleanse((char *)body_str, body_len);
506 if (jv.type != DOWA_JSON_OBJECT)
507 return admin_error(p_arena, "400", "bad_request", "Expected JSON object");
508
509 Dowa_JSON_Entry *obj = (Dowa_JSON_Entry *)jv.object_val;
510 char *op_raw = Dowa_JSON_Get_String(obj, "op");
511 char temporary_password[AUTH_CRYPTO_PASSWORD_MAX_BYTES + 1];
512 memset(temporary_password, 0, sizeof(temporary_password));
513 boolean have_temporary_password = admin_extract_secret_field(
514 obj, "temporaryPassword", temporary_password,
515 AUTH_CRYPTO_PASSWORD_MAX_BYTES);
516 if (!op_raw || op_raw[0] == '\0')
517 {
518 OPENSSL_cleanse(temporary_password, sizeof(temporary_password));
519 return admin_error(p_arena, "400", "bad_request", "op field required");
520 }
521
522 Auth_Store *store = Auth_API_Get_Store();
523 Auth_Store_Result res = AUTH_STORE_OK;
524
525 if (strcmp(op_raw, "enable") == 0)
526 {
527 res = Auth_Store_Enable_User(store, target_id, principal.user_id);
528 }
529 else if (strcmp(op_raw, "disable") == 0)
530 {
531 res = Auth_Store_Disable_User_And_Revoke_Sessions(
532 store, target_id, principal.user_id);
533 }
534 else if (strcmp(op_raw, "set_role") == 0)
535 {
536 char *new_role = Dowa_JSON_Get_String(obj, "role");
537 if (!new_role || new_role[0] == '\0')
538 {
539 OPENSSL_cleanse(temporary_password, sizeof(temporary_password));
540 return admin_error(p_arena, "400", "bad_request",
541 "role required for set_role op");
542 }
543 if (strcmp(new_role, "admin") != 0 && strcmp(new_role, "member") != 0)
544 {
545 OPENSSL_cleanse(temporary_password, sizeof(temporary_password));
546 return admin_error(p_arena, "400", "invalid_role",
547 "role must be admin or member");
548 }
549 res = Auth_Store_Update_Role_And_Revoke_Sessions(
550 store, target_id, new_role, principal.user_id);
551 }
552 else if (strcmp(op_raw, "temp_reset") == 0)
553 {
554 if (!have_temporary_password)
555 {
556 OPENSSL_cleanse(
557 temporary_password, sizeof(temporary_password));
558 return admin_error(p_arena, "400", "bad_request",
559 "temporaryPassword required for temp_reset op");
560 }
561 char *pw_raw = temporary_password;
562
563 size_t pw_len = strlen(pw_raw);
564 if (pw_len < 12 || pw_len > AUTH_CRYPTO_PASSWORD_MAX_BYTES)
565 {
566 OPENSSL_cleanse(pw_raw, pw_len);
567 return admin_error(p_arena, "400", "password_policy",
568 "Password must be 12 to 1024 characters");
569 }
570
571 char encoded_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
572 Auth_Crypto_Result hr =
573 Auth_Crypto_Password_Hash(pw_raw, encoded_hash, sizeof(encoded_hash));
574 OPENSSL_cleanse(pw_raw, pw_len);
575
576 if (hr != AUTH_CRYPTO_OK)
577 {
578 OPENSSL_cleanse(encoded_hash, sizeof(encoded_hash));
579 return admin_error(p_arena, "500", "internal_error", "Hash error");
580 }
581
582 res = Auth_Store_Admin_Reset_Password(store, target_id, encoded_hash,
583 principal.user_id);
584 OPENSSL_cleanse(encoded_hash, sizeof(encoded_hash));
585 }
586 else
587 {
588 OPENSSL_cleanse(temporary_password, sizeof(temporary_password));
589 return admin_error(p_arena, "400", "invalid_op",
590 "op must be enable, disable, set_role, or temp_reset");
591 }
592
593 OPENSSL_cleanse(temporary_password, sizeof(temporary_password));
594
595 if (res == AUTH_STORE_NOT_FOUND)
596 return admin_error(p_arena, "404", "not_found", "User not found");
597 if (res == AUTH_STORE_LAST_ADMIN)
598 return admin_error(p_arena, "409", "last_admin",
599 "Cannot remove the last active admin");
600 if (res == AUTH_STORE_INVALID_ARG)
601 return admin_error(p_arena, "400", "bad_request", "Invalid argument");
602 if (res != AUTH_STORE_OK)
603 return admin_error(p_arena, "500", "internal_error", "Update failed");
604
605 /* Return updated user record. */
606 Auth_User_Record rec;
607 memset(&rec, 0, sizeof(rec));
608 if (Auth_Store_Get_User(store, target_id, &rec) != AUTH_STORE_OK)
609 return admin_json_response(p_arena, "200", "{\"ok\":true}");
610
611 char *safe_id = Dowa_JSON_Escape_String(rec.id, 0, p_arena);
612 char *safe_user = Dowa_JSON_Escape_String(rec.username, 0, p_arena);
613 char *safe_role = Dowa_JSON_Escape_String(rec.role, 0, p_arena);
614 char *safe_stat = Dowa_JSON_Escape_String(rec.status, 0, p_arena);
615 if (!safe_id || !safe_user || !safe_role || !safe_stat)
616 return admin_error(p_arena, "500", "internal_error", "Encode error");
617
618 char body_buf[512];
619 snprintf(body_buf, sizeof(body_buf),
620 "{\"id\":\"%s\",\"username\":\"%s\",\"role\":\"%s\","
621 "\"status\":\"%s\",\"mustChangePassword\":%s,"
622 "\"updatedAt\":%lld}",
623 safe_id, safe_user, safe_role, safe_stat,
624 rec.must_change_password ? "true" : "false",
625 (long long)rec.updated_at);
626
627 char *body_copy = Dowa_Arena_Allocate(p_arena, strlen(body_buf) + 1);
628 if (!body_copy)
629 return admin_error(p_arena, "500", "internal_error", "OOM");
630 strcpy(body_copy, body_buf);
631
632 return admin_json_response(p_arena, "200", body_copy);
633 }
634
635 /* ------------------------------------------------------------------ */
636 /* Route: DELETE /api/admin/users/:id/sessions (revoke sessions) */
637 /* ------------------------------------------------------------------ */
638
639 static Seobeo_Request_Entry *admin_revoke_sessions_handler(
640 Seobeo_Request_Entry *p_req,
641 Dowa_Arena *p_arena)
642 {
643 Auth_Principal principal;
644 Seobeo_Request_Entry *err = NULL;
645 if (!admin_require_admin(p_req, &principal, p_arena, FALSE, &err))
646 return err;
647
648 if (!Auth_API_Verify_CSRF(p_req, &principal))
649 return admin_error(p_arena, "403", "csrf_invalid", "CSRF check failed");
650
651 char target_id[ADMIN_UUID_SIZE];
652 if (!admin_get_id_param(p_req, target_id, sizeof(target_id)))
653 return admin_error(p_arena, "400", "bad_request", "Invalid or missing id");
654
655 Auth_Store *store = Auth_API_Get_Store();
656
657 Auth_Store_Result res = Auth_Store_Revoke_All_Sessions_Audited(
658 store, target_id, NULL, principal.user_id);
659 if (res == AUTH_STORE_NOT_FOUND)
660 return admin_error(p_arena, "404", "not_found", "User not found");
661 if (res != AUTH_STORE_OK)
662 return admin_error(p_arena, "500", "internal_error", "Revoke failed");
663
664 return admin_json_response(p_arena, "200", "{\"ok\":true}");
665 }
666
667 /* ------------------------------------------------------------------ */
668 /* Public API */
669 /* ------------------------------------------------------------------ */
670
671 void Admin_API_Register_Routes(void)
672 {
673 Seobeo_Router_Register("GET", "/admin/users", admin_page_handler);
674 Seobeo_Router_Register("GET", "/api/admin/users", admin_list_handler);
675 Seobeo_Router_Register("POST", "/api/admin/users", admin_create_handler);
676 Seobeo_Router_Register("PATCH", "/api/admin/users/:id", admin_update_handler);
677 Seobeo_Router_Register("DELETE", "/api/admin/users/:id/sessions", admin_revoke_sessions_handler);
678 }
679
680 /* ------------------------------------------------------------------ */
681 /* Test hooks */
682 /* ------------------------------------------------------------------ */
683
684 #ifdef ADMIN_API_TEST_HOOKS
685 Seobeo_Request_Entry *Admin_API_Test_Page_Handler(
686 Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena)
687 { return admin_page_handler(p_req, p_arena); }
688
689 Seobeo_Request_Entry *Admin_API_Test_List_Handler(
690 Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena)
691 { return admin_list_handler(p_req, p_arena); }
692
693 Seobeo_Request_Entry *Admin_API_Test_Create_Handler(
694 Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena)
695 { return admin_create_handler(p_req, p_arena); }
696
697 Seobeo_Request_Entry *Admin_API_Test_Update_Handler(
698 Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena)
699 { return admin_update_handler(p_req, p_arena); }
700
701 Seobeo_Request_Entry *Admin_API_Test_Revoke_Sessions_Handler(
702 Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena)
703 { return admin_revoke_sessions_handler(p_req, p_arena); }
704 #endif /* ADMIN_API_TEST_HOOKS */