From e06248ef69b33d102a57aaa31fffcde3240dad9c Mon Sep 17 00:00:00 2001 From: Anders Betts Date: Wed, 23 Sep 2026 10:08:44 +0200 Subject: user.set_password: change your own password; TUI "Byt lösenord" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Needs the current password (wrong ones rate limited like logins) and a password session, requires at least 10 characters, closes the user's other sessions and is audited without secrets. The TUI main menu gets "Byt lösenord" with masked prompts; ^R keeps working with the new password. Masked prompt buffers are wiped before they are freed. Co-Authored-By: Claude Opus 5.5 --- src/cmd_auth.c | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/sessions.c | 17 ++++++++++++++ src/sessions.h | 2 ++ 3 files changed, 92 insertions(+) (limited to 'src') diff --git a/src/cmd_auth.c b/src/cmd_auth.c index 9eb628f..86dab12 100644 --- a/src/cmd_auth.c +++ b/src/cmd_auth.c @@ -414,6 +414,77 @@ static const struct cmd_arg args_session_use_org[] = { { "org", ARG_INT, 1, NULL, NULL, "Org id" }, }; +#define PASSWORD_MIN_LEN 10 + +/* The logged-in user changes their own password. Needs the current one + (wrong guesses count like failed logins) and a password session: a token + must not be able to take over the account. */ +static yyjson_mut_val *h_user_set_password(struct req *r) +{ + if (r->sess->token_id) + return fail(r, "FORBIDDEN", + "log in with your password to change it (not a token)"); + const char *cur = arg_str(r->args, "current_password"); + const char *pw = arg_str(r->args, "new_password"); + if (!cur || !pw) + return fail(r, "INVALID_ARGS", + "current_password and new_password are required"); + if (strlen(pw) < PASSWORD_MIN_LEN) + return failf(r, "INVALID_ARGS", + "new password must be at least %d characters", + PASSWORD_MIN_LEN); + if (strcmp(cur, pw) == 0) + return fail(r, "INVALID_ARGS", + "new password must differ from the current one"); + char key[64]; + snprintf(key, sizeof key, "pw:%lld", (long long)r->sess->user_id); + int64_t retry = 0; + if (rl_blocked(key, &retry)) + return failf(r, "RATE_LIMITED", + "too many wrong passwords; retry in %lld s", + (long long)retry); + + sqlite3_stmt *st = db_prepare_bound( + r->db, "SELECT pw_hash FROM users WHERE id=?1", "i", + r->sess->user_id); + if (!st) + return db_error(r); + char *hash = NULL; + if (sqlite3_step(st) == SQLITE_ROW && sqlite3_column_text(st, 0)) + hash = xstrdup((const char *)sqlite3_column_text(st, 0)); + sqlite3_finalize(st); + int ok = hash && auth_verify_password(hash, cur) == 0; + free(hash); + if (!ok) { + rl_fail(key); + audit_append(r->db, 0, r->sess->user_id, 0, "user.set_password", + "{}", "AUTH_FAILED", NULL); + return fail(r, "AUTH_FAILED", "current password is wrong"); + } + rl_ok(key); + + char *phc = NULL; + if (auth_hash_password(pw, &phc) != 0) + return fail(r, "INTERNAL", "password hashing failed"); + int rc = req_exec(r, "UPDATE users SET pw_hash=?1 WHERE id=?2", "si", phc, + r->sess->user_id); + free(phc); + if (rc != 0) + return NULL; + int closed = sessions_destroy_user(r->sess->user_id, r->sess->id); + audit_append(r->db, 0, r->sess->user_id, 0, "user.set_password", "{}", + "OK", NULL); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "sessions_closed", closed); + return o; +} + +static const struct cmd_arg args_user_set_password[] = { + { "current_password", ARG_STR, 1, NULL, NULL, "Current password" }, + { "new_password", ARG_STR, 1, NULL, NULL, + "New password, at least 10 characters" }, +}; + const struct command g_cmd_auth[] = { { "health", "Liveness probe", PERM_PUBLIC, 0, 0, 0, h_health, NULL, 0 }, { "meta", "Server metadata and limits", PERM_PUBLIC, 0, 0, 0, h_meta, NULL, @@ -428,6 +499,8 @@ const struct command g_cmd_auth[] = { 0, 0, 0, h_session_list_orgs, NULL, 0 }, { "session.use_org", "Switch active org", PERM_READ, 0, 0, 0, h_session_use_org, CMD_ARGS(args_session_use_org) }, + { "user.set_password", "Change your own password", PERM_READ, 0, 1, 0, + h_user_set_password, CMD_ARGS(args_user_set_password) }, }; const struct cmd_table g_cmd_table_auth = { diff --git a/src/sessions.c b/src/sessions.c index 66ec375..21fc1ae 100644 --- a/src/sessions.c +++ b/src/sessions.c @@ -74,6 +74,23 @@ void sessions_destroy(const char *id) } } +int sessions_destroy_user(int64_t user_id, const char *keep_id) +{ + int n = 0; + struct session **pp = &g_sessions; + while (*pp) { + struct session *s = *pp; + if (s->user_id == user_id && (!keep_id || strcmp(s->id, keep_id))) { + *pp = s->next; + free(s); + n++; + continue; + } + pp = &s->next; + } + return n; +} + void sessions_free_all(void) { struct session *s = g_sessions; diff --git a/src/sessions.h b/src/sessions.h index d573b23..9c084e0 100644 --- a/src/sessions.h +++ b/src/sessions.h @@ -23,6 +23,8 @@ struct session *sessions_create(int64_t user_id, int is_admin, const char *scopes); struct session *sessions_get(const char *id); void sessions_destroy(const char *id); +/* Ends every session of user_id except keep_id; returns how many. */ +int sessions_destroy_user(int64_t user_id, const char *keep_id); void sessions_free_all(void); #endif -- cgit v1.3