From d99f8ee82ae4a9f45bb9cd55279b804b75b1a71e Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Sun, 31 May 2026 12:50:13 +0300 Subject: [PATCH] feat(auth): support API tokens and bearer token authentication - Add backend services to create, list, and delete API tokens. - Implement Bearer token authentication to resolve tokens to users. - Register HTTP routes for managing user tokens under `/account/tokens`. - Add tests to verify that uploads with valid Bearer tokens associate the upload with the correct user, while invalid tokens fall back to anonymous uploads. --- backend/libs/handlers/accounts_test.go | 55 +++++++++ backend/libs/handlers/app.go | 2 + backend/libs/handlers/auth.go | 92 +++++++++++++- backend/libs/services/auth.go | 158 ++++++++++++++++++++++++- backend/libs/services/auth_test.go | 102 ++++++++++++++++ backend/libs/services/settings.go | 2 + backend/static/css/app.css | 60 ++++++++++ backend/static/js/app.js | 10 ++ backend/templates/pages/account.html | 55 +++++++++ 9 files changed, 533 insertions(+), 3 deletions(-) diff --git a/backend/libs/handlers/accounts_test.go b/backend/libs/handlers/accounts_test.go index fceef0a..2b20ec2 100644 --- a/backend/libs/handlers/accounts_test.go +++ b/backend/libs/handlers/accounts_test.go @@ -67,6 +67,61 @@ func TestLoggedInUploadStoresOwnerAndAnonymousUploadDoesNot(t *testing.T) { } } +func TestBearerTokenUploadActsAsUser(t *testing.T) { + app, cleanup := newTestApp(t) + defer cleanup() + + user, err := app.authService.CreateBootstrapUser("daniel", "daniel@example.test", "password123") + if err != nil { + t.Fatalf("CreateBootstrapUser returned error: %v", err) + } + tokenResult, err := app.authService.CreateAPIToken(user.ID, "cli") + if err != nil { + t.Fatalf("CreateAPIToken returned error: %v", err) + } + + request := multipartUploadRequest(t, "/api/v1/upload", "file", "owned.txt", "owned") + request.Header.Set("Accept", "application/json") + request.Header.Set("Authorization", "Bearer "+tokenResult.Plaintext) + response := httptest.NewRecorder() + app.Upload(response, request) + if response.Code != http.StatusCreated { + t.Fatalf("token upload status = %d, body = %s", response.Code, response.Body.String()) + } + var payload services.UploadResult + if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { + t.Fatalf("json.Unmarshal returned error: %v", err) + } + box, err := app.uploadService.GetBox(payload.BoxID) + if err != nil { + t.Fatalf("GetBox returned error: %v", err) + } + if box.OwnerID != user.ID { + t.Fatalf("OwnerID = %q, want %q", box.OwnerID, user.ID) + } + + // An invalid bearer token must not authenticate as the user. + badRequest := multipartUploadRequest(t, "/api/v1/upload", "file", "x.txt", "x") + badRequest.Header.Set("Accept", "application/json") + badRequest.Header.Set("Authorization", "Bearer wbx_bogus.secret") + badResponse := httptest.NewRecorder() + app.Upload(badResponse, badRequest) + if badResponse.Code != http.StatusCreated { + t.Fatalf("anonymous fallback upload status = %d, body = %s", badResponse.Code, badResponse.Body.String()) + } + var badPayload services.UploadResult + if err := json.Unmarshal(badResponse.Body.Bytes(), &badPayload); err != nil { + t.Fatalf("json.Unmarshal returned error: %v", err) + } + badBox, err := app.uploadService.GetBox(badPayload.BoxID) + if err != nil { + t.Fatalf("GetBox returned error: %v", err) + } + if badBox.OwnerID != "" { + t.Fatalf("invalid token OwnerID = %q, want empty", badBox.OwnerID) + } +} + func TestInviteHandlerCreatesUserAndMarksInviteUsed(t *testing.T) { app, cleanup := newTestApp(t) defer cleanup() diff --git a/backend/libs/handlers/app.go b/backend/libs/handlers/app.go index fd3ebbc..eb7341b 100644 --- a/backend/libs/handlers/app.go +++ b/backend/libs/handlers/app.go @@ -56,6 +56,8 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /app/boxes/{boxID}/delete", a.DeleteUserBox) mux.HandleFunc("GET /account/settings", a.AccountSettings) mux.HandleFunc("POST /account/password", a.ChangePassword) + mux.HandleFunc("POST /account/tokens", a.CreateUserToken) + mux.HandleFunc("POST /account/tokens/{tokenID}/delete", a.DeleteUserToken) mux.HandleFunc("GET /admin/login", a.AdminLogin) mux.HandleFunc("POST /admin/login", a.AdminLoginPost) mux.HandleFunc("POST /admin/logout", a.AdminLogout) diff --git a/backend/libs/handlers/auth.go b/backend/libs/handlers/auth.go index ce725cd..6ea5fea 100644 --- a/backend/libs/handlers/auth.go +++ b/backend/libs/handlers/auth.go @@ -2,6 +2,7 @@ package handlers import ( "net/http" + "strings" "time" "warpbox.dev/backend/libs/services" @@ -122,16 +123,92 @@ func (a *App) InvitePost(w http.ResponseWriter, r *http.Request) { a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app") } +type apiTokenView struct { + ID string + Name string + CreatedAt string + LastUsedAt string +} + +type accountData struct { + ID string + Email string + Role string + Tokens []apiTokenView + NewToken string + Error string +} + func (a *App) AccountSettings(w http.ResponseWriter, r *http.Request) { user, ok := a.requireUser(w, r) if !ok { return } - a.renderPage(w, r, http.StatusOK, "account.html", web.PageData{ + a.renderAccount(w, r, http.StatusOK, user, accountData{}) +} + +// CreateUserToken mints a new personal access token and renders the account +// page with the one-time plaintext shown. The secret is never recoverable after +// this response. +func (a *App) CreateUserToken(w http.ResponseWriter, r *http.Request) { + user, ok := a.requireUser(w, r) + if !ok || !a.validateCSRF(w, r) { + return + } + if err := r.ParseForm(); err != nil { + a.renderAccount(w, r, http.StatusBadRequest, user, accountData{Error: "Unable to read form."}) + return + } + result, err := a.authService.CreateAPIToken(user.ID, r.FormValue("name")) + if err != nil { + a.logger.Warn("api token create failed", "source", "user_activity", "severity", "warn", "code", 4420, "user_id", user.ID, "error", err.Error()) + a.renderAccount(w, r, http.StatusBadRequest, user, accountData{Error: "Could not create token."}) + return + } + a.logger.Info("api token created", "source", "user_activity", "severity", "user_activity", "code", 2420, "user_id", user.ID, "token_id", result.Token.ID) + a.renderAccount(w, r, http.StatusOK, user, accountData{NewToken: result.Plaintext}) +} + +func (a *App) DeleteUserToken(w http.ResponseWriter, r *http.Request) { + user, ok := a.requireUser(w, r) + if !ok || !a.validateCSRF(w, r) { + return + } + if err := a.authService.DeleteAPIToken(user.ID, r.PathValue("tokenID")); err != nil { + a.logger.Warn("api token delete failed", "source", "user_activity", "severity", "warn", "code", 4421, "user_id", user.ID, "error", err.Error()) + } + http.Redirect(w, r, "/account/settings", http.StatusSeeOther) +} + +func (a *App) renderAccount(w http.ResponseWriter, r *http.Request, status int, user services.User, data accountData) { + tokens, err := a.authService.ListAPITokens(user.ID) + if err != nil { + http.Error(w, "unable to load tokens", http.StatusInternalServerError) + return + } + views := make([]apiTokenView, 0, len(tokens)) + for _, token := range tokens { + lastUsed := "Never" + if token.LastUsedAt != nil { + lastUsed = token.LastUsedAt.Format("Jan 2, 2006 15:04") + } + views = append(views, apiTokenView{ + ID: token.ID, + Name: token.Name, + CreatedAt: token.CreatedAt.Format("Jan 2, 2006"), + LastUsedAt: lastUsed, + }) + } + data.ID = user.ID + data.Email = user.Email + data.Role = user.Role + data.Tokens = views + + a.renderPage(w, r, status, "account.html", web.PageData{ Title: "Account settings", Description: "Manage your Warpbox account.", CurrentUser: a.authService.PublicUser(user), - Data: user, + Data: data, }) } @@ -174,6 +251,17 @@ func (a *App) loginAndRedirect(w http.ResponseWriter, r *http.Request, email, pa } func (a *App) currentUser(r *http.Request) (services.User, bool) { + // Personal access tokens via Authorization: Bearer act as their owning user. + // A bearer header is never set by browsers cross-site, so this path is not + // subject to CSRF and intentionally bypasses the session cookie. + if header := r.Header.Get("Authorization"); header != "" { + if raw, ok := strings.CutPrefix(header, "Bearer "); ok { + if user, err := a.authService.UserForAPIToken(raw); err == nil { + return user, true + } + return services.User{}, false + } + } cookie, err := r.Cookie(userSessionCookieName) if err != nil { return services.User{}, false diff --git a/backend/libs/services/auth.go b/backend/libs/services/auth.go index d4f7abc..b66172b 100644 --- a/backend/libs/services/auth.go +++ b/backend/libs/services/auth.go @@ -25,6 +25,15 @@ var ( sessionsBucket = []byte("sessions") invitesBucket = []byte("invites") collectionsBucket = []byte("collections") + apiTokensBucket = []byte("api_tokens") +) + +// apiTokenPrefix marks raw API tokens so clients and logs can recognise them. +const apiTokenPrefix = "wbx_" + +var ( + ErrTokenInvalid = errors.New("api token is invalid") + ErrTokenNotFound = errors.New("api token not found") ) const ( @@ -111,6 +120,23 @@ type Collection struct { UpdatedAt time.Time `json:"updatedAt"` } +// APIToken is a long-lived personal access token. Only the SHA-256 hash of the +// secret is stored; the plaintext is shown to the user exactly once at creation. +type APIToken struct { + ID string `json:"id"` + UserID string `json:"userId"` + Name string `json:"name"` + TokenHash string `json:"tokenHash"` + CreatedAt time.Time `json:"createdAt"` + LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` +} + +// APITokenResult carries the one-time plaintext alongside the stored token. +type APITokenResult struct { + Token APIToken + Plaintext string +} + type InviteResult struct { Invite Invite URL string @@ -120,7 +146,7 @@ type InviteResult struct { func NewAuthService(db *bbolt.DB, baseURL string) (*AuthService, error) { service := &AuthService{db: db, baseURL: strings.TrimRight(baseURL, "/")} err := db.Update(func(tx *bbolt.Tx) error { - for _, bucket := range [][]byte{usersBucket, userEmailsBucket, sessionsBucket, invitesBucket, collectionsBucket} { + for _, bucket := range [][]byte{usersBucket, userEmailsBucket, sessionsBucket, invitesBucket, collectionsBucket, apiTokensBucket} { if _, err := tx.CreateBucketIfNotExists(bucket); err != nil { return err } @@ -225,6 +251,131 @@ func (s *AuthService) Logout(raw string) error { }) } +// CreateAPIToken mints a new personal access token for the user. The returned +// plaintext is the only time the secret is available; only its hash is stored. +func (s *AuthService) CreateAPIToken(userID, name string) (APITokenResult, error) { + if userID == "" { + return APITokenResult{}, fmt.Errorf("user is required") + } + name = strings.TrimSpace(name) + if name == "" { + name = "Untitled token" + } + if len(name) > 80 { + name = name[:80] + } + + secret := randomID(32) + token := APIToken{ + ID: randomID(12), + UserID: userID, + Name: name, + TokenHash: apiTokenHash(secret), + CreatedAt: time.Now().UTC(), + } + if err := s.saveAPIToken(token); err != nil { + return APITokenResult{}, err + } + plaintext := apiTokenPrefix + token.ID + "." + secret + return APITokenResult{Token: token, Plaintext: plaintext}, nil +} + +// ListAPITokens returns the user's tokens, newest first. +func (s *AuthService) ListAPITokens(userID string) ([]APIToken, error) { + tokens := make([]APIToken, 0) + err := s.db.View(func(tx *bbolt.Tx) error { + return tx.Bucket(apiTokensBucket).ForEach(func(_, data []byte) error { + var token APIToken + if err := json.Unmarshal(data, &token); err != nil { + return err + } + if token.UserID == userID { + tokens = append(tokens, token) + } + return nil + }) + }) + if err != nil { + return nil, err + } + sort.Slice(tokens, func(i, j int) bool { + return tokens[i].CreatedAt.After(tokens[j].CreatedAt) + }) + return tokens, nil +} + +// DeleteAPIToken removes a token, but only if it belongs to the given user. +func (s *AuthService) DeleteAPIToken(userID, tokenID string) error { + if userID == "" || tokenID == "" { + return ErrTokenNotFound + } + return s.db.Update(func(tx *bbolt.Tx) error { + bucket := tx.Bucket(apiTokensBucket) + data := bucket.Get([]byte(tokenID)) + if data == nil { + return ErrTokenNotFound + } + var token APIToken + if err := json.Unmarshal(data, &token); err != nil { + return err + } + if token.UserID != userID { + return ErrTokenNotFound + } + return bucket.Delete([]byte(tokenID)) + }) +} + +// UserForAPIToken resolves a raw bearer token to its owning user. It records +// last-used time on a best-effort basis. The user must exist and be enabled. +func (s *AuthService) UserForAPIToken(raw string) (User, error) { + raw = strings.TrimSpace(raw) + raw = strings.TrimPrefix(raw, apiTokenPrefix) + tokenID, secret, ok := strings.Cut(raw, ".") + if !ok || tokenID == "" || secret == "" { + return User{}, ErrTokenInvalid + } + + var token APIToken + err := s.db.View(func(tx *bbolt.Tx) error { + data := tx.Bucket(apiTokensBucket).Get([]byte(tokenID)) + if data == nil { + return ErrTokenInvalid + } + return json.Unmarshal(data, &token) + }) + if err != nil { + return User{}, ErrTokenInvalid + } + if subtle.ConstantTimeCompare([]byte(apiTokenHash(secret)), []byte(token.TokenHash)) != 1 { + return User{}, ErrTokenInvalid + } + + user, err := s.UserByID(token.UserID) + if err != nil { + return User{}, ErrTokenInvalid + } + if user.Status != UserStatusActive { + return User{}, ErrUserDisabled + } + + now := time.Now().UTC() + token.LastUsedAt = &now + _ = s.saveAPIToken(token) + + return user, nil +} + +func (s *AuthService) saveAPIToken(token APIToken) error { + return s.db.Update(func(tx *bbolt.Tx) error { + data, err := json.Marshal(token) + if err != nil { + return err + } + return tx.Bucket(apiTokensBucket).Put([]byte(token.ID), data) + }) +} + func (s *AuthService) CreateInvite(email, role, createdBy string, expiresIn time.Duration) (InviteResult, error) { email, err := normalizeEmail(email) if err != nil { @@ -673,6 +824,11 @@ func tokenHash(token string) string { return hex.EncodeToString(sum[:]) } +func apiTokenHash(secret string) string { + sum := sha256.Sum256([]byte("warpbox-api-token:" + secret)) + return hex.EncodeToString(sum[:]) +} + func HashPassword(password string) string { salt := make([]byte, 16) if _, err := rand.Read(salt); err != nil { diff --git a/backend/libs/services/auth_test.go b/backend/libs/services/auth_test.go index d992bfd..b1dd2b8 100644 --- a/backend/libs/services/auth_test.go +++ b/backend/libs/services/auth_test.go @@ -3,6 +3,7 @@ package services import ( "log/slog" "path/filepath" + "strings" "testing" "time" ) @@ -103,6 +104,107 @@ func TestInviteAcceptsOnceAndResetChangesPassword(t *testing.T) { } } +func TestAPITokenLifecycle(t *testing.T) { + auth := newTestAuthService(t) + user, err := auth.CreateBootstrapUser("daniel", "daniel@example.test", "password123") + if err != nil { + t.Fatalf("CreateBootstrapUser returned error: %v", err) + } + + result, err := auth.CreateAPIToken(user.ID, "CLI laptop") + if err != nil { + t.Fatalf("CreateAPIToken returned error: %v", err) + } + if result.Plaintext == "" || !strings.HasPrefix(result.Plaintext, apiTokenPrefix) { + t.Fatalf("plaintext = %q, want %q prefix", result.Plaintext, apiTokenPrefix) + } + // The secret must never be stored in plaintext — only its hash. + if strings.Contains(result.Token.TokenHash, result.Plaintext) || result.Token.TokenHash == result.Plaintext { + t.Fatalf("stored token hash leaks the plaintext secret") + } + + resolved, err := auth.UserForAPIToken(result.Plaintext) + if err != nil { + t.Fatalf("UserForAPIToken returned error: %v", err) + } + if resolved.ID != user.ID { + t.Fatalf("resolved user = %q, want %q", resolved.ID, user.ID) + } + + tokens, err := auth.ListAPITokens(user.ID) + if err != nil { + t.Fatalf("ListAPITokens returned error: %v", err) + } + if len(tokens) != 1 { + t.Fatalf("token count = %d, want 1", len(tokens)) + } + if tokens[0].Name != "CLI laptop" { + t.Fatalf("token name = %q, want %q", tokens[0].Name, "CLI laptop") + } + if tokens[0].LastUsedAt == nil { + t.Fatalf("LastUsedAt not recorded after UserForAPIToken") + } + + if _, err := auth.UserForAPIToken(result.Plaintext + "tampered"); err == nil { + t.Fatalf("UserForAPIToken accepted a tampered token") + } + if _, err := auth.UserForAPIToken("wbx_deadbeef.nope"); err == nil { + t.Fatalf("UserForAPIToken accepted an unknown token") + } + + if err := auth.DeleteAPIToken(user.ID, tokens[0].ID); err != nil { + t.Fatalf("DeleteAPIToken returned error: %v", err) + } + if _, err := auth.UserForAPIToken(result.Plaintext); err == nil { + t.Fatalf("deleted token still resolved") + } + remaining, err := auth.ListAPITokens(user.ID) + if err != nil { + t.Fatalf("ListAPITokens returned error: %v", err) + } + if len(remaining) != 0 { + t.Fatalf("token count after delete = %d, want 0", len(remaining)) + } +} + +func TestAPITokenScopedToOwnerAndDisabledUser(t *testing.T) { + auth := newTestAuthService(t) + owner, err := auth.CreateBootstrapUser("owner", "owner@example.test", "password123") + if err != nil { + t.Fatalf("CreateBootstrapUser returned error: %v", err) + } + invite, err := auth.CreateInvite("other@example.test", UserRoleUser, owner.ID, time.Hour) + if err != nil { + t.Fatalf("CreateInvite returned error: %v", err) + } + other, err := auth.AcceptInvite(invite.Token, "other", "password123") + if err != nil { + t.Fatalf("AcceptInvite returned error: %v", err) + } + + result, err := auth.CreateAPIToken(owner.ID, "owner token") + if err != nil { + t.Fatalf("CreateAPIToken returned error: %v", err) + } + tokens, err := auth.ListAPITokens(owner.ID) + if err != nil { + t.Fatalf("ListAPITokens returned error: %v", err) + } + + // Another user cannot delete tokens they do not own. + if err := auth.DeleteAPIToken(other.ID, tokens[0].ID); err == nil { + t.Fatalf("DeleteAPIToken allowed deletion across users") + } + + // A disabled owner cannot authenticate with their token. + if err := auth.DisableUser(owner.ID, true); err != nil { + t.Fatalf("DisableUser returned error: %v", err) + } + if _, err := auth.UserForAPIToken(result.Plaintext); err == nil { + t.Fatalf("disabled user token still resolved") + } +} + func newTestAuthService(t *testing.T) *AuthService { t.Helper() root := t.TempDir() diff --git a/backend/libs/services/settings.go b/backend/libs/services/settings.go index 6d7419c..8413d55 100644 --- a/backend/libs/services/settings.go +++ b/backend/libs/services/settings.go @@ -3,6 +3,7 @@ package services import ( "encoding/json" "fmt" + "math" "net" "strconv" "strings" @@ -431,6 +432,7 @@ func GigabytesToBytes(value float64) int64 { func FormatMegabytesFromBytes(value int64) string { mb := float64(value) / 1024 / 1024 + mb = math.Round(mb*100) / 100 return FormatMegabytesLabel(mb) } diff --git a/backend/static/css/app.css b/backend/static/css/app.css index f433f56..2fa950d 100644 --- a/backend/static/css/app.css +++ b/backend/static/css/app.css @@ -1818,3 +1818,63 @@ pre code { .storage-new-card .storage-card-body { border-top: none; } + +/* ── Access tokens ───────────────────────────────────────────────────────── */ + +.token-create-form { + display: flex; + align-items: end; + gap: 0.65rem; + flex-wrap: wrap; + margin-bottom: 1rem; +} + +.token-create-form label { + display: grid; + gap: 0.35rem; + color: var(--muted-foreground); + font-size: 0.82rem; + flex: 1; + min-width: 14rem; +} + +.token-reveal { + margin-bottom: 1rem; + padding: 0.9rem 1rem; + border: 1px solid rgba(134, 239, 172, 0.3); + border-radius: var(--radius); + background: rgba(134, 239, 172, 0.08); +} + +.token-reveal-title { + margin: 0 0 0.6rem; + font-size: 0.85rem; + font-weight: 650; + color: #86efac; +} + +.token-reveal-row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.token-reveal-value { + flex: 1; + min-width: 0; + padding: 0.5rem 0.65rem; + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 0.125rem); + background: var(--background); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.82rem; + word-break: break-all; +} + +.token-reveal .muted-copy { + margin: 0.6rem 0 0; +} + +.token-reveal .muted-copy code { + word-break: break-all; +} diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 24a94db..54c757b 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -244,6 +244,16 @@ } } + /* Access token: copy one-time secret */ + const tokenCopyBtn = document.querySelector("[data-token-copy]"); + if (tokenCopyBtn) { + tokenCopyBtn.addEventListener("click", () => { + const valueEl = document.querySelector("[data-token-value]"); + if (!valueEl) return; + copyText(valueEl.textContent.trim(), tokenCopyBtn, "Copied"); + }); + } + if (!form || !dropZone || !fileInput) { return; } diff --git a/backend/templates/pages/account.html b/backend/templates/pages/account.html index 768aafe..28beda6 100644 --- a/backend/templates/pages/account.html +++ b/backend/templates/pages/account.html @@ -42,6 +42,61 @@

Public forgot-password is deferred until SMTP support is added. Admins can generate reset links.

+ +
+
+
+
+

Access tokens

+

Personal tokens act as your account for the API and CLI. They never expire until you delete them.

+
+
+ + {{if .Data.Error}}

{{.Data.Error}}

{{end}} + + {{if .Data.NewToken}} +
+

Copy your new token now — it won't be shown again.

+
+ {{.Data.NewToken}} + +
+

Use it as a bearer token: Authorization: Bearer {{.Data.NewToken}}

+
+ {{end}} + +
+ + + +
+ + {{if .Data.Tokens}} +
+ + + + {{range .Data.Tokens}} + + + + + + + {{end}} + +
NameCreatedLast used
{{.Name}}{{.CreatedAt}}{{.LastUsedAt}} +
+ + +
+
+
+ {{else}} +

No tokens yet. Generate one above to use the API or CLI.

+ {{end}} +
+