From b18ffaa85be057f3386d2b6d47af8c93106a751b Mon Sep 17 00:00:00 2001 From: O M Date: Wed, 14 Oct 2026 15:03:14 -0400 Subject: [PATCH] Let console admins reset a forgotten password from an emailed link. Co-authored-by: Cursor --- README.md | 2 +- deploy/helm/opensourceom/values.yaml | 1 + docs/adr/005-console-admins.md | 2 +- internal/api/console_auth.go | 15 ++- internal/api/console_auth_test.go | 163 +++++++++++++++++++++++++++ internal/api/password_auth.go | 78 +++++++++++++ internal/api/server.go | 5 +- internal/api/web/app.js | 109 +++++++++++++++++- internal/api/web/styles.css | 9 ++ internal/auth/auth.go | 3 + internal/auth/mail.go | 12 ++ internal/auth/store_password.go | 102 ++++++++++++++++- internal/auth/store_test.go | 73 ++++++++++++ internal/cmd/serve.go | 14 ++- migrations/006_password_resets.sql | 13 +++ 15 files changed, 587 insertions(+), 14 deletions(-) create mode 100644 migrations/006_password_resets.sql diff --git a/README.md b/README.md index f141fdc..2b0c632 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ The API image compiles `github.com/aws/aws-sdk-go-v2/service/ec2`. Give Docker a The console signs people in with an email and password. While no admin exists, the page asks you to create one. Keep the API port private until you do. Set `OM_ADMIN_EMAIL` when that first address should be fixed in advance. `om admin create --email you@example.com` does the same from the machine before the port is reachable. -An admin adds the next person from the console. That person gets a temporary password, shown once, and chooses their own at the next sign-in. SMTP is optional; when it is set, the temporary password is emailed too. Google and GitHub buttons appear only when those client credentials are set, and the provider email must match an existing admin. Every console account is an admin. Two-factor authentication is optional per admin. +An admin adds the next person from the console. That person gets a temporary password, shown once, and chooses their own at the next sign-in. SMTP is optional. When it is set, the temporary password is emailed, and a forgotten password can be reset from a one-hour link. Google and GitHub buttons appear only when those client credentials are set, and the provider email must match an existing admin. Every console account is an admin. Two-factor authentication is optional per admin. Each admin gets a personal API token, shown once in the console. Collectors, CI, and `curl` send it as `Authorization: Bearer` or `X-API-Key` on every `/v1` call except health, the session probe, and logout. The CLI talks to Postgres and does not use the token. A token cannot add or remove admins. diff --git a/deploy/helm/opensourceom/values.yaml b/deploy/helm/opensourceom/values.yaml index 4cc3bda..2bfa2df 100644 --- a/deploy/helm/opensourceom/values.yaml +++ b/deploy/helm/opensourceom/values.yaml @@ -24,6 +24,7 @@ api: github: clientId: "" clientSecret: "" + # Optional. Sends temporary admin passwords and password-reset links. smtp: host: "" port: 587 diff --git a/docs/adr/005-console-admins.md b/docs/adr/005-console-admins.md index 63f3751..f6a717d 100644 --- a/docs/adr/005-console-admins.md +++ b/docs/adr/005-console-admins.md @@ -18,7 +18,7 @@ People using the console need a login. Scripts that call the HTTP API need a cre - The console signs people in with an email and password. Passwords are stored as Argon2id hashes. There is no shared deployment secret. - While no admin exists, the console shows a setup form. `OM_ADMIN_EMAIL`, when set, is the only address that form accepts. `om admin create --email` creates that account from the machine before the port is reachable. Keep the port private until an admin exists. -- An admin adds the next admin by email. The new account gets a temporary password, shown once in the console, and must choose a new password at the next sign-in. SMTP is optional. When it is configured, the temporary password is emailed as well. +- An admin adds the next admin by email. The new account gets a temporary password, shown once in the console, and must choose a new password at the next sign-in. SMTP is optional. When it is configured, the temporary password is emailed as well, and the sign-in page can email a password-reset link. The link works once, expires in one hour, and does not say whether the address is an admin. Using it sets a new password and ends that admin's sessions. Personal API tokens stay valid. Without SMTP, the page does not offer reset. `om admin reset-password --email` still sets a password from the machine. - Google and GitHub are optional. Buttons appear only when that provider's client id and secret are set. A verified email from the provider must match an existing admin. A new provider account does not become an admin on its own. - Each admin can turn on TOTP two-factor authentication. Recovery codes are shown once. It is not required to finish setup. - Google accounts must have a verified email. GitHub login requests `user:email` and accepts any verified address. diff --git a/internal/api/console_auth.go b/internal/api/console_auth.go index e9fc7b7..da62d90 100644 --- a/internal/api/console_auth.go +++ b/internal/api/console_auth.go @@ -35,6 +35,7 @@ type ConsoleAuth struct { Providers map[string]auth.Provider SetupEmail string SendPassword func(ctx context.Context, to, password string) error + SendReset func(ctx context.Context, to, resetURL string) error } func (s *Server) WithConsoleAuth(cfg ConsoleAuth) *Server { @@ -43,6 +44,7 @@ func (s *Server) WithConsoleAuth(cfg ConsoleAuth) *Server { s.providers = cfg.Providers s.setupEmail = strings.TrimSpace(cfg.SetupEmail) s.sendPassword = cfg.SendPassword + s.sendReset = cfg.SendReset s.secureCookies = strings.HasPrefix(s.publicURL, "https://") if s.loginLimit == nil { s.loginLimit = newLoginLimiter() @@ -67,10 +69,11 @@ func (s *Server) handleAuthSession(w http.ResponseWriter, r *http.Request) { return } body := map[string]any{ - "authenticated": false, - "login_enabled": true, - "needs_setup": count == 0, - "providers": s.providerNames(), + "authenticated": false, + "login_enabled": true, + "needs_setup": count == 0, + "password_reset": s.sendReset != nil, + "providers": s.providerNames(), } if count == 0 && s.setupEmail != "" { body["setup_email"] = s.setupEmail @@ -464,6 +467,10 @@ func publicV1(r *http.Request) bool { return true case r.Method == http.MethodPost && r.URL.Path == "/v1/auth/totp": return true + case r.Method == http.MethodPost && r.URL.Path == "/v1/auth/forgot": + return true + case r.Method == http.MethodPost && r.URL.Path == "/v1/auth/reset": + return true default: return false } diff --git a/internal/api/console_auth_test.go b/internal/api/console_auth_test.go index 90ab8a1..f60168a 100644 --- a/internal/api/console_auth_test.go +++ b/internal/api/console_auth_test.go @@ -133,6 +133,101 @@ func TestConsoleLoginRejectsUnknownAccount(t *testing.T) { } } +func TestForgotPasswordResetsTheAccount(t *testing.T) { + directory := newMemoryDirectory() + var resetTo, resetURL string + var sent int + handler := NewServer(nil).WithConsoleAuth(ConsoleAuth{ + Directory: directory, + PublicURL: "https://om.example", + SendReset: func(_ context.Context, to, link string) error { + sent++ + resetTo, resetURL = to, link + return nil + }, + }).Handler() + + setup := httptest.NewRequest(http.MethodPost, "/v1/auth/setup", strings.NewReader(`{"email":"ada@example.com","password":"correct-horse-battery"}`)) + setupRec := httptest.NewRecorder() + handler.ServeHTTP(setupRec, setup) + if setupRec.Code != http.StatusOK { + t.Fatalf("setup = %d %s", setupRec.Code, setupRec.Body.String()) + } + session := cookieNamed(setupRec.Result().Cookies(), sessionCookie) + + probe := httptest.NewRecorder() + handler.ServeHTTP(probe, httptest.NewRequest(http.MethodGet, "/v1/auth/session", nil)) + if !strings.Contains(probe.Body.String(), `"password_reset":true`) { + t.Fatalf("session = %s", probe.Body.String()) + } + + missing := httptest.NewRecorder() + handler.ServeHTTP(missing, httptest.NewRequest(http.MethodPost, "/v1/auth/forgot", strings.NewReader(`{"email":"missing@example.com"}`))) + if missing.Code != http.StatusOK || sent != 0 { + t.Fatalf("unknown email = %d sent %d", missing.Code, sent) + } + + forgot := httptest.NewRecorder() + handler.ServeHTTP(forgot, httptest.NewRequest(http.MethodPost, "/v1/auth/forgot", strings.NewReader(`{"email":"Ada@Example.com"}`))) + if forgot.Code != http.StatusOK || sent != 1 || resetTo != "ada@example.com" { + t.Fatalf("forgot = %d sent %d to %s", forgot.Code, sent, resetTo) + } + parsed, err := url.Parse(resetURL) + if err != nil { + t.Fatal(err) + } + token := parsed.Query().Get("reset") + if token == "" || !strings.HasPrefix(resetURL, "https://om.example/login?reset=") { + t.Fatalf("reset url = %s", resetURL) + } + + short := httptest.NewRecorder() + handler.ServeHTTP(short, httptest.NewRequest(http.MethodPost, "/v1/auth/reset", strings.NewReader(`{"token":"`+token+`","password":"short"}`))) + if short.Code != http.StatusBadRequest { + t.Fatalf("short password = %d %s", short.Code, short.Body.String()) + } + + reset := httptest.NewRecorder() + handler.ServeHTTP(reset, httptest.NewRequest(http.MethodPost, "/v1/auth/reset", strings.NewReader(`{"token":"`+token+`","password":"a-new-password-ok"}`))) + if reset.Code != http.StatusNoContent { + t.Fatalf("reset = %d %s", reset.Code, reset.Body.String()) + } + + oldSession := httptest.NewRecorder() + rules := httptest.NewRequest(http.MethodGet, "/v1/rules", nil) + rules.AddCookie(session) + handler.ServeHTTP(oldSession, rules) + if oldSession.Code != http.StatusUnauthorized { + t.Fatalf("old session = %d", oldSession.Code) + } + + reuse := httptest.NewRecorder() + handler.ServeHTTP(reuse, httptest.NewRequest(http.MethodPost, "/v1/auth/reset", strings.NewReader(`{"token":"`+token+`","password":"another-password-ok"}`))) + if reuse.Code != http.StatusBadRequest { + t.Fatalf("reuse = %d %s", reuse.Code, reuse.Body.String()) + } + + oldLogin := httptest.NewRecorder() + handler.ServeHTTP(oldLogin, httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"ada@example.com","password":"correct-horse-battery"}`))) + if oldLogin.Code != http.StatusUnauthorized { + t.Fatalf("old password = %d", oldLogin.Code) + } + newLogin := httptest.NewRecorder() + handler.ServeHTTP(newLogin, httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"ada@example.com","password":"a-new-password-ok"}`))) + if newLogin.Code != http.StatusOK { + t.Fatalf("new password = %d %s", newLogin.Code, newLogin.Body.String()) + } +} + +func TestForgotPasswordRequiresMail(t *testing.T) { + handler := NewServer(nil).WithConsoleAuth(ConsoleAuth{Directory: newMemoryDirectory()}).Handler() + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/auth/forgot", strings.NewReader(`{"email":"ada@example.com"}`))) + if recorder.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d %s", recorder.Code, recorder.Body.String()) + } +} + func TestAuthSessionStaysOpen(t *testing.T) { handler := (&Server{}).Handler() recorder := httptest.NewRecorder() @@ -170,9 +265,15 @@ type memoryDirectory struct { previews map[string]string passwords map[string]string challenges map[string]string + resets map[string]memoryReset plainToken string } +type memoryReset struct { + adminID string + expires time.Time +} + type memoryInvite struct { expires time.Time consumed bool @@ -187,6 +288,7 @@ func newMemoryDirectory() *memoryDirectory { previews: map[string]string{}, passwords: map[string]string{}, challenges: map[string]string{}, + resets: map[string]memoryReset{}, } } @@ -536,6 +638,67 @@ func (d *memoryDirectory) ChangePassword(_ context.Context, adminID, current, ne return auth.ErrNotFound } +func (d *memoryDirectory) RequestPasswordReset(_ context.Context, email string) (string, error) { + email, err := auth.NormalizeEmail(email) + if err != nil { + return "", nil + } + d.mu.Lock() + defer d.mu.Unlock() + var admin auth.Admin + found := false + for _, candidate := range d.admins { + if candidate.Provider == auth.ProviderPassword && strings.EqualFold(candidate.Email, email) && d.passwords[email] != "" { + admin = candidate + found = true + break + } + } + if !found { + return "", nil + } + for hash, reset := range d.resets { + if reset.adminID == admin.ID { + delete(d.resets, hash) + } + } + token := "reset-" + admin.ID + d.resets[auth.HashToken(token)] = memoryReset{adminID: admin.ID, expires: time.Now().Add(time.Hour)} + return token, nil +} + +func (d *memoryDirectory) CompletePasswordReset(_ context.Context, token, password string) error { + if err := auth.ValidatePassword(password); err != nil { + return err + } + if token == "" || len(token) > 128 { + return auth.ErrResetInvalid + } + d.mu.Lock() + defer d.mu.Unlock() + key := auth.HashToken(token) + reset, ok := d.resets[key] + if !ok || !reset.expires.After(time.Now()) { + delete(d.resets, key) + return auth.ErrResetInvalid + } + delete(d.resets, key) + for i, admin := range d.admins { + if admin.ID != reset.adminID { + continue + } + d.passwords[admin.Email] = password + d.admins[i].MustChangePassword = false + for hash, id := range d.sessions { + if id == admin.ID { + delete(d.sessions, hash) + } + } + return nil + } + return auth.ErrResetInvalid +} + func (d *memoryDirectory) SetPassword(ctx context.Context, email, password string) error { admin, found, err := d.FindByEmail(ctx, email) if err != nil || !found { diff --git a/internal/api/password_auth.go b/internal/api/password_auth.go index 696c6ab..5dbe768 100644 --- a/internal/api/password_auth.go +++ b/internal/api/password_auth.go @@ -6,7 +6,10 @@ package api import ( "encoding/json" "errors" + "log" "net/http" + "net/url" + "strings" "time" "github.com/OpenSourceOM/core/internal/auth" @@ -133,6 +136,81 @@ func (s *Server) handleAuthTOTP(w http.ResponseWriter, r *http.Request) { s.startSession(w, r, admin) } +func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if s.admins == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "console login is not available"}) + return + } + var req struct { + Email string `json:"email"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Enter an email address."}) + return + } + if s.tooManyAttempts(r, "reset") || s.tooManyAttempts(r, "reset:"+req.Email) { + writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "Too many attempts. Try again later."}) + return + } + s.noteFailure(r, "reset") + s.noteFailure(r, "reset:"+req.Email) + if s.sendReset == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Password reset email is not configured."}) + return + } + email, err := auth.NormalizeEmail(req.Email) + if err == nil { + token, resetErr := s.admins.RequestPasswordReset(r.Context(), email) + if resetErr != nil { + writeAdminStoreError(w, resetErr) + return + } + if token != "" { + if err := s.sendReset(r.Context(), email, resetLink(s.publicURL, token)); err != nil { + log.Printf("password reset email: %v", err) + } + } + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if s.admins == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "console login is not available"}) + return + } + var req struct { + Token string `json:"token"` + Password string `json:"password"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Enter a new password."}) + return + } + if s.tooManyAttempts(r, "reset-token") { + writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "Too many attempts. Try again later."}) + return + } + err := s.admins.CompletePasswordReset(r.Context(), req.Token, req.Password) + switch { + case err == nil: + w.WriteHeader(http.StatusNoContent) + case errors.Is(err, auth.ErrPasswordShort): + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Use at least 12 characters."}) + case errors.Is(err, auth.ErrResetInvalid): + s.noteFailure(r, "reset-token") + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "That reset link is not valid. Request a new one."}) + default: + writeAdminStoreError(w, err) + } +} + +func resetLink(publicURL, token string) string { + return strings.TrimRight(publicURL, "/") + "/login?reset=" + url.QueryEscape(token) +} + func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") admin, ok := s.requireAdmin(w, r) diff --git a/internal/api/server.go b/internal/api/server.go index 3467d71..b50c488 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -37,6 +37,7 @@ type Server struct { providers map[string]auth.Provider setupEmail string sendPassword func(ctx context.Context, to, password string) error + sendReset func(ctx context.Context, to, resetURL string) error loginLimit *loginLimiter secureCookies bool } @@ -74,6 +75,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /v1/auth/login", s.handleAuthLogin) mux.HandleFunc("POST /v1/auth/totp", s.handleAuthTOTP) mux.HandleFunc("POST /v1/auth/password", s.handleChangePassword) + mux.HandleFunc("POST /v1/auth/forgot", s.handleForgotPassword) + mux.HandleFunc("POST /v1/auth/reset", s.handleResetPassword) mux.HandleFunc("POST /v1/auth/totp/setup", s.handleTOTPSetup) mux.HandleFunc("POST /v1/auth/totp/confirm", s.handleTOTPConfirm) mux.HandleFunc("POST /v1/auth/totp/disable", s.handleTOTPDisable) @@ -91,7 +94,7 @@ func (s *Server) Handler() http.Handler { } // requireAPIAuth demands a console session or that admin's API token on every -// /v1 route except health, the session probe, and logout. The HTML stays +// /v1 route except the public auth routes in publicV1. The HTML stays // open so the login page can load. Admin management still requires a session. func (s *Server) requireAPIAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/web/app.js b/internal/api/web/app.js index 884231c..da125a2 100644 --- a/internal/api/web/app.js +++ b/internal/api/web/app.js @@ -12,6 +12,7 @@ let network = null; let graphData = { nodes: [], edges: [] }; let lastGraphNodes = []; let currentAdmin = null; +let gateSession = null; const providerLabels = { google: "Google", github: "GitHub" }; const authErrors = { @@ -137,8 +138,10 @@ function showSignedOut(message) { } function renderGate(session) { + gateSession = session; const params = new URLSearchParams(window.location.search); const errorCode = params.get("error"); + const reset = params.get("reset"); const providers = session.providers || []; const gate = document.getElementById("gate"); gate.classList.remove("hidden"); @@ -151,12 +154,17 @@ function renderGate(session) { if (!session.login_enabled) { title = "Console login is not available"; message = "The API process does not have an admin store."; + } else if (reset && reset !== "done") { + title = "Choose a new password"; + message = "This link works once and expires one hour after it was sent."; } else if (params.get("totp") === "1") { title = "Two-factor authentication"; message = "Enter the code from your authenticator app, or a recovery code."; } else if (session.needs_setup) { title = "Create the admin account"; message = "Choose an email and password. Whoever can open this page first becomes the admin, so keep the port private until you finish."; + } else if (reset === "done") { + message = "Password saved. Sign in with the new one."; } if (errorCode && authErrors[errorCode]) { @@ -168,6 +176,10 @@ function renderGate(session) { const actions = document.getElementById("gate-actions"); actions.replaceChildren(); if (!session.login_enabled) return; + if (reset && reset !== "done") { + actions.appendChild(resetPasswordForm(reset)); + return; + } if (params.get("totp") === "1") { actions.appendChild(totpForm()); return; @@ -176,7 +188,7 @@ function renderGate(session) { actions.appendChild(setupForm(session.setup_email || "")); return; } - actions.appendChild(loginForm()); + actions.appendChild(loginForm(Boolean(session.password_reset))); providers.forEach((provider) => { const link = document.createElement("a"); link.className = "button"; @@ -243,7 +255,7 @@ function setupForm(pinnedEmail) { return form; } -function loginForm() { +function loginForm(resetEnabled) { const form = document.createElement("form"); form.className = "auth-form"; const email = field("email", "email", "Email"); @@ -253,6 +265,18 @@ function loginForm() { submit.textContent = "Sign in"; const status = formStatus(); form.append(email, password, submit, status); + if (resetEnabled) { + const forgot = document.createElement("button"); + forgot.type = "button"; + forgot.className = "link"; + forgot.textContent = "Forgot password?"; + forgot.addEventListener("click", () => { + document.getElementById("gate-title").textContent = "Reset password"; + document.getElementById("gate-message").textContent = "Enter the email on the admin account. If it uses a password, we will send a reset link."; + document.getElementById("gate-actions").replaceChildren(forgotForm(email.value)); + }); + form.append(forgot); + } form.addEventListener("submit", async (event) => { event.preventDefault(); submit.disabled = true; @@ -275,6 +299,82 @@ function loginForm() { return form; } +function showSignIn() { + if (window.location.pathname !== "/login" || window.location.search) { + window.history.replaceState({}, "", "/login"); + } + renderGate(gateSession || { login_enabled: true, providers: [], needs_setup: false }); +} + +function forgotForm(prefill) { + const form = document.createElement("form"); + form.className = "auth-form"; + const email = field("email", "email", "Email", prefill); + if (prefill) email.readOnly = false; + email.autocomplete = "email"; + const submit = document.createElement("button"); + submit.type = "submit"; + submit.textContent = "Send reset link"; + const back = document.createElement("button"); + back.type = "button"; + back.className = "link"; + back.textContent = "Back to sign in"; + back.addEventListener("click", showSignIn); + const status = formStatus(); + form.append(email, submit, back, status); + form.addEventListener("submit", async (event) => { + event.preventDefault(); + submit.disabled = true; + try { + await fetchJSON("/v1/auth/forgot", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: email.value }), + }); + status.textContent = "If that email has a password, a reset link is on its way. It expires in one hour."; + } catch (err) { + status.textContent = err.message; + submit.disabled = false; + } + }); + return form; +} + +function resetPasswordForm(token) { + const form = document.createElement("form"); + form.className = "auth-form"; + const password = field("password", "password", "New password"); + password.autocomplete = "new-password"; + password.minLength = 12; + const confirm = field("password", "confirm", "Confirm password"); + confirm.autocomplete = "new-password"; + const submit = document.createElement("button"); + submit.type = "submit"; + submit.textContent = "Save password"; + const status = formStatus(); + form.append(password, confirm, submit, status); + form.addEventListener("submit", async (event) => { + event.preventDefault(); + if (password.value !== confirm.value) { + status.textContent = "Passwords do not match."; + return; + } + submit.disabled = true; + try { + await fetchJSON("/v1/auth/reset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, password: password.value }), + }); + window.location.href = "/login?reset=done"; + } catch (err) { + status.textContent = err.message; + submit.disabled = false; + } + }); + return form; +} + function totpForm() { const form = document.createElement("form"); form.className = "auth-form"; @@ -826,6 +926,11 @@ document.getElementById("admins-btn").addEventListener("click", () => { localStorage.removeItem("om_api_key"); try { const session = await fetchJSON("/v1/auth/session"); + const reset = new URLSearchParams(window.location.search).get("reset"); + if (reset && reset !== "done") { + renderGate(session); + return; + } if (session.authenticated) { showConsole(session); await loadConsole(); diff --git a/internal/api/web/styles.css b/internal/api/web/styles.css index 1210428..fb328ea 100644 --- a/internal/api/web/styles.css +++ b/internal/api/web/styles.css @@ -45,6 +45,15 @@ header h1 { margin: 0; font-size: 1.4rem; } .gate p { color: var(--muted); line-height: 1.5; } .gate-actions { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center; } .auth-form { display: grid; gap: 0.55rem; width: min(100%, 22rem); } +.auth-form button.link { + justify-self: start; + background: none; + border: 0; + padding: 0; + color: var(--accent); + cursor: pointer; + font: inherit; +} .auth-form input, .gate input { background: #0a1224; color: var(--text); diff --git a/internal/auth/auth.go b/internal/auth/auth.go index dc1aeec..3a90124 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -39,6 +39,7 @@ var ( ErrPasswordShort = errors.New("password too short") ErrEmailTaken = errors.New("email taken") ErrBadCredentials = errors.New("invalid email or password") + ErrResetInvalid = errors.New("password reset invalid") ErrSetupEmail = errors.New("setup email mismatch") ErrTOTPInvalid = errors.New("invalid authentication code") ErrTOTPEnabled = errors.New("two-factor authentication is already on") @@ -91,6 +92,8 @@ type Directory interface { CheckPassword(ctx context.Context, email, password string) (Admin, error) ChangePassword(ctx context.Context, adminID, current, next string) error SetPassword(ctx context.Context, email, password string) error + RequestPasswordReset(ctx context.Context, email string) (token string, err error) + CompletePasswordReset(ctx context.Context, token, password string) error BeginTOTP(ctx context.Context, adminID string) (secret string, err error) ConfirmTOTP(ctx context.Context, adminID, code string) (recoveryCodes []string, err error) DisableTOTP(ctx context.Context, adminID, password, code string) error diff --git a/internal/auth/mail.go b/internal/auth/mail.go index ceb047a..4ee74da 100644 --- a/internal/auth/mail.go +++ b/internal/auth/mail.go @@ -39,6 +39,18 @@ func (m InviteMailer) SendTemporaryPassword(ctx context.Context, to, password, l return m.deliver(ctx, to, body) } +func (m InviteMailer) SendPasswordReset(ctx context.Context, to, resetURL string) error { + to, err := NormalizeEmail(to) + if err != nil { + return err + } + body := fmt.Sprintf( + "To: %s\r\nFrom: %s\r\nSubject: Reset your OpenSourceOM password\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nA password reset was requested for %s.\r\n\r\nOpen this link to choose a new password. It works once and expires in one hour:\r\n%s\r\n\r\nIf you did not request this, you can ignore this email.\r\n", + to, m.From, to, resetURL, + ) + return m.deliver(ctx, to, body) +} + func (m InviteMailer) deliver(ctx context.Context, to, body string) error { if !m.Enabled() { return fmt.Errorf("smtp is not configured") diff --git a/internal/auth/store_password.go b/internal/auth/store_password.go index 369ac4a..ee511dd 100644 --- a/internal/auth/store_password.go +++ b/internal/auth/store_password.go @@ -11,7 +11,10 @@ import ( "github.com/jackc/pgx/v5" ) -const loginChallengeTTL = 5 * time.Minute +const ( + loginChallengeTTL = 5 * time.Minute + passwordResetTTL = time.Hour +) func (s *Store) FindByEmail(ctx context.Context, email string) (Admin, bool, error) { email, err := NormalizeEmail(email) @@ -152,6 +155,103 @@ func (s *Store) SetPassword(ctx context.Context, email, password string) error { return s.storePassword(ctx, email, password, false) } +// RequestPasswordReset stores a one-hour token for a password admin. +// An unknown address returns an empty token and no error. +func (s *Store) RequestPasswordReset(ctx context.Context, email string) (string, error) { + email, err := NormalizeEmail(email) + if err != nil { + return "", nil + } + admin, hash, err := s.passwordRecord(ctx, email) + if err != nil { + return "", err + } + if admin.ID == "" || hash == "" { + return "", nil + } + raw, err := NewToken() + if err != nil { + return "", err + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return "", err + } + defer tx.Rollback(ctx) + if _, err := tx.Exec(ctx, `DELETE FROM admin_password_resets WHERE admin_id = $1`, admin.ID); err != nil { + return "", err + } + if _, err := tx.Exec(ctx, ` + INSERT INTO admin_password_resets (id, admin_id, expires_at) + VALUES ($1, $2, $3) + `, HashToken(raw), admin.ID, time.Now().UTC().Add(passwordResetTTL)); err != nil { + return "", err + } + if err := tx.Commit(ctx); err != nil { + return "", err + } + return raw, nil +} + +// CompletePasswordReset sets the password from a single-use token and ends that admin's sessions. +func (s *Store) CompletePasswordReset(ctx context.Context, token, password string) error { + if err := ValidatePassword(password); err != nil { + return err + } + if token == "" || len(token) > 128 { + return ErrResetInvalid + } + encoded, err := HashPassword(password) + if err != nil { + return err + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + var adminID string + var expires time.Time + err = tx.QueryRow(ctx, ` + DELETE FROM admin_password_resets + WHERE id = $1 + RETURNING admin_id::text, expires_at + `, HashToken(token)).Scan(&adminID, &expires) + if errors.Is(err, pgx.ErrNoRows) { + return ErrResetInvalid + } + if err != nil { + return err + } + if !expires.After(time.Now()) { + if err := tx.Commit(ctx); err != nil { + return err + } + return ErrResetInvalid + } + tag, err := tx.Exec(ctx, ` + UPDATE admins + SET password_hash = $1, must_change_password = false + WHERE id = $2 AND provider = 'password' + `, encoded, adminID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrResetInvalid + } + if _, err := tx.Exec(ctx, `DELETE FROM admin_sessions WHERE admin_id = $1`, adminID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `DELETE FROM admin_login_challenges WHERE admin_id = $1`, adminID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `DELETE FROM admin_password_resets WHERE admin_id = $1`, adminID); err != nil { + return err + } + return tx.Commit(ctx) +} + func (s *Store) BeginTOTP(ctx context.Context, adminID string) (string, error) { var enabled bool err := s.pool.QueryRow(ctx, `SELECT totp_enabled FROM admins WHERE id = $1`, adminID).Scan(&enabled) diff --git a/internal/auth/store_test.go b/internal/auth/store_test.go index 4af3dff..c3f082a 100644 --- a/internal/auth/store_test.go +++ b/internal/auth/store_test.go @@ -129,6 +129,79 @@ func TestStoreInviteSessionAndLastAdmin(t *testing.T) { } } +func TestPasswordResetReplacesThePasswordAndSessions(t *testing.T) { + store := openAuthStore(t) + ctx := context.Background() + admin, err := store.SetupAdmin(ctx, "ada@example.com", "correct-horse-battery", "") + if err != nil { + t.Fatal(err) + } + if _, err := store.CreateWithPassword(ctx, "temp@example.com", "temporary-password", true); err != nil { + t.Fatal(err) + } + if token, err := store.RequestPasswordReset(ctx, "nobody@example.com"); err != nil || token != "" { + t.Fatalf("unknown = %q err=%v", token, err) + } + if _, err := store.pool.Exec(ctx, ` + INSERT INTO admins (provider, subject, login, email) + VALUES ('github', '9', 'octo', 'octo@example.com') + `); err != nil { + t.Fatal(err) + } + if token, err := store.RequestPasswordReset(ctx, "octo@example.com"); err != nil || token != "" { + t.Fatalf("oauth = %q err=%v", token, err) + } + + first, err := store.RequestPasswordReset(ctx, "Ada@Example.com") + if err != nil || first == "" { + t.Fatalf("first token = %q err=%v", first, err) + } + second, err := store.RequestPasswordReset(ctx, "ada@example.com") + if err != nil || second == "" || second == first { + t.Fatalf("second token = %q err=%v", second, err) + } + if err := store.CompletePasswordReset(ctx, first, "another-password-ok"); err != ErrResetInvalid { + t.Fatalf("replaced token = %v", err) + } + if err := store.CompletePasswordReset(ctx, second, "short"); err != ErrPasswordShort { + t.Fatalf("short = %v", err) + } + session, _, err := store.CreateSession(ctx, admin.ID) + if err != nil { + t.Fatal(err) + } + if err := store.CompletePasswordReset(ctx, second, "a-new-password-ok"); err != nil { + t.Fatal(err) + } + if _, err := store.AdminForSession(ctx, session); err != ErrNotFound { + t.Fatalf("session after reset = %v", err) + } + if _, err := store.CheckPassword(ctx, "ada@example.com", "correct-horse-battery"); err != ErrBadCredentials { + t.Fatalf("old password = %v", err) + } + if _, err := store.CheckPassword(ctx, "ada@example.com", "a-new-password-ok"); err != nil { + t.Fatalf("new password = %v", err) + } + if err := store.CompletePasswordReset(ctx, second, "another-password-ok"); err != ErrResetInvalid { + t.Fatalf("reuse = %v", err) + } + + tempToken, err := store.RequestPasswordReset(ctx, "temp@example.com") + if err != nil || tempToken == "" { + t.Fatal(err) + } + if _, err := store.pool.Exec(ctx, `UPDATE admin_password_resets SET expires_at = now() - interval '1 minute' WHERE id = $1`, HashToken(tempToken)); err != nil { + t.Fatal(err) + } + if err := store.CompletePasswordReset(ctx, tempToken, "another-password-ok"); err != ErrResetInvalid { + t.Fatalf("expired = %v", err) + } + loaded, found, err := store.FindByEmail(ctx, "temp@example.com") + if err != nil || !found || !loaded.MustChangePassword { + t.Fatalf("temp admin = %+v found=%v err=%v", loaded, found, err) + } +} + func TestStoreExpiredInvite(t *testing.T) { store := openAuthStore(t) ctx := context.Background() diff --git a/internal/cmd/serve.go b/internal/cmd/serve.go index 0e06375..1f30ae4 100644 --- a/internal/cmd/serve.go +++ b/internal/cmd/serve.go @@ -35,12 +35,14 @@ var serveCmd = &cobra.Command{ warnOAuthConfig(cfg) providers := auth.ProvidersFromConfig(cfg.GoogleClientID, cfg.GoogleClientSecret, cfg.GitHubClientID, cfg.GitHubClientSecret) + sendPassword, sendReset := consoleMail(cfg) server := api.NewServer(store).WithConsoleAuth(api.ConsoleAuth{ Directory: admins, PublicURL: cfg.PublicURL, Providers: providers, SetupEmail: cfg.AdminEmail, - SendPassword: passwordSender(cfg), + SendPassword: sendPassword, + SendReset: sendReset, }) addr := fmt.Sprintf(":%d", cfg.APIPort) return server.ListenAndServe(addr) @@ -56,7 +58,7 @@ func warnOAuthConfig(cfg config.Config) { } } -func passwordSender(cfg config.Config) func(context.Context, string, string) error { +func consoleMail(cfg config.Config) (func(context.Context, string, string) error, func(context.Context, string, string) error) { mailer := auth.InviteMailer{ Host: cfg.SMTPHost, Port: cfg.SMTPPort, @@ -65,13 +67,17 @@ func passwordSender(cfg config.Config) func(context.Context, string, string) err From: cfg.SMTPFrom, } if !mailer.Enabled() { - return nil + return nil, nil } loginURL := strings.TrimRight(cfg.PublicURL, "/") if loginURL == "" { loginURL = "/" } - return func(ctx context.Context, to, password string) error { + sendPassword := func(ctx context.Context, to, password string) error { return mailer.SendTemporaryPassword(ctx, to, password, loginURL) } + sendReset := func(ctx context.Context, to, resetURL string) error { + return mailer.SendPasswordReset(ctx, to, resetURL) + } + return sendPassword, sendReset } diff --git a/migrations/006_password_resets.sql b/migrations/006_password_resets.sql new file mode 100644 index 0000000..e12d4f8 --- /dev/null +++ b/migrations/006_password_resets.sql @@ -0,0 +1,13 @@ +-- Copyright 2026 OpenSourceOM +-- SPDX-License-Identifier: Apache-2.0 +-- One-time password reset links. The raw token is emailed; only its hash is stored. + +CREATE TABLE IF NOT EXISTS admin_password_resets ( + id TEXT PRIMARY KEY, + admin_id UUID NOT NULL REFERENCES admins (id) ON DELETE CASCADE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_admin_password_resets_admin + ON admin_password_resets (admin_id);