mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
The headless flow printed a short code to retype into a bare "Approve" form. Now `bdrive login --device` prints a single link — the token lives in the path (/auth/device/<token>), so there is nothing to read off one screen and type into another. The page it opens is a consent page rather than a text field: it names the account the device would act as, offers Switch account (logout now honors ?next, so you land back here), and shows the device name, OS, and the address the server observed. That matters because this flow's weakness is a stranger sending you their pending link; an anonymous "Approve" gives you nothing to notice with. Approval is still a POST from the page, so a link alone cannot grant, and SameSite=Lax keeps a cross-site form out. Also aligns the /auth/* pages with the app's tokens, which had drifted: card #0c0e10 vs --color-card #15171b, 8px controls vs --radius-ctl 7px, hand-picked #ff9b91/#6fd699 vs --color-del/--color-add. The style block now declares the tw.css tokens by name and every rule uses them. Older CLIs still print /auth/device?code=…, so that shape 303s to the path form; a pre-0.13 hub returning no verify_url still gets the old type-the-code instruction from the CLI. Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1134 lines
41 KiB
Go
1134 lines
41 KiB
Go
package webapp
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html"
|
|
"io"
|
|
"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
|
|
|
|
// 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
|
|
|
|
store AccountRepo
|
|
|
|
mu sync.Mutex
|
|
users map[string]*authUser // by id
|
|
tokens map[string]authToken // by sha256(token)
|
|
|
|
// Ephemeral single-use state; a server restart just cancels pending
|
|
// logins and resets.
|
|
pending map[string]pendingGrant // auth codes, device codes, 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 // "code" (CLI callback), "device" (poll flow), "reset"
|
|
user string // set once granted
|
|
device string // device flow: requested device name
|
|
os string // device flow: requested device's OS
|
|
ip string // device flow: where the request came from, as the server saw it
|
|
granted bool
|
|
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),
|
|
}
|
|
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.
|
|
func (a *BuiltinAuth) SetPolicy(requireVerification, requireApproval bool) error {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
a.RequireVerification = requireVerification
|
|
a.RequireApproval = requireApproval
|
|
return a.store.PutPolicy(authPolicy{RequireVerification: requireVerification, RequireApproval: requireApproval})
|
|
}
|
|
|
|
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))
|
|
name = strings.TrimSpace(name)
|
|
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()
|
|
defer a.mu.Unlock()
|
|
if a.findByEmail(email) != nil {
|
|
return nil, fmt.Errorf("an account with this email already exists")
|
|
}
|
|
u := &authUser{
|
|
ID: "u-" + randHex(4), 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 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 {
|
|
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()
|
|
defer a.mu.Unlock()
|
|
return len(a.users)
|
|
}
|
|
|
|
func (a *BuiltinAuth) verifyPassword(email, password string) *authUser {
|
|
a.mu.Lock()
|
|
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()
|
|
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) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if _, ok := a.tokens[hashToken(tok)]; ok {
|
|
delete(a.tokens, hashToken(tok))
|
|
a.store.DeleteToken(hashToken(tok))
|
|
}
|
|
}
|
|
|
|
func (a *BuiltinAuth) userForToken(tok string) (User, bool) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
t, ok := a.tokens[hashToken(tok)]
|
|
if !ok {
|
|
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(r *http.Request, u *authUser) {
|
|
tok := a.newGrant("verify", u.ID, "", true, 24*time.Hour)
|
|
link := requestBaseURL(r) + "/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 codes with expiry.
|
|
func (a *BuiltinAuth) newGrant(kind, user, device string, granted bool, ttl time.Duration) string {
|
|
id := randHex(16)
|
|
a.mu.Lock()
|
|
a.pending[id] = pendingGrant{kind: kind, user: user, device: device, granted: granted, 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
|
|
}
|
|
|
|
// peekGrant reads without consuming (device polling).
|
|
func (a *BuiltinAuth) peekGrant(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) {
|
|
return pendingGrant{}, false
|
|
}
|
|
return g, true
|
|
}
|
|
|
|
func (a *BuiltinAuth) grantDevice(id, userID string) bool {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
g, ok := a.pending[id]
|
|
if !ok || g.kind != "device" || time.Now().After(g.expires) {
|
|
return false
|
|
}
|
|
g.user, g.granted = userID, true
|
|
a.pending[id] = g
|
|
return 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()
|
|
defer a.mu.Unlock()
|
|
var us []*authUser
|
|
for _, u := range a.users {
|
|
if u.Status == statusPending {
|
|
us = append(us, u)
|
|
}
|
|
}
|
|
sort.Slice(us, func(i, j int) bool { return us[i].Created.Before(us[j].Created) })
|
|
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()
|
|
defer a.mu.Unlock()
|
|
u, ok := a.users[id]
|
|
if !ok {
|
|
return fmt.Errorf("no such account")
|
|
}
|
|
u.Status = statusActive
|
|
return a.store.PutAccount(u)
|
|
}
|
|
|
|
// Deny removes a pending account.
|
|
func (a *BuiltinAuth) Deny(id string) error {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if _, ok := a.users[id]; !ok {
|
|
return fmt.Errorf("no such account")
|
|
}
|
|
delete(a.users, id)
|
|
return a.store.DeleteAccount(id)
|
|
}
|
|
|
|
// 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()
|
|
defer a.mu.Unlock()
|
|
users := make([]*authUser, 0, len(a.users))
|
|
for _, u := range a.users {
|
|
if u.active() {
|
|
users = append(users, u)
|
|
}
|
|
}
|
|
sort.Slice(users, func(i, j int) bool { return users[i].Created.Before(users[j].Created) })
|
|
out := make([]User, len(users))
|
|
for i, u := range users {
|
|
out[i] = User{ID: u.ID, Email: u.Email, Name: u.Name}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---- AuthProvider ----
|
|
|
|
const sessionCookie = "bdrive_session"
|
|
|
|
func (a *BuiltinAuth) CLILoginPath() string { return "/auth/cli" }
|
|
|
|
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/cli", a.pageCLI)
|
|
mux.HandleFunc("GET /auth/device/{token}", a.pageDevice)
|
|
mux.HandleFunc("POST /auth/device/{token}", a.pageDevice)
|
|
mux.HandleFunc("GET /auth/device", a.pageDeviceLegacy)
|
|
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("POST /api/auth/exchange", a.apiExchange)
|
|
mux.HandleFunc("POST /api/auth/device/start", a.apiDeviceStart)
|
|
mux.HandleFunc("POST /api/auth/device/poll", a.apiDevicePoll)
|
|
mux.HandleFunc("GET /api/auth/me", a.apiMe)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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 `<p class="msg" style="margin:0 0 14px">You've been invited to a team. Sign in (or sign up) to accept.</p>`
|
|
}
|
|
|
|
// safeNext keeps post-login redirects on this site.
|
|
func safeNext(next string) string {
|
|
if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
|
|
return "/"
|
|
}
|
|
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) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprintf(w, `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1"><title>%s — BearDrive</title>
|
|
<style>
|
|
/* The app's tokens, name for name, so sign-in and the app read as one
|
|
product. Source of truth: frontend/src/tw.css @theme — keep the values
|
|
here identical to the token of the same name there. */
|
|
:root{--bg:#0a0b0d;--raise:#15171b;--surface:rgba(255,255,255,.03);--hovered:rgba(255,255,255,.06);
|
|
--line:rgba(255,255,255,.07);--line-2:rgba(255,255,255,.11);--text:#eef0f3;--dim:#9aa0a9;--faint:#868b93;
|
|
--honey:#f5a623;--honey-bright:#ffcf85;--on-honey:#1a1204;--add:#4cc38a;--del:#f26d6d;
|
|
--radius-ctl:7px;--radius-over:14px;
|
|
--mono:ui-monospace,"SF Mono","JetBrains Mono",Menlo,Consolas,monospace}
|
|
body{font:14px/1.5 -apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;
|
|
background:var(--bg);color:var(--text);display:flex;justify-content:center;padding:13vh 16px;margin:0;
|
|
letter-spacing:-.006em;-webkit-font-smoothing:antialiased}
|
|
.card{background:var(--raise);border:1px solid var(--line);border-radius:var(--radius-over);padding:28px 30px;
|
|
width:344px;max-width:100%%;box-sizing:border-box;box-shadow:0 24px 70px -24px rgba(0,0,0,.7)}
|
|
.logo{width:30px;height:30px;display:grid;place-items:center;color:var(--honey);margin-bottom:16px}
|
|
.logo svg{width:30px;height:30px;fill:currentColor}
|
|
h1{font-size:18px;font-weight:640;letter-spacing:-.02em;margin:0 0 18px}
|
|
label{display:block;font-size:12px;color:var(--dim);margin:14px 0 5px;font-weight:500}
|
|
input{width:100%%;box-sizing:border-box;height:38px;padding:0 12px;border-radius:var(--radius-ctl);
|
|
border:1px solid var(--line-2);background:var(--surface);color:var(--text);font:inherit;font-size:14px;outline:none}
|
|
input:focus-visible{outline:2px solid var(--honey);outline-offset:1px;border-color:var(--honey)}
|
|
button{margin-top:20px;width:100%%;height:40px;border:none;border-radius:var(--radius-ctl);background:var(--honey);
|
|
color:var(--on-honey);font:inherit;font-size:14px;font-weight:600;cursor:pointer}
|
|
button:hover{background:var(--honey-bright)}
|
|
button:focus-visible{outline:2px solid var(--honey-bright);outline-offset:2px}
|
|
.err{color:var(--del);font-size:13px;margin:12px 0 0}
|
|
.msg{color:var(--add);font-size:13px;margin:12px 0 0}
|
|
.lede{margin:0;color:var(--dim);font-size:13px}
|
|
.alt{margin-top:16px;font-size:12.5px;color:var(--faint)}
|
|
.alt a{color:var(--honey-bright);text-decoration:none}
|
|
.alt a:hover{text-decoration:underline}
|
|
/* Device approval: who you'd be granting as, then what is asking. */
|
|
.who{display:flex;align-items:center;gap:12px;justify-content:space-between;margin:16px 0 4px;
|
|
padding:12px 14px;border:1px solid var(--line);border-radius:var(--radius-ctl);background:var(--surface)}
|
|
.who-id{min-width:0}
|
|
.who-l{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--faint)}
|
|
.who-id b{display:block;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
.who-sub{display:block;font-size:12px;color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
.who-swap{flex:none;font-size:12.5px;color:var(--honey-bright);text-decoration:none}
|
|
.who-swap:hover{text-decoration:underline}
|
|
.rows{display:grid;grid-template-columns:auto 1fr;gap:6px 14px;margin:14px 0 0;font-size:13px}
|
|
.rows dt{color:var(--faint)}
|
|
.rows dd{margin:0;font-family:var(--mono);font-size:12.5px;overflow-wrap:anywhere}
|
|
@media (max-width:900px){input{height:44px}button{height:44px}}
|
|
code{background:var(--hovered);border:1px solid var(--line);padding:2px 6px;border-radius:5px;
|
|
font-family:var(--mono)}
|
|
</style></head><body><div class="card"><div class="logo"><svg viewBox="0 0 32 32" role="img" aria-label="BearDrive">` +
|
|
`<rect x="4" y="4" width="5.6" height="24"/><rect x="11.2" y="4" width="14.4" height="11.2"/>` +
|
|
`<rect x="11.2" y="16.8" width="16.8" height="11.2"/></svg></div><h1>%s</h1>%s</div></body></html>`,
|
|
html.EscapeString(title), html.EscapeString(title), body)
|
|
}
|
|
|
|
func field(label, name, typ, value string) string {
|
|
return fmt.Sprintf(`<label for="f-%s">%s</label><input id="f-%s" name=%q type=%q value=%q autocomplete=%q required>`,
|
|
name, html.EscapeString(label), name, name, typ, html.EscapeString(value), autocompleteFor(name, typ))
|
|
}
|
|
|
|
// autocompleteFor names a field's purpose so browsers and password managers
|
|
// fill it (WCAG 1.3.5). A password field's purpose depends on the page, not
|
|
// its name: offering a saved credential on a signup form is worse than
|
|
// offering nothing, so those callers use newPasswordField.
|
|
func autocompleteFor(name, typ string) string {
|
|
switch name {
|
|
case "email":
|
|
return "email"
|
|
case "name":
|
|
return "name"
|
|
case "code":
|
|
return "one-time-code"
|
|
}
|
|
if typ == "password" {
|
|
return "current-password"
|
|
}
|
|
return "on"
|
|
}
|
|
|
|
// newPasswordField is field() for a password being CREATED (signup, reset),
|
|
// so a manager generates one instead of filling an existing login.
|
|
func newPasswordField(label, name string) string {
|
|
return fmt.Sprintf(`<label for="f-%s">%s</label><input id="f-%s" name=%q type="password" autocomplete="new-password" required>`,
|
|
name, html.EscapeString(label), name, name)
|
|
}
|
|
|
|
func (a *BuiltinAuth) pageLogin(w http.ResponseWriter, r *http.Request) {
|
|
next := safeNext(r.FormValue("next"))
|
|
var errMsg string
|
|
if r.Method == http.MethodPost {
|
|
if u := a.verifyPassword(r.FormValue("email"), r.FormValue("password")); u != nil {
|
|
switch u.Status {
|
|
case statusUnverified:
|
|
a.sendVerification(r, u)
|
|
errMsg = `<p class="err">Please verify your email first — we've re-sent the link.</p>`
|
|
case statusPending:
|
|
errMsg = `<p class="err">Your account is still awaiting administrator approval.</p>`
|
|
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 = `<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 := ""
|
|
invited := a.invitedVia(next) != ""
|
|
if a.AllowSignup || invited {
|
|
note := ""
|
|
if a.AllowSignup && len(a.AllowedDomains) > 0 {
|
|
note = ` <span style="color:var(--faint)">(` + html.EscapeString(a.domainList()) + ` only)</span>`
|
|
}
|
|
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 != "" {
|
|
brand = `<p class="alt" style="margin:0 0 14px;color:var(--dim)">` + html.EscapeString(a.Brand) + `</p>`
|
|
}
|
|
authPage(w, "Sign in", brand+inviteBanner(next)+fmt.Sprintf(`<form method="post" action="/auth/login?next=%s">%s%s%s<button>Sign in</button></form>
|
|
%s<p class="alt"><a href="/auth/reset">Forgot password?</a></p>`,
|
|
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", `<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 {
|
|
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(r, u)
|
|
authPage(w, "Verify your email", `<p class="msg">Almost there — we sent a verification link to <b>`+
|
|
html.EscapeString(u.Email)+`</b>.</p><p class="alt">Click it to activate your account. No email on this server? The link is in the server log.</p>`)
|
|
return
|
|
case statusPending:
|
|
authPage(w, "Awaiting approval", `<p class="msg">Thanks — your account was created and is waiting for an administrator to approve it.</p>
|
|
<p class="alt">You'll be able to sign in once it's approved.</p>`)
|
|
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 = `<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. An invite bypasses the domain
|
|
// allowlist, so don't show it when arriving through one.
|
|
domainNote := ""
|
|
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 := ""
|
|
if a.Brand != "" {
|
|
brand = `<p class="alt" style="margin:0 0 14px;color:var(--dim)">` + html.EscapeString(a.Brand) + `</p>`
|
|
}
|
|
authPage(w, "Create account", brand+inviteBanner(next)+fmt.Sprintf(`<form method="post" action="/auth/signup?next=%s">%s%s%s%s%s<button>Sign up</button></form>
|
|
<p class="alt">Have an account? <a href="/auth/login?next=%s">Sign in</a></p>`,
|
|
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)
|
|
}
|
|
|
|
// pageCLI completes `bdrive login`: once the browser has a session, mint a
|
|
// one-time code and bounce it to the CLI's loopback listener. Redirects are
|
|
// restricted to loopback addresses so the code can't be sent anywhere else.
|
|
func (a *BuiltinAuth) pageCLI(w http.ResponseWriter, r *http.Request) {
|
|
redirect := r.URL.Query().Get("redirect")
|
|
state := r.URL.Query().Get("state")
|
|
u, err := url.Parse(redirect)
|
|
if err != nil || (u.Scheme != "http") || (u.Hostname() != "127.0.0.1" && u.Hostname() != "localhost" && u.Hostname() != "::1") {
|
|
http.Error(w, "invalid redirect (must be a loopback URL)", http.StatusBadRequest)
|
|
return
|
|
}
|
|
user, ok := a.sessionUser(r)
|
|
if !ok {
|
|
http.Redirect(w, r, "/auth/login?next="+url.QueryEscape(r.URL.String()), http.StatusSeeOther)
|
|
return
|
|
}
|
|
code := a.newGrant("code", user.ID, "", true, time.Minute)
|
|
q := u.Query()
|
|
q.Set("code", code)
|
|
q.Set("state", state)
|
|
u.RawQuery = q.Encode()
|
|
http.Redirect(w, r, u.String(), http.StatusSeeOther)
|
|
}
|
|
|
|
// pageDevice is the headless-login approval page, reached by opening the link
|
|
// `bdrive login` printed: the token lives in the path, so there is no code to
|
|
// read off one screen and type into another. It names the account the device
|
|
// will act as (with a way to switch), and what is asking, because approving
|
|
// hands that machine a token that acts as you.
|
|
func (a *BuiltinAuth) pageDevice(w http.ResponseWriter, r *http.Request) {
|
|
token := r.PathValue("token")
|
|
user, ok := a.sessionUser(r)
|
|
if !ok {
|
|
http.Redirect(w, r, "/auth/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
|
|
return
|
|
}
|
|
g, ok := a.peekGrant("device", token)
|
|
if !ok {
|
|
authPage(w, "Link expired", `<p class="err">This sign-in link is invalid, already used, or older than 10 minutes.</p>
|
|
<p class="alt">Run <code>bdrive login --device</code> again for a fresh one.</p>`)
|
|
return
|
|
}
|
|
if r.Method == http.MethodPost {
|
|
if !a.grantDevice(token, user.ID) {
|
|
authPage(w, "Link expired", `<p class="err">This sign-in link expired while the page was open.</p>
|
|
<p class="alt">Run <code>bdrive login --device</code> again for a fresh one.</p>`)
|
|
return
|
|
}
|
|
authPage(w, "Device connected", fmt.Sprintf(`<p class="msg">%s can now sync as %s.</p>
|
|
<p class="alt">You can close this tab — the terminal finishes on its own.</p>`,
|
|
html.EscapeString(orDash(g.device)), html.EscapeString(user.Email)))
|
|
return
|
|
}
|
|
authPage(w, "Connect a device", fmt.Sprintf(`<p class="lede">A device is asking to sign in to BearDrive.</p>
|
|
%s
|
|
<form method="post"><button>Approve</button></form>
|
|
<p class="alt">Approve this only if you just started a sign-in on that machine.</p>`,
|
|
whoBlock(user, g, r.URL.RequestURI())))
|
|
}
|
|
|
|
// whoBlock renders the two things the approver needs: who they'd be granting
|
|
// as (with an escape hatch back to this same page), and what is asking.
|
|
func whoBlock(user User, g pendingGrant, back string) string {
|
|
name := user.Name
|
|
if name == "" {
|
|
name = user.Email
|
|
}
|
|
return fmt.Sprintf(`<div class="who">
|
|
<div class="who-id"><span class="who-l">Signing in as</span><b>%s</b><span class="who-sub">%s</span></div>
|
|
<a class="who-swap" href="/auth/logout?next=%s">Switch account</a>
|
|
</div>
|
|
<dl class="rows"><dt>Device</dt><dd>%s</dd><dt>System</dt><dd>%s</dd><dt>Address</dt><dd>%s</dd></dl>`,
|
|
html.EscapeString(name), html.EscapeString(user.Email), url.QueryEscape(safeNext(back)),
|
|
html.EscapeString(orDash(g.device)), html.EscapeString(orDash(g.os)), html.EscapeString(orDash(g.ip)))
|
|
}
|
|
|
|
func orDash(s string) string {
|
|
if strings.TrimSpace(s) == "" {
|
|
return "—"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// pageDeviceLegacy forwards the pre-0.13 link shape (/auth/device?code=…),
|
|
// which older CLIs still print, to the path form.
|
|
func (a *BuiltinAuth) pageDeviceLegacy(w http.ResponseWriter, r *http.Request) {
|
|
code := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("code")))
|
|
if code == "" {
|
|
authPage(w, "Connect a device", `<p>Run <code>bdrive login --device</code> on the machine you want to connect; it prints a link to open here.</p>`)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/auth/device/"+url.PathEscape(code), 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", `<p class="err">This verification link is invalid or expired.</p>
|
|
<p class="alt"><a href="/auth/login">Back to sign in</a></p>`)
|
|
return
|
|
}
|
|
a.mu.Lock()
|
|
u := a.users[g.user]
|
|
next := a.afterVerify()
|
|
if u != nil && u.Status == statusUnverified {
|
|
u.Status = next
|
|
a.store.PutAccount(u)
|
|
}
|
|
a.mu.Unlock()
|
|
if u != nil && u.Status == statusPending {
|
|
authPage(w, "Email verified", `<p class="msg">Your email is verified. Your account is now waiting for an administrator to approve it.</p>`)
|
|
return
|
|
}
|
|
if u != nil {
|
|
if err := a.startSession(w, u.ID); err == nil {
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
return
|
|
}
|
|
}
|
|
authPage(w, "Email verified", `<p class="msg">Your email is verified.</p><p class="alt"><a href="/auth/login">Sign in</a></p>`)
|
|
}
|
|
|
|
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()
|
|
u := a.findByEmail(email)
|
|
a.mu.Unlock()
|
|
if u != nil {
|
|
tok := a.newGrant("reset", u.ID, "", true, time.Hour)
|
|
link := requestBaseURL(r) + "/auth/reset/confirm?token=" + tok
|
|
subject := "Reset your BearDrive password"
|
|
body := "Someone (hopefully you) asked to reset the BearDrive password for " + u.Email +
|
|
".\n\nReset it here (valid for 1 hour):\n\n " + link + "\n\nIf this wasn't you, ignore this email."
|
|
if err := a.Mail.Send(u.Email, 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", u.Email, err, link)
|
|
}
|
|
}
|
|
authPage(w, "Check your email", `<p class="msg">If that account exists, a reset link is on its way.</p>
|
|
<p class="alt">No email configured on this server? The link is in the server log.</p>`)
|
|
return
|
|
}
|
|
authPage(w, "Reset password", fmt.Sprintf(`<form method="post">%s<button>Send reset link</button></form>
|
|
<p class="alt"><a href="/auth/login">Back to sign in</a></p>`,
|
|
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, `<p class="err">Password must be at least 8 characters.</p>`))
|
|
return
|
|
}
|
|
g, ok := a.takeGrant("reset", tok)
|
|
if !ok {
|
|
authPage(w, "Link expired", `<p class="err">This reset link is invalid or expired.</p>
|
|
<p class="alt"><a href="/auth/reset">Request a new one</a></p>`)
|
|
return
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
a.mu.Lock()
|
|
if u := a.users[g.user]; u != nil {
|
|
u.Pass = string(hash)
|
|
a.store.PutAccount(u)
|
|
}
|
|
a.mu.Unlock()
|
|
authPage(w, "Password updated", `<p class="msg">Your password is updated.</p>
|
|
<p class="alt"><a href="/auth/login">Sign in</a></p>`)
|
|
return
|
|
}
|
|
authPage(w, "Set a new password", resetForm(r.URL.Query().Get("token"), ""))
|
|
}
|
|
|
|
func resetForm(token, msg string) string {
|
|
return fmt.Sprintf(`<form method="post"><input type="hidden" name="token" value=%q>%s%s<button>Set password</button></form>`,
|
|
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
|
|
}
|
|
|
|
// ---- CLI API ----
|
|
|
|
// apiExchange trades the one-time code from the browser redirect for a
|
|
// long-lived device token.
|
|
func (a *BuiltinAuth) apiExchange(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Code string `json:"code"`
|
|
Device string `json:"device"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
|
|
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
g, ok := a.takeGrant("code", req.Code)
|
|
if !ok || !g.granted {
|
|
http.Error(w, "invalid or expired code", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
a.finishLogin(w, g.user, req.Device)
|
|
}
|
|
|
|
// apiDeviceStart begins the headless flow: the CLI prints the approval link,
|
|
// the user opens it in any signed-in browser, the CLI polls. The link itself
|
|
// is the secret (RFC 8628 calls this verification_uri_complete), so it is a
|
|
// full-length token, not something short enough to retype — nobody has to
|
|
// read a code off one screen and type it into another.
|
|
//
|
|
// The requesting device's name, OS, and address are recorded here so the
|
|
// approval page can show WHAT is being approved: this flow's weakness is that
|
|
// a stranger can send you their own pending link, and a page that just says
|
|
// "Approve" gives you nothing to notice with.
|
|
func (a *BuiltinAuth) apiDeviceStart(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Device string `json:"device"`
|
|
OS string `json:"os"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
|
|
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
code := randHex(16)
|
|
a.mu.Lock()
|
|
a.pending[code] = pendingGrant{
|
|
kind: "device", device: req.Device, os: req.OS, ip: requestIP(r),
|
|
expires: time.Now().Add(10 * time.Minute),
|
|
}
|
|
a.mu.Unlock()
|
|
writeJSON(w, map[string]any{
|
|
// "code" keeps its wire name: it is what the CLI polls with, and
|
|
// older clients still print it.
|
|
"code": code,
|
|
"verify_url": requestBaseURL(r) + "/auth/device/" + code,
|
|
"interval": 2,
|
|
})
|
|
}
|
|
|
|
func (a *BuiltinAuth) apiDevicePoll(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Code string `json:"code"`
|
|
Device string `json:"device"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
|
|
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
g, ok := a.peekGrant("device", req.Code)
|
|
if !ok {
|
|
http.Error(w, "invalid or expired code", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if !g.granted {
|
|
writeJSON(w, map[string]any{"pending": true})
|
|
return
|
|
}
|
|
a.takeGrant("device", req.Code)
|
|
device := req.Device
|
|
if device == "" {
|
|
device = g.device
|
|
}
|
|
a.finishLogin(w, g.user, device)
|
|
}
|
|
|
|
func (a *BuiltinAuth) finishLogin(w http.ResponseWriter, userID, device string) {
|
|
if device == "" {
|
|
device = "cli"
|
|
}
|
|
tok, err := a.issueToken(userID, device)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
a.mu.Lock()
|
|
u := a.users[userID]
|
|
a.mu.Unlock()
|
|
if u == nil {
|
|
http.Error(w, "unknown user", http.StatusUnauthorized)
|
|
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)
|
|
}
|