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:
@@ -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) {
|
func TestInviteHandlerCreatesUserAndMarksInviteUsed(t *testing.T) {
|
||||||
app, cleanup := newTestApp(t)
|
app, cleanup := newTestApp(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("POST /app/boxes/{boxID}/delete", a.DeleteUserBox)
|
mux.HandleFunc("POST /app/boxes/{boxID}/delete", a.DeleteUserBox)
|
||||||
mux.HandleFunc("GET /account/settings", a.AccountSettings)
|
mux.HandleFunc("GET /account/settings", a.AccountSettings)
|
||||||
mux.HandleFunc("POST /account/password", a.ChangePassword)
|
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("GET /admin/login", a.AdminLogin)
|
||||||
mux.HandleFunc("POST /admin/login", a.AdminLoginPost)
|
mux.HandleFunc("POST /admin/login", a.AdminLoginPost)
|
||||||
mux.HandleFunc("POST /admin/logout", a.AdminLogout)
|
mux.HandleFunc("POST /admin/logout", a.AdminLogout)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"warpbox.dev/backend/libs/services"
|
"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")
|
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) {
|
func (a *App) AccountSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := a.requireUser(w, r)
|
user, ok := a.requireUser(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
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",
|
Title: "Account settings",
|
||||||
Description: "Manage your Warpbox account.",
|
Description: "Manage your Warpbox account.",
|
||||||
CurrentUser: a.authService.PublicUser(user),
|
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) {
|
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)
|
cookie, err := r.Cookie(userSessionCookieName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return services.User{}, false
|
return services.User{}, false
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ var (
|
|||||||
sessionsBucket = []byte("sessions")
|
sessionsBucket = []byte("sessions")
|
||||||
invitesBucket = []byte("invites")
|
invitesBucket = []byte("invites")
|
||||||
collectionsBucket = []byte("collections")
|
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 (
|
const (
|
||||||
@@ -111,6 +120,23 @@ type Collection struct {
|
|||||||
UpdatedAt time.Time `json:"updatedAt"`
|
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 {
|
type InviteResult struct {
|
||||||
Invite Invite
|
Invite Invite
|
||||||
URL string
|
URL string
|
||||||
@@ -120,7 +146,7 @@ type InviteResult struct {
|
|||||||
func NewAuthService(db *bbolt.DB, baseURL string) (*AuthService, error) {
|
func NewAuthService(db *bbolt.DB, baseURL string) (*AuthService, error) {
|
||||||
service := &AuthService{db: db, baseURL: strings.TrimRight(baseURL, "/")}
|
service := &AuthService{db: db, baseURL: strings.TrimRight(baseURL, "/")}
|
||||||
err := db.Update(func(tx *bbolt.Tx) error {
|
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 {
|
if _, err := tx.CreateBucketIfNotExists(bucket); err != nil {
|
||||||
return err
|
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) {
|
func (s *AuthService) CreateInvite(email, role, createdBy string, expiresIn time.Duration) (InviteResult, error) {
|
||||||
email, err := normalizeEmail(email)
|
email, err := normalizeEmail(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -673,6 +824,11 @@ func tokenHash(token string) string {
|
|||||||
return hex.EncodeToString(sum[:])
|
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 {
|
func HashPassword(password string) string {
|
||||||
salt := make([]byte, 16)
|
salt := make([]byte, 16)
|
||||||
if _, err := rand.Read(salt); err != nil {
|
if _, err := rand.Read(salt); err != nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package services
|
|||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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 {
|
func newTestAuthService(t *testing.T) *AuthService {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package services
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -431,6 +432,7 @@ func GigabytesToBytes(value float64) int64 {
|
|||||||
|
|
||||||
func FormatMegabytesFromBytes(value int64) string {
|
func FormatMegabytesFromBytes(value int64) string {
|
||||||
mb := float64(value) / 1024 / 1024
|
mb := float64(value) / 1024 / 1024
|
||||||
|
mb = math.Round(mb*100) / 100
|
||||||
return FormatMegabytesLabel(mb)
|
return FormatMegabytesLabel(mb)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1818,3 +1818,63 @@ pre code {
|
|||||||
.storage-new-card .storage-card-body {
|
.storage-new-card .storage-card-body {
|
||||||
border-top: none;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
if (!form || !dropZone || !fileInput) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,61 @@
|
|||||||
<p class="muted-copy">Public forgot-password is deferred until SMTP support is added. Admins can generate reset links.</p>
|
<p class="muted-copy">Public forgot-password is deferred until SMTP support is added. Admins can generate reset links.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card settings-panel">
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="table-header">
|
||||||
|
<div>
|
||||||
|
<h2>Access tokens</h2>
|
||||||
|
<p>Personal tokens act as your account for the API and CLI. They never expire until you delete them.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
|
||||||
|
|
||||||
|
{{if .Data.NewToken}}
|
||||||
|
<div class="token-reveal">
|
||||||
|
<p class="token-reveal-title">Copy your new token now — it won't be shown again.</p>
|
||||||
|
<div class="token-reveal-row">
|
||||||
|
<code class="token-reveal-value" data-token-value>{{.Data.NewToken}}</code>
|
||||||
|
<button class="button button-outline button-sm" type="button" data-token-copy>Copy</button>
|
||||||
|
</div>
|
||||||
|
<p class="muted-copy">Use it as a bearer token: <code>Authorization: Bearer {{.Data.NewToken}}</code></p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form class="token-create-form" action="/account/tokens" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
<label><span>Token name</span><input name="name" placeholder="e.g. CLI on laptop" maxlength="80" required></label>
|
||||||
|
<button class="button button-primary button-sm" type="submit">Generate token</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{if .Data.Tokens}}
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead><tr><th>Name</th><th>Created</th><th>Last used</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Data.Tokens}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Name}}</td>
|
||||||
|
<td>{{.CreatedAt}}</td>
|
||||||
|
<td>{{.LastUsedAt}}</td>
|
||||||
|
<td class="table-actions">
|
||||||
|
<form action="/account/tokens/{{.ID}}/delete" method="post" onsubmit="return confirm('Delete this token? Any client using it will stop working.');">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
|
<button class="button button-danger button-sm" type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="muted-copy">No tokens yet. Generate one above to use the API or CLI.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user