mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
feat(server): expose REST CRUD for app passwords
Adds /api/appPassword endpoints (list/create/delete) under the existing native API, scoped to the authenticated user. Create returns the plaintext secret once on issuance; subsequent reads return only metadata.
This commit is contained in:
parent
14ba9c7ed3
commit
1353243126
134
server/nativeapi/app_password.go
Normal file
134
server/nativeapi/app_password.go
Normal file
@ -0,0 +1,134 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
)
|
||||
|
||||
// addAppPasswordRoute mounts the /user/{userId}/app-password CRUD endpoints.
|
||||
//
|
||||
// The route is owner-or-admin gated: a non-admin user can only manage their
|
||||
// own passwords; an admin can manage any user's. POST returns the freshly
|
||||
// generated plaintext secret exactly once; subsequent GETs only return
|
||||
// metadata.
|
||||
func (api *Router) addAppPasswordRoute(r chi.Router) {
|
||||
r.Route("/user/{userId}/app-password", func(r chi.Router) {
|
||||
r.Use(appPasswordOwnerOrAdminMiddleware)
|
||||
r.Get("/", listAppPasswords(api.ds))
|
||||
r.Post("/", createAppPassword(api.ds))
|
||||
r.Delete("/{id}", deleteAppPassword(api.ds))
|
||||
})
|
||||
}
|
||||
|
||||
// appPasswordOwnerOrAdminMiddleware permits the request only if the
|
||||
// authenticated user owns the userId in the path or is an admin. Any other
|
||||
// caller (including unauthenticated requests, which should not reach this
|
||||
// far in normal routing) gets 403.
|
||||
func appPasswordOwnerOrAdminMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
caller, ok := request.UserFrom(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "not authenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
userID := chi.URLParam(r, "userId")
|
||||
if !caller.IsAdmin && caller.ID != userID {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// listAppPasswords returns the metadata-only public projection of the
|
||||
// caller's (or, for admins, the target user's) app passwords. The plaintext
|
||||
// secret is never re-served — it is only returned by POST at creation time.
|
||||
func listAppPasswords(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID := chi.URLParam(r, "userId")
|
||||
rows, err := ds.AppPassword(r.Context()).ListForUser(r.Context(), userID)
|
||||
if err != nil {
|
||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
_ = rest.RespondWithJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
}
|
||||
|
||||
// createAppPasswordRequest is the JSON payload accepted by POST.
|
||||
type createAppPasswordRequest struct {
|
||||
Name string `json:"name"`
|
||||
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||||
}
|
||||
|
||||
// createAppPasswordResponse is the JSON payload returned by POST. The
|
||||
// "secret" field is the plaintext shown only once.
|
||||
type createAppPasswordResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Secret string `json:"secret"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||||
}
|
||||
|
||||
// createAppPassword generates and stores a new app password and returns the
|
||||
// plaintext secret in the response body. Callers must surface the secret to
|
||||
// the user immediately — Navidrome cannot retrieve it again.
|
||||
func createAppPassword(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID := chi.URLParam(r, "userId")
|
||||
|
||||
var req createAppPasswordRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
_ = rest.RespondWithError(w, http.StatusUnprocessableEntity, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
_ = rest.RespondWithError(w, http.StatusUnprocessableEntity, "name is required")
|
||||
return
|
||||
}
|
||||
|
||||
plaintext, ap, err := ds.AppPassword(r.Context()).Create(r.Context(), userID, req.Name, req.ExpiresAt)
|
||||
if err != nil {
|
||||
log.Error(r, "Failed to create app password", "userId", userID, err)
|
||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
_ = rest.RespondWithJSON(w, http.StatusCreated, createAppPasswordResponse{
|
||||
ID: ap.ID,
|
||||
Name: ap.Name,
|
||||
Secret: plaintext,
|
||||
CreatedAt: ap.CreatedAt,
|
||||
ExpiresAt: ap.ExpiresAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// deleteAppPassword removes an app password the caller is allowed to
|
||||
// manage. The repository enforces the ownership check redundantly: even a
|
||||
// path-confusion attack that smuggled a foreign userId past the middleware
|
||||
// would not delete other users' rows because the WHERE clause includes
|
||||
// user_id.
|
||||
func deleteAppPassword(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID := chi.URLParam(r, "userId")
|
||||
appPwdID := chi.URLParam(r, "id")
|
||||
if err := ds.AppPassword(r.Context()).Delete(r.Context(), appPwdID, userID); err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
_ = rest.RespondWithError(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
274
server/nativeapi/app_password_test.go
Normal file
274
server/nativeapi/app_password_test.go
Normal file
@ -0,0 +1,274 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// stubAppPasswordRepo gives the handler-level tests deterministic control over
|
||||
// what the repo returns, without dragging in encryption / DB plumbing.
|
||||
type stubAppPasswordRepo struct {
|
||||
model.AppPasswordRepository
|
||||
list []model.AppPasswordPublic
|
||||
listErr error
|
||||
createErr error
|
||||
deleteErr error
|
||||
|
||||
createdName string
|
||||
createdUserID string
|
||||
createdExpiresAt *time.Time
|
||||
deletedID string
|
||||
deletedOwnerID string
|
||||
}
|
||||
|
||||
func (s *stubAppPasswordRepo) ListForUser(ctx context.Context, userID string) ([]model.AppPasswordPublic, error) {
|
||||
if s.listErr != nil {
|
||||
return nil, s.listErr
|
||||
}
|
||||
out := make([]model.AppPasswordPublic, 0, len(s.list))
|
||||
for _, p := range s.list {
|
||||
if p.UserID == userID {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *stubAppPasswordRepo) Create(ctx context.Context, userID, name string, expiresAt *time.Time) (string, *model.AppPassword, error) {
|
||||
s.createdUserID = userID
|
||||
s.createdName = name
|
||||
s.createdExpiresAt = expiresAt
|
||||
if s.createErr != nil {
|
||||
return "", nil, s.createErr
|
||||
}
|
||||
ap := &model.AppPassword{
|
||||
ID: "new-id",
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
return "plaintext-secret", ap, nil
|
||||
}
|
||||
|
||||
func (s *stubAppPasswordRepo) Delete(ctx context.Context, id, ownerUserID string) error {
|
||||
s.deletedID = id
|
||||
s.deletedOwnerID = ownerUserID
|
||||
return s.deleteErr
|
||||
}
|
||||
|
||||
var _ = Describe("App Password API", func() {
|
||||
var (
|
||||
ds *tests.MockDataStore
|
||||
repo *stubAppPasswordRepo
|
||||
router chi.Router
|
||||
api *Router
|
||||
|
||||
owner = model.User{ID: "user-1", UserName: "alice", IsAdmin: false}
|
||||
admin = model.User{ID: "admin-1", UserName: "root", IsAdmin: true}
|
||||
other = model.User{ID: "user-2", UserName: "bob", IsAdmin: false}
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = &stubAppPasswordRepo{}
|
||||
ds = &tests.MockDataStore{MockedAppPassword: repo}
|
||||
api = &Router{ds: ds}
|
||||
|
||||
router = chi.NewRouter()
|
||||
api.addAppPasswordRoute(router)
|
||||
})
|
||||
|
||||
// serve wraps the user into the request context (the way JWTVerifier would
|
||||
// in production) and exercises the chi router so the URL params are
|
||||
// populated for the middleware and handlers.
|
||||
serve := func(method, path string, body []byte, caller *model.User) *httptest.ResponseRecorder {
|
||||
var reader *bytes.Reader
|
||||
if body != nil {
|
||||
reader = bytes.NewReader(body)
|
||||
} else {
|
||||
reader = bytes.NewReader(nil)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, reader)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if caller != nil {
|
||||
req = req.WithContext(request.WithUser(req.Context(), *caller))
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
Describe("appPasswordOwnerOrAdminMiddleware", func() {
|
||||
It("lets the owner manage their own passwords", func() {
|
||||
w := serve("GET", "/user/user-1/app-password/", nil, &owner)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("lets an admin manage any user's passwords", func() {
|
||||
w := serve("GET", "/user/user-1/app-password/", nil, &admin)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("forbids non-owner non-admin access", func() {
|
||||
w := serve("GET", "/user/user-1/app-password/", nil, &other)
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
|
||||
It("rejects unauthenticated requests with 401", func() {
|
||||
w := serve("GET", "/user/user-1/app-password/", nil, nil)
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("listAppPasswords", func() {
|
||||
It("returns the public projection without any secret fields", func() {
|
||||
repo.list = []model.AppPasswordPublic{
|
||||
{ID: "ap-1", UserID: "user-1", Name: "Phone", CreatedAt: time.Now()},
|
||||
{ID: "ap-2", UserID: "user-1", Name: "Laptop", CreatedAt: time.Now()},
|
||||
}
|
||||
w := serve("GET", "/user/user-1/app-password/", nil, &owner)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
// Decode into the typed projection - if any unexpected secret field
|
||||
// leaks in, json.Decoder won't see it here, so additionally inspect
|
||||
// the raw bytes.
|
||||
var got []model.AppPasswordPublic
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &got)).To(Succeed())
|
||||
Expect(got).To(HaveLen(2))
|
||||
|
||||
raw := w.Body.String()
|
||||
Expect(raw).NotTo(ContainSubstring("secret"))
|
||||
Expect(raw).NotTo(ContainSubstring("Secret"))
|
||||
Expect(raw).NotTo(ContainSubstring("secret_encrypted"))
|
||||
})
|
||||
|
||||
It("returns 500 when the repo errors", func() {
|
||||
repo.listErr = errors.New("boom")
|
||||
w := serve("GET", "/user/user-1/app-password/", nil, &owner)
|
||||
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("createAppPassword", func() {
|
||||
It("returns 201 with the one-time plaintext secret", func() {
|
||||
body, _ := json.Marshal(map[string]any{"name": "Phone"})
|
||||
w := serve("POST", "/user/user-1/app-password/", body, &owner)
|
||||
Expect(w.Code).To(Equal(http.StatusCreated))
|
||||
|
||||
var resp createAppPasswordResponse
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
|
||||
Expect(resp.ID).To(Equal("new-id"))
|
||||
Expect(resp.Name).To(Equal("Phone"))
|
||||
Expect(resp.Secret).To(Equal("plaintext-secret"))
|
||||
|
||||
Expect(repo.createdUserID).To(Equal("user-1"))
|
||||
Expect(repo.createdName).To(Equal("Phone"))
|
||||
Expect(repo.createdExpiresAt).To(BeNil())
|
||||
})
|
||||
|
||||
It("forwards expiresAt to the repo", func() {
|
||||
exp := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
body, _ := json.Marshal(map[string]any{"name": "TempKey", "expiresAt": exp})
|
||||
w := serve("POST", "/user/user-1/app-password/", body, &owner)
|
||||
Expect(w.Code).To(Equal(http.StatusCreated))
|
||||
Expect(repo.createdExpiresAt).NotTo(BeNil())
|
||||
Expect(repo.createdExpiresAt.Equal(exp)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("rejects an empty name with 422", func() {
|
||||
body, _ := json.Marshal(map[string]any{"name": ""})
|
||||
w := serve("POST", "/user/user-1/app-password/", body, &owner)
|
||||
Expect(w.Code).To(Equal(http.StatusUnprocessableEntity))
|
||||
Expect(repo.createdName).To(Equal("")) // repo was not called
|
||||
Expect(repo.createdUserID).To(Equal(""))
|
||||
})
|
||||
|
||||
It("rejects a malformed body with 422", func() {
|
||||
w := serve("POST", "/user/user-1/app-password/", []byte("{not json"), &owner)
|
||||
Expect(w.Code).To(Equal(http.StatusUnprocessableEntity))
|
||||
})
|
||||
|
||||
It("rejects access from a non-owner before reaching the handler", func() {
|
||||
body, _ := json.Marshal(map[string]any{"name": "Phone"})
|
||||
w := serve("POST", "/user/user-1/app-password/", body, &other)
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
Expect(repo.createdName).To(Equal("")) // repo was not called
|
||||
})
|
||||
|
||||
It("returns 500 when the repo errors", func() {
|
||||
repo.createErr = errors.New("boom")
|
||||
body, _ := json.Marshal(map[string]any{"name": "Phone"})
|
||||
w := serve("POST", "/user/user-1/app-password/", body, &owner)
|
||||
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("deleteAppPassword", func() {
|
||||
It("returns 204 on success and forwards the owner scope", func() {
|
||||
w := serve("DELETE", "/user/user-1/app-password/ap-1", nil, &owner)
|
||||
Expect(w.Code).To(Equal(http.StatusNoContent))
|
||||
Expect(repo.deletedID).To(Equal("ap-1"))
|
||||
Expect(repo.deletedOwnerID).To(Equal("user-1"))
|
||||
})
|
||||
|
||||
It("returns 404 when the repo reports not found", func() {
|
||||
repo.deleteErr = model.ErrNotFound
|
||||
w := serve("DELETE", "/user/user-1/app-password/missing", nil, &owner)
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
|
||||
It("returns 500 on unexpected repo errors", func() {
|
||||
repo.deleteErr = errors.New("boom")
|
||||
w := serve("DELETE", "/user/user-1/app-password/ap-1", nil, &owner)
|
||||
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
|
||||
It("forbids non-owner non-admin callers before reaching the repo", func() {
|
||||
w := serve("DELETE", "/user/user-1/app-password/ap-1", nil, &other)
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
Expect(repo.deletedID).To(Equal(""))
|
||||
})
|
||||
|
||||
It("scopes admin deletes to the path's userId, not the admin's", func() {
|
||||
// Even when an admin deletes on behalf of someone else, the
|
||||
// owner-id passed to the repo must be the path's userId so the
|
||||
// WHERE clause stays correct.
|
||||
w := serve("DELETE", "/user/user-1/app-password/ap-1", nil, &admin)
|
||||
Expect(w.Code).To(Equal(http.StatusNoContent))
|
||||
Expect(repo.deletedOwnerID).To(Equal("user-1"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("end-to-end URL shapes", func() {
|
||||
It("404s for an unknown subpath under app-password", func() {
|
||||
w := serve("GET", "/user/user-1/app-password/unknown/extra", nil, &owner)
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
// nothing should hit the create/delete tracking
|
||||
Expect(repo.deletedID).To(Equal(""))
|
||||
})
|
||||
|
||||
It("preserves the URL parameter through the middleware (sanity)", func() {
|
||||
// Smoke-test: hit a path that includes a hyphen in the userId,
|
||||
// confirm the middleware sees the same value chi parsed.
|
||||
hyphenated := model.User{ID: "user-with-hyphen", UserName: "x", IsAdmin: false}
|
||||
w := serve("GET", "/user/user-with-hyphen/app-password/", nil, &hyphenated)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
// The list path uses URLParam internally - we hit the trailing
|
||||
// slash one so chi resolves the param.
|
||||
Expect(strings.Contains(w.Body.String(), "[]")).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -84,6 +84,7 @@ func (api *Router) routes() http.Handler {
|
||||
api.addMissingFilesRoute(r)
|
||||
api.addKeepAliveRoute(r)
|
||||
api.addInsightsRoute(r)
|
||||
api.addAppPasswordRoute(r)
|
||||
|
||||
r.With(adminOnlyMiddleware).Group(func(r chi.Router) {
|
||||
api.addInspectRoute(r)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user