// Changing your own password. // // This did not exist, and the cost of that was measured rather than guessed: // rotating three production accounts took a shell on the server, three round // trips, and briefly left a PLATFORM ADMIN - the account that reads every // company on the estate - with a password anyone watching could guess, because // a placeholder in a pasted command was taken literally. // // A manager could always reset somebody ELSE's password, and a platform admin // could be reset by nobody at all: they have no client, so the team routes are // not theirs, and `provision user` on the host was the only way. For a product // that puts accounts on shop-floor PCs and staff phones, "change my password" // is not a feature, it is the thing that makes every other credential decision // recoverable. package api import ( "net/http" "github.com/loyaly/behavision-server/internal/auth" ) // handleChangePassword is on `authed`, NOT `tenantOnly`. // // A session is not a company's data. A platform admin has no client and must // still be able to change their own password - they are precisely the account // for which there was no other route. func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) var in ChangePassword if err := decode(w, r, &in); err != nil { badRequest(w, err.Error()) return } // The CURRENT password is required, and that is the whole security // argument. An access token lives twelve hours and travels on shop-floor // devices; without this, anyone holding a stolen one could set a new // password and own the account permanently rather than for the rest of // the day. rec, err := s.Store.UserByEmail(r.Context(), p.Email) if err != nil { s.serverError(w, "change password", err) return } if !rec.Found || !auth.VerifyPassword(rec.PasswordHash, in.CurrentPassword) { // Deliberately not throttled separately: this needs a live session, so // it is not reachable by anyone guessing from outside, and the login // throttle already governs getting one. writeErr(w, http.StatusForbidden, "wrong_password", "That is not your current password.") return } if in.CurrentPassword == in.NewPassword { badRequest(w, "the new password is the same as the old one") return } hash, err := auth.HashPassword(in.NewPassword) if err != nil { // HashPassword enforces the length floor, and its message names it. badRequest(w, err.Error()) return } if err := s.Store.SetUserPassword(r.Context(), p.UserID, hash); err != nil { s.serverError(w, "change password", err) return } // Every OTHER session goes, and the caller's stays. Somebody changing // their password because they think it is known must not have to guess // whether the change took effect on the device that already had it - and // must not be signed out of the one in their hand while they deal with it. revoked, err := s.Store.RevokeOtherSessions(r.Context(), p.UserID, p.SessionID) if err != nil { // The password IS changed. Reporting a failure here would tell the // user to try again, and the retry would fail on the current password // they just replaced. s.logf("change password: revoke other sessions: %v", err) } s.Store.Audit(r.Context(), AuditEntry{ ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user", Action: "auth.change_password", Entity: "user", EntityID: p.UserID, Detail: map[string]any{"sessions_revoked": revoked}, }) writeJSON(w, http.StatusOK, map[string]any{ "changed": true, "sessions_revoked": revoked, }) }