mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
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>
127 lines
3.8 KiB
Go
127 lines
3.8 KiB
Go
package auth
|
|
|
|
import "testing"
|
|
|
|
func TestIsAdminName(t *testing.T) {
|
|
for _, name := range []string{"admin", "ADMIN", "sysop", "root"} {
|
|
if !IsAdminName(name) {
|
|
t.Errorf("IsAdminName(%q) = false, want true", name)
|
|
}
|
|
}
|
|
for _, name := range []string{"bbs", "pod", "anthony", ""} {
|
|
if IsAdminName(name) {
|
|
t.Errorf("IsAdminName(%q) = true, want false", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
for _, want := range []string{"anthony", "root", "ops"} {
|
|
if !admins[want] {
|
|
t.Errorf("expected %q in allowlist, got %v", want, admins)
|
|
}
|
|
}
|
|
if !IsAdmin("ANTHONY") {
|
|
t.Error("IsAdmin should be case-insensitive")
|
|
}
|
|
if IsAdmin("eve") {
|
|
t.Error("eve must not be an admin")
|
|
}
|
|
}
|
|
|
|
func TestAdminsEmpty(t *testing.T) {
|
|
t.Setenv("AGENTBBS_ADMINS", "")
|
|
if len(Admins()) != 0 {
|
|
t.Error("empty env should yield no admins")
|
|
}
|
|
if IsAdmin("anyone") {
|
|
t.Error("nobody is admin when allowlist is empty")
|
|
}
|
|
}
|
|
|
|
func TestSanitizeUsername(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
want string
|
|
ok bool
|
|
}{
|
|
{"anthony", "anthony", true},
|
|
{" Cool_Name 42 ", "cool-name-42", true},
|
|
{"a--b__c", "a-b-c", true},
|
|
{"-Edge--", "edge", true},
|
|
{"MixedCASE", "mixedcase", true},
|
|
{"ab", "ab", false}, // too short
|
|
{"!!", "", false}, // nothing usable
|
|
{"this-name-is-way-too-long-to-accept", "", false}, // >20 after... actually long
|
|
{"admin", "admin", false}, // reserved (route/infra)
|
|
{"pod", "pod", false}, // reserved route
|
|
{"video-7f3a", "video-7f3a", false}, // reserved call route
|
|
{"WWW", "www", false}, // reserved infra label
|
|
}
|
|
for _, c := range cases {
|
|
got, ok := SanitizeUsername(c.in)
|
|
if ok != c.ok {
|
|
t.Errorf("SanitizeUsername(%q) ok=%v, want %v (got name %q)", c.in, ok, c.ok, got)
|
|
}
|
|
// For valid results the cleaned name must match; for invalid ones we
|
|
// only assert the usability flag (the cleaned form is advisory).
|
|
if c.ok && got != c.want {
|
|
t.Errorf("SanitizeUsername(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsReservedName(t *testing.T) {
|
|
for _, n := range []string{"admin", "bbs", "pod", "join", "domain", "agent", "www", "video", "video-abc", "ROOT"} {
|
|
if !IsReservedName(n) {
|
|
t.Errorf("IsReservedName(%q) = false, want true", n)
|
|
}
|
|
}
|
|
for _, n := range []string{"anthony", "cool-name-42", "member-zafztqdk"} {
|
|
if IsReservedName(n) {
|
|
t.Errorf("IsReservedName(%q) = true, want false", n)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsFilesAdminName(t *testing.T) {
|
|
for _, name := range []string{"sftp", "SFTP", "sftpadmin", "filesadmin"} {
|
|
if !IsFilesAdminName(name) {
|
|
t.Errorf("IsFilesAdminName(%q) = false, want true", name)
|
|
}
|
|
}
|
|
for _, name := range []string{"files", "bbs", "anthony", ""} {
|
|
if IsFilesAdminName(name) {
|
|
t.Errorf("IsFilesAdminName(%q) = true, want false", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFilesAdminNamesReserved(t *testing.T) {
|
|
for _, name := range []string{"sftp", "sftpadmin", "filesadmin", "mail"} {
|
|
if !IsReservedName(name) {
|
|
t.Errorf("IsReservedName(%q) = false, want true (route name)", name)
|
|
}
|
|
}
|
|
}
|