mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
feat(passwd): self-service password reset across git, mail & chat (#59)
Add a key-gated `ssh passwd@host` route (alias `password@`) that sets ONE
member-chosen password across every service with its own credential:
- git (Forgejo) new forgejo.SetPassword (PATCH /admin/users, clears
must_change; EnsureUser first so the account exists)
- mail (Mailu webmail) existing mailu.SetPassword
- chat (IRC/Ergo + The Lounge) new internal/ircpass package
Because the route authenticates by the member's registered SSH key, it also
serves as the forgot-password path — no old password required.
The BBS runs as a non-root service user, but the Ergo password store and The
Lounge user files are root-owned. internal/ircpass bridges this by shelling out
to scripts/set-irc-password.sh through a narrow sudoers rule (installed by
setup.sh). The new password travels on stdin (a new `set-irc-password.sh
<member> -` form), so it never appears in the process table or sudo's log.
UX: masked entry typed twice (readSecret); no-PTY reads stdin; empty input
generates a strong password and shows it once. Each service leg is independent
and best-effort with a per-service ✓/✗ summary, plus a confirmation email that
never contains the password.
Tests: ircpass (stdin contract + member/password rejection), forgejo.SetPassword,
auth IsPasswdName + reservation. Docs: credentials.md (passwd@ section) + irc.md.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f2bcb7e063
commit
54da317f4e
11 changed files with 625 additions and 3 deletions
|
|
@ -113,6 +113,16 @@ func IsMailName(u string) bool { return MailNames[strings.ToLower(u)] }
|
|||
// management TUI (operator-gated).
|
||||
func IsFilesAdminName(u string) bool { return FilesAdminNames[strings.ToLower(u)] }
|
||||
|
||||
// PasswdNames route a member into the self-service password reset: a key-gated
|
||||
// flow that sets ONE new password across every downstream service that has its
|
||||
// own credential — git (Forgejo), mail (Mailu webmail), and chat (IRC/The Lounge).
|
||||
// Because the member is authenticated by their registered SSH key, this doubles
|
||||
// as the "forgot password" path: no old password is required.
|
||||
var PasswdNames = map[string]bool{"passwd": true, "password": true}
|
||||
|
||||
// IsPasswdName reports whether the SSH username requests the password reset flow.
|
||||
func IsPasswdName(u string) bool { return PasswdNames[strings.ToLower(u)] }
|
||||
|
||||
// MsgNames route a member-to-member message: `ssh msg@host <user>` leaves a
|
||||
// note in the recipient's BBS inbox (store-and-forward, see the Members plugin).
|
||||
var MsgNames = map[string]bool{"msg": true, "message": true}
|
||||
|
|
@ -137,7 +147,8 @@ func IsReservedName(name string) bool {
|
|||
n := strings.ToLower(name)
|
||||
if GuestNames[n] || PodNames[n] || JoinNames[n] || DomainNames[n] || AdminNames[n] ||
|
||||
TorURLNames[n] || TorIRCNames[n] || TorNames[n] || IRCNames[n] || NewsNames[n] ||
|
||||
MailNames[n] || FilesAdminNames[n] || MsgNames[n] || GameNames[n] || systemReserved[n] {
|
||||
MailNames[n] || FilesAdminNames[n] || MsgNames[n] || GameNames[n] ||
|
||||
PasswdNames[n] || systemReserved[n] {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(n, "video-") // video-<code> call routes
|
||||
|
|
|
|||
|
|
@ -15,6 +15,25 @@ func TestIsAdminName(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestIsPasswdName(t *testing.T) {
|
||||
for _, name := range []string{"passwd", "PASSWD", "password", "Password"} {
|
||||
if !IsPasswdName(name) {
|
||||
t.Errorf("IsPasswdName(%q) = false, want true", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"pass", "pw", "anthony", ""} {
|
||||
if IsPasswdName(name) {
|
||||
t.Errorf("IsPasswdName(%q) = true, want false", name)
|
||||
}
|
||||
}
|
||||
// The route names must not be claimable as account names.
|
||||
for _, name := range []string{"passwd", "password"} {
|
||||
if _, ok := SanitizeUsername(name); ok {
|
||||
t.Errorf("SanitizeUsername(%q) should be reserved", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminsAllowlist(t *testing.T) {
|
||||
t.Setenv("AGENTBBS_ADMINS", "anthony, Root ops")
|
||||
admins := Admins()
|
||||
|
|
|
|||
|
|
@ -142,6 +142,38 @@ func (c Config) EnsureUserReset(username, email string) (created bool, password
|
|||
return false, pw, nil
|
||||
}
|
||||
|
||||
// SetPassword sets an existing account's password to the member-chosen value and
|
||||
// clears must_change_password (they picked it, so don't force another change on
|
||||
// next sign-in). Unlike EnsureUserReset it never generates a password and never
|
||||
// creates the account: the caller is a member resetting their own credential
|
||||
// across services, and the Forgejo account is expected to already exist (it is
|
||||
// created at email-verification time). A missing account is reported as an error
|
||||
// so the caller can surface "no git account yet" rather than silently succeeding.
|
||||
func (c Config) SetPassword(username, password string) error {
|
||||
if !c.Configured() {
|
||||
return fmt.Errorf("forgejo not configured")
|
||||
}
|
||||
exists, err := c.userExists(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("forgejo user %q does not exist", username)
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"password": password,
|
||||
"must_change_password": false,
|
||||
})
|
||||
status, resp, err := c.do(http.MethodPatch, "/admin/users/"+username, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return fmt.Errorf("forgejo set password %q: %d: %s", username, status, truncate(resp, 200))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureKey registers an SSH public key on the member's Forgejo account so the
|
||||
// key they use for the BBS is also their git push key ("BBS membership is the
|
||||
// git account"). It is idempotent: added is false when the same key material is
|
||||
|
|
|
|||
|
|
@ -148,6 +148,60 @@ func TestEnsureUserResetPatchesWhenExists(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSetPasswordPatchesChosenPassword(t *testing.T) {
|
||||
var body map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/users/alice":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"id":1}`))
|
||||
case r.Method == http.MethodPatch && r.URL.Path == "/api/v1/admin/users/alice":
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"id":1}`))
|
||||
default:
|
||||
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := Config{BaseURL: srv.URL, Token: "secret"}
|
||||
if err := c.SetPassword("alice", "member-chosen-pw"); err != nil {
|
||||
t.Fatalf("SetPassword: %v", err)
|
||||
}
|
||||
if body["password"] != "member-chosen-pw" {
|
||||
t.Errorf("sent password %v, want member-chosen-pw", body["password"])
|
||||
}
|
||||
// They chose it, so don't force another change on next sign-in.
|
||||
if body["must_change_password"] != false {
|
||||
t.Errorf("expected must_change_password=false, got %v", body["must_change_password"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPasswordErrorsWhenMissing(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
t.Errorf("must not PATCH a non-existent user (%s %s)", r.Method, r.URL.Path)
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := Config{BaseURL: srv.URL, Token: "secret"}
|
||||
if err := c.SetPassword("ghost", "pw"); err == nil {
|
||||
t.Fatal("expected an error when the account does not exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPasswordUnconfigured(t *testing.T) {
|
||||
if err := (Config{}).SetPassword("alice", "pw"); err == nil {
|
||||
t.Fatal("expected an error when Forgejo is not configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureUserNoOpWhenExists(t *testing.T) {
|
||||
created := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
|
|||
128
internal/ircpass/ircpass.go
Normal file
128
internal/ircpass/ircpass.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
// Package ircpass sets a member's chat/IRC password from the (non-root) BBS
|
||||
// process. The Ergo password store (/var/lib/ergo/irc-passwd, ergo:ergo 0600)
|
||||
// and each member's The Lounge user file are root-owned, so the BBS — which runs
|
||||
// as an unprivileged service user — cannot write them directly. Instead it shells
|
||||
// out to scripts/set-irc-password.sh through a narrow sudo rule (installed by
|
||||
// setup.sh) that lets only that one command run as root for a single member.
|
||||
//
|
||||
// This is the chat leg of the unified "reset my password everywhere" flow
|
||||
// (passwd@): git (Forgejo) and mail (Mailu) are set in-process via their admin
|
||||
// APIs; chat is set here. The script writes the Ergo pbkdf2 hash AND syncs the
|
||||
// member's The Lounge saslPassword, so native IRC clients and the web client both
|
||||
// keep working with the same secret.
|
||||
//
|
||||
// Config (env):
|
||||
//
|
||||
// AGENTBBS_SET_IRC_PASSWD path to set-irc-password.sh (enables the chat leg)
|
||||
// AGENTBBS_SET_IRC_SUDO "1" (default) to invoke it via sudo; "0" to call it
|
||||
// directly (e.g. when the BBS already runs as root, or
|
||||
// in tests). When sudo is used the binary is taken from
|
||||
// AGENTBBS_SUDO_BIN (default "sudo").
|
||||
package ircpass
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config locates the privileged helper and how to invoke it.
|
||||
type Config struct {
|
||||
Script string // path to set-irc-password.sh; empty disables the chat leg
|
||||
UseSudo bool // run the script through sudo
|
||||
SudoBin string // sudo binary (default "sudo")
|
||||
}
|
||||
|
||||
// ConfigFromEnv reads the chat-password settings from the environment.
|
||||
func ConfigFromEnv() Config {
|
||||
return Config{
|
||||
Script: strings.TrimSpace(os.Getenv("AGENTBBS_SET_IRC_PASSWD")),
|
||||
UseSudo: os.Getenv("AGENTBBS_SET_IRC_SUDO") != "0",
|
||||
SudoBin: env("AGENTBBS_SUDO_BIN", "sudo"),
|
||||
}
|
||||
}
|
||||
|
||||
// Configured reports whether the chat password can actually be set (the helper
|
||||
// script path is set). When false, callers skip the chat leg and say so.
|
||||
func (c Config) Configured() bool { return c.Script != "" }
|
||||
|
||||
// SetPassword sets member's chat/IRC password by running the privileged helper as
|
||||
// `set-irc-password.sh <member> -` (optionally via sudo), feeding the password on
|
||||
// STDIN. Passing it on stdin — not argv — keeps it out of the process table (ps)
|
||||
// and out of sudo's command log. member is the authenticated SSH account name; we
|
||||
// still reject anything that isn't a plain account token as defence in depth, so
|
||||
// it can never be read as a flag or path.
|
||||
func (c Config) SetPassword(member, password string) error {
|
||||
if !c.Configured() {
|
||||
return fmt.Errorf("chat password helper not configured")
|
||||
}
|
||||
if !validMember(member) {
|
||||
return fmt.Errorf("invalid member name %q", member)
|
||||
}
|
||||
if password == "" || strings.ContainsAny(password, "\r\n") {
|
||||
return fmt.Errorf("invalid password")
|
||||
}
|
||||
|
||||
// "-" tells the helper to read the password from stdin.
|
||||
name, args := c.Script, []string{member, "-"}
|
||||
if c.UseSudo {
|
||||
name = c.SudoBin
|
||||
args = []string{"-n", c.Script, member, "-"}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
// Never let the helper inherit the BBS environment wholesale; pass only the
|
||||
// store/Lounge paths it reads, so an operator override flows through.
|
||||
cmd.Env = passthroughEnv()
|
||||
cmd.Stdin = strings.NewReader(password + "\n")
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("set-irc-password: %v: %s", err, strings.TrimSpace(out.String()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validMember accepts the same charset the BBS allows for account names
|
||||
// ([a-z0-9-], the output of auth.SanitizeUsername) so a member string can never
|
||||
// smuggle a flag or path separator into the helper's argv.
|
||||
func validMember(m string) bool {
|
||||
if m == "" || len(m) > 32 || strings.HasPrefix(m, "-") {
|
||||
return false
|
||||
}
|
||||
for _, r := range m {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// passthroughEnv builds a minimal environment for the helper: PATH plus the few
|
||||
// AGENTBBS_/ERGO_ knobs that select the password store and Lounge user dir.
|
||||
func passthroughEnv() []string {
|
||||
keep := []string{"PATH", "ERGO_IRC_PASSWD", "AGENTBBS_LOUNGE_USERS"}
|
||||
var env []string
|
||||
for _, k := range keep {
|
||||
if v, ok := os.LookupEnv(k); ok {
|
||||
env = append(env, k+"="+v)
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func env(k, def string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
75
internal/ircpass/ircpass_test.go
Normal file
75
internal/ircpass/ircpass_test.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package ircpass
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfiguredRequiresScript(t *testing.T) {
|
||||
if (Config{}).Configured() {
|
||||
t.Fatal("empty config should not be Configured")
|
||||
}
|
||||
if !(Config{Script: "/x/set-irc-password.sh"}).Configured() {
|
||||
t.Fatal("config with a script path should be Configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPasswordRunsHelperWithArgs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "args.txt")
|
||||
script := filepath.Join(dir, "set-irc-password.sh")
|
||||
// A fake helper mirroring the real contract: member in $1, "-" in $2, and the
|
||||
// password on stdin. Records member + stdin so the test can assert both.
|
||||
body := "#!/bin/sh\nread pw\nprintf '%s\\n%s\\n%s\\n' \"$1\" \"$2\" \"$pw\" > " + out + "\n"
|
||||
if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
c := Config{Script: script, UseSudo: false}
|
||||
if err := c.SetPassword("alice", "s3cret-pw"); err != nil {
|
||||
t.Fatalf("SetPassword: %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// member as argv[0], "-" sentinel as argv[1], password only on stdin.
|
||||
want := "alice\n-\ns3cret-pw\n"
|
||||
if string(got) != want {
|
||||
t.Fatalf("helper got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPasswordRejectsBadMember(t *testing.T) {
|
||||
c := Config{Script: "/bin/true", UseSudo: false}
|
||||
for _, bad := range []string{"", "-rf", "a b", "alice;rm", "../etc", "Alice"} {
|
||||
if err := c.SetPassword(bad, "pw"); err == nil {
|
||||
t.Fatalf("expected error for member %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPasswordRejectsBadPassword(t *testing.T) {
|
||||
for _, bad := range []string{"", "with\nnewline", "carriage\rreturn"} {
|
||||
if err := (Config{Script: "/bin/true"}).SetPassword("alice", bad); err == nil {
|
||||
t.Fatalf("expected error for password %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPasswordUnconfigured(t *testing.T) {
|
||||
if err := (Config{}).SetPassword("alice", "pw"); err == nil ||
|
||||
!strings.Contains(err.Error(), "not configured") {
|
||||
t.Fatalf("want not-configured error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPasswordSurfacesHelperFailure(t *testing.T) {
|
||||
c := Config{Script: "/bin/false", UseSudo: false}
|
||||
if err := c.SetPassword("alice", "pw"); err == nil {
|
||||
t.Fatal("expected error when helper exits non-zero")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue