package webapp import ( "crypto/rand" "crypto/sha256" "encoding/hex" "fmt" "html" "log" "net/http" "net/url" "sort" "strings" "sync" "time" "golang.org/x/crypto/bcrypt" ) // BuiltinAuth is the open-source identity provider: email + password + name // accounts and long-lived device tokens, persisted in one JSON file (loaded // at open, rewritten atomically on every change — same discipline as the // project registry). It owns the /auth/* pages the browser sees and the // /api/auth/* endpoints the CLI uses. type BuiltinAuth struct { AllowSignup bool Mail *Mailer // nil → reset links go to the server log // Public-URL signup gating (all optional; set after Open). A hub reachable // from the internet should use at least one of these. AllowedDomains []string // if non-empty, signup email domain must match one RequireVerification bool // new accounts must click an email link before activation RequireApproval bool // new accounts wait for an admin to approve them Admins map[string]bool // hub admins (lowercase emails): approve users, govern shares Brand string // optional name shown on the sign-in page // BaseURL is the hub's public origin ("https://drive.acme.com"). Links the // hub MAILS are built from it. Empty → the hub has no origin it can trust // and mailed links stop being absolute as soon as two requests disagree // about the host; see mailBaseURL. BaseURL string // 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 // Offboard, when set, is called with the address of an account that has // just been removed. Everything downstream of removal is keyed by email // (org role, project grant, share liveness), so without it the grants // outlive the account. Wired to Server.offboard by the server. Offboard func(email string) // BindDevice, when set, records that a device id belongs to an account, at // the moment a token is minted for it. It is the ONLY way an ownership row // is created for an id that has never synced — see DeviceRegistry.Bind for // why first-claim-on-write could not be. Installed by UseDeviceBinder. BindDevice DeviceBinder store AccountRepo ver versionGate // skips the re-read when the store has not moved // cli serves `bdrive login` — the browser and device flows, shared with // every other provider (see CLIAuth), which is why nothing about them // lives in this file. cli *CLIAuth mu sync.Mutex warnedBase bool // "no auth.base_url" logged once (see mailBaseURL) warnedLoad bool // "re-read failed" logged once (see refresh) users map[string]*authUser // by id tokens map[string]authToken // by sha256(token) // Ephemeral single-use state; a server restart just cancels pending // verifications and resets. pending map[string]pendingGrant // verification links, reset tokens } type authUser struct { ID string `json:"id"` Email string `json:"email"` Name string `json:"name"` Pass string `json:"pass"` // bcrypt hash Status string `json:"status,omitempty"` Created time.Time `json:"created"` } // Account status. Empty is treated as active so accounts created before // gating existed keep working. const ( statusActive = "active" statusUnverified = "unverified" // awaiting email verification statusPending = "pending" // verified (or verification off) but awaiting admin approval ) func (u *authUser) active() bool { return u.Status == "" || u.Status == statusActive } type authToken struct { Hash string `json:"hash"` // sha256 of the token; plaintext is never stored User string `json:"user"` Device string `json:"device"` Created time.Time `json:"created"` } type pendingGrant struct { kind string // "verify" (email link) | "reset" (password reset link) user string expires time.Time } // NewBuiltinAuth builds the account service over an AccountRepo, loading its // accounts, tokens, and persisted policy. func NewBuiltinAuth(store AccountRepo, allowSignup bool, mail *Mailer) (*BuiltinAuth, error) { a := &BuiltinAuth{ AllowSignup: allowSignup, Mail: mail, store: store, users: make(map[string]*authUser), tokens: make(map[string]authToken), pending: make(map[string]pendingGrant), } a.cli = NewCLIAuth(a.sessionUser, a.finishLogin) users, tokens, policy, err := store.Load() if err != nil { return nil, err } for _, u := range users { a.users[u.ID] = u } for _, t := range tokens { a.tokens[t.Hash] = t } // A UI-saved policy is the persisted operational default; the server // config can still override it at startup (see web.go), so a sysadmin who // pins a value in the config file always wins over a browser toggle. if policy != nil { a.RequireVerification = policy.RequireVerification a.RequireApproval = policy.RequireApproval } return a, nil } // OpenBuiltinAuth loads (or starts) the file-backed account registry at path. func OpenBuiltinAuth(path string, allowSignup bool, mail *Mailer) (*BuiltinAuth, error) { return NewBuiltinAuth(newFileAccountRepo(path), allowSignup, mail) } // authPolicy is the UI-tunable slice of gating (persisted in auth.json). // Domain allowlist and the admin list are intentionally NOT here — they are // security-critical identity config owned by whoever controls the server, // not something a browser session should be able to widen. type authPolicy struct { RequireVerification bool `json:"require_verification"` RequireApproval bool `json:"require_approval"` } // SetPolicy updates the tunable gating toggles and persists them. // // The prospective policy goes through the startup validator FIRST. The hub // starts legally as {allow_signup:true, require_approval:true}; one admin POST // used to remove the only gate, and because SetPolicy persists, the hub then // survived a restart the same binary refuses to perform — CLAUDE.md states the // guarantee as "refuses an ungated open hub rather than silently leaving the // door open". Here rather than in handleAdminPolicy because the handler is one // caller of this and a second caller would arrive without the check; the // handler only had the mailer half of the rule anyway. func (a *BuiltinAuth) SetPolicy(requireVerification, requireApproval bool) error { if err := a.signupPolicyError(requireVerification, requireApproval); err != nil { return err } a.mu.Lock() defer a.mu.Unlock() // Persist first: a gating change the store refused must not un-gate the // hub in memory, which is the widening direction — new signups become // active across a restart the store never agreed to. if err := a.store.PutPolicy(authPolicy{RequireVerification: requireVerification, RequireApproval: requireApproval}); err != nil { return err } a.RequireVerification = requireVerification a.RequireApproval = requireApproval return nil } // refresh re-reads accounts and device tokens from the store. Callers hold mu. // // Same defect ProjectDB.refresh closed in round 12, one wall further out: these // maps are the CREDENTIAL, not a grant on top of one. Loaded at open and never // re-read, a hub running two processes served `bdrive logout` (or an admin's // "revoke this device") on whichever process handled it and on no other — the // lost laptop's token kept authenticating everywhere else for the life of those // processes — and a deleted account could sign in again with its old password // as soon as any second-process write rewrote auth.json. // // Policy is deliberately NOT re-read: the config file overrides it at startup // (web.go), and reloading would let the persisted value quietly win back over // the value the sysadmin pinned. // // A store that cannot answer leaves the maps in place — see ProjectDB.refresh // for the trade. // // Gated on the store's change token (Versioned) like ProjectDB.refresh: still // a re-read on every authenticated request, but only when something moved. func (a *BuiltinAuth) refresh() { token, stale := a.ver.stale(a.store) if !stale { return } users, tokens, _, err := a.store.Load() if err != nil { if !a.warnedLoad { a.warnedLoad = true log.Printf("beardrive: account store re-read failed, serving the last known accounts: %v", err) } return } a.warnedLoad = false a.ver.fresh(token) nextUsers := make(map[string]*authUser, len(users)) for _, u := range users { nextUsers[u.ID] = u } nextTokens := make(map[string]authToken, len(tokens)) for _, t := range tokens { nextTokens[t.Hash] = t } a.users, a.tokens = nextUsers, nextTokens } func randHex(n int) string { b := make([]byte, n) rand.Read(b) return hex.EncodeToString(b) } func hashToken(tok string) string { sum := sha256.Sum256([]byte(tok)) return hex.EncodeToString(sum[:]) } // ---- account + token operations ---- func (a *BuiltinAuth) findByEmail(email string) *authUser { for _, u := range a.users { if strings.EqualFold(u.Email, email) { return u } } 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)) // trimText, not TrimSpace: a display name is peer-written text that travels // as far as a project name does. RemoteSource.Commit stamps it as // Op.UserName on every browser write, so it lands in the journal every // device replays and in the History row whoChanged() renders — carrying the // bidi overrides and C0/C1 runs a NOTE on the same row is refused for, with // no device and no journal access needed. Route it through the choke point // that already normalizes project names rather than growing a second rule. name = trimText(name, 128) if email == "" || !strings.Contains(email, "@") { return nil, fmt.Errorf("a valid email is required") } if !viaInvite && !a.domainAllowed(email) { return nil, fmt.Errorf("this server only accepts %s email addresses", a.domainList()) } if name == "" { return nil, fmt.Errorf("a name is required") } if len(password) < 8 { return nil, fmt.Errorf("password must be at least 8 characters") } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return nil, err } status := a.initialStatus() // An invite is an owner's explicit grant; an email on the config's admin // list is the operator's own. Both are stronger vetting than the signup // gates, so these accounts activate immediately — otherwise a fresh // approval-gated hub would strand its first admin as pending forever. if viaInvite || a.isAdmin(email) { status = statusActive } a.mu.Lock() a.refresh() defer a.mu.Unlock() if a.findByEmail(email) != nil { return nil, fmt.Errorf("an account with this email already exists") } // 128 bits, and never one already in use. randHex(4) was 32 bits with no // uniqueness check: no attacker is needed, the birthday bound alone gives // ~1% at 9,300 accounts and even odds at 77,000 — and a collision put a new // account on a live one's id, so the victim's device tokens authenticated // as the newcomer and PutAccount overwrote the victim's row, password hash // included, with no way back. The repos refuse the overwrite too; this is // the half that makes a legitimate signup never ask for it. id := "u-" + randHex(16) for a.users[id] != nil { id = "u-" + randHex(16) } u := &authUser{ ID: id, Email: email, Name: name, Pass: string(hash), Status: status, Created: time.Now().UTC(), } a.users[u.ID] = u if err := a.store.PutAccount(u); err != nil { delete(a.users, u.ID) return nil, err } return u, nil } // initialStatus is the account state a new signup starts in, given the // server's gating config: verify first, else approve first, else active. func (a *BuiltinAuth) initialStatus() string { switch { case a.RequireVerification: return statusUnverified case a.RequireApproval: return statusPending default: return statusActive } } // 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 err := a.signupPolicyError(a.RequireVerification, a.RequireApproval); err != nil { return err } // Startup only: SetPolicy cannot change either side of this, so refusing a // live toggle over it would be refusing an unrelated misconfiguration. if a.Mail != nil && a.BaseURL == "" { return fmt.Errorf("auth: smtp is configured but auth.base_url is not — the only other origin a mailed link could carry is the Host header of the request that triggered it, which an anonymous stranger chooses; set auth.base_url to this hub's public origin") } return nil } // signupPolicyError is the gating rule applied to a PROSPECTIVE pair of // toggles, so the startup check and the live change (SetPolicy) are literally // the same predicate rather than two that drift — which is how a browser POST // reached a posture the same binary refuses to boot in. func (a *BuiltinAuth) signupPolicyError(requireVerification, requireApproval bool) error { if 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 && !requireApproval && !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 { return statusPending } return statusActive } func emailDomain(email string) string { if i := strings.LastIndex(email, "@"); i >= 0 { return strings.ToLower(email[i+1:]) } return "" } func (a *BuiltinAuth) domainAllowed(email string) bool { if len(a.AllowedDomains) == 0 { return true } d := emailDomain(email) for _, allowed := range a.AllowedDomains { if strings.EqualFold(strings.TrimPrefix(strings.TrimSpace(allowed), "@"), d) { return true } } return false } func (a *BuiltinAuth) domainList() string { parts := make([]string, len(a.AllowedDomains)) for i, d := range a.AllowedDomains { parts[i] = "@" + strings.TrimPrefix(strings.TrimSpace(d), "@") } return strings.Join(parts, ", ") } func (a *BuiltinAuth) isAdmin(email string) bool { return a.Admins != nil && a.Admins[normEmail(email)] } func (a *BuiltinAuth) accountCount() int { a.mu.Lock() a.refresh() defer a.mu.Unlock() return len(a.users) } func (a *BuiltinAuth) verifyPassword(email, password string) *authUser { a.mu.Lock() a.refresh() u := a.findByEmail(email) a.mu.Unlock() if u == nil { // burn comparable time so missing accounts aren't detectable bcrypt.CompareHashAndPassword([]byte("$2a$10$0000000000000000000000000000000000000000000000000000"), []byte(password)) return nil } if bcrypt.CompareHashAndPassword([]byte(u.Pass), []byte(password)) != nil { return nil } return u } // issueToken mints a device token for the user and persists its hash. The // plaintext is returned exactly once. func (a *BuiltinAuth) issueToken(userID, device string) (string, error) { tok := "bdt_" + randHex(20) a.mu.Lock() a.refresh() defer a.mu.Unlock() t := authToken{Hash: hashToken(tok), User: userID, Device: device, Created: time.Now().UTC()} a.tokens[t.Hash] = t if err := a.store.PutToken(t); err != nil { delete(a.tokens, t.Hash) return "", err } return tok, nil } func (a *BuiltinAuth) revokeToken(tok string) error { a.mu.Lock() a.refresh() defer a.mu.Unlock() t, ok := a.tokens[hashToken(tok)] if !ok { return nil // already gone: the postcondition holds } delete(a.tokens, hashToken(tok)) return a.killToken(t) } // killToken ends one credential durably. The delete alone was not durable: its // error was discarded, so a logout or a password reset reported success while // the row survived on disk and came back live at the next restart. Voiding the // row first is a write that has to succeed for the revocation to be reported // as done — a row naming no account resolves to no user in userForToken, so // even if the delete then fails the credential is dead. Caller holds a.mu. func (a *BuiltinAuth) killToken(t authToken) error { void := t void.User = "" if err := a.store.PutToken(void); err != nil { log.Printf("beardrive: could not revoke a token durably: %v", err) return err } if err := a.store.DeleteToken(t.Hash); err != nil { log.Printf("beardrive: revoked token row left void on disk (delete failed): %v", err) } return nil } // revokeTokensFor kills every credential a user holds — browser sessions and // device tokens alike, since both are rows in a.tokens. Used by the password // reset: re-keying an account that a thief still has a live token for would // recover nothing. func (a *BuiltinAuth) revokeTokensFor(userID string) { a.mu.Lock() a.refresh() defer a.mu.Unlock() a.revokeTokensForLocked(userID) } func (a *BuiltinAuth) revokeTokensForLocked(userID string) { if userID == "" { return } for hash, t := range a.tokens { if t.User == userID { delete(a.tokens, hash) a.killToken(t) } } a.revokeGrantsForLocked(userID) } // revokeGrantsForLocked drops every outstanding one-time mail grant an account // holds. Callers hold mu. // // a.pending is a credential table, not a scratchpad: a "reset" grant sets the // password without knowing the old one, and a "verify" grant signs its holder // straight in (pageVerify's last arm calls startSession) with no password at // all, on a 24-hour TTL minted at signup. Revoking the token table and stopping // there left both alive across the ONE action a user is told to take when they // suspect compromise — so a thief who requested a reset link before the victim // recovered still held a password-setting capability afterwards, and a stale // verification mail was still a passwordless sign-in. // // It lives inside revokeTokensForLocked rather than beside its two call sites // because "end every credential this account holds" is one operation with one // meaning; the reset page and Deny already both ask for it. func (a *BuiltinAuth) revokeGrantsForLocked(userID string) { for id, g := range a.pending { if g.user == userID { delete(a.pending, id) } } } func (a *BuiltinAuth) userForToken(tok string) (User, bool) { a.mu.Lock() a.refresh() defer a.mu.Unlock() t, ok := a.tokens[hashToken(tok)] if !ok || t.User == "" { return User{}, false } u, ok := a.users[t.User] if !ok || !u.active() { return User{}, false } return User{ID: u.ID, Email: u.Email, Name: u.Name, Admin: a.isAdmin(u.Email)}, true } // sendVerification emails (or logs) a verification link for the account. func (a *BuiltinAuth) sendVerification(u *authUser) { tok := a.newGrant("verify", u.ID, 24*time.Hour) link := a.mailBaseURL() + "/auth/verify?token=" + tok subject := "Verify your BearDrive account" body := "Confirm your email to activate your BearDrive account:\n\n " + link + "\n\nThis link is valid for 24 hours. If you didn't sign up, ignore this email." if a.Mail == nil { fmt.Printf("verification link for %s:\n %s\n", u.Email, link) return } if err := a.Mail.Send(u.Email, subject, body); err != nil { fmt.Printf("verification link for %s (email not sent: %v):\n %s\n", u.Email, err, link) } } // grant helpers: single-use email links with expiry (verification, reset). // The CLI's own pending sign-ins live in CLIAuth, not here. func (a *BuiltinAuth) newGrant(kind, user string, ttl time.Duration) string { id := randHex(16) a.mu.Lock() a.pending[id] = pendingGrant{kind: kind, user: user, expires: time.Now().Add(ttl)} a.mu.Unlock() return id } func (a *BuiltinAuth) takeGrant(kind, id string) (pendingGrant, bool) { a.mu.Lock() defer a.mu.Unlock() g, ok := a.pending[id] if !ok || g.kind != kind || time.Now().After(g.expires) { delete(a.pending, id) return pendingGrant{}, false } delete(a.pending, id) return g, true } // Branding is the hub name this provider renders on its own pages. func (a *BuiltinAuth) Branding() string { return a.Brand } // Policy reports this provider's signup gates (webapp.AccountApprover). The // provider assembles it so the hub never reaches into these fields itself. func (a *BuiltinAuth) Policy() SignupPolicy { a.mu.Lock() defer a.mu.Unlock() admins := make([]string, 0, len(a.Admins)) for e := range a.Admins { admins = append(admins, e) } sort.Strings(admins) return SignupPolicy{ RequireVerification: a.RequireVerification, RequireApproval: a.RequireApproval, AllowSignup: a.AllowSignup, AllowedDomains: a.AllowedDomains, Admins: admins, Mailer: a.Mail != nil, } } // PendingUsers lists accounts awaiting admin approval, oldest first. func (a *BuiltinAuth) PendingUsers() []User { a.mu.Lock() a.refresh() defer a.mu.Unlock() var us []*authUser for _, u := range a.users { if u.Status == statusPending { us = append(us, u) } } sortByAge(us) out := make([]User, len(us)) for i, u := range us { out[i] = User{ID: u.ID, Email: u.Email, Name: u.Name} } return out } // Approve activates a pending account. func (a *BuiltinAuth) Approve(id string) error { a.mu.Lock() a.refresh() defer a.mu.Unlock() u, ok := a.users[id] if !ok { return fmt.Errorf("no such account") } // Persist first, apply after: an approval the store refused must not // activate the account in memory — the admin is told it failed while the // account authenticates until the next restart. next := *u next.Status = statusActive if err := a.store.PutAccount(&next); err != nil { return err } u.Status = statusActive return nil } // Deny removes a pending account. func (a *BuiltinAuth) Deny(id string) error { a.mu.Lock() a.refresh() u, ok := a.users[id] if !ok { a.mu.Unlock() return fmt.Errorf("no such account") } // Persist first: a removal the store refused must not empty the registry // anyway, or the account is gone until the next restart and then signs in // again with its old password. if err := a.store.DeleteAccount(id); err != nil { a.mu.Unlock() return err } // Removing the account is not enough on its own: its device tokens and // session cookies stay rows in the token table, dead today only because // userForToken also has to resolve the account — so an id that ever came // back (a restore, a re-created account, a repo that reuses ids) would // resurrect every credential with it. a.revokeTokensForLocked(id) email := u.Email delete(a.users, id) a.mu.Unlock() // Outside the lock, and outside this provider: org roles, project grants // and share liveness all key on the address, not on the account id. if a.Offboard != nil { a.Offboard(email) } return nil } // Accounts returns every account, oldest first (used by the org migration // to pick the default org's owner). func (a *BuiltinAuth) Accounts() []User { a.mu.Lock() a.refresh() defer a.mu.Unlock() users := make([]*authUser, 0, len(a.users)) for _, u := range a.users { if u.active() { users = append(users, u) } } sortByAge(users) out := make([]User, len(users)) for i, u := range users { out[i] = User{ID: u.ID, Email: u.Email, Name: u.Name} } return out } // sortByAge is the one "oldest first" order, and it is a TOTAL order. // // It used to be `sort.Slice` on Created alone, over a slice built by ranging a // map. Created arrived as a column after the fact, so on every upgraded hub // every row ties at the zero time, an unstable sort over a random permutation // is a random permutation, and the org heir — which reads this list — was // therefore drawn by Go map iteration. The ID tiebreak makes the answer a fact // about the store instead of a fact about this process. // // A deterministic order is not the same as evidence of age. Anything that // needs the latter asks Seniority. func sortByAge(users []*authUser) { sort.SliceStable(users, func(i, j int) bool { if !users[i].Created.Equal(users[j].Created) { return users[i].Created.Before(users[j].Created) } return users[i].ID < users[j].ID }) } // Seniority is the oldest-first account order, and it is EMPTY when this hub // holds no evidence of age at all. // // OrgDB.heir breaks a Joined tie on it, and its own doc comment says "with no // seniority available there is NO evidence, and the answer is nobody: an // ownerless org is a repair a hub admin makes deliberately, while an arbitrary // heir is a privilege grant nobody asked for". Handing back a merely // deterministic order would satisfy the letter and not the sentence — it would // promote the same arbitrary member every time, which is round 8's finding // with a different arbitrary key. Rows with no Created stamp are dropped, so // an upgraded hub that recorded nothing says nothing. func (a *BuiltinAuth) Seniority() []string { a.mu.Lock() a.refresh() defer a.mu.Unlock() dated := make([]*authUser, 0, len(a.users)) for _, u := range a.users { if u.active() && !u.Created.IsZero() { dated = append(dated, u) } } sortByAge(dated) out := make([]string, len(dated)) for i, u := range dated { out[i] = u.Email } return out } // ---- AuthProvider ---- const sessionCookie = "bdrive_session" func (a *BuiltinAuth) CLILoginPath() string { return "/auth/cli" } // UseDeviceBinder installs the hub's binding hook. finishLogin — the one place // this provider mints a CLI token, reached by all three flows — calls it before // it issues anything. func (a *BuiltinAuth) UseDeviceBinder(bind DeviceBinder) { a.BindDevice = bind } func (a *BuiltinAuth) Authenticate(r *http.Request) (User, bool) { if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { return a.userForToken(strings.TrimPrefix(h, "Bearer ")) } if c, err := r.Cookie(sessionCookie); err == nil { return a.userForToken(c.Value) } return User{}, false } func (a *BuiltinAuth) Register(mux *http.ServeMux) { mux.HandleFunc("GET /auth/login", a.pageLogin) mux.HandleFunc("POST /auth/login", a.pageLogin) mux.HandleFunc("GET /auth/signup", a.pageSignup) mux.HandleFunc("POST /auth/signup", a.pageSignup) mux.HandleFunc("GET /auth/logout", a.pageLogout) mux.HandleFunc("GET /auth/verify", a.pageVerify) mux.HandleFunc("GET /auth/reset", a.pageReset) mux.HandleFunc("POST /auth/reset", a.pageReset) mux.HandleFunc("GET /auth/reset/confirm", a.pageResetConfirm) mux.HandleFunc("POST /auth/reset/confirm", a.pageResetConfirm) mux.HandleFunc("GET /api/auth/me", a.apiMe) mux.HandleFunc("DELETE /api/auth/token", a.apiRevokeToken) a.cli.Register(mux) } // apiRevokeToken ends the credential the request presents — `bdrive logout` on // the wire. The token authenticates its own revocation, so no other permission // is involved and nothing else can be revoked with it. // // Device tokens have no expiry, so without this the documented way to sign a // device out ("no longer authenticated to the bdrive server") only rewrote a // local file: a lost laptop or a leaked token could be answered only by // resetting the account's password hub-wide. The browser half already ended // its session server-side. func (a *BuiltinAuth) apiRevokeToken(w http.ResponseWriter, r *http.Request) { h := r.Header.Get("Authorization") if !strings.HasPrefix(h, "Bearer ") { http.Error(w, "a device token is required", http.StatusUnauthorized) return } tok := strings.TrimPrefix(h, "Bearer ") if _, ok := a.userForToken(tok); !ok { http.Error(w, "invalid or expired token", http.StatusUnauthorized) return } if err := a.revokeToken(tok); err != nil { // Reported, never swallowed: a revocation that only happened in // memory is a credential that comes back at the next restart. http.Error(w, "could not revoke the token", http.StatusInternalServerError) return } writeJSON(w, map[string]any{"ok": true}) } // sessionUser resolves the browser session (cookie only, not Bearer). func (a *BuiltinAuth) sessionUser(r *http.Request) (User, bool) { if c, err := r.Cookie(sessionCookie); err == nil { return a.userForToken(c.Value) } return User{}, false } // cliSignIn reports whether a next URL is a pending CLI sign-in. func cliSignIn(next string) bool { return strings.HasPrefix(next, "/auth/cli?") } func (a *BuiltinAuth) startSession(w http.ResponseWriter, userID string) error { tok, err := a.issueToken(userID, "web-session") if err != nil { return err } http.SetCookie(w, &http.Cookie{ Name: sessionCookie, Value: tok, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, }) return nil } // inviteBanner shows an invitation cue when the post-login destination is a // join link, so a visitor who clicked an invite knows why they're here. func inviteBanner(next string) string { if !strings.Contains(next, "join/") && !strings.Contains(next, "join%2F") { return "" } return `
You've been invited to a team. Sign in (or sign up) to accept.
` } // cliBanner says what a sign-in reached from `bdrive login` is for, so the form // is not a bare password prompt appearing for no visible reason. Approving is // still its own step on the next page — this only explains why signing in is // being asked for at all. func cliBanner(next string) string { if !cliSignIn(next) { return "" } return `A terminal on this computer is waiting to sign in. ` + `The account you use here is the one it will act as.
` } // safeNext keeps post-login redirects on this site. A browser fills in the // authority slot for anything that looks like "//host", and it does that // AFTER stripping tab/CR/LF and after treating a backslash as a separator — // so "/\evil.example", "/\t/evil.example" and "//evil.example" are all the // same off-site jump, arriving straight from the page where the user just // typed their password. Strip what a browser strips, then demand a single // leading slash followed by neither. func safeNext(next string) string { next = strings.Map(func(r rune) rune { if r == '\t' || r == '\r' || r == '\n' { return -1 } return r }, next) if len(next) < 1 || next[0] != '/' { return "/" } if len(next) > 1 && (next[1] == '/' || next[1] == '\\') { return "/" } return next } // inviteTokenFromNext pulls an org-invite token out of a post-login target // like "/join/Please verify your email first — we've re-sent the link.
` case statusPending: errMsg = `Your account is still awaiting administrator approval.
` default: if err := a.startSession(w, u.ID); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, next, http.StatusSeeOther) return } } else { errMsg = `Wrong email or password.
` } } // 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 := "" invited := a.invitedVia(next) != "" if a.AllowSignup || invited { note := "" if a.AllowSignup && len(a.AllowedDomains) > 0 { note = ` (` + html.EscapeString(a.domainList()) + ` only)` } label := "No account?" if invited && !a.AllowSignup { label = "New here?" } signup = fmt.Sprintf(`%s Sign up%s
`, label, url.QueryEscape(next), note) } brand := "" if a.Brand != "" { brand = `` + html.EscapeString(a.Brand) + `
` } authPage(w, "Sign in", brand+inviteBanner(next)+cliBanner(next)+fmt.Sprintf(` %s`, url.QueryEscape(next), field("Email", "email", "email", r.FormValue("email")), field("Password", "password", "password", ""), errMsg, signup)) } func (a *BuiltinAuth) pageSignup(w http.ResponseWriter, r *http.Request) { next := safeNext(r.FormValue("next")) // An invite link authorizes account creation even when public self-signup // is closed — it's the only way into an invite-only hub. Except once: a // brand-new hub has no accounts to mint an invite with, so until the // first account exists, the emails on the config's admin list may sign // up directly (the operator wrote them there — that's the vetting). inviteTok := a.invitedVia(next) bootstrap := !a.AllowSignup && inviteTok == "" && len(a.Admins) > 0 && a.accountCount() == 0 if !a.AllowSignup && inviteTok == "" && !bootstrap { authPage(w, "Sign up disabled", `This server is invite-only. Ask a team owner for an invite link, or sign in if you already have an account.
`) return } var errMsg string if r.Method == http.MethodPost { signup := a.signup if inviteTok != "" { signup = a.signupInvited // invite is the vetting: skip gates, activate } if bootstrap && !a.isAdmin(r.FormValue("email")) { signup = func(string, string, string) (*authUser, error) { return nil, fmt.Errorf("this server is invite-only; only a hub admin can create the first account") } } u, err := signup(r.FormValue("email"), r.FormValue("name"), r.FormValue("password")) if err == nil { switch u.Status { case statusUnverified: a.sendVerification(u) authPage(w, "Verify your email", `Almost there — we sent a verification link to `+ html.EscapeString(u.Email)+`.
Click it to activate your account. No email on this server? The link is in the server log.
`) return case statusPending: authPage(w, "Awaiting approval", `Thanks — your account was created and is waiting for an administrator to approve it.
You'll be able to sign in once it's approved.
`) return } if err := a.startSession(w, u.ID); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, next, http.StatusSeeOther) return } errMsg = `` + html.EscapeString(err.Error()) + `
` } // State the domain restriction up front, where the stranger types their // email — not only after a rejected submit. An invite bypasses the domain // allowlist, so don't show it when arriving through one. domainNote := "" if inviteTok == "" && len(a.AllowedDomains) > 0 { domainNote = `Only ` + html.EscapeString(a.domainList()) + ` email addresses can sign up here.
` } brand := "" if a.Brand != "" { brand = `` + html.EscapeString(a.Brand) + `
` } authPage(w, "Create account", brand+inviteBanner(next)+cliBanner(next)+fmt.Sprintf(`Have an account? Sign in
`, url.QueryEscape(next), field("Name", "name", "text", r.FormValue("name")), field("Email", "email", "email", r.FormValue("email")), domainNote, newPasswordField("Password (min 8 chars)", "password"), errMsg, url.QueryEscape(next))) } // pageLogout ends the browser session. It honors ?next= so "switch account" // on a page that needed one (device approval, an invite) lands back there as // the new account instead of dumping the visitor at the hub root. func (a *BuiltinAuth) pageLogout(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie(sessionCookie); err == nil { a.revokeToken(c.Value) } http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1}) dest := "/auth/login" if next := safeNext(r.FormValue("next")); next != "/" { dest += "?next=" + url.QueryEscape(next) } http.Redirect(w, r, dest, http.StatusSeeOther) } // pageVerify activates an account from an email link, then either starts a // session (or explains it's now awaiting approval). func (a *BuiltinAuth) pageVerify(w http.ResponseWriter, r *http.Request) { g, ok := a.takeGrant("verify", r.URL.Query().Get("token")) if !ok { authPage(w, "Link expired", `This verification link is invalid or expired.
`) return } a.mu.Lock() a.refresh() u := a.users[g.user] next := a.afterVerify() var perr error if u != nil && u.Status == statusUnverified { // Persist first: a verification the store refused must not activate // the account in memory. Same shape as Approve. row := *u row.Status = next if perr = a.store.PutAccount(&row); perr == nil { u.Status = next } } a.mu.Unlock() if perr != nil { authPage(w, "Not verified yet", `Your account could not be updated, so it is not verified yet.
`) return } if u != nil && u.Status == statusPending { authPage(w, "Email verified", `Your email is verified. Your account is now waiting for an administrator to approve it.
`) return } if u != nil { if err := a.startSession(w, u.ID); err == nil { http.Redirect(w, r, "/", http.StatusSeeOther) return } } authPage(w, "Email verified", `Your email is verified.
`) } func (a *BuiltinAuth) pageReset(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { email := strings.TrimSpace(strings.ToLower(r.FormValue("email"))) a.mu.Lock() a.refresh() u := a.findByEmail(email) a.mu.Unlock() if u != nil { tok := a.newGrant("reset", u.ID, time.Hour) addr := u.Email link := a.mailBaseURL() + "/auth/reset/confirm?token=" + tok subject := "Reset your BearDrive password" body := "Someone (hopefully you) asked to reset the BearDrive password for " + addr + ".\n\nReset it here (valid for 1 hour):\n\n " + link + "\n\nIf this wasn't you, ignore this email." // Sent off the request path. Mail is the one step whose cost // depends on whether the address exists, so a handler that waits // for it answers a known address in SMTP-round-trip time and an // unknown one instantly — an account-enumeration oracle that needs // no statistics, just one slow mail server. Nothing is lost by not // waiting: a delivery failure was never surfaced to the caller // anyway, only logged. go func() { if err := a.Mail.Send(addr, subject, body); err != nil { // Never break reset: the admin can hand over the logged link. fmt.Printf("password reset for %s (email not sent: %v):\n %s\n", addr, err, link) } }() } authPage(w, "Check your email", `If that account exists, a reset link is on its way.
No email configured on this server? The link is in the server log.
`) return } authPage(w, "Reset password", fmt.Sprintf(` `, field("Email", "email", "email", ""))) } func (a *BuiltinAuth) pageResetConfirm(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { tok, password := r.FormValue("token"), r.FormValue("password") if len(password) < 8 { authPage(w, "Set a new password", resetForm(tok, `Password must be at least 8 characters.
`)) return } g, ok := a.takeGrant("reset", tok) if !ok { authPage(w, "Link expired", `This reset link is invalid or expired.
`) return } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Persist first, apply after, and surface a refusal: round 5 made the // TOKEN half of a reset durable and left this half discarding the // store's error, so the page said "Password updated" while the hub // came back at the next restart with the password the thief chose. a.mu.Lock() a.refresh() u := a.users[g.user] var perr error if u != nil { next := *u next.Pass = string(hash) if perr = a.store.PutAccount(&next); perr == nil { u.Pass = string(hash) } } a.mu.Unlock() if perr != nil { authPage(w, "Password not changed", `Your password could not be saved, so nothing was changed.
`) return } // A reset is the documented recovery for a stolen account, so it has // to end the thief's access too: every session cookie and device token // minted under the old password dies with it. a.revokeTokensFor(g.user) authPage(w, "Password updated", `Your password is updated.
`) return } authPage(w, "Set a new password", resetForm(r.URL.Query().Get("token"), "")) } func resetForm(token, msg string) string { return fmt.Sprintf(``, html.EscapeString(token), newPasswordField("New password (min 8 chars)", "password"), msg) } func requestBaseURL(r *http.Request) string { scheme := "http" if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { scheme = "https" } return scheme + "://" + r.Host } // mailBaseURL is where a link the hub SENDS SOMEWHERE ELSE points. It is // deliberately not requestBaseURL: the Host header (and X-Forwarded-Proto) are // chosen by whoever made the request, and /auth/reset is unauthenticated — so // a stranger posting a victim's address with a Host of their choosing had the // hub mail the victim a genuine link that hands the single-use grant to the // attacker's server. Classic reset poisoning. The three other requestBaseURL // callers hand the URL back to the caller who chose the host, which is // self-inflicted; these two do not. // // A configured origin (auth.base_url) is the only trustworthy answer, and it // is now required whenever smtp is configured (ValidateSignupPolicy). Two // weaker rules were tried and both were the same hole in a different shape: // using the request's own host aims the victim's link wherever the requester // says, and pinning the first host seen only moves the choice to whoever mails // first — on a fresh process that is one anonymous POST, which both picks the // origin and picks who receives it. // // So with no configured origin the hub has nothing it can trust and says so: // the link goes out root-relative (usable by hand, and the log names the // config that fixes it) rather than aimed somewhere a stranger picked. func (a *BuiltinAuth) mailBaseURL() string { if a.BaseURL != "" { return strings.TrimRight(a.BaseURL, "/") } a.mu.Lock() defer a.mu.Unlock() if !a.warnedBase { a.warnedBase = true log.Print("beardrive: mailed links are root-relative because auth.base_url is not set; " + "configure it with this hub's public origin so reset and verification links are usable") } return "" } // ---- CLI API ---- func (a *BuiltinAuth) finishLogin(w http.ResponseWriter, r *http.Request, userID, device string) { if device == "" { device = "cli" } a.mu.Lock() a.refresh() u := a.users[userID] a.mu.Unlock() if u == nil { http.Error(w, "unknown user", http.StatusUnauthorized) return } // Minting the token is where a device identity is BOUND to an account, and // it is the only place a binding is created. Every mint point routes // through here — the loopback browser flow, the device-code flow, and the // login `bdrive init` runs inside itself — so binding here covers all three // rather than the one a fix would otherwise name. // // Before the token, not after: a login that cannot bind must not hand back // a credential that then cannot push. 409 is the honest status — the id is // taken, and the message says what to do about it. if a.BindDevice != nil { if err := a.BindDevice(u.Email, r); err != nil { http.Error(w, err.Error(), http.StatusConflict) return } } tok, err := a.issueToken(userID, device) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, map[string]any{ "token": tok, "user": User{ID: u.ID, Email: u.Email, Name: u.Name}, }) } func (a *BuiltinAuth) apiMe(w http.ResponseWriter, r *http.Request) { u, ok := a.Authenticate(r) if !ok { http.Error(w, "not signed in", http.StatusUnauthorized) return } writeJSON(w, u) }