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.
This commit is contained in:
2026-05-31 12:50:13 +03:00
parent 0503fad9af
commit d99f8ee82a
9 changed files with 533 additions and 3 deletions

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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)
}