diff options
| -rw-r--r-- | clients/screens_dashboard.c | 66 | ||||
| -rw-r--r-- | clients/tui.c | 2 | ||||
| -rw-r--r-- | docs/PROTOCOL.md | 15 | ||||
| -rw-r--r-- | docs/STATE.md | 7 | ||||
| -rw-r--r-- | docs/TUI-GUIDELINES.md | 7 | ||||
| -rwxr-xr-x | scripts/tui-golden.py | 46 | ||||
| -rw-r--r-- | src/cmd_auth.c | 73 | ||||
| -rw-r--r-- | src/sessions.c | 17 | ||||
| -rw-r--r-- | src/sessions.h | 2 | ||||
| -rw-r--r-- | tests/test_core.c | 96 |
10 files changed, 325 insertions, 6 deletions
diff --git a/clients/screens_dashboard.c b/clients/screens_dashboard.c index 127d4df..45f0aed 100644 --- a/clients/screens_dashboard.c +++ b/clients/screens_dashboard.c @@ -275,9 +275,72 @@ static const char *const MAIN_ITEMS[] = { "Bolaget", "System", "Ingående balans", "Räkenskapsår", "Byt bolag", + "Byt lösenord", "Logga ut / avsluta", }; +#define PASSWORD_MIN_LEN 10 /* user.set_password's server-side minimum */ + +/* Changes the logged-in user's password (user.set_password). Every prompt + is masked; Esc anywhere cancels without a request. */ +static void change_password(struct app *a) +{ + char cur[128] = "", pw[128] = "", again[128] = ""; + if (!tui_prompt_into(cur, sizeof cur, "Nuvarande lösenord: ", "", 1) || + !tui_prompt_into(pw, sizeof pw, "Nytt lösenord (minst 10 tecken): ", + "", 1) || + !tui_prompt_into(again, sizeof again, "Upprepa nytt lösenord: ", "", + 1)) + goto done; + if (strlen(pw) < PASSWORD_MIN_LEN) { + tui_message("Byt lösenord", + "Det nya lösenordet måste ha minst %d tecken.", + PASSWORD_MIN_LEN); + goto done; + } + if (strcmp(pw, again) != 0) { + tui_message("Byt lösenord", "Lösenorden stämmer inte överens."); + goto done; + } + if (strcmp(pw, cur) == 0) { + tui_message("Byt lösenord", + "Det nya lösenordet måste skilja sig från det nuvarande."); + goto done; + } + yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); + yyjson_mut_val *o = yyjson_mut_obj(d); + yyjson_mut_doc_set_root(d, o); + yyjson_mut_obj_add_strcpy(d, o, "current_password", cur); + yyjson_mut_obj_add_strcpy(d, o, "new_password", pw); + char *args = yyjson_mut_write(d, 0, NULL); + yyjson_mut_doc_free(d); + char *resp = client_rpc(&a->conn, "user.set_password", a->session, 0, + args); + if (args) { + memset(args, 0, strlen(args)); + free(args); + } + if (resp && client_ok(resp)) { + int64_t closed = jint_val(resp, "result.sessions_closed", 0); + /* ^R re-logs in with the kept password */ + if (a->password[0]) + snprintf(a->password, sizeof a->password, "%s", pw); + tui_message("Byt lösenord", + "Lösenordet är bytt.%s\n" + "Loggar du in via bokftui-bw: uppdatera lösenordet i " + "Bitwarden.", + closed > 0 ? " Dina andra inloggningar har loggats ut." + : ""); + } else { + show_error("Byt lösenord", resp); + } + free(resp); +done: + memset(cur, 0, sizeof cur); + memset(pw, 0, sizeof pw); + memset(again, 0, sizeof again); +} + /* Picks another org and reloads everything that belongs to the org: its context (name, role, current fiscal year, series) and the remembered list selections. */ @@ -356,6 +419,9 @@ void dashboard(struct app *a) switch_org(a); break; case 13: + change_password(a); + break; + case 14: return; default: break; diff --git a/clients/tui.c b/clients/tui.c index 4bc1d66..43a3d27 100644 --- a/clients/tui.c +++ b/clients/tui.c @@ -596,6 +596,8 @@ static int field_scratch(int y, int x, int width, char *buf, size_t cap, int rc = field_loop(y, x, width, scratch, cap, mask, date, tabs, 1, first); if (rc) snprintf(buf, cap, "%s", scratch); + if (mask) + memset(scratch, 0, cap); /* no password copy left in freed memory */ free(scratch); return rc; } diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index ae1098f..9ab9620 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -86,6 +86,13 @@ and returns an opaque, high-entropy session id: - Sliding TTL, `session_ttl` default 8 h. `session.close` ends one explicitly. - Passwords are stored as Argon2id hashes. Failed logins are rate limited per peer (default: 5 failures per 15 minutes, then `RATE_LIMITED`). +- A user changes their own password with `user.set_password` from a + password session (a token session gets `FORBIDDEN`). The current password + is required — 5 wrong ones per 15 minutes give `RATE_LIMITED` — and the + new one must have at least 10 characters and differ from it. On success + every other session of the user is closed (`sessions_closed`); the call's + own session stays. The audit entry (`user.set_password`, also for a wrong + current password) carries no password. - Token lookups compare SHA-256 hashes in constant time. Token values are shown exactly once at creation and are never logged. @@ -97,7 +104,7 @@ and returns an opaque, high-entropy session id: - Tokens are the intended mechanism for agents and for accountant/viewer access. They can be revoked immediately (`token.revoke`). Scopes are enforced for every command, including admin commands: `backup.snapshot` - and `user.*` need a token with the `admin` scope. + and `user.create`/`user.list` need a token with the `admin` scope. ### 4.3 Roles and permissions @@ -115,6 +122,7 @@ Scopes on a token can narrow but never widen the user's role. | `payroll.agi` (decrypted personnummer), `payroll.tax_tables_fetch/import` | | | ● | | | `org.member_*`, `token.create` for others | | | ● | | | `user.create`, any org | | | | ● | +| `user.set_password` (own password) | ● | ● | ● | ● | | `backup.snapshot` | | | ● | ● | Any authenticated user may create a new org (config `allow_org_create`, @@ -295,6 +303,7 @@ Arguments are shown abbreviated; `describe` is authoritative. | `board.add` / `board.update` / `board.remove` | `name`,`title?` / `id`,`name?`,`title?` / `id` | owner; audited | | `user.create` | `username`, `password`, `display_name`, `is_admin?` | `user` (system admin) | | `user.list` | — | `items[]` (system admin) | +| `user.set_password` | `current_password`, `new_password` | `sessions_closed` (own password; password session only) | | `token.create` | `label`, `scopes[]`, `org`, `expires_at?` | `token` (shown once), `id` | | `token.list` / `token.revoke` | — / `id` | `items[]` / `{}` | @@ -826,6 +835,7 @@ Args: `name:type(values)[!][=default]`, `!` = required. | `session.whoami` | viewer | no | no | no | — | | `session.list_orgs` | viewer | no | no | no | — | | `session.use_org` | viewer | no | no | no | `org:int!` | +| `user.set_password` | viewer | no | yes | no | `current_password:string!`, `new_password:string!` | | `org.create` | viewer | no | yes | yes | `name:string!`, `org_nr:string`, `fiscal_year_start_month:int=1`, `moms_period:enum(month\|quarter\|year)=month`, `framework:enum(K2\|K3)=K2` | | `org.list` | viewer | no | no | no | — | | `org.get` | viewer | yes | no | no | — | @@ -935,7 +945,8 @@ Args: `name:type(values)[!][=default]`, `!` = required. commands. Implemented screens (0.1.0-dev): - **Inloggning** — server, user, password; org picker when several exist. - **Byt bolag** in the main menu reopens the picker during the session. + **Byt bolag** in the main menu reopens the picker during the session; + **Byt lösenord** calls `user.set_password`. - **Dashboard** — status line with org, fiscal year, role and user. - **Verifikat** — list and detail view (rows with column headers, an underlag section separated by a rule, hash, link to corrected voucher); `c` diff --git a/docs/STATE.md b/docs/STATE.md index 31d6b14..128e824 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -14,6 +14,13 @@ unit tests and the docs consistency check. ## Resume here (2026-09-22) +- **Byt lösenord (2026-09-23, branch `eff/set-password`, needs a server + deploy)**: new command `user.set_password` (own password, password + session only, current password required and rate limited like logins, + ≥ 10 characters, other sessions closed, audited without secrets) and a + main-menu item in the TUI. Until the server is deployed the TUI item + answers `UNKNOWN_COMMAND`. Follow-up to consider: an admin reset of + another user's password (today only `user.create` sets one). - **Byt bolag (2026-09-23, branch `eff/switch-org`)**: a main-menu item reopens the org picker and switches org in the running session (context reloaded, list selections cleared). `app_refresh_context` now clears the diff --git a/docs/TUI-GUIDELINES.md b/docs/TUI-GUIDELINES.md index 68316e3..28b7f89 100644 --- a/docs/TUI-GUIDELINES.md +++ b/docs/TUI-GUIDELINES.md @@ -130,7 +130,12 @@ opens the same picker, with the cursor on the current org, and switches the session to the chosen one: `session.use_org`, then the org's name, role, current fiscal year and series are reloaded and the remembered list selections are cleared; `Esc` keeps the current org. `Ctrl+R` keeps the -switched org. The fiscal year is chosen from the dashboard and is changeable +switched org. "Byt lösenord" asks for the current password, the new one +and the new one again in masked prompts (`Esc` in any of them cancels), +checks length (≥ 10), match and difference before calling +`user.set_password`, and reports that the user's other logins were logged +out and that a Bitwarden item has to be updated; `Ctrl+R` keeps working +with the new password. The fiscal year is chosen from the dashboard and is changeable during the session; the `Räkenskapsår` screen also closes and reopens years there. diff --git a/scripts/tui-golden.py b/scripts/tui-golden.py index 0dfde5f..b6aaada 100755 --- a/scripts/tui-golden.py +++ b/scripts/tui-golden.py @@ -689,6 +689,42 @@ SCENARIOS = [ }, ], }, + { + # Keep last: changes the rig's login password, then restores it so + # a rerun of the list still logs in. + "name": "change-password", + "screen": "dashboard", + "expect": ["Byt lösenord", "Logga ut / avsluta"], + "steps": [ + { + "keys": ["g", "lösen", "enter", "enter"], + "expect": ["Nuvarande lösenord:"], + }, + { + "keys": ["{password}", "enter", "kort", "enter", "kort", + "enter"], + "expect": ["minst 10 tecken"], + }, + { + "keys": ["enter", "g", "lösen", "enter", "enter", + "fel-lösenord", "enter", "nyttlosenord99", "enter", + "nyttlosenord99", "enter"], + "expect": ["AUTH_FAILED"], + }, + { + "keys": ["enter", "g", "lösen", "enter", "enter", + "{password}", "enter", "nyttlosenord99", "enter", + "nyttlosenord99", "enter"], + "expect": ["Lösenordet är bytt."], + }, + { + "keys": ["enter", "g", "lösen", "enter", "enter", + "nyttlosenord99", "enter", "{password}", "enter", + "{password}", "enter"], + "expect": ["Lösenordet är bytt."], + }, + ], + }, ] # -------------------------------------------------------------------------- @@ -1098,10 +1134,13 @@ def format_expect(strings, ctx): return [s.format(**ctx) for s in strings] -def resolve_keys(spec): +def resolve_keys(spec, ctx=None): if isinstance(spec, str): spec = [spec] - return "".join(KEYS.get(k, k) for k in spec).encode("utf-8") + text = "".join(KEYS.get(k, k) for k in spec) + if ctx and "{password}" in text: # the rig's random login password + text = text.replace("{password}", ctx["password"]) + return text.encode("utf-8") def report_failure(name, missing, before, current): @@ -1285,6 +1324,7 @@ def main(argv): "fy_label": fy.get("label", ""), "fy_start": fy.get("start_date", ""), "fy_end": fy.get("end_date", ""), + "password": password, } print(f"bokf TUI golden tests: org {org_id} \"{ORG_NAME}\", " f"fy {fy.get('label')} (id {fy.get('id')}), socket {sock}") @@ -1323,7 +1363,7 @@ def main(argv): continue current = before for step in sc.get("steps", []): - keys = resolve_keys(step["keys"]) + keys = resolve_keys(step["keys"], ctx) want = format_expect(step.get("expect", []), ctx) for attempt in range(1, KEY_ATTEMPTS + 1): before = app.render() 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 diff --git a/tests/test_core.c b/tests/test_core.c index 071b5ce..6376517 100644 --- a/tests/test_core.c +++ b/tests/test_core.c @@ -1309,6 +1309,101 @@ static void test_tokens(struct tctx *t) yyjson_doc_free(d); } +static void set_pw(const char *sess, const char *cur, const char *pw, + const char *want_code) +{ + yyjson_doc *d = call(reqf( + "{\"v\":1,\"id\":\"pw\",\"cmd\":\"user.set_password\"," + "\"session\":\"%s\",\"args\":{\"current_password\":\"%s\"," + "\"new_password\":\"%s\"}}", + sess, cur, pw)); + if (want_code) + CHECK_STR(d, "error.code", want_code); + else + CHECK_OK(d); + yyjson_doc_free(d); +} + +static void test_set_password(struct tctx *t) +{ + yyjson_doc *d; + CHECK(login("admin", "secret123")); + d = call(reqf("{\"v\":1,\"id\":\"p1\",\"cmd\":\"user.create\"," + "\"session\":\"%s\",\"args\":{\"username\":\"pwuser\"," + "\"password\":\"oldpassword1\"}}", + g_session)); + CHECK_OK(d); + yyjson_doc_free(d); + + /* two sessions of the same user */ + CHECK(login("pwuser", "oldpassword1")); + char other[128]; + snprintf(other, sizeof other, "%s", g_session); + CHECK(login("pwuser", "oldpassword1")); + char mine[128]; + snprintf(mine, sizeof mine, "%s", g_session); + + set_pw(mine, "wrongpassword", "newpassword22", "AUTH_FAILED"); + set_pw(mine, "oldpassword1", "short", "INVALID_ARGS"); + set_pw(mine, "oldpassword1", "oldpassword1", "INVALID_ARGS"); + d = call(reqf("{\"v\":1,\"id\":\"p2\",\"cmd\":\"user.set_password\"," + "\"session\":\"%s\",\"args\":{\"new_password\":" + "\"newpassword22\"}}", + mine)); + CHECK_STR(d, "error.code", "INVALID_ARGS"); + yyjson_doc_free(d); + /* nothing changed yet: the other session and the old password work */ + d = call(reqf("{\"v\":1,\"id\":\"p3\",\"cmd\":\"session.whoami\"," + "\"session\":\"%s\"}", other)); + CHECK_OK(d); + yyjson_doc_free(d); + + d = call(reqf("{\"v\":1,\"id\":\"p4\",\"cmd\":\"user.set_password\"," + "\"session\":\"%s\",\"args\":{\"current_password\":" + "\"oldpassword1\",\"new_password\":\"newpassword22\"}}", + mine)); + CHECK_OK(d); + CHECK(jint(d, "result.sessions_closed") == 1); + yyjson_doc_free(d); + /* other sessions are closed, this one stays */ + d = call(reqf("{\"v\":1,\"id\":\"p5\",\"cmd\":\"session.whoami\"," + "\"session\":\"%s\"}", other)); + CHECK_STR(d, "error.code", "SESSION_EXPIRED"); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"p6\",\"cmd\":\"session.whoami\"," + "\"session\":\"%s\"}", mine)); + CHECK_OK(d); + CHECK_STR(d, "result.user.username", "pwuser"); + yyjson_doc_free(d); + CHECK(!login("pwuser", "oldpassword1")); + CHECK(login("pwuser", "newpassword22")); + snprintf(mine, sizeof mine, "%s", g_session); + + /* wrong current passwords are rate limited like logins */ + for (int i = 0; i < 5; i++) + set_pw(mine, "wrongpassword", "otherpassword3", "AUTH_FAILED"); + set_pw(mine, "newpassword22", "otherpassword3", "RATE_LIMITED"); + + /* a token session cannot change the password */ + CHECK(login("admin", "secret123")); + d = call(reqf("{\"v\":1,\"id\":\"p7\",\"cmd\":\"token.create\"," + "\"session\":\"%s\",\"org\":%d,\"args\":" + "{\"label\":\"pw-test\",\"scopes\":[\"read\"]}}", + g_session, (int)t->org_id)); + CHECK_OK(d); + char tok[128]; + snprintf(tok, sizeof tok, "%s", jstr(d, "result.token")); + yyjson_doc_free(d); + d = call(reqf("{\"v\":1,\"id\":\"p8\",\"cmd\":\"session.open\",\"args\":" + "{\"method\":\"token\",\"token\":\"%s\"}}", tok)); + CHECK_OK(d); + char tsess[128]; + snprintf(tsess, sizeof tsess, "%s", jstr(d, "result.session")); + yyjson_doc_free(d); + set_pw(tsess, "secret123", "anotherpassword4", "FORBIDDEN"); + CHECK(login("admin", "secret123")); +} + static void test_accounts(struct tctx *t) { yyjson_doc *d; @@ -5801,6 +5896,7 @@ static const struct ttest TESTS[] = { { "session_auth", test_session_auth, "" }, { "org_members", test_org_members, "session_auth" }, { "tokens", test_tokens, "org_members" }, + { "set_password", test_set_password, "org_members" }, { "accounts", test_accounts, "org_members" }, { "fiscal_years", test_fiscal_years, "org_members" }, { "vouchers", test_vouchers, "org_members" }, |
