mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(web): invite-only signup by default; harden signup postures
A hub on a public URL could be signed up to with any fake email. Make the safe posture the default and turn the dangerous ones into startup errors. Three supported postures: - invite-only (NEW default): allow_signup defaults false. A valid org invite link bootstraps an account even when self-signup is closed — the only way in. pageLogin/pageSignup detect a /join/<token> target via BuiltinAuth.InviteValid (wired to OrgDB.ValidInvite) and offer account creation; signupInvited skips the domain/approval/verification gates and activates immediately (the owner's invite is the vetting). - approval-gated: allow_signup + require_approval (no SMTP needed). - domain-restricted + verified: allow_signup + allowed_domains + require_verification (needs smtp). Hardening: - BuiltinAuth.ValidateSignupPolicy (run at hub startup in web.go) refuses to boot an open hub with no gate, or require_verification without a mailer (the link would otherwise only reach the server log). - handleAdminPolicy rejects enabling verification without SMTP; the UI toggle is disabled with clearer copy in that case. Tests: TestValidateSignupPolicy, TestInviteBootstrapsAccountWhenSignupClosed, TestPolicyVerificationNeedsMailer. Verified end to end in the running hub (outside-domain invitee onboards via a link; direct signup stays closed). Docs updated (README, CLAUDE.md, SKILL.md). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
fd29352dd5
commit
bd7ea16200
@@ -43,7 +43,7 @@ Package roles (`internal/`):
|
||||
|
||||
`cmd/bdrive/` is a thin cobra CLI over these packages (`login`, `init`, `stop`, `sync`, `status`, `log`, `remote`, `web`, `whoami`, `daemon`, `version` — `mnt`/`umnt` are gone; `init` is the front door and `stop` pauses). `bdrive login` signs the device in (bare form uses the remembered server or `config.DefaultServer` = beardrive.ai; loopback-callback browser flow in `login.go`, `--device` for headless) and stores server+token+account in `settings.json`. `bdrive init` is interactive on a TTY (survey menus: create-new vs connect-existing with a project list; whole-folder vs `--shared <dir>`, which becomes the include list) with full flag bypass (`--name/--project/--shared/--yes`) and never prompts without a TTY; it runs the login flow first when there is no session, writes `.bdrive/config.json`, seeds `.bdriveignore`, and starts sync via `startSync`; re-running it resumes — including after a folder move. `bdrive web -c config.json` configures the server from a file, explicit flags winning.
|
||||
|
||||
Authentication (`webapp/auth.go`, `authlocal.go`, `mail.go`) is **mandatory in hub mode** — the config's `auth` block only tunes `users_db`/`allow_signup`/`smtp`; the plain-folder viewer stays auth-free — and sits behind the `AuthProvider` interface — the OSS server ships only `BuiltinAuth` (email+password accounts and device tokens in a file-backed `auth.json`; bcrypt for passwords, SHA-256 digests for tokens, plaintext never stored; server-owned `/auth/*` pages; one-time codes for the CLI callback and device flows; SMTP reset mail with a log-link fallback). A managed deployment can swap in a different provider (e.g. PropelAuth) without touching the CLI or API — keep provider-specific code out of this repo. The sync client picks up its token from `BDRIVE_TOKEN` or `settings.json` and sends `X-Bdrive-Device{,-Name,-Os}` headers (`remote/http.go`); the hub's file-backed device registry (`webapp/devices.go`) records per-device name/OS/account/server-observed IP. Journal ops carry the signed-in account (`Op.User`/`UserName` from `Session.Account`; `Author` remains the git/OS fallback). History (`webapp/history.go`): `GET /api/p/<id>/history?path=|prefix=` (newest first, device-registry join) and `GET /api/p/<id>/blob?sha=` stream any exact version — blobs are retained forever, so the future revert phase is just re-putting an old blob as a new op. Share links (`webapp/shares.go`, file-backed `shares.json`): any signed-in member mints `/s/<token>` public URLs (`bdrive share`, or the UI's Share button) serving the file's LATEST content until revoked (optional expiry); `/s/*` responses are sandboxed (CSP `sandbox allow-scripts`, no auth cookies) so shared HTML can't attack hub sessions — keep that header on any change; `/s/*` also sits behind a per-IP token bucket (`ratelimit.go`, `share_rpm` config), and markdown share pages get a "Shared with BearDrive" footer (raw HTML is never injected into).
|
||||
Authentication (`webapp/auth.go`, `authlocal.go`, `mail.go`) is **mandatory in hub mode** — the config's `auth` block tunes `users_db`/`allow_signup`/`allowed_domains`/`require_verification`/`require_approval`/`admins`/`smtp`; the plain-folder viewer stays auth-free — and sits behind the `AuthProvider` interface — the OSS server ships only `BuiltinAuth` (email+password accounts and device tokens in a file-backed `auth.json`; bcrypt for passwords, SHA-256 digests for tokens, plaintext never stored; server-owned `/auth/*` pages; one-time codes for the CLI callback and device flows; SMTP reset mail with a log-link fallback). **Signup is invite-only by default** (`allow_signup` defaults false): a valid org invite bootstraps an account even when self-signup is closed — `BuiltinAuth.InviteValid` (wired to `OrgDB.ValidInvite`) lets `pageSignup`/`pageLogin` offer account creation for a `/join/<token>` target, and `signupInvited` skips the domain/verification/approval gates and activates immediately (the invite is the vetting). `BuiltinAuth.ValidateSignupPolicy` (called at hub startup, `web.go`) refuses an ungated open hub and email-verification-without-SMTP rather than silently leaving the door open. The three postures: invite-only (default), approval-gated (`require_approval`), and domain-restricted+verified (`allowed_domains`+`require_verification`+`smtp`); `allow_signup`/`allowed_domains`/`admins` stay server-config-owned so a browser session can't widen access. A managed deployment can swap in a different provider (e.g. PropelAuth) without touching the CLI or API — keep provider-specific code out of this repo. The sync client picks up its token from `BDRIVE_TOKEN` or `settings.json` and sends `X-Bdrive-Device{,-Name,-Os}` headers (`remote/http.go`); the hub's file-backed device registry (`webapp/devices.go`) records per-device name/OS/account/server-observed IP. Journal ops carry the signed-in account (`Op.User`/`UserName` from `Session.Account`; `Author` remains the git/OS fallback). History (`webapp/history.go`): `GET /api/p/<id>/history?path=|prefix=` (newest first, device-registry join) and `GET /api/p/<id>/blob?sha=` stream any exact version — blobs are retained forever, so the future revert phase is just re-putting an old blob as a new op. Share links (`webapp/shares.go`, file-backed `shares.json`): any signed-in member mints `/s/<token>` public URLs (`bdrive share`, or the UI's Share button) serving the file's LATEST content until revoked (optional expiry); `/s/*` responses are sandboxed (CSP `sandbox allow-scripts`, no auth cookies) so shared HTML can't attack hub sessions — keep that header on any change; `/s/*` also sits behind a per-IP token bucket (`ratelimit.go`, `share_rpm` config), and markdown share pages get a "Shared with BearDrive" footer (raw HTML is never injected into).
|
||||
|
||||
## Invariants — do not break these
|
||||
|
||||
|
||||
@@ -187,8 +187,13 @@ the positional argument), `--upload` (allow client writes, off by default),
|
||||
"projects_db": "/var/lib/bdrive/projects.json",
|
||||
"share_rpm": 120, // per-IP rate limit on public /s/* links
|
||||
"auth": { // optional knobs; hub auth is always on
|
||||
// Signup is invite-only by default. To allow self-service signup,
|
||||
// open it WITH a gate (an ungated open hub is refused at startup):
|
||||
"allow_signup": true,
|
||||
"allowed_domains": ["example.com"], // only these domains may sign up
|
||||
"require_approval": true, // …and an admin must approve each one
|
||||
"users_db": "/var/lib/bdrive/auth.json",
|
||||
"admins": ["admin@example.com"],
|
||||
"smtp": { "host": "smtp.example.com", "port": 587,
|
||||
"user": "drive@example.com", "pass": "…", "from": "drive@example.com" }
|
||||
}
|
||||
@@ -306,8 +311,22 @@ session; only `/api/config` and the auth pages stay open (the plain-folder
|
||||
viewer, `bdrive web ./folder`, remains auth-free). Accounts are
|
||||
email + password + name, kept in a file-backed registry (`auth.json`:
|
||||
bcrypt password hashes and SHA-256 token digests, atomically rewritten —
|
||||
no plaintext credentials ever touch disk). Sign-up is open by default;
|
||||
`"allow_signup": false` closes it once the team is onboarded.
|
||||
no plaintext credentials ever touch disk).
|
||||
|
||||
**Signup is invite-only by default** — the safe posture for a hub on a
|
||||
public URL. New people get in only through an expiring invite link an owner
|
||||
mints; the link lets them create an account (bypassing the gates below) and
|
||||
join, in one step. To allow self-service signup instead, set
|
||||
`"allow_signup": true` **with a gate** — the server refuses to start an open
|
||||
hub that has none, so a fake email can never just walk in. Three postures:
|
||||
|
||||
- **Invite-only** (default): `allow_signup` unset/false. Only invite links create accounts.
|
||||
- **Approval-gated**: `allow_signup: true` + `require_approval: true` — anyone can sign up, but a hub admin approves each new account before it works (no SMTP needed).
|
||||
- **Domain-restricted + verified**: `allow_signup: true` + `allowed_domains: ["you.com"]` + `require_verification: true` (needs `smtp`) — only your company's addresses may sign up, each confirming an emailed link. Verification without SMTP is refused (the link would otherwise only reach the server log).
|
||||
|
||||
Admins tune verification/approval live from the web UI (**Admin → Signup &
|
||||
access**); `allowed_domains`, the admin list, and `allow_signup` are
|
||||
server-config-owned so a browser session can never widen who gets in.
|
||||
|
||||
`bdrive login <url>` on a client device opens the server's sign-in page in
|
||||
a browser (sign up right there if needed); when the user signs in, the
|
||||
|
||||
+12
-1
@@ -211,7 +211,10 @@ credentials); otherwise it is relayed through this server.`,
|
||||
// stays auth-free.
|
||||
if srv.Root != nil {
|
||||
usersDB := ""
|
||||
allowSignup := true
|
||||
// Invite-only by default: a fresh hub doesn't accept self-signup
|
||||
// until an admin opts in (with a gate). Owners onboard people
|
||||
// with /join/<token> invite links.
|
||||
allowSignup := false
|
||||
var mail *webapp.Mailer
|
||||
if cfg.Auth != nil {
|
||||
usersDB = cfg.Auth.UsersDB
|
||||
@@ -256,12 +259,20 @@ credentials); otherwise it is relayed through this server.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
// Refuse to boot an incoherent signup posture (open with no
|
||||
// gate, or verification without a mailer) rather than silently
|
||||
// leaving the door open.
|
||||
if err := auth.ValidateSignupPolicy(); err != nil {
|
||||
return err
|
||||
}
|
||||
srv.Auth = auth
|
||||
orgs, err := webapp.OpenOrgDB(filepath.Join(filepath.Dir(projectsDB), "orgs.json"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("open org registry: %w", err)
|
||||
}
|
||||
srv.Orgs = orgs
|
||||
// Invite links can bootstrap an account on an invite-only hub.
|
||||
auth.InviteValid = orgs.ValidInvite
|
||||
// A hub that predates organizations: sweep its projects into
|
||||
// a default org so it keeps working with zero manual steps.
|
||||
if err := webapp.MigrateOrgs(srv.Projects, orgs, auth.Accounts()); err != nil {
|
||||
|
||||
@@ -147,6 +147,12 @@ func (s *Server) handleAdminPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Email verification is only a real gate with a mailer; refuse to turn
|
||||
// it on without SMTP rather than silently logging links.
|
||||
if req.RequireVerification && a.Mail == nil {
|
||||
http.Error(w, "email verification needs SMTP configured on the server", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := a.SetPolicy(req.RequireVerification, req.RequireApproval); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -134,7 +134,7 @@ func TestSignupDisabled(t *testing.T) {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "does not allow self-signup") {
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "invite-only") {
|
||||
t.Fatalf("signup while disabled: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if len(auth.users) != 0 {
|
||||
|
||||
+103
-10
@@ -37,6 +37,12 @@ type BuiltinAuth struct {
|
||||
Admins map[string]bool // hub admins (lowercase emails): approve users, govern shares
|
||||
Brand string // optional name shown on the sign-in page
|
||||
|
||||
// InviteValid, when set, reports whether a token is a live org invite.
|
||||
// It lets an invite link bootstrap an account on an invite-only hub
|
||||
// (AllowSignup false) — the one path in without self-signup. Wired to
|
||||
// OrgDB.ValidInvite by the server. Nil → no invite-based signup.
|
||||
InviteValid func(token string) bool
|
||||
|
||||
path string
|
||||
|
||||
mu sync.Mutex
|
||||
@@ -200,13 +206,27 @@ func (a *BuiltinAuth) findByEmail(email string) *authUser {
|
||||
return nil
|
||||
}
|
||||
|
||||
// signup creates a self-service account, subject to the domain allowlist and
|
||||
// starting in the state the gating policy dictates.
|
||||
func (a *BuiltinAuth) signup(email, name, password string) (*authUser, error) {
|
||||
return a.createAccount(email, name, password, false)
|
||||
}
|
||||
|
||||
// signupInvited creates an account from a valid invite link. An invite is an
|
||||
// explicit grant by an owner, so it is the vetting: the domain allowlist and
|
||||
// the approval/verification gates are bypassed and the account is active. The
|
||||
// caller must have already checked the invite token is live.
|
||||
func (a *BuiltinAuth) signupInvited(email, name, password string) (*authUser, error) {
|
||||
return a.createAccount(email, name, password, true)
|
||||
}
|
||||
|
||||
func (a *BuiltinAuth) createAccount(email, name, password string, viaInvite bool) (*authUser, error) {
|
||||
email = strings.TrimSpace(strings.ToLower(email))
|
||||
name = strings.TrimSpace(name)
|
||||
if email == "" || !strings.Contains(email, "@") {
|
||||
return nil, fmt.Errorf("a valid email is required")
|
||||
}
|
||||
if !a.domainAllowed(email) {
|
||||
if !viaInvite && !a.domainAllowed(email) {
|
||||
return nil, fmt.Errorf("this server only accepts %s email addresses", a.domainList())
|
||||
}
|
||||
if name == "" {
|
||||
@@ -219,6 +239,10 @@ func (a *BuiltinAuth) signup(email, name, password string) (*authUser, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status := a.initialStatus()
|
||||
if viaInvite {
|
||||
status = statusActive
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.findByEmail(email) != nil {
|
||||
@@ -226,7 +250,7 @@ func (a *BuiltinAuth) signup(email, name, password string) (*authUser, error) {
|
||||
}
|
||||
u := &authUser{
|
||||
ID: "u-" + randHex(4), Email: email, Name: name,
|
||||
Pass: string(hash), Status: a.initialStatus(), Created: time.Now().UTC(),
|
||||
Pass: string(hash), Status: status, Created: time.Now().UTC(),
|
||||
}
|
||||
a.users[u.ID] = u
|
||||
if err := a.save(); err != nil {
|
||||
@@ -249,6 +273,26 @@ func (a *BuiltinAuth) initialStatus() string {
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateSignupPolicy rejects incoherent signup configurations at startup so
|
||||
// a hub is never accidentally left open to fake-email signups. The three
|
||||
// supported postures are: invite-only (AllowSignup false — the default),
|
||||
// approval-gated, and domain-restricted with email verification.
|
||||
//
|
||||
// - Open self-signup must carry at least one gate (allowed domains, admin
|
||||
// approval, or email verification). Without one, anyone can register any
|
||||
// address — the exact hole this guards.
|
||||
// - Email verification needs a mailer: without SMTP the link only reaches
|
||||
// the server log, so it can't actually gate real users.
|
||||
func (a *BuiltinAuth) ValidateSignupPolicy() error {
|
||||
if a.RequireVerification && a.Mail == nil {
|
||||
return fmt.Errorf("auth: require_verification needs an smtp mailer — without one the verification link only reaches the server log; configure auth.smtp or turn verification off")
|
||||
}
|
||||
if a.AllowSignup && len(a.AllowedDomains) == 0 && !a.RequireApproval && !a.RequireVerification {
|
||||
return fmt.Errorf("auth: open self-signup has no gate, so anyone could register any email — set allow_signup:false (invite-only, the default), or add allowed_domains, require_approval, or require_verification")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// afterVerify is the state a just-verified account moves to.
|
||||
func (a *BuiltinAuth) afterVerify() string {
|
||||
if a.RequireApproval {
|
||||
@@ -536,6 +580,40 @@ func safeNext(next string) string {
|
||||
return next
|
||||
}
|
||||
|
||||
// inviteTokenFromNext pulls an org-invite token out of a post-login target
|
||||
// like "/join/<token>". Tokens are lowercase hex.
|
||||
func inviteTokenFromNext(next string) string {
|
||||
const marker = "/join/"
|
||||
i := strings.Index(next, marker)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
tok := next[i+len(marker):]
|
||||
if j := strings.IndexAny(tok, "/?&#"); j >= 0 {
|
||||
tok = tok[:j]
|
||||
}
|
||||
if tok == "" {
|
||||
return ""
|
||||
}
|
||||
for _, c := range tok {
|
||||
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
// invitedVia returns the invite token in next when it points at a live invite,
|
||||
// so the login/signup pages can let an invitee create an account even on an
|
||||
// invite-only hub. Empty string when there's no valid invite.
|
||||
func (a *BuiltinAuth) invitedVia(next string) string {
|
||||
tok := inviteTokenFromNext(next)
|
||||
if tok != "" && a.InviteValid != nil && a.InviteValid(tok) {
|
||||
return tok
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---- pages ----
|
||||
|
||||
func authPage(w http.ResponseWriter, title, body string) {
|
||||
@@ -600,13 +678,20 @@ func (a *BuiltinAuth) pageLogin(w http.ResponseWriter, r *http.Request) {
|
||||
errMsg = `<p class="err">Wrong email or password.</p>`
|
||||
}
|
||||
}
|
||||
// Offer account creation when public signup is open, or when the visitor
|
||||
// arrived through a valid invite (the way into an invite-only hub).
|
||||
signup := ""
|
||||
if a.AllowSignup {
|
||||
invited := a.invitedVia(next) != ""
|
||||
if a.AllowSignup || invited {
|
||||
note := ""
|
||||
if len(a.AllowedDomains) > 0 {
|
||||
if a.AllowSignup && len(a.AllowedDomains) > 0 {
|
||||
note = ` <span style="color:#868b93">(` + html.EscapeString(a.domainList()) + ` only)</span>`
|
||||
}
|
||||
signup = fmt.Sprintf(`<p class="alt">No account? <a href="/auth/signup?next=%s">Sign up</a>%s</p>`, url.QueryEscape(next), note)
|
||||
label := "No account?"
|
||||
if invited && !a.AllowSignup {
|
||||
label = "New here?"
|
||||
}
|
||||
signup = fmt.Sprintf(`<p class="alt">%s <a href="/auth/signup?next=%s">Sign up</a>%s</p>`, label, url.QueryEscape(next), note)
|
||||
}
|
||||
brand := ""
|
||||
if a.Brand != "" {
|
||||
@@ -622,14 +707,21 @@ func (a *BuiltinAuth) pageLogin(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *BuiltinAuth) pageSignup(w http.ResponseWriter, r *http.Request) {
|
||||
next := safeNext(r.FormValue("next"))
|
||||
if !a.AllowSignup {
|
||||
authPage(w, "Sign up disabled", `<p>This server does not allow self-signup. Ask the server admin for an account.</p>
|
||||
// An invite link authorizes account creation even when public self-signup
|
||||
// is closed — it's the only way into an invite-only hub.
|
||||
inviteTok := a.invitedVia(next)
|
||||
if !a.AllowSignup && inviteTok == "" {
|
||||
authPage(w, "Sign up disabled", `<p>This server is invite-only. Ask a team owner for an invite link, or sign in if you already have an account.</p>
|
||||
<p class="alt"><a href="/auth/login">Back to sign in</a></p>`)
|
||||
return
|
||||
}
|
||||
var errMsg string
|
||||
if r.Method == http.MethodPost {
|
||||
u, err := a.signup(r.FormValue("email"), r.FormValue("name"), r.FormValue("password"))
|
||||
signup := a.signup
|
||||
if inviteTok != "" {
|
||||
signup = a.signupInvited // invite is the vetting: skip gates, activate
|
||||
}
|
||||
u, err := signup(r.FormValue("email"), r.FormValue("name"), r.FormValue("password"))
|
||||
if err == nil {
|
||||
switch u.Status {
|
||||
case statusUnverified:
|
||||
@@ -652,9 +744,10 @@ func (a *BuiltinAuth) pageSignup(w http.ResponseWriter, r *http.Request) {
|
||||
errMsg = `<p class="err">` + html.EscapeString(err.Error()) + `</p>`
|
||||
}
|
||||
// State the domain restriction up front, where the stranger types their
|
||||
// email — not only after a rejected submit.
|
||||
// email — not only after a rejected submit. An invite bypasses the domain
|
||||
// allowlist, so don't show it when arriving through one.
|
||||
domainNote := ""
|
||||
if len(a.AllowedDomains) > 0 {
|
||||
if inviteTok == "" && len(a.AllowedDomains) > 0 {
|
||||
domainNote = `<p class="alt" style="margin:2px 0 0">Only ` + html.EscapeString(a.domainList()) + ` email addresses can sign up here.</p>`
|
||||
}
|
||||
brand := ""
|
||||
|
||||
@@ -147,6 +147,91 @@ func TestPolicyAPIAdminOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The three supported postures validate; an ungated open hub and
|
||||
// verification-without-a-mailer are refused.
|
||||
func TestValidateSignupPolicy(t *testing.T) {
|
||||
ok := func(name string, tune func(*BuiltinAuth)) {
|
||||
t.Helper()
|
||||
a := gatedAuth(t, tune)
|
||||
if err := a.ValidateSignupPolicy(); err != nil {
|
||||
t.Fatalf("%s: unexpected error: %v", name, err)
|
||||
}
|
||||
}
|
||||
bad := func(name string, tune func(*BuiltinAuth)) {
|
||||
t.Helper()
|
||||
a := gatedAuth(t, tune)
|
||||
if err := a.ValidateSignupPolicy(); err == nil {
|
||||
t.Fatalf("%s: expected an error", name)
|
||||
}
|
||||
}
|
||||
ok("invite-only", func(a *BuiltinAuth) { a.AllowSignup = false })
|
||||
ok("open+domain", func(a *BuiltinAuth) { a.AllowedDomains = []string{"x.io"} })
|
||||
ok("open+approval", func(a *BuiltinAuth) { a.RequireApproval = true })
|
||||
ok("open+verify+mailer", func(a *BuiltinAuth) { a.RequireVerification = true; a.Mail = &Mailer{Host: "smtp"} })
|
||||
bad("open+no-gate", func(a *BuiltinAuth) { a.AllowSignup = true })
|
||||
bad("verify-without-mailer", func(a *BuiltinAuth) { a.RequireVerification = true })
|
||||
}
|
||||
|
||||
// On an invite-only hub, a valid invite link lets a brand-new person create an
|
||||
// account (bypassing the closed signup and the domain allowlist); an invalid
|
||||
// or absent invite still hits the disabled page.
|
||||
func TestInviteBootstrapsAccountWhenSignupClosed(t *testing.T) {
|
||||
srv, auth, _ := authHub(t, false) // AllowSignup=false (invite-only)
|
||||
auth.AllowedDomains = []string{"runbear.io"}
|
||||
auth.InviteValid = func(tok string) bool { return tok == "abc123" }
|
||||
h := srv.Handler()
|
||||
|
||||
signupVia := func(next, email string) *httptest.ResponseRecorder {
|
||||
form := url.Values{"email": {email}, "name": {"New"}, "password": {"password1"}}
|
||||
u := "/auth/signup"
|
||||
if next != "" {
|
||||
u += "?next=" + url.QueryEscape(next)
|
||||
}
|
||||
req := httptest.NewRequest("POST", u, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// Valid invite → account created and active, even for an outside domain.
|
||||
rec := signupVia("/join/abc123", "contractor@elsewhere.com")
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("invited signup: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
u := auth.findByEmail("contractor@elsewhere.com")
|
||||
if u == nil || !u.active() {
|
||||
t.Fatalf("invited account should exist and be active: %+v", u)
|
||||
}
|
||||
|
||||
// No invite → disabled, no account.
|
||||
if rec := signupVia("", "nobody@runbear.io"); !strings.Contains(rec.Body.String(), "invite-only") {
|
||||
t.Fatalf("uninvited signup should be disabled: %s", rec.Body)
|
||||
}
|
||||
// Invalid/expired invite token → disabled too.
|
||||
if rec := signupVia("/join/deadbeef", "nobody@runbear.io"); !strings.Contains(rec.Body.String(), "invite-only") {
|
||||
t.Fatalf("bad-invite signup should be disabled: %s", rec.Body)
|
||||
}
|
||||
if auth.findByEmail("nobody@runbear.io") != nil {
|
||||
t.Fatal("account created without a valid invite")
|
||||
}
|
||||
}
|
||||
|
||||
// Turning on email verification without a mailer is refused over the policy API.
|
||||
func TestPolicyVerificationNeedsMailer(t *testing.T) {
|
||||
srv, auth, _ := authHub(t, true)
|
||||
auth.Admins = map[string]bool{"boss@x.io": true}
|
||||
h := srv.Handler()
|
||||
boss := signupAndSession(t, h, "boss@x.io", "Boss", "password1")
|
||||
rec := doAs(t, h, "POST", "/api/admin/policy", map[string]bool{"require_verification": true}, boss)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("verification without mailer should be refused: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if auth.RequireVerification {
|
||||
t.Fatal("verification was enabled despite having no mailer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRateLimit(t *testing.T) {
|
||||
srv, _, _ := newHub(t, true, nil)
|
||||
srv.Auth = gatedAuth(t, nil)
|
||||
|
||||
@@ -348,6 +348,16 @@ func (db *OrgDB) Redeem(token string) (OrgInvite, bool) {
|
||||
return inv, true
|
||||
}
|
||||
|
||||
// ValidInvite reports whether a token is a live invite, without consuming it.
|
||||
// It lets the signup page permit account creation from an invite link even
|
||||
// when public self-signup is closed (invite-only hubs).
|
||||
func (db *OrgDB) ValidInvite(token string) bool {
|
||||
db.mu.Lock()
|
||||
defer db.mu.Unlock()
|
||||
inv, ok := db.invites[token]
|
||||
return ok && !inv.expired()
|
||||
}
|
||||
|
||||
// ---- migration ----
|
||||
|
||||
// MigrateOrgs assigns every org-less project to a default org so a hub that
|
||||
|
||||
@@ -550,20 +550,21 @@ async function showHubSettings() {
|
||||
|
||||
el(panel, "h3", null, "New-account vetting");
|
||||
const toggles = el(panel, "div", "admin-list");
|
||||
const mkToggle = (label, desc, key, on) => {
|
||||
const mkToggle = (label, desc, key, on, disabled) => {
|
||||
const row = el(toggles, "label", "admin-item toggle");
|
||||
const left = el(row, "span", "ai-main");
|
||||
el(left, "div", "tg-label", label);
|
||||
el(left, "div", "tg-desc", desc);
|
||||
const cb = document.createElement("input");
|
||||
cb.type = "checkbox"; cb.checked = !!on; cb.dataset.key = key;
|
||||
if (disabled) { cb.disabled = true; row.style.opacity = ".55"; }
|
||||
row.appendChild(cb);
|
||||
return cb;
|
||||
};
|
||||
const ver = mkToggle("Require email verification",
|
||||
pol.mailer ? "New accounts must click an emailed link before they can sign in." :
|
||||
"New accounts must verify via a link (no mailer configured — the link is written to the server log).",
|
||||
"require_verification", pol.require_verification);
|
||||
pol.mailer ? "New accounts must click an emailed link before they can sign in — proves they control the address."
|
||||
: "Configure SMTP on the server (auth.smtp) to enable email verification.",
|
||||
"require_verification", pol.require_verification && pol.mailer, !pol.mailer);
|
||||
const app = mkToggle("Require admin approval",
|
||||
"New accounts wait for a hub admin to approve them before they gain access.",
|
||||
"require_approval", pol.require_approval);
|
||||
@@ -580,10 +581,10 @@ async function showHubSettings() {
|
||||
const info = el(panel, "div", "admin-list");
|
||||
const dom = el(info, "div", "admin-item");
|
||||
el(dom, "span", "ai-main", "Allowed email domains");
|
||||
el(dom, "span", "ai-tag", (pol.allowed_domains && pol.allowed_domains.length) ? pol.allowed_domains.map((d) => "@" + d).join(", ") : "any (open signup)");
|
||||
el(dom, "span", "ai-tag", (pol.allowed_domains && pol.allowed_domains.length) ? pol.allowed_domains.map((d) => "@" + d).join(", ") : "any");
|
||||
const sg = el(info, "div", "admin-item");
|
||||
el(sg, "span", "ai-main", "Self-signup");
|
||||
el(sg, "span", "ai-tag", pol.allow_signup ? "open" : "closed");
|
||||
el(sg, "span", "ai-tag", pol.allow_signup ? "open" : "invite-only");
|
||||
const ad = el(info, "div", "admin-item");
|
||||
el(ad, "span", "ai-main", "Hub admins");
|
||||
el(ad, "span", "ai-tag", (pol.admins && pol.admins.length) ? pol.admins.join(", ") : "none");
|
||||
|
||||
@@ -93,7 +93,7 @@ cd ~/agent-workspace && bdrive init --name agent-workspace
|
||||
|
||||
Devices connecting the same project (by name or id) converge through the hub. Direct-to-bucket setups (no hub) remain possible via `bdrive remote set <folder> s3://…` after an offline init.
|
||||
|
||||
Hub projects belong to an **organization**: only members of the project's org can see or sync it (project names are scoped per org too). Your first `bdrive init` creates your org automatically. To give a teammate access, an org **owner** opens the web UI and clicks **Invite** in the sidebar footer — it mints an expiring join link (`…/join/<token>`); the teammate opens it, signs in (or up), and is in. If a teammate's `bdrive init --project <id>` gets 403/404 or the project list looks empty, the missing invite is the reason. Public share links (`bdrive share`) intentionally bypass the org wall.
|
||||
Hub projects belong to an **organization**: only members of the project's org can see or sync it (project names are scoped per org too). Your first `bdrive init` creates your org automatically. **Hubs are invite-only by default** — the safe posture for a public URL. To give a teammate access, an org **owner** opens the web UI and clicks **Invite** in the sidebar footer — it mints an expiring join link (`…/join/<token>`); the teammate opens it and creates an account through the link (invites bootstrap signup even when public self-signup is closed), and is in. An admin can instead open self-service signup with a gate (admin approval, or allowed-domains + email verification) under **Admin → Signup & access** / the config's `auth` block. If a teammate's `bdrive init --project <id>` gets 403/404 or the project list looks empty, the missing invite is the reason. Public share links (`bdrive share`) intentionally bypass the org wall.
|
||||
|
||||
### Renames and moves
|
||||
|
||||
|
||||
Reference in New Issue
Block a user