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