Files
beardrive/internal/webapp/auth.go
T
Snow LeeandClaude Fable 5 baad9e6dc2 feat(hub): signup gating, admin lifecycle, and onboarding UX
Makes a self-hosted hub safe to expose on a public URL and operable
without hand-editing JSON — addressing the blocker/major findings from
the persona usability evaluations.

Signup gating (config auth block, all optional):
- allowed_domains: signup email must match (e.g. only @runbear.io)
- require_verification: email-link activation before sign-in (reuses mailer)
- require_approval: hub admins approve new accounts (admins list)
- brand shown on the sign-in page; allow_signup:false already hid Sign up
Accounts carry a Status (active/unverified/pending); non-active accounts
cannot authenticate.

Admin lifecycle (endpoints + web UI):
- org: rename, member role change, member remove (last-owner guarded),
  invite list + revoke
- project: create (web), rename, delete (from the org panel)
- hub admins: approve/deny pending signups (sidebar bell + panel)
- org-wide public-share audit with revoke

UX: onboarding empty-state (explains invites, paste-invite + create-project)
instead of a blank sidebar; visible "Search ⌘K" button; toasts replace
blocking alert(); responsive layout with an off-canvas sidebar; joining
via #join now survives a logged-out click (token carried through login).
Web uploads are attributed to the signed-in account, not the server.
Login/signup are rate-limited per IP.

Tests: domain/verification/approval gates, auth rate limit, org+project
lifecycle, owner-only guards, invite→join→role→remove over HTTP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
2026-07-08 22:23:27 -07:00

69 lines
2.4 KiB
Go

package webapp
import (
"net/http"
"strings"
)
// Authentication is opt-in (`auth` in the server config) and sits behind the
// AuthProvider interface. The open-source server ships exactly one
// implementation, BuiltinAuth (email + password accounts in a file-backed
// registry, server-owned /auth/* pages). A managed deployment can swap in a
// different provider (e.g. PropelAuth-backed) without touching the CLI or
// the API: the CLI learns the login page from /api/config and the callback
// flow is provider-agnostic.
// User is an authenticated account as the rest of the server sees it.
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Admin bool `json:"admin,omitempty"` // hub admin (approve users, govern shares)
}
// AuthProvider is the seam between the server and an identity system.
type AuthProvider interface {
// CLILoginPath is the page `bdrive login` opens in a browser. The CLI
// appends ?redirect=http://127.0.0.1:<port>/callback&state=<nonce>.
CLILoginPath() string
// Authenticate resolves the request's Bearer token or session cookie.
Authenticate(r *http.Request) (User, bool)
// Register mounts the provider's own pages and endpoints (/auth/*,
// /api/auth/*) on the server mux.
Register(mux *http.ServeMux)
}
// authGate wraps the API with authentication when a provider is configured.
// The static frontend and the provider's own surface stay reachable so a
// browser can get to the login page; everything else under /api/ needs a
// valid identity.
func (s *Server) authGate(next http.Handler) http.Handler {
if s.Auth == nil {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
open := strings.HasPrefix(p, "/auth/") ||
strings.HasPrefix(p, "/api/auth/") ||
p == "/api/config" ||
!strings.HasPrefix(p, "/api/") // static frontend; its API calls are gated
if !open {
if _, ok := s.Auth.Authenticate(r); !ok {
http.Error(w, "authentication required (bdrive login, or sign in at /auth/login)", http.StatusUnauthorized)
return
}
}
next.ServeHTTP(w, r)
})
}
// requestUser returns the authenticated user, or a zero User when auth is
// disabled (everything then runs as an anonymous single user).
func (s *Server) requestUser(r *http.Request) User {
if s.Auth == nil {
return User{}
}
u, _ := s.Auth.Authenticate(r)
return u
}