mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
Merge a60a9e6642 into caa2f8a0c0
This commit is contained in:
commit
38960501fd
91 changed files with 7115 additions and 157 deletions
|
|
@ -36,6 +36,14 @@ rules:
|
|||
- sharing
|
||||
- radio
|
||||
- admin
|
||||
- password
|
||||
nd-operation-security-required:
|
||||
description: Every operation declares security explicitly (use [] for public operations).
|
||||
severity: error
|
||||
given: $.paths[*][get,put,post,delete,patch]
|
||||
then:
|
||||
field: security
|
||||
function: defined
|
||||
nd-operation-stability-level-required:
|
||||
description: Every operation declares its stability level, which the breaking-change gate relies on.
|
||||
severity: error
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"info": {
|
||||
"title": "Navidrome API",
|
||||
"version": "1.0.0",
|
||||
"description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /server` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n",
|
||||
"description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /capabilities` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n\nOperations that need a grant declare `security: [{bearerAuth: []}]` and the scope they need in\n`x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). Clients send the grant secret as\n`Authorization: Bearer \u003csecret\u003e`. A revoked grant stops working within one minute at most.\n",
|
||||
"license": {
|
||||
"name": "GPL-3.0",
|
||||
"url": "https://www.gnu.org/licenses/gpl-3.0.html"
|
||||
|
|
@ -18,6 +18,10 @@
|
|||
{
|
||||
"name": "server",
|
||||
"description": "Server discovery and the published OpenAPI document."
|
||||
},
|
||||
{
|
||||
"name": "auth",
|
||||
"description": "Grants and login methods."
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
|
|
@ -29,8 +33,9 @@
|
|||
"tags": [
|
||||
"server"
|
||||
],
|
||||
"security": [],
|
||||
"summary": "Describe the server",
|
||||
"description": "Returns the public server description. No authentication required.\nAuthenticated requests will additionally receive the implemented capability modules\nonce authentication is available.\n",
|
||||
"description": "Returns the public server description. No authentication required.\nCapability modules are listed by `GET /capabilities`.\n",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Server description.",
|
||||
|
|
@ -48,6 +53,41 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/capabilities": {
|
||||
"get": {
|
||||
"operationId": "getCapabilities",
|
||||
"x-module": "core",
|
||||
"x-stability-level": "alpha",
|
||||
"tags": [
|
||||
"server"
|
||||
],
|
||||
"summary": "List implemented capability modules",
|
||||
"description": "The capability modules this server implements. Any valid grant may read it, whatever its scopes.",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Implemented modules.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Capabilities"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/InternalError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/openapi.json": {
|
||||
"get": {
|
||||
"operationId": "getOpenAPISpecJSON",
|
||||
|
|
@ -56,6 +96,7 @@
|
|||
"tags": [
|
||||
"server"
|
||||
],
|
||||
"security": [],
|
||||
"summary": "Get the OpenAPI document (JSON)",
|
||||
"description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.",
|
||||
"responses": {
|
||||
|
|
@ -81,6 +122,56 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/auth/grants": {
|
||||
"get": {
|
||||
"operationId": "listGrants",
|
||||
"x-module": "core",
|
||||
"x-scope": "read",
|
||||
"x-stability-level": "alpha",
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "List my grants",
|
||||
"description": "The caller's grants, most recently used first. Grants idle long enough to have expired are not listed.",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/offset"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/limit"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A page of grants.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/GrantList"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/BadRequest"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/Forbidden"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/InternalError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/openapi.yaml": {
|
||||
"get": {
|
||||
"operationId": "getOpenAPISpecYAML",
|
||||
|
|
@ -89,6 +180,7 @@
|
|||
"tags": [
|
||||
"server"
|
||||
],
|
||||
"security": [],
|
||||
"summary": "Get the OpenAPI document (YAML)",
|
||||
"description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.",
|
||||
"responses": {
|
||||
|
|
@ -113,6 +205,262 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/grants/{id}": {
|
||||
"delete": {
|
||||
"operationId": "revokeGrant",
|
||||
"x-module": "core",
|
||||
"x-scope": "read",
|
||||
"x-stability-level": "alpha",
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Revoke one of my grants",
|
||||
"description": "Revokes the grant; requests with its secret fail from then on. Another user's grant id answers 404.",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "Grant id.",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"maxLength": 64
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Revoked."
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/BadRequest"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/NotFound"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/InternalError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/logout": {
|
||||
"post": {
|
||||
"operationId": "logout",
|
||||
"x-module": "core",
|
||||
"x-scope": "read",
|
||||
"x-stability-level": "alpha",
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Log out",
|
||||
"description": "Revokes the grant that made this request.",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Logged out.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LogoutResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/Forbidden"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/InternalError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/login": {
|
||||
"post": {
|
||||
"operationId": "login",
|
||||
"x-module": "password",
|
||||
"x-stability-level": "alpha",
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Log in with a password",
|
||||
"description": "Checks the username and password and returns a new grant. Unknown user and wrong password fail the same way.",
|
||||
"security": [],
|
||||
"requestBody": {
|
||||
"description": "The credentials and a description of the client.",
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CredentialsRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The new grant.",
|
||||
"headers": {
|
||||
"Cache-Control": {
|
||||
"$ref": "#/components/headers/CacheControlNoStore"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/GrantCreated"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/BadRequest"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"413": {
|
||||
"$ref": "#/components/responses/PayloadTooLarge"
|
||||
},
|
||||
"429": {
|
||||
"$ref": "#/components/responses/TooManyRequests"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/InternalError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/setup": {
|
||||
"post": {
|
||||
"operationId": "setupFirstAdmin",
|
||||
"x-module": "password",
|
||||
"x-stability-level": "alpha",
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Create the first admin",
|
||||
"description": "Creates the first administrator while `setupRequired` is true and returns a grant for it. Answers 409 `setup_complete` once any user exists. A server with no setup step always answers 409.",
|
||||
"security": [],
|
||||
"requestBody": {
|
||||
"description": "The credentials and a description of the client.",
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CredentialsRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "The admin was created.",
|
||||
"headers": {
|
||||
"Cache-Control": {
|
||||
"$ref": "#/components/headers/CacheControlNoStore"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/GrantCreated"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/BadRequest"
|
||||
},
|
||||
"409": {
|
||||
"$ref": "#/components/responses/Conflict"
|
||||
},
|
||||
"413": {
|
||||
"$ref": "#/components/responses/PayloadTooLarge"
|
||||
},
|
||||
"429": {
|
||||
"$ref": "#/components/responses/TooManyRequests"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/InternalError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/password": {
|
||||
"post": {
|
||||
"operationId": "changePassword",
|
||||
"x-module": "password",
|
||||
"x-scope": "password",
|
||||
"x-stability-level": "alpha",
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Change my password",
|
||||
"description": "Changes the caller's password. By default every other grant of the user is revoked; the calling grant survives. On Navidrome the change also ends the user's sessions on its other APIs, regardless of `revokeOtherGrants`, which only covers API v1 grants. Answers 409 `password_managed_externally` when the password is not stored by this server.",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"description": "The current and the new password.",
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PasswordChangeRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Password changed."
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/BadRequest"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/Forbidden"
|
||||
},
|
||||
"409": {
|
||||
"$ref": "#/components/responses/Conflict"
|
||||
},
|
||||
"413": {
|
||||
"$ref": "#/components/responses/PayloadTooLarge"
|
||||
},
|
||||
"429": {
|
||||
"$ref": "#/components/responses/TooManyRequests"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/InternalError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
|
|
@ -120,8 +468,7 @@
|
|||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT",
|
||||
"description": "Short-lived access token minted from a device grant. Not yet applied to any operation."
|
||||
"description": "Grant secret from a login method (`POST /auth/login`, `POST /auth/setup`). Opaque. The required scope is in each operation's `x-scope`."
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
|
|
@ -153,17 +500,23 @@
|
|||
"description": "True until the first admin user has been created."
|
||||
},
|
||||
"loginMethods": {
|
||||
"type": "array",
|
||||
"description": "Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"password"
|
||||
]
|
||||
}
|
||||
"$ref": "#/components/schemas/LoginMethods"
|
||||
}
|
||||
}
|
||||
},
|
||||
"LoginMethods": {
|
||||
"type": "object",
|
||||
"description": "Login methods this server accepts, keyed by method. A missing key means the method is not offered.\nKeys are optional on purpose: discovery is read by clients of any version against servers of any\nversion, so new methods are added as new optional keys. Clients ignore keys they do not know.\n",
|
||||
"properties": {
|
||||
"password": {
|
||||
"$ref": "#/components/schemas/PasswordLoginMethod"
|
||||
}
|
||||
}
|
||||
},
|
||||
"PasswordLoginMethod": {
|
||||
"type": "object",
|
||||
"description": "Username and password login (`POST /auth/login`). No settings yet."
|
||||
},
|
||||
"Problem": {
|
||||
"type": "object",
|
||||
"description": "RFC 9457 problem details, returned for every 4xx and 5xx response.",
|
||||
|
|
@ -187,7 +540,7 @@
|
|||
},
|
||||
"detail": {
|
||||
"type": "string",
|
||||
"description": "Human-readable explanation specific to this occurrence. Omitted for internal errors."
|
||||
"description": "Human-readable explanation specific to this occurrence. Omitted unless the server marked the text as safe to show clients."
|
||||
},
|
||||
"code": {
|
||||
"type": "string",
|
||||
|
|
@ -196,12 +549,21 @@
|
|||
"validation",
|
||||
"unauthorized",
|
||||
"forbidden",
|
||||
"insufficient_scope",
|
||||
"not_found",
|
||||
"method_not_allowed",
|
||||
"setup_complete",
|
||||
"password_managed_externally",
|
||||
"payload_too_large",
|
||||
"rate_limited",
|
||||
"unavailable",
|
||||
"internal"
|
||||
]
|
||||
},
|
||||
"referenceId": {
|
||||
"type": "string",
|
||||
"description": "Present on internal errors. Quote it when reporting a problem; it tags the server's log lines for this request."
|
||||
},
|
||||
"errors": {
|
||||
"type": "array",
|
||||
"description": "Per-field failures. Present only when `code` is `validation`.",
|
||||
|
|
@ -228,6 +590,310 @@
|
|||
"description": "Why the value was rejected."
|
||||
}
|
||||
}
|
||||
},
|
||||
"Capabilities": {
|
||||
"type": "object",
|
||||
"description": "Capability modules this server implements, keyed by module. Keys are optional; a missing key means the\nmodule is not implemented. New modules are added as new optional keys. These are server facts, not what\nthe calling grant may use.\n",
|
||||
"properties": {
|
||||
"core": {
|
||||
"$ref": "#/components/schemas/CoreCapability"
|
||||
},
|
||||
"password": {
|
||||
"$ref": "#/components/schemas/PasswordCapability"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CoreCapability": {
|
||||
"type": "object",
|
||||
"description": "The mandatory core module.",
|
||||
"required": [
|
||||
"version"
|
||||
],
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "integer",
|
||||
"description": "Module version. Bumped only on semantic change."
|
||||
}
|
||||
}
|
||||
},
|
||||
"PasswordCapability": {
|
||||
"type": "object",
|
||||
"description": "The password login module (login, first-admin setup, password change).",
|
||||
"required": [
|
||||
"version"
|
||||
],
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "integer",
|
||||
"description": "Module version. Bumped only on semantic change."
|
||||
}
|
||||
}
|
||||
},
|
||||
"GrantList": {
|
||||
"type": "object",
|
||||
"description": "A page of the caller's grants.",
|
||||
"required": [
|
||||
"items",
|
||||
"total",
|
||||
"offset",
|
||||
"limit"
|
||||
],
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"description": "Grants on this page, by last use, most recent first; never-used grants last.",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Grant"
|
||||
}
|
||||
},
|
||||
"total": {
|
||||
"type": "integer",
|
||||
"description": "Total number of grants."
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"description": "Zero-based index of the first returned item."
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of items in this page."
|
||||
}
|
||||
}
|
||||
},
|
||||
"LogoutResponse": {
|
||||
"type": "object",
|
||||
"description": "Result of a logout.",
|
||||
"required": [
|
||||
"logoutUrl"
|
||||
],
|
||||
"properties": {
|
||||
"logoutUrl": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Where to send the browser to finish logging out of an external provider. Null when there is nothing more to do."
|
||||
}
|
||||
}
|
||||
},
|
||||
"CredentialsRequest": {
|
||||
"type": "object",
|
||||
"description": "Username, password and client description for a login or first-admin setup.",
|
||||
"required": [
|
||||
"username",
|
||||
"password",
|
||||
"client"
|
||||
],
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 255,
|
||||
"description": "Login name."
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1024,
|
||||
"description": "Password."
|
||||
},
|
||||
"client": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"description": "Name of the client app."
|
||||
},
|
||||
"clientVersion": {
|
||||
"type": "string",
|
||||
"maxLength": 32,
|
||||
"description": "Version of the client app."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"description": "Label for this grant. Defaults to `client`."
|
||||
},
|
||||
"scopes": {
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"description": "Scopes the grant may hold. Omit for `all`.",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ScopeRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"GrantCreated": {
|
||||
"type": "object",
|
||||
"description": "Returned by every login method. The secret is shown only here; store it and never parse it.",
|
||||
"required": [
|
||||
"secret",
|
||||
"grant",
|
||||
"user"
|
||||
],
|
||||
"properties": {
|
||||
"secret": {
|
||||
"type": "string",
|
||||
"maxLength": 512,
|
||||
"description": "Opaque grant secret. Send it as `Authorization: Bearer \u003csecret\u003e`."
|
||||
},
|
||||
"grant": {
|
||||
"description": "The new grant.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Grant"
|
||||
}
|
||||
]
|
||||
},
|
||||
"user": {
|
||||
"description": "The user the grant belongs to.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/AuthUser"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"PasswordChangeRequest": {
|
||||
"type": "object",
|
||||
"description": "Change the caller's own password.",
|
||||
"required": [
|
||||
"currentPassword",
|
||||
"newPassword"
|
||||
],
|
||||
"properties": {
|
||||
"currentPassword": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1024,
|
||||
"description": "The current password."
|
||||
},
|
||||
"newPassword": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1024,
|
||||
"description": "The new password."
|
||||
},
|
||||
"revokeOtherGrants": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Revoke every other grant of the user. The calling grant always survives. Default true."
|
||||
}
|
||||
}
|
||||
},
|
||||
"Grant": {
|
||||
"type": "object",
|
||||
"description": "A long-lived grant held by one client of one user.",
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"client",
|
||||
"clientVersion",
|
||||
"scopes",
|
||||
"provider",
|
||||
"createdAt",
|
||||
"lastUsedAt",
|
||||
"lastUsedIp",
|
||||
"current"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Grant id."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Label shown to the user."
|
||||
},
|
||||
"client": {
|
||||
"type": "string",
|
||||
"description": "Name of the client app that holds the grant."
|
||||
},
|
||||
"clientVersion": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Version of the client app, when it sent one."
|
||||
},
|
||||
"scopes": {
|
||||
"type": "array",
|
||||
"description": "Scopes this grant carries.",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Scope"
|
||||
}
|
||||
},
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"description": "How the grant was created, for example `password` or `setup`. Free-form; new values may appear."
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "When the grant was created."
|
||||
},
|
||||
"lastUsedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"nullable": true,
|
||||
"description": "When the grant was last used, at a coarse granularity. Null until first use."
|
||||
},
|
||||
"lastUsedIp": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Client IP of the last use. Null until first use."
|
||||
},
|
||||
"current": {
|
||||
"type": "boolean",
|
||||
"description": "True for the grant that made this request."
|
||||
}
|
||||
}
|
||||
},
|
||||
"Scope": {
|
||||
"type": "string",
|
||||
"description": "A permission scope. Scopes mirror capability modules; `x:write` includes `x`. `all` appears only on\ngrants and means every scope the user is entitled to, now and in future releases. New scopes may be added.\n",
|
||||
"enum": [
|
||||
"all",
|
||||
"read",
|
||||
"password"
|
||||
]
|
||||
},
|
||||
"ScopeRequest": {
|
||||
"type": "string",
|
||||
"description": "A requested scope. Scopes the server does not know are dropped, not rejected, so newer clients keep working.",
|
||||
"pattern": "^[a-z][a-z-]*(:write)?$",
|
||||
"maxLength": 64
|
||||
},
|
||||
"AuthUser": {
|
||||
"type": "object",
|
||||
"description": "The user a grant belongs to.",
|
||||
"required": [
|
||||
"id",
|
||||
"userName",
|
||||
"name",
|
||||
"isAdmin",
|
||||
"passwordChangeable"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "User id."
|
||||
},
|
||||
"userName": {
|
||||
"type": "string",
|
||||
"description": "Login name."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Display name."
|
||||
},
|
||||
"isAdmin": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the user is an administrator."
|
||||
},
|
||||
"passwordChangeable": {
|
||||
"type": "boolean",
|
||||
"description": "Whether `POST /auth/password` can change this user's password. Clients hide \"change password\" when false."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
|
|
@ -241,6 +907,21 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Unauthorized": {
|
||||
"description": "Missing, invalid, or expired credentials.",
|
||||
"headers": {
|
||||
"WWW-Authenticate": {
|
||||
"$ref": "#/components/headers/WWWAuthenticate"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Problem"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NotModified": {
|
||||
"description": "Not modified.",
|
||||
"headers": {
|
||||
|
|
@ -248,14 +929,127 @@
|
|||
"$ref": "#/components/headers/ETag"
|
||||
}
|
||||
}
|
||||
},
|
||||
"BadRequest": {
|
||||
"description": "The request is malformed or fails validation.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Problem"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Forbidden": {
|
||||
"description": "The caller is authenticated but not allowed to do this.",
|
||||
"headers": {
|
||||
"WWW-Authenticate": {
|
||||
"$ref": "#/components/headers/WWWAuthenticate"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Problem"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NotFound": {
|
||||
"description": "No such resource or endpoint.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Problem"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PayloadTooLarge": {
|
||||
"description": "The request body is too large (`payload_too_large`).",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Problem"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"TooManyRequests": {
|
||||
"description": "Rate limited (`rate_limited`). Retry after the `Retry-After` seconds.",
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Seconds to wait before retrying.",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Problem"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Conflict": {
|
||||
"description": "The request conflicts with the server's state, for example `setup_complete` or `password_managed_externally`.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Problem"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": {
|
||||
"offset": {
|
||||
"name": "offset",
|
||||
"in": "query",
|
||||
"description": "Zero-based index of the first item to return.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"limit": {
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"description": "Maximum number of items to return.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 2000,
|
||||
"default": 100
|
||||
}
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"WWWAuthenticate": {
|
||||
"description": "RFC 6750 Bearer challenge, for example `Bearer error=\"insufficient_scope\", scope=\"read\"`.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"ETag": {
|
||||
"description": "Entity tag for `If-None-Match` revalidation.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"CacheControlNoStore": {
|
||||
"description": "Always `no-store`, because the response carries a secret.",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"no-store"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ info:
|
|||
version: 1.0.0
|
||||
description: |
|
||||
Navidrome API v1. Spec-first, additive within v1. Clients discover implemented
|
||||
capability modules through `GET /server` and never sniff versions.
|
||||
capability modules through `GET /capabilities` and never sniff versions.
|
||||
|
||||
Enums are open: new values may be added to any enum within v1. Clients must
|
||||
accept values they do not recognise instead of failing.
|
||||
|
|
@ -15,6 +15,10 @@ info:
|
|||
|
||||
`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods
|
||||
in its `Allow` header.
|
||||
|
||||
Operations that need a grant declare `security: [{bearerAuth: []}]` and the scope they need in
|
||||
`x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). Clients send the grant secret as
|
||||
`Authorization: Bearer <secret>`. A revoked grant stops working within one minute at most.
|
||||
license:
|
||||
name: GPL-3.0
|
||||
url: https://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
|
@ -23,6 +27,8 @@ servers:
|
|||
tags:
|
||||
- name: server
|
||||
description: Server discovery and the published OpenAPI document.
|
||||
- name: auth
|
||||
description: Grants and login methods.
|
||||
paths:
|
||||
/server:
|
||||
get:
|
||||
|
|
@ -30,11 +36,11 @@ paths:
|
|||
x-module: core
|
||||
x-stability-level: alpha
|
||||
tags: [server]
|
||||
security: []
|
||||
summary: Describe the server
|
||||
description: |
|
||||
Returns the public server description. No authentication required.
|
||||
Authenticated requests will additionally receive the implemented capability modules
|
||||
once authentication is available.
|
||||
Capability modules are listed by `GET /capabilities`.
|
||||
responses:
|
||||
'200':
|
||||
description: Server description.
|
||||
|
|
@ -44,12 +50,30 @@ paths:
|
|||
$ref: '#/components/schemas/ServerInfo'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
/capabilities:
|
||||
get:
|
||||
operationId: getCapabilities
|
||||
x-module: core
|
||||
x-stability-level: alpha
|
||||
tags: [server]
|
||||
summary: List implemented capability modules
|
||||
description: The capability modules this server implements. Any valid grant may read it, whatever its scopes.
|
||||
security: [{bearerAuth: []}]
|
||||
responses:
|
||||
'200':
|
||||
description: Implemented modules.
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/Capabilities'}
|
||||
'401': {$ref: '#/components/responses/Unauthorized'}
|
||||
'500': {$ref: '#/components/responses/InternalError'}
|
||||
/openapi.json:
|
||||
get:
|
||||
operationId: getOpenAPISpecJSON
|
||||
x-module: core
|
||||
x-stability-level: alpha
|
||||
tags: [server]
|
||||
security: []
|
||||
summary: Get the OpenAPI document (JSON)
|
||||
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
|
||||
responses:
|
||||
|
|
@ -65,12 +89,41 @@ paths:
|
|||
description: OpenAPI 3.0 document.
|
||||
'304':
|
||||
$ref: '#/components/responses/NotModified'
|
||||
/auth/grants:
|
||||
get:
|
||||
operationId: listGrants
|
||||
x-module: core
|
||||
x-scope: read
|
||||
x-stability-level: alpha
|
||||
tags: [auth]
|
||||
summary: List my grants
|
||||
description: "The caller's grants, most recently used first. Grants idle long enough to have expired are not listed."
|
||||
security: [{bearerAuth: []}]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/offset'
|
||||
- $ref: '#/components/parameters/limit'
|
||||
responses:
|
||||
'200':
|
||||
description: A page of grants.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GrantList'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
/openapi.yaml:
|
||||
get:
|
||||
operationId: getOpenAPISpecYAML
|
||||
x-module: core
|
||||
x-stability-level: alpha
|
||||
tags: [server]
|
||||
security: []
|
||||
summary: Get the OpenAPI document (YAML)
|
||||
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
|
||||
responses:
|
||||
|
|
@ -86,13 +139,172 @@ paths:
|
|||
description: OpenAPI 3.0 document.
|
||||
'304':
|
||||
$ref: '#/components/responses/NotModified'
|
||||
/auth/grants/{id}:
|
||||
delete:
|
||||
operationId: revokeGrant
|
||||
x-module: core
|
||||
x-scope: read
|
||||
x-stability-level: alpha
|
||||
tags: [auth]
|
||||
summary: Revoke one of my grants
|
||||
description: "Revokes the grant; requests with its secret fail from then on. Another user's grant id answers 404."
|
||||
security: [{bearerAuth: []}]
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Grant id.
|
||||
schema:
|
||||
type: string
|
||||
maxLength: 64
|
||||
responses:
|
||||
'204':
|
||||
description: Revoked.
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
/auth/logout:
|
||||
post:
|
||||
operationId: logout
|
||||
x-module: core
|
||||
x-scope: read
|
||||
x-stability-level: alpha
|
||||
tags: [auth]
|
||||
summary: Log out
|
||||
description: Revokes the grant that made this request.
|
||||
security: [{bearerAuth: []}]
|
||||
responses:
|
||||
'200':
|
||||
description: Logged out.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LogoutResponse'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
/auth/login:
|
||||
post:
|
||||
operationId: login
|
||||
x-module: password
|
||||
x-stability-level: alpha
|
||||
tags: [auth]
|
||||
summary: Log in with a password
|
||||
description: Checks the username and password and returns a new grant. Unknown user and wrong password fail the same way.
|
||||
security: []
|
||||
requestBody:
|
||||
description: The credentials and a description of the client.
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CredentialsRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: The new grant.
|
||||
headers:
|
||||
Cache-Control:
|
||||
$ref: '#/components/headers/CacheControlNoStore'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GrantCreated'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'413':
|
||||
$ref: '#/components/responses/PayloadTooLarge'
|
||||
'429':
|
||||
$ref: '#/components/responses/TooManyRequests'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
/auth/setup:
|
||||
post:
|
||||
operationId: setupFirstAdmin
|
||||
x-module: password
|
||||
x-stability-level: alpha
|
||||
tags: [auth]
|
||||
summary: Create the first admin
|
||||
description: "Creates the first administrator while `setupRequired` is true and returns a grant for it. Answers 409 `setup_complete` once any user exists. A server with no setup step always answers 409."
|
||||
security: []
|
||||
requestBody:
|
||||
description: The credentials and a description of the client.
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CredentialsRequest'
|
||||
responses:
|
||||
'201':
|
||||
description: The admin was created.
|
||||
headers:
|
||||
Cache-Control:
|
||||
$ref: '#/components/headers/CacheControlNoStore'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GrantCreated'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'409':
|
||||
$ref: '#/components/responses/Conflict'
|
||||
'413':
|
||||
$ref: '#/components/responses/PayloadTooLarge'
|
||||
'429':
|
||||
$ref: '#/components/responses/TooManyRequests'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
/auth/password:
|
||||
post:
|
||||
operationId: changePassword
|
||||
x-module: password
|
||||
x-scope: password
|
||||
x-stability-level: alpha
|
||||
tags: [auth]
|
||||
summary: Change my password
|
||||
description: "Changes the caller's password. By default every other grant of the user is revoked; the calling grant survives. On Navidrome the change also ends the user's sessions on its other APIs, regardless of `revokeOtherGrants`, which only covers API v1 grants. Answers 409 `password_managed_externally` when the password is not stored by this server."
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
description: The current and the new password.
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PasswordChangeRequest'
|
||||
responses:
|
||||
'204':
|
||||
description: Password changed.
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'409':
|
||||
$ref: '#/components/responses/Conflict'
|
||||
'413':
|
||||
$ref: '#/components/responses/PayloadTooLarge'
|
||||
'429':
|
||||
$ref: '#/components/responses/TooManyRequests'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: Short-lived access token minted from a device grant. Not yet applied to any operation.
|
||||
description: "Grant secret from a login method (`POST /auth/login`, `POST /auth/setup`). Opaque. The required scope is in each operation's `x-scope`."
|
||||
schemas:
|
||||
ServerInfo:
|
||||
type: object
|
||||
|
|
@ -117,12 +329,19 @@ components:
|
|||
type: boolean
|
||||
description: True until the first admin user has been created.
|
||||
loginMethods:
|
||||
type: array
|
||||
description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- password
|
||||
$ref: '#/components/schemas/LoginMethods'
|
||||
LoginMethods:
|
||||
type: object
|
||||
description: |
|
||||
Login methods this server accepts, keyed by method. A missing key means the method is not offered.
|
||||
Keys are optional on purpose: discovery is read by clients of any version against servers of any
|
||||
version, so new methods are added as new optional keys. Clients ignore keys they do not know.
|
||||
properties:
|
||||
password:
|
||||
$ref: '#/components/schemas/PasswordLoginMethod'
|
||||
PasswordLoginMethod:
|
||||
type: object
|
||||
description: Username and password login (`POST /auth/login`). No settings yet.
|
||||
Problem:
|
||||
type: object
|
||||
description: RFC 9457 problem details, returned for every 4xx and 5xx response.
|
||||
|
|
@ -145,7 +364,7 @@ components:
|
|||
description: HTTP status code of this response.
|
||||
detail:
|
||||
type: string
|
||||
description: Human-readable explanation specific to this occurrence. Omitted for internal errors.
|
||||
description: Human-readable explanation specific to this occurrence. Omitted unless the server marked the text as safe to show clients.
|
||||
code:
|
||||
type: string
|
||||
description: Machine-readable error code, and the value clients switch on. New codes may be added.
|
||||
|
|
@ -153,10 +372,18 @@ components:
|
|||
- validation
|
||||
- unauthorized
|
||||
- forbidden
|
||||
- insufficient_scope
|
||||
- not_found
|
||||
- method_not_allowed
|
||||
- setup_complete
|
||||
- password_managed_externally
|
||||
- payload_too_large
|
||||
- rate_limited
|
||||
- unavailable
|
||||
- internal
|
||||
referenceId:
|
||||
type: string
|
||||
description: Present on internal errors. Quote it when reporting a problem; it tags the server's log lines for this request.
|
||||
errors:
|
||||
type: array
|
||||
description: Per-field failures. Present only when `code` is `validation`.
|
||||
|
|
@ -175,6 +402,238 @@ components:
|
|||
message:
|
||||
type: string
|
||||
description: Why the value was rejected.
|
||||
Capabilities:
|
||||
type: object
|
||||
description: |
|
||||
Capability modules this server implements, keyed by module. Keys are optional; a missing key means the
|
||||
module is not implemented. New modules are added as new optional keys. These are server facts, not what
|
||||
the calling grant may use.
|
||||
properties:
|
||||
core:
|
||||
$ref: '#/components/schemas/CoreCapability'
|
||||
password:
|
||||
$ref: '#/components/schemas/PasswordCapability'
|
||||
CoreCapability:
|
||||
type: object
|
||||
description: The mandatory core module.
|
||||
required:
|
||||
- version
|
||||
properties:
|
||||
version:
|
||||
type: integer
|
||||
description: Module version. Bumped only on semantic change.
|
||||
PasswordCapability:
|
||||
type: object
|
||||
description: The password login module (login, first-admin setup, password change).
|
||||
required:
|
||||
- version
|
||||
properties:
|
||||
version:
|
||||
type: integer
|
||||
description: Module version. Bumped only on semantic change.
|
||||
GrantList:
|
||||
type: object
|
||||
description: "A page of the caller's grants."
|
||||
required:
|
||||
- items
|
||||
- total
|
||||
- offset
|
||||
- limit
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
description: "Grants on this page, by last use, most recent first; never-used grants last."
|
||||
items:
|
||||
$ref: '#/components/schemas/Grant'
|
||||
total:
|
||||
type: integer
|
||||
description: Total number of grants.
|
||||
offset:
|
||||
type: integer
|
||||
description: Zero-based index of the first returned item.
|
||||
limit:
|
||||
type: integer
|
||||
description: Maximum number of items in this page.
|
||||
LogoutResponse:
|
||||
type: object
|
||||
description: Result of a logout.
|
||||
required:
|
||||
- logoutUrl
|
||||
properties:
|
||||
logoutUrl:
|
||||
type: string
|
||||
nullable: true
|
||||
description: "Where to send the browser to finish logging out of an external provider. Null when there is nothing more to do."
|
||||
CredentialsRequest:
|
||||
type: object
|
||||
description: "Username, password and client description for a login or first-admin setup."
|
||||
required:
|
||||
- username
|
||||
- password
|
||||
- client
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 255
|
||||
description: Login name.
|
||||
password:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 1024
|
||||
description: Password.
|
||||
client:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
description: Name of the client app.
|
||||
clientVersion:
|
||||
type: string
|
||||
maxLength: 32
|
||||
description: Version of the client app.
|
||||
name:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
description: "Label for this grant. Defaults to `client`."
|
||||
scopes:
|
||||
type: array
|
||||
maxItems: 32
|
||||
description: "Scopes the grant may hold. Omit for `all`."
|
||||
items:
|
||||
$ref: '#/components/schemas/ScopeRequest'
|
||||
GrantCreated:
|
||||
type: object
|
||||
description: "Returned by every login method. The secret is shown only here; store it and never parse it."
|
||||
required:
|
||||
- secret
|
||||
- grant
|
||||
- user
|
||||
properties:
|
||||
secret:
|
||||
type: string
|
||||
maxLength: 512
|
||||
description: "Opaque grant secret. Send it as `Authorization: Bearer <secret>`."
|
||||
grant:
|
||||
description: The new grant.
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Grant'
|
||||
user:
|
||||
description: The user the grant belongs to.
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/AuthUser'
|
||||
PasswordChangeRequest:
|
||||
type: object
|
||||
description: "Change the caller's own password."
|
||||
required:
|
||||
- currentPassword
|
||||
- newPassword
|
||||
properties:
|
||||
currentPassword:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 1024
|
||||
description: The current password.
|
||||
newPassword:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 1024
|
||||
description: The new password.
|
||||
revokeOtherGrants:
|
||||
type: boolean
|
||||
default: true
|
||||
description: "Revoke every other grant of the user. The calling grant always survives. Default true."
|
||||
Grant:
|
||||
type: object
|
||||
description: A long-lived grant held by one client of one user.
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- client
|
||||
- clientVersion
|
||||
- scopes
|
||||
- provider
|
||||
- createdAt
|
||||
- lastUsedAt
|
||||
- lastUsedIp
|
||||
- current
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Grant id.
|
||||
name:
|
||||
type: string
|
||||
description: Label shown to the user.
|
||||
client:
|
||||
type: string
|
||||
description: Name of the client app that holds the grant.
|
||||
clientVersion:
|
||||
type: string
|
||||
nullable: true
|
||||
description: "Version of the client app, when it sent one."
|
||||
scopes:
|
||||
type: array
|
||||
description: Scopes this grant carries.
|
||||
items:
|
||||
$ref: '#/components/schemas/Scope'
|
||||
provider:
|
||||
type: string
|
||||
description: "How the grant was created, for example `password` or `setup`. Free-form; new values may appear."
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: When the grant was created.
|
||||
lastUsedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: "When the grant was last used, at a coarse granularity. Null until first use."
|
||||
lastUsedIp:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Client IP of the last use. Null until first use.
|
||||
current:
|
||||
type: boolean
|
||||
description: True for the grant that made this request.
|
||||
Scope:
|
||||
type: string
|
||||
description: |
|
||||
A permission scope. Scopes mirror capability modules; `x:write` includes `x`. `all` appears only on
|
||||
grants and means every scope the user is entitled to, now and in future releases. New scopes may be added.
|
||||
enum:
|
||||
- all
|
||||
- read
|
||||
- password
|
||||
ScopeRequest:
|
||||
type: string
|
||||
description: "A requested scope. Scopes the server does not know are dropped, not rejected, so newer clients keep working."
|
||||
pattern: '^[a-z][a-z-]*(:write)?$'
|
||||
maxLength: 64
|
||||
AuthUser:
|
||||
type: object
|
||||
description: The user a grant belongs to.
|
||||
required:
|
||||
- id
|
||||
- userName
|
||||
- name
|
||||
- isAdmin
|
||||
- passwordChangeable
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: User id.
|
||||
userName:
|
||||
type: string
|
||||
description: Login name.
|
||||
name:
|
||||
type: string
|
||||
description: Display name.
|
||||
isAdmin:
|
||||
type: boolean
|
||||
description: Whether the user is an administrator.
|
||||
passwordChangeable:
|
||||
type: boolean
|
||||
description: "Whether `POST /auth/password` can change this user's password. Clients hide \"change password\" when false."
|
||||
responses:
|
||||
InternalError:
|
||||
description: Unexpected server failure. Details are in the server log.
|
||||
|
|
@ -182,13 +641,96 @@ components:
|
|||
application/problem+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Problem'
|
||||
Unauthorized:
|
||||
description: Missing, invalid, or expired credentials.
|
||||
headers:
|
||||
WWW-Authenticate:
|
||||
$ref: '#/components/headers/WWWAuthenticate'
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Problem'
|
||||
NotModified:
|
||||
description: Not modified.
|
||||
headers:
|
||||
ETag:
|
||||
$ref: '#/components/headers/ETag'
|
||||
BadRequest:
|
||||
description: The request is malformed or fails validation.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Problem'
|
||||
Forbidden:
|
||||
description: The caller is authenticated but not allowed to do this.
|
||||
headers:
|
||||
WWW-Authenticate:
|
||||
$ref: '#/components/headers/WWWAuthenticate'
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Problem'
|
||||
NotFound:
|
||||
description: No such resource or endpoint.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Problem'
|
||||
PayloadTooLarge:
|
||||
description: "The request body is too large (`payload_too_large`)."
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Problem'
|
||||
TooManyRequests:
|
||||
description: "Rate limited (`rate_limited`). Retry after the `Retry-After` seconds."
|
||||
headers:
|
||||
Retry-After:
|
||||
description: Seconds to wait before retrying.
|
||||
schema:
|
||||
type: integer
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Problem'
|
||||
Conflict:
|
||||
description: "The request conflicts with the server's state, for example `setup_complete` or `password_managed_externally`."
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Problem'
|
||||
parameters:
|
||||
offset:
|
||||
name: offset
|
||||
in: query
|
||||
description: Zero-based index of the first item to return.
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
default: 0
|
||||
limit:
|
||||
name: limit
|
||||
in: query
|
||||
description: Maximum number of items to return.
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 2000
|
||||
default: 100
|
||||
headers:
|
||||
WWWAuthenticate:
|
||||
description: 'RFC 6750 Bearer challenge, for example `Bearer error="insufficient_scope", scope="read"`.'
|
||||
schema:
|
||||
type: string
|
||||
ETag:
|
||||
description: Entity tag for `If-None-Match` revalidation.
|
||||
schema:
|
||||
type: string
|
||||
CacheControlNoStore:
|
||||
description: Always `no-store`, because the response carries a secret.
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- no-store
|
||||
|
|
|
|||
4
api/openapi/components/headers/CacheControlNoStore.yaml
Normal file
4
api/openapi/components/headers/CacheControlNoStore.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
description: Always `no-store`, because the response carries a secret.
|
||||
schema:
|
||||
type: string
|
||||
enum: [no-store]
|
||||
3
api/openapi/components/headers/WWWAuthenticate.yaml
Normal file
3
api/openapi/components/headers/WWWAuthenticate.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
description: 'RFC 6750 Bearer challenge, for example `Bearer error="insufficient_scope", scope="read"`.'
|
||||
schema:
|
||||
type: string
|
||||
5
api/openapi/components/responses/Conflict.yaml
Normal file
5
api/openapi/components/responses/Conflict.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
description: "The request conflicts with the server's state, for example `setup_complete` or `password_managed_externally`."
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: ../schemas/Problem.yaml
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
description: The caller is authenticated but not allowed to do this.
|
||||
headers:
|
||||
WWW-Authenticate:
|
||||
$ref: ../headers/WWWAuthenticate.yaml
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
|
|
|
|||
5
api/openapi/components/responses/PayloadTooLarge.yaml
Normal file
5
api/openapi/components/responses/PayloadTooLarge.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
description: "The request body is too large (`payload_too_large`)."
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: ../schemas/Problem.yaml
|
||||
10
api/openapi/components/responses/TooManyRequests.yaml
Normal file
10
api/openapi/components/responses/TooManyRequests.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
description: "Rate limited (`rate_limited`). Retry after the `Retry-After` seconds."
|
||||
headers:
|
||||
Retry-After:
|
||||
description: Seconds to wait before retrying.
|
||||
schema:
|
||||
type: integer
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: ../schemas/Problem.yaml
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
description: Missing, invalid, or expired credentials.
|
||||
headers:
|
||||
WWW-Authenticate:
|
||||
$ref: ../headers/WWWAuthenticate.yaml
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
|
|
|
|||
19
api/openapi/components/schemas/AuthUser.yaml
Normal file
19
api/openapi/components/schemas/AuthUser.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
type: object
|
||||
description: The user a grant belongs to.
|
||||
required: [id, userName, name, isAdmin, passwordChangeable]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: User id.
|
||||
userName:
|
||||
type: string
|
||||
description: Login name.
|
||||
name:
|
||||
type: string
|
||||
description: Display name.
|
||||
isAdmin:
|
||||
type: boolean
|
||||
description: Whether the user is an administrator.
|
||||
passwordChangeable:
|
||||
type: boolean
|
||||
description: "Whether `POST /auth/password` can change this user's password. Clients hide \"change password\" when false."
|
||||
10
api/openapi/components/schemas/Capabilities.yaml
Normal file
10
api/openapi/components/schemas/Capabilities.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
type: object
|
||||
description: |
|
||||
Capability modules this server implements, keyed by module. Keys are optional; a missing key means the
|
||||
module is not implemented. New modules are added as new optional keys. These are server facts, not what
|
||||
the calling grant may use.
|
||||
properties:
|
||||
core:
|
||||
$ref: ./CoreCapability.yaml
|
||||
password:
|
||||
$ref: ./PasswordCapability.yaml
|
||||
5
api/openapi/components/schemas/CoreCapability.yaml
Normal file
5
api/openapi/components/schemas/CoreCapability.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
type: object
|
||||
description: The mandatory core module.
|
||||
required: [version]
|
||||
properties:
|
||||
version: {type: integer, description: Module version. Bumped only on semantic change.}
|
||||
34
api/openapi/components/schemas/CredentialsRequest.yaml
Normal file
34
api/openapi/components/schemas/CredentialsRequest.yaml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
type: object
|
||||
description: "Username, password and client description for a login or first-admin setup."
|
||||
required: [username, password, client]
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 255
|
||||
description: Login name.
|
||||
password:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 1024
|
||||
description: Password.
|
||||
client:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
description: Name of the client app.
|
||||
clientVersion:
|
||||
type: string
|
||||
maxLength: 32
|
||||
description: Version of the client app.
|
||||
name:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
description: "Label for this grant. Defaults to `client`."
|
||||
scopes:
|
||||
type: array
|
||||
maxItems: 32
|
||||
description: "Scopes the grant may hold. Omit for `all`."
|
||||
items:
|
||||
$ref: ./ScopeRequest.yaml
|
||||
41
api/openapi/components/schemas/Grant.yaml
Normal file
41
api/openapi/components/schemas/Grant.yaml
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
type: object
|
||||
description: A long-lived grant held by one client of one user.
|
||||
required: [id, name, client, clientVersion, scopes, provider, createdAt, lastUsedAt, lastUsedIp, current]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Grant id.
|
||||
name:
|
||||
type: string
|
||||
description: Label shown to the user.
|
||||
client:
|
||||
type: string
|
||||
description: Name of the client app that holds the grant.
|
||||
clientVersion:
|
||||
type: string
|
||||
nullable: true
|
||||
description: "Version of the client app, when it sent one."
|
||||
scopes:
|
||||
type: array
|
||||
description: Scopes this grant carries.
|
||||
items:
|
||||
$ref: ./Scope.yaml
|
||||
provider:
|
||||
type: string
|
||||
description: "How the grant was created, for example `password` or `setup`. Free-form; new values may appear."
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: When the grant was created.
|
||||
lastUsedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: "When the grant was last used, at a coarse granularity. Null until first use."
|
||||
lastUsedIp:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Client IP of the last use. Null until first use.
|
||||
current:
|
||||
type: boolean
|
||||
description: True for the grant that made this request.
|
||||
16
api/openapi/components/schemas/GrantCreated.yaml
Normal file
16
api/openapi/components/schemas/GrantCreated.yaml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
type: object
|
||||
description: "Returned by every login method. The secret is shown only here; store it and never parse it."
|
||||
required: [secret, grant, user]
|
||||
properties:
|
||||
secret:
|
||||
type: string
|
||||
maxLength: 512
|
||||
description: "Opaque grant secret. Send it as `Authorization: Bearer <secret>`."
|
||||
grant:
|
||||
description: The new grant.
|
||||
allOf:
|
||||
- $ref: ./Grant.yaml
|
||||
user:
|
||||
description: The user the grant belongs to.
|
||||
allOf:
|
||||
- $ref: ./AuthUser.yaml
|
||||
18
api/openapi/components/schemas/GrantList.yaml
Normal file
18
api/openapi/components/schemas/GrantList.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
type: object
|
||||
description: "A page of the caller's grants."
|
||||
required: [items, total, offset, limit]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
description: "Grants on this page, by last use, most recent first; never-used grants last."
|
||||
items:
|
||||
$ref: ./Grant.yaml
|
||||
total:
|
||||
type: integer
|
||||
description: Total number of grants.
|
||||
offset:
|
||||
type: integer
|
||||
description: Zero-based index of the first returned item.
|
||||
limit:
|
||||
type: integer
|
||||
description: Maximum number of items in this page.
|
||||
8
api/openapi/components/schemas/LoginMethods.yaml
Normal file
8
api/openapi/components/schemas/LoginMethods.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
type: object
|
||||
description: |
|
||||
Login methods this server accepts, keyed by method. A missing key means the method is not offered.
|
||||
Keys are optional on purpose: discovery is read by clients of any version against servers of any
|
||||
version, so new methods are added as new optional keys. Clients ignore keys they do not know.
|
||||
properties:
|
||||
password:
|
||||
$ref: ./PasswordLoginMethod.yaml
|
||||
8
api/openapi/components/schemas/LogoutResponse.yaml
Normal file
8
api/openapi/components/schemas/LogoutResponse.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
type: object
|
||||
description: Result of a logout.
|
||||
required: [logoutUrl]
|
||||
properties:
|
||||
logoutUrl:
|
||||
type: string
|
||||
nullable: true
|
||||
description: "Where to send the browser to finish logging out of an external provider. Null when there is nothing more to do."
|
||||
5
api/openapi/components/schemas/PasswordCapability.yaml
Normal file
5
api/openapi/components/schemas/PasswordCapability.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
type: object
|
||||
description: The password login module (login, first-admin setup, password change).
|
||||
required: [version]
|
||||
properties:
|
||||
version: {type: integer, description: Module version. Bumped only on semantic change.}
|
||||
18
api/openapi/components/schemas/PasswordChangeRequest.yaml
Normal file
18
api/openapi/components/schemas/PasswordChangeRequest.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
type: object
|
||||
description: "Change the caller's own password."
|
||||
required: [currentPassword, newPassword]
|
||||
properties:
|
||||
currentPassword:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 1024
|
||||
description: The current password.
|
||||
newPassword:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 1024
|
||||
description: The new password.
|
||||
revokeOtherGrants:
|
||||
type: boolean
|
||||
default: true
|
||||
description: "Revoke every other grant of the user. The calling grant always survives. Default true."
|
||||
2
api/openapi/components/schemas/PasswordLoginMethod.yaml
Normal file
2
api/openapi/components/schemas/PasswordLoginMethod.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
type: object
|
||||
description: Username and password login (`POST /auth/login`). No settings yet.
|
||||
|
|
@ -16,7 +16,7 @@ properties:
|
|||
description: HTTP status code of this response.
|
||||
detail:
|
||||
type: string
|
||||
description: Human-readable explanation specific to this occurrence. Omitted for internal errors.
|
||||
description: Human-readable explanation specific to this occurrence. Omitted unless the server marked the text as safe to show clients.
|
||||
code:
|
||||
type: string
|
||||
description: Machine-readable error code, and the value clients switch on. New codes may be added.
|
||||
|
|
@ -24,10 +24,18 @@ properties:
|
|||
- validation
|
||||
- unauthorized
|
||||
- forbidden
|
||||
- insufficient_scope
|
||||
- not_found
|
||||
- method_not_allowed
|
||||
- setup_complete
|
||||
- password_managed_externally
|
||||
- payload_too_large
|
||||
- rate_limited
|
||||
- unavailable
|
||||
- internal
|
||||
referenceId:
|
||||
type: string
|
||||
description: Present on internal errors. Quote it when reporting a problem; it tags the server's log lines for this request.
|
||||
errors:
|
||||
type: array
|
||||
description: Per-field failures. Present only when `code` is `validation`.
|
||||
|
|
|
|||
5
api/openapi/components/schemas/Scope.yaml
Normal file
5
api/openapi/components/schemas/Scope.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
type: string
|
||||
description: |
|
||||
A permission scope. Scopes mirror capability modules; `x:write` includes `x`. `all` appears only on
|
||||
grants and means every scope the user is entitled to, now and in future releases. New scopes may be added.
|
||||
enum: [all, read, password]
|
||||
4
api/openapi/components/schemas/ScopeRequest.yaml
Normal file
4
api/openapi/components/schemas/ScopeRequest.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
type: string
|
||||
description: "A requested scope. Scopes the server does not know are dropped, not rejected, so newer clients keep working."
|
||||
pattern: '^[a-z][a-z-]*(:write)?$'
|
||||
maxLength: 64
|
||||
|
|
@ -15,8 +15,4 @@ properties:
|
|||
type: boolean
|
||||
description: True until the first admin user has been created.
|
||||
loginMethods:
|
||||
type: array
|
||||
description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.
|
||||
items:
|
||||
type: string
|
||||
enum: [password]
|
||||
$ref: ./LoginMethods.yaml
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ info:
|
|||
version: 1.0.0
|
||||
description: |
|
||||
Navidrome API v1. Spec-first, additive within v1. Clients discover implemented
|
||||
capability modules through `GET /server` and never sniff versions.
|
||||
capability modules through `GET /capabilities` and never sniff versions.
|
||||
|
||||
Enums are open: new values may be added to any enum within v1. Clients must
|
||||
accept values they do not recognise instead of failing.
|
||||
|
|
@ -15,6 +15,10 @@ info:
|
|||
|
||||
`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods
|
||||
in its `Allow` header.
|
||||
|
||||
Operations that need a grant declare `security: [{bearerAuth: []}]` and the scope they need in
|
||||
`x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). Clients send the grant secret as
|
||||
`Authorization: Bearer <secret>`. A revoked grant stops working within one minute at most.
|
||||
license:
|
||||
name: GPL-3.0
|
||||
url: https://www.gnu.org/licenses/gpl-3.0.html
|
||||
|
|
@ -23,17 +27,32 @@ servers:
|
|||
tags:
|
||||
- name: server
|
||||
description: Server discovery and the published OpenAPI document.
|
||||
- name: auth
|
||||
description: Grants and login methods.
|
||||
paths:
|
||||
/server:
|
||||
$ref: ./paths/server.yaml
|
||||
/capabilities:
|
||||
$ref: ./paths/capabilities.yaml
|
||||
/openapi.json:
|
||||
$ref: ./paths/openapi.yaml#/json
|
||||
/openapi.yaml:
|
||||
$ref: ./paths/openapi.yaml#/yaml
|
||||
/auth/grants:
|
||||
$ref: ./paths/auth.yaml#/grants
|
||||
/auth/grants/{id}:
|
||||
$ref: ./paths/auth.yaml#/grant
|
||||
/auth/logout:
|
||||
$ref: ./paths/auth.yaml#/logout
|
||||
/auth/login:
|
||||
$ref: ./paths/auth.yaml#/login
|
||||
/auth/setup:
|
||||
$ref: ./paths/auth.yaml#/setup
|
||||
/auth/password:
|
||||
$ref: ./paths/auth.yaml#/password
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: Short-lived access token minted from a device grant. Not yet applied to any operation.
|
||||
description: "Grant secret from a login method (`POST /auth/login`, `POST /auth/setup`). Opaque. The required scope is in each operation's `x-scope`."
|
||||
|
|
|
|||
188
api/openapi/paths/auth.yaml
Normal file
188
api/openapi/paths/auth.yaml
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
grants:
|
||||
get:
|
||||
operationId: listGrants
|
||||
x-module: core
|
||||
x-scope: read
|
||||
x-stability-level: alpha
|
||||
tags: [auth]
|
||||
summary: List my grants
|
||||
description: "The caller's grants, most recently used first. Grants idle long enough to have expired are not listed."
|
||||
security: [{bearerAuth: []}]
|
||||
parameters:
|
||||
- $ref: ../components/parameters/offset.yaml
|
||||
- $ref: ../components/parameters/limit.yaml
|
||||
responses:
|
||||
'200':
|
||||
description: A page of grants.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas/GrantList.yaml
|
||||
'400':
|
||||
$ref: ../components/responses/BadRequest.yaml
|
||||
'401':
|
||||
$ref: ../components/responses/Unauthorized.yaml
|
||||
'403':
|
||||
$ref: ../components/responses/Forbidden.yaml
|
||||
'500':
|
||||
$ref: ../components/responses/InternalError.yaml
|
||||
grant:
|
||||
delete:
|
||||
operationId: revokeGrant
|
||||
x-module: core
|
||||
x-scope: read
|
||||
x-stability-level: alpha
|
||||
tags: [auth]
|
||||
summary: Revoke one of my grants
|
||||
description: "Revokes the grant; requests with its secret fail from then on. Another user's grant id answers 404."
|
||||
security: [{bearerAuth: []}]
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Grant id.
|
||||
schema:
|
||||
type: string
|
||||
maxLength: 64
|
||||
responses:
|
||||
'204':
|
||||
description: Revoked.
|
||||
'400':
|
||||
$ref: ../components/responses/BadRequest.yaml
|
||||
'401':
|
||||
$ref: ../components/responses/Unauthorized.yaml
|
||||
'403':
|
||||
$ref: ../components/responses/Forbidden.yaml
|
||||
'404':
|
||||
$ref: ../components/responses/NotFound.yaml
|
||||
'500':
|
||||
$ref: ../components/responses/InternalError.yaml
|
||||
logout:
|
||||
post:
|
||||
operationId: logout
|
||||
x-module: core
|
||||
x-scope: read
|
||||
x-stability-level: alpha
|
||||
tags: [auth]
|
||||
summary: Log out
|
||||
description: Revokes the grant that made this request.
|
||||
security: [{bearerAuth: []}]
|
||||
responses:
|
||||
'200':
|
||||
description: Logged out.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas/LogoutResponse.yaml
|
||||
'401':
|
||||
$ref: ../components/responses/Unauthorized.yaml
|
||||
'403':
|
||||
$ref: ../components/responses/Forbidden.yaml
|
||||
'500':
|
||||
$ref: ../components/responses/InternalError.yaml
|
||||
login:
|
||||
post:
|
||||
operationId: login
|
||||
x-module: password
|
||||
x-stability-level: alpha
|
||||
tags: [auth]
|
||||
summary: Log in with a password
|
||||
description: Checks the username and password and returns a new grant. Unknown user and wrong password fail the same way.
|
||||
security: []
|
||||
requestBody:
|
||||
description: The credentials and a description of the client.
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas/CredentialsRequest.yaml
|
||||
responses:
|
||||
'200':
|
||||
description: The new grant.
|
||||
headers:
|
||||
Cache-Control:
|
||||
$ref: ../components/headers/CacheControlNoStore.yaml
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas/GrantCreated.yaml
|
||||
'400':
|
||||
$ref: ../components/responses/BadRequest.yaml
|
||||
'401':
|
||||
$ref: ../components/responses/Unauthorized.yaml
|
||||
'413':
|
||||
$ref: ../components/responses/PayloadTooLarge.yaml
|
||||
'429':
|
||||
$ref: ../components/responses/TooManyRequests.yaml
|
||||
'500':
|
||||
$ref: ../components/responses/InternalError.yaml
|
||||
setup:
|
||||
post:
|
||||
operationId: setupFirstAdmin
|
||||
x-module: password
|
||||
x-stability-level: alpha
|
||||
tags: [auth]
|
||||
summary: Create the first admin
|
||||
description: "Creates the first administrator while `setupRequired` is true and returns a grant for it. Answers 409 `setup_complete` once any user exists. A server with no setup step always answers 409."
|
||||
security: []
|
||||
requestBody:
|
||||
description: The credentials and a description of the client.
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas/CredentialsRequest.yaml
|
||||
responses:
|
||||
'201':
|
||||
description: The admin was created.
|
||||
headers:
|
||||
Cache-Control:
|
||||
$ref: ../components/headers/CacheControlNoStore.yaml
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas/GrantCreated.yaml
|
||||
'400':
|
||||
$ref: ../components/responses/BadRequest.yaml
|
||||
'409':
|
||||
$ref: ../components/responses/Conflict.yaml
|
||||
'413':
|
||||
$ref: ../components/responses/PayloadTooLarge.yaml
|
||||
'429':
|
||||
$ref: ../components/responses/TooManyRequests.yaml
|
||||
'500':
|
||||
$ref: ../components/responses/InternalError.yaml
|
||||
password:
|
||||
post:
|
||||
operationId: changePassword
|
||||
x-module: password
|
||||
x-scope: password
|
||||
x-stability-level: alpha
|
||||
tags: [auth]
|
||||
summary: Change my password
|
||||
description: "Changes the caller's password. By default every other grant of the user is revoked; the calling grant survives. On Navidrome the change also ends the user's sessions on its other APIs, regardless of `revokeOtherGrants`, which only covers API v1 grants. Answers 409 `password_managed_externally` when the password is not stored by this server."
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
description: The current and the new password.
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../components/schemas/PasswordChangeRequest.yaml
|
||||
responses:
|
||||
'204':
|
||||
description: Password changed.
|
||||
'400':
|
||||
$ref: ../components/responses/BadRequest.yaml
|
||||
'401':
|
||||
$ref: ../components/responses/Unauthorized.yaml
|
||||
'403':
|
||||
$ref: ../components/responses/Forbidden.yaml
|
||||
'409':
|
||||
$ref: ../components/responses/Conflict.yaml
|
||||
'413':
|
||||
$ref: ../components/responses/PayloadTooLarge.yaml
|
||||
'429':
|
||||
$ref: ../components/responses/TooManyRequests.yaml
|
||||
'500':
|
||||
$ref: ../components/responses/InternalError.yaml
|
||||
16
api/openapi/paths/capabilities.yaml
Normal file
16
api/openapi/paths/capabilities.yaml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
get:
|
||||
operationId: getCapabilities
|
||||
x-module: core
|
||||
x-stability-level: alpha
|
||||
tags: [server]
|
||||
summary: List implemented capability modules
|
||||
description: The capability modules this server implements. Any valid grant may read it, whatever its scopes.
|
||||
security: [{bearerAuth: []}]
|
||||
responses:
|
||||
'200':
|
||||
description: Implemented modules.
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: ../components/schemas/Capabilities.yaml}
|
||||
'401': {$ref: ../components/responses/Unauthorized.yaml}
|
||||
'500': {$ref: ../components/responses/InternalError.yaml}
|
||||
|
|
@ -4,6 +4,7 @@ json:
|
|||
x-module: core
|
||||
x-stability-level: alpha
|
||||
tags: [server]
|
||||
security: []
|
||||
summary: Get the OpenAPI document (JSON)
|
||||
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
|
||||
responses:
|
||||
|
|
@ -25,6 +26,7 @@ yaml:
|
|||
x-module: core
|
||||
x-stability-level: alpha
|
||||
tags: [server]
|
||||
security: []
|
||||
summary: Get the OpenAPI document (YAML)
|
||||
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
|
||||
responses:
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ get:
|
|||
x-module: core
|
||||
x-stability-level: alpha
|
||||
tags: [server]
|
||||
security: []
|
||||
summary: Describe the server
|
||||
description: |
|
||||
Returns the public server description. No authentication required.
|
||||
Authenticated requests will additionally receive the implemented capability modules
|
||||
once authentication is available.
|
||||
Capability modules are listed by `GET /capabilities`.
|
||||
responses:
|
||||
'200':
|
||||
description: Server description.
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ const (
|
|||
JWTPublicSecretKey = "JWTPublicSecret"
|
||||
JWTIssuer = "ND"
|
||||
DefaultSessionTimeout = 48 * time.Hour
|
||||
APIv1GrantIdleExpiry = 90 * 24 * time.Hour
|
||||
DefaultSmartRefresh = 5 * time.Second
|
||||
DefaultShareExpiration = 8760 * time.Hour
|
||||
CookieExpiry = 365 * 24 * 3600 // One year
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package agents
|
|||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
|
|
@ -13,6 +14,7 @@ type SessionKeys struct {
|
|||
}
|
||||
|
||||
func (sk *SessionKeys) Put(ctx context.Context, userId, sessionKey string) error {
|
||||
ctx = log.WithSecrets(ctx, sessionKey)
|
||||
return sk.DataStore.UserProps().Put(ctx, userId, sk.KeyName, sessionKey)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,31 @@
|
|||
package agents
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/pocketbase/dbx"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("SessionKeys", func() {
|
||||
ctx := context.Background()
|
||||
var ctx context.Context
|
||||
user := model.User{ID: "u-1"}
|
||||
ds := &tests.MockDataStore{MockedUserProps: &tests.MockedUserPropsRepo{}}
|
||||
sk := SessionKeys{DataStore: ds, KeyName: "fakeSessionKey"}
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
})
|
||||
|
||||
It("uses the assigned key name", func() {
|
||||
Expect(sk.KeyName).To(Equal("fakeSessionKey"))
|
||||
})
|
||||
|
|
@ -34,4 +44,34 @@ var _ = Describe("SessionKeys", func() {
|
|||
_, err := sk.Get(ctx, "u-2")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("never logs the session key, but still logs the user id and key name", func() {
|
||||
conn, err := sql.Open("sqlite3", ":memory:")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
DeferCleanup(conn.Close)
|
||||
conn.SetMaxOpenConns(1)
|
||||
_, err = conn.ExecContext(ctx, "create table user_props (user_id varchar, key varchar, value varchar)")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
props := persistence.NewUserPropsRepository(dbx.NewFromDB(conn, "sqlite3"))
|
||||
dbKeys := SessionKeys{DataStore: &tests.MockDataStore{MockedUserProps: props}, KeyName: "LastFMSessionKey"}
|
||||
|
||||
logs := &bytes.Buffer{}
|
||||
log.SetOutput(logs)
|
||||
log.SetLevel(log.LevelTrace)
|
||||
DeferCleanup(func() {
|
||||
log.SetOutput(os.Stderr)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
})
|
||||
|
||||
Expect(dbKeys.Put(ctx, "logged-user-id", "inserted-session-key")).To(Succeed())
|
||||
Expect(dbKeys.Put(ctx, "logged-user-id", "updated-session-key")).To(Succeed())
|
||||
|
||||
Expect(dbKeys.Get(ctx, "logged-user-id")).To(Equal("updated-session-key"))
|
||||
Expect(logs.String()).To(ContainSubstring("INSERT INTO user_props"))
|
||||
Expect(logs.String()).To(ContainSubstring("UPDATE user_props"))
|
||||
Expect(logs.String()).To(ContainSubstring("logged-user-id"))
|
||||
Expect(logs.String()).To(ContainSubstring("LastFMSessionKey"))
|
||||
Expect(logs.String()).ToNot(ContainSubstring("inserted-session-key"))
|
||||
Expect(logs.String()).ToNot(ContainSubstring("updated-session-key"))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
17
core/apiauth/apiauth_suite_test.go
Normal file
17
core/apiauth/apiauth_suite_test.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package apiauth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestAPIAuth(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "API Auth Suite")
|
||||
}
|
||||
14
core/apiauth/context.go
Normal file
14
core/apiauth/context.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
package apiauth
|
||||
|
||||
import "context"
|
||||
|
||||
type principalKey struct{}
|
||||
|
||||
func WithPrincipal(ctx context.Context, p *Principal) context.Context {
|
||||
return context.WithValue(ctx, principalKey{}, p)
|
||||
}
|
||||
|
||||
func PrincipalFrom(ctx context.Context) (*Principal, bool) {
|
||||
p, ok := ctx.Value(principalKey{}).(*Principal)
|
||||
return p, ok
|
||||
}
|
||||
68
core/apiauth/credentials.go
Normal file
68
core/apiauth/credentials.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package apiauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type Outcome int
|
||||
|
||||
const (
|
||||
NotMine Outcome = iota
|
||||
Authenticated
|
||||
Rejected
|
||||
Unavailable
|
||||
)
|
||||
|
||||
type CredentialResult struct {
|
||||
Outcome Outcome
|
||||
User *model.User
|
||||
Provider string
|
||||
PasswordLocal bool
|
||||
}
|
||||
|
||||
type CredentialChecker interface {
|
||||
Check(ctx context.Context, username, password string) (CredentialResult, error)
|
||||
}
|
||||
|
||||
// checkCredentials asks each checker in turn; only NotMine moves on, so an owning provider's "no" is final.
|
||||
func checkCredentials(ctx context.Context, checkers []CredentialChecker, username, password string) (CredentialResult, error) {
|
||||
for _, c := range checkers {
|
||||
res, err := c.Check(ctx, username, password)
|
||||
if err != nil {
|
||||
return CredentialResult{}, err
|
||||
}
|
||||
switch res.Outcome {
|
||||
case NotMine:
|
||||
continue
|
||||
case Authenticated:
|
||||
return res, nil
|
||||
case Unavailable:
|
||||
return CredentialResult{}, model.ErrNotAvailable
|
||||
default:
|
||||
return CredentialResult{}, model.ErrInvalidAuth
|
||||
}
|
||||
}
|
||||
return CredentialResult{}, model.ErrInvalidAuth
|
||||
}
|
||||
|
||||
type dbChecker struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func (c dbChecker) Check(ctx context.Context, username, password string) (CredentialResult, error) {
|
||||
u, err := c.ds.User().FindByUsernameWithPassword(ctx, username)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return CredentialResult{Outcome: NotMine}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return CredentialResult{}, err
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(u.Password), []byte(password)) != 1 {
|
||||
return CredentialResult{Outcome: Rejected}, nil
|
||||
}
|
||||
return CredentialResult{Outcome: Authenticated, User: u, Provider: "password", PasswordLocal: true}, nil
|
||||
}
|
||||
63
core/apiauth/credentials_test.go
Normal file
63
core/apiauth/credentials_test.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package apiauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
type fakeChecker struct {
|
||||
res CredentialResult
|
||||
err error
|
||||
hit bool
|
||||
}
|
||||
|
||||
func (f *fakeChecker) Check(context.Context, string, string) (CredentialResult, error) {
|
||||
f.hit = true
|
||||
return f.res, f.err
|
||||
}
|
||||
|
||||
var _ = Describe("credential chain", func() {
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
})
|
||||
|
||||
It("authenticates against the database with the stored password", func() {
|
||||
u := createUser(ctx, "pw", false)
|
||||
res, err := checkCredentials(ctx, []CredentialChecker{dbChecker{ds: realDS}}, u.UserName, "pw")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.Outcome).To(Equal(Authenticated))
|
||||
Expect(res.User.ID).To(Equal(u.ID))
|
||||
Expect(res.Provider).To(Equal("password"))
|
||||
Expect(res.PasswordLocal).To(BeTrue())
|
||||
})
|
||||
|
||||
It("rejects a wrong password and an unknown user the same way", func() {
|
||||
u := createUser(ctx, "pw", false)
|
||||
_, err := checkCredentials(ctx, []CredentialChecker{dbChecker{ds: realDS}}, u.UserName, "nope")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
_, err = checkCredentials(ctx, []CredentialChecker{dbChecker{ds: realDS}}, "ghost", "pw")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
|
||||
It("moves on only from NotMine, and an owner's rejection stops the chain", func() {
|
||||
owner := &fakeChecker{res: CredentialResult{Outcome: Rejected}}
|
||||
later := &fakeChecker{res: CredentialResult{Outcome: Authenticated, User: &model.User{ID: "x"}}}
|
||||
_, err := checkCredentials(ctx, []CredentialChecker{&fakeChecker{res: CredentialResult{Outcome: NotMine}}, owner, later}, "a", "b")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
Expect(later.hit).To(BeFalse())
|
||||
})
|
||||
|
||||
It("maps Unavailable to ErrNotAvailable and passes through checker errors", func() {
|
||||
_, err := checkCredentials(ctx, []CredentialChecker{&fakeChecker{res: CredentialResult{Outcome: Unavailable}}}, "a", "b")
|
||||
Expect(err).To(MatchError(model.ErrNotAvailable))
|
||||
boom := errors.New("boom")
|
||||
_, err = checkCredentials(ctx, []CredentialChecker{&fakeChecker{err: boom}}, "a", "b")
|
||||
Expect(err).To(MatchError(boom))
|
||||
})
|
||||
})
|
||||
43
core/apiauth/db_test.go
Normal file
43
core/apiauth/db_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package apiauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var realDS model.DataStore
|
||||
|
||||
// One database for the whole suite: db.Db() is a process-wide singleton.
|
||||
var _ = BeforeSuite(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DbPath = filepath.Join(GinkgoT().TempDir(), "apiauth.db") + "?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000"
|
||||
DeferCleanup(db.Init(GinkgoT().Context()))
|
||||
realDS = persistence.New(db.Db())
|
||||
})
|
||||
|
||||
func createUser(ctx context.Context, password string, admin bool) model.User {
|
||||
name := "user-" + id.NewRandom()
|
||||
u := model.User{UserName: name, Name: name, NewPassword: password, IsAdmin: admin}
|
||||
ExpectWithOffset(1, realDS.User().Put(ctx, &u)).To(Succeed())
|
||||
stored, err := realDS.User().FindByUsername(ctx, name)
|
||||
ExpectWithOffset(1, err).ToNot(HaveOccurred())
|
||||
return *stored
|
||||
}
|
||||
|
||||
// login signs u in (nil scopes asks for all) and authenticates with the new grant secret.
|
||||
func login(ctx context.Context, svc *Service, u model.User, password string, scopes []string) (*Issued, *Principal) {
|
||||
issued, err := svc.Login(ctx, u.UserName, password, meta, scopes)
|
||||
ExpectWithOffset(1, err).ToNot(HaveOccurred())
|
||||
p, err := svc.Authenticate(ctx, issued.Secret, "")
|
||||
ExpectWithOffset(1, err).ToNot(HaveOccurred())
|
||||
return issued, p
|
||||
}
|
||||
11
core/apiauth/export_test.go
Normal file
11
core/apiauth/export_test.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package apiauth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
func (s *Service) SetClock(now func() time.Time) { s.now = now }
|
||||
|
||||
func (s *Service) SetCheckers(f func(model.DataStore) []CredentialChecker) { s.checkers = f }
|
||||
63
core/apiauth/scopes.go
Normal file
63
core/apiauth/scopes.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package apiauth
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
ScopeAll = "all"
|
||||
ScopeRead = "read"
|
||||
ScopePassword = "password"
|
||||
ScopeAdmin = "admin"
|
||||
)
|
||||
|
||||
// KnownScopes lists the scopes of modules this server implements; `all` expands to these.
|
||||
var KnownScopes = []string{ScopeRead, ScopePassword}
|
||||
|
||||
func known(s string) bool {
|
||||
return slices.Contains(KnownScopes, s)
|
||||
}
|
||||
|
||||
func grantable(s string, isAdmin bool) bool {
|
||||
return known(s) && (s != ScopeAdmin || isAdmin)
|
||||
}
|
||||
|
||||
func normalize(in []string) []string {
|
||||
out := slices.Clone(in)
|
||||
slices.Sort(out)
|
||||
return slices.Compact(out)
|
||||
}
|
||||
|
||||
// Entitled returns the scopes a new grant stores.
|
||||
func Entitled(requested []string, isAdmin bool) []string {
|
||||
if requested == nil {
|
||||
return []string{ScopeAll}
|
||||
}
|
||||
var out []string
|
||||
for _, s := range requested {
|
||||
if s == ScopeAll || grantable(s, isAdmin) {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return normalize(out)
|
||||
}
|
||||
|
||||
// Expand turns a grant's stored scopes into the concrete scopes it carries right now.
|
||||
func Expand(granted []string, isAdmin bool) []string {
|
||||
var out []string
|
||||
for _, s := range granted {
|
||||
if s == ScopeAll {
|
||||
out = append(out, KnownScopes...)
|
||||
continue
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
out = slices.DeleteFunc(out, func(s string) bool { return !grantable(s, isAdmin) })
|
||||
return normalize(out)
|
||||
}
|
||||
|
||||
func Satisfies(scopes []string, required string) bool {
|
||||
return slices.Contains(scopes, required) ||
|
||||
(!strings.HasSuffix(required, ":write") && slices.Contains(scopes, required+":write"))
|
||||
}
|
||||
50
core/apiauth/scopes_test.go
Normal file
50
core/apiauth/scopes_test.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package apiauth
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("scopes", func() {
|
||||
BeforeEach(func() {
|
||||
saved := KnownScopes
|
||||
KnownScopes = []string{ScopeRead, ScopePassword, ScopeAdmin, "playlists", "playlists:write"}
|
||||
DeferCleanup(func() { KnownScopes = saved })
|
||||
})
|
||||
|
||||
Describe("Entitled", func() {
|
||||
It("stores all when nothing is requested", func() {
|
||||
Expect(Entitled(nil, false)).To(Equal([]string{ScopeAll}))
|
||||
})
|
||||
It("drops unknown scopes and admin for non-admins", func() {
|
||||
Expect(Entitled([]string{"read", "future", "admin"}, false)).To(Equal([]string{"read"}))
|
||||
})
|
||||
It("keeps admin for admins and keeps all", func() {
|
||||
Expect(Entitled([]string{"admin", "all"}, true)).To(Equal([]string{"admin", "all"}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Expand", func() {
|
||||
It("replaces all with every known scope except admin for non-admins", func() {
|
||||
Expect(Expand([]string{ScopeAll}, false)).To(Equal([]string{"password", "playlists", "playlists:write", "read"}))
|
||||
})
|
||||
It("includes admin for admins", func() {
|
||||
Expect(Expand([]string{ScopeAll}, true)).To(ContainElement("admin"))
|
||||
})
|
||||
It("drops admin from explicit scopes when the user is no longer an admin", func() {
|
||||
Expect(Expand([]string{"admin", "read"}, false)).To(Equal([]string{"read"}))
|
||||
})
|
||||
It("drops scopes that are no longer known", func() {
|
||||
Expect(Expand([]string{"read", "retired"}, false)).To(Equal([]string{"read"}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Satisfies", func() {
|
||||
It("accepts the exact scope or its :write form", func() {
|
||||
Expect(Satisfies([]string{"read"}, "read")).To(BeTrue())
|
||||
Expect(Satisfies([]string{"playlists:write"}, "playlists")).To(BeTrue())
|
||||
Expect(Satisfies([]string{"playlists"}, "playlists:write")).To(BeFalse())
|
||||
Expect(Satisfies(nil, "read")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
20
core/apiauth/secret.go
Normal file
20
core/apiauth/secret.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package apiauth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
)
|
||||
|
||||
const secretPrefix = "ndg_"
|
||||
|
||||
func newSecret() (secret, hash string) {
|
||||
secret = secretPrefix + id.NewRandom()
|
||||
return secret, hashSecret(secret)
|
||||
}
|
||||
|
||||
func hashSecret(secret string) string {
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
23
core/apiauth/secret_test.go
Normal file
23
core/apiauth/secret_test.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package apiauth
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("grant secrets", func() {
|
||||
It("are ndg_ plus 22 base62 characters, hashed as hex SHA-256", func() {
|
||||
secret, hash := newSecret()
|
||||
Expect(secret).To(MatchRegexp(`^ndg_[0-9A-Za-z]{22}$`))
|
||||
Expect(hash).To(MatchRegexp(`^[0-9a-f]{64}$`))
|
||||
Expect(hashSecret(secret)).To(Equal(hash))
|
||||
})
|
||||
It("are unique", func() {
|
||||
a, _ := newSecret()
|
||||
b, _ := newSecret()
|
||||
Expect(a).ToNot(Equal(b))
|
||||
Expect(regexp.MustCompile(`^ndg_`).MatchString(a)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
264
core/apiauth/service.go
Normal file
264
core/apiauth/service.go
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
package apiauth
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
)
|
||||
|
||||
const (
|
||||
IdleExpiry = consts.APIv1GrantIdleExpiry
|
||||
touchInterval = 5 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPasswordManagedExternally = errors.New("password is managed externally")
|
||||
ErrCurrentPasswordMismatch = errors.New("current password does not match")
|
||||
)
|
||||
|
||||
type ClientMeta struct {
|
||||
Name string
|
||||
Client string
|
||||
ClientVersion string
|
||||
}
|
||||
|
||||
type Issued struct {
|
||||
Secret string
|
||||
Grant model.Grant
|
||||
User model.User
|
||||
}
|
||||
|
||||
type Principal struct {
|
||||
User model.User
|
||||
GrantID string
|
||||
Scopes []string
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
ds model.DataStore
|
||||
checkers func(ds model.DataStore) []CredentialChecker // per datastore, so password change can check inside its transaction
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(ds model.DataStore) *Service {
|
||||
return &Service{
|
||||
ds: ds,
|
||||
checkers: func(ds model.DataStore) []CredentialChecker {
|
||||
return []CredentialChecker{dbChecker{ds: ds}}
|
||||
},
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func PasswordChangeable(u model.User) bool {
|
||||
return u.IsAdmin || conf.Server.EnableUserEditing
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, username, password string, meta ClientMeta, scopes []string) (*Issued, error) {
|
||||
res, err := checkCredentials(ctx, s.checkers(s.ds), username, password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
issued, err := s.issue(ctx, s.ds, *res.User, res.Provider, meta, scopes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.ds.User().UpdateLastLoginAt(ctx, res.User.ID); err != nil {
|
||||
log.Warn(ctx, "API v1: could not update last login", "user", res.User.UserName, err)
|
||||
}
|
||||
return issued, nil
|
||||
}
|
||||
|
||||
func (s *Service) Setup(ctx context.Context, username, password string, meta ClientMeta, scopes []string) (*Issued, error) {
|
||||
var issued *Issued
|
||||
_, err := auth.CreateFirstAdmin(ctx, s.ds, username, password, func(tx model.DataStore, u *model.User) error {
|
||||
var err error
|
||||
issued, err = s.issue(ctx, tx, *u, "setup", meta, scopes)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return issued, nil
|
||||
}
|
||||
|
||||
// issue stores a grant bound to the epoch read with the user, so a racing password change leaves it dead.
|
||||
func (s *Service) issue(ctx context.Context, ds model.DataStore, u model.User, provider string, meta ClientMeta, scopes []string) (*Issued, error) {
|
||||
u.Password = ""
|
||||
secret, hash := newSecret()
|
||||
g := model.Grant{
|
||||
UserID: u.ID,
|
||||
Name: cmp.Or(meta.Name, meta.Client),
|
||||
Client: meta.Client,
|
||||
ClientVersion: meta.ClientVersion,
|
||||
Scopes: Entitled(scopes, u.IsAdmin),
|
||||
Provider: provider,
|
||||
SecretHash: hash,
|
||||
UserEpoch: u.TokenEpoch,
|
||||
CreatedAt: s.now(),
|
||||
}
|
||||
if err := ds.Grant().Put(ctx, &g); err != nil {
|
||||
return nil, fmt.Errorf("storing grant: %w", err)
|
||||
}
|
||||
return &Issued{Secret: secret, Grant: g, User: u}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Authenticate(ctx context.Context, secret, ip string) (*Principal, error) {
|
||||
g, err := s.ds.Grant().FindBySecretHash(ctx, hashSecret(secret))
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, model.ErrInvalidAuth
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if idleSince := s.now().Add(-IdleExpiry); g.LastActivity().Before(idleSince) {
|
||||
s.dropIdle(ctx, g.ID, idleSince)
|
||||
return nil, model.ErrInvalidAuth
|
||||
}
|
||||
u, err := s.ds.User().Get(ctx, g.UserID)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, model.ErrInvalidAuth
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if g.UserEpoch != u.TokenEpoch {
|
||||
if g, u, err = s.settleEpoch(ctx, g.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
s.touch(ctx, g, ip)
|
||||
return &Principal{User: *u, GrantID: g.ID, Scopes: Expand(g.Scopes, u.IsAdmin)}, nil
|
||||
}
|
||||
|
||||
// dropIdle deletes only still-idle grants, sparing one renewed meanwhile.
|
||||
func (s *Service) dropIdle(ctx context.Context, id string, idleSince time.Time) {
|
||||
if _, err := s.ds.Grant().DeleteIdle(ctx, idleSince); err != nil {
|
||||
log.Warn(ctx, "API v1: could not delete idle grants", "grant", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// settleEpoch re-reads grant and user in one read transaction: separate reads can straddle a password change
|
||||
// and make a kept grant look dead. Deleting below the snapshot's epoch is safe: a later change only moves kept grants up.
|
||||
func (s *Service) settleEpoch(ctx context.Context, grantID string) (*model.Grant, *model.User, error) {
|
||||
var g *model.Grant
|
||||
var u *model.User
|
||||
err := s.ds.WithTx(func(tx model.DataStore) error {
|
||||
var err error
|
||||
if g, err = tx.Grant().Get(ctx, grantID); err != nil {
|
||||
return err
|
||||
}
|
||||
u, err = tx.User().Get(ctx, g.UserID)
|
||||
return err
|
||||
})
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, nil, model.ErrInvalidAuth
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if g.UserEpoch != u.TokenEpoch {
|
||||
if err := s.ds.Grant().DeleteStaleEpochs(ctx, u.ID, u.TokenEpoch); err != nil {
|
||||
log.Warn(ctx, "API v1: could not delete the user's grants from older epochs", "user", u.ID, "grant", grantID, err)
|
||||
}
|
||||
return nil, nil, model.ErrInvalidAuth
|
||||
}
|
||||
return g, u, nil
|
||||
}
|
||||
|
||||
// touch writes last_used at most every touchInterval (zero lastUsed: never used); the SQL condition holds that across nodes.
|
||||
func (s *Service) touch(ctx context.Context, g *model.Grant, ip string) {
|
||||
now := s.now()
|
||||
if lastUsed := gg.V(g.LastUsedAt); !lastUsed.IsZero() && now.Before(lastUsed.Add(touchInterval)) {
|
||||
return
|
||||
}
|
||||
if err := s.ds.Grant().Touch(ctx, g.ID, ip, now, now.Add(-touchInterval)); err != nil {
|
||||
log.Warn(ctx, "API v1: could not record grant use", "grant", g.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ListGrants shows only the current epoch: grants left on an older one are dead but only deleted when presented.
|
||||
func (s *Service) ListGrants(ctx context.Context, p *Principal, offset, limit int) (model.Grants, int64, error) {
|
||||
idleSince := s.now().Add(-IdleExpiry)
|
||||
grants, err := s.ds.Grant().GetAllForUser(ctx, p.User.ID, p.User.TokenEpoch, idleSince, offset, limit)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
total, err := s.ds.Grant().CountForUser(ctx, p.User.ID, p.User.TokenEpoch, idleSince)
|
||||
return grants, total, err
|
||||
}
|
||||
|
||||
func (s *Service) RevokeGrant(ctx context.Context, p *Principal, grantID string) error {
|
||||
return s.ds.Grant().DeleteForUser(ctx, p.User.ID, grantID)
|
||||
}
|
||||
|
||||
// Logout succeeds when the grant is already gone, e.g. revoked by another node or a concurrent logout.
|
||||
func (s *Service) Logout(ctx context.Context, p *Principal) error {
|
||||
err := s.RevokeGrant(ctx, p, p.GrantID)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ChangePassword does every check inside the locked transaction, so a reset that lands first is never overwritten.
|
||||
func (s *Service) ChangePassword(ctx context.Context, p *Principal, current, newPassword string, revokeOthers bool) error {
|
||||
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
|
||||
u, err := tx.User().Get(ctx, p.User.ID)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return model.ErrInvalidAuth
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g, err := tx.Grant().Get(ctx, p.GrantID)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return model.ErrInvalidAuth
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if g.UserID != u.ID || g.UserEpoch != u.TokenEpoch {
|
||||
return model.ErrInvalidAuth
|
||||
}
|
||||
if !PasswordChangeable(*u) {
|
||||
return model.ErrNotAuthorized
|
||||
}
|
||||
res, err := checkCredentials(ctx, s.checkers(tx), u.UserName, current)
|
||||
if errors.Is(err, model.ErrInvalidAuth) {
|
||||
return ErrCurrentPasswordMismatch
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !res.PasswordLocal {
|
||||
return ErrPasswordManagedExternally
|
||||
}
|
||||
oldEpoch := u.TokenEpoch
|
||||
u.NewPassword = newPassword
|
||||
if err := tx.User().Put(ctx, u); err != nil {
|
||||
return err
|
||||
}
|
||||
updated, err := tx.User().Get(ctx, u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keep := ""
|
||||
if revokeOthers {
|
||||
keep = p.GrantID
|
||||
}
|
||||
if err := tx.Grant().SetEpoch(ctx, u.ID, oldEpoch, updated.TokenEpoch, keep); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Grant().DeleteStaleEpochs(ctx, u.ID, updated.TokenEpoch)
|
||||
})
|
||||
}
|
||||
463
core/apiauth/service_test.go
Normal file
463
core/apiauth/service_test.go
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
package apiauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var meta = ClientMeta{Name: "Living room", Client: "TestApp", ClientVersion: "1.0"}
|
||||
|
||||
var _ = Describe("Service", func() {
|
||||
var ctx context.Context
|
||||
var svc *Service
|
||||
var now time.Time
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
now = time.Now().UTC().Truncate(time.Second)
|
||||
svc = New(realDS)
|
||||
svc.SetClock(func() time.Time { return now })
|
||||
})
|
||||
|
||||
Describe("Login", func() {
|
||||
It("creates a grant storing all, the user's epoch and the client metadata", func() {
|
||||
u := createUser(ctx, "pw", false)
|
||||
issued, err := svc.Login(ctx, u.UserName, "pw", meta, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(issued.Secret).To(HavePrefix("ndg_"))
|
||||
Expect(issued.User.ID).To(Equal(u.ID))
|
||||
Expect(issued.User.Password).To(BeEmpty())
|
||||
Expect(issued.Grant.Scopes).To(Equal(model.Scopes{ScopeAll}))
|
||||
Expect(issued.Grant.Provider).To(Equal("password"))
|
||||
Expect(issued.Grant.Name).To(Equal("Living room"))
|
||||
Expect(issued.Grant.UserEpoch).To(Equal(u.TokenEpoch))
|
||||
|
||||
stored, err := realDS.Grant().FindBySecretHash(ctx, hashSecret(issued.Secret))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(stored.ID).To(Equal(issued.Grant.ID))
|
||||
})
|
||||
|
||||
It("defaults the grant name to the client", func() {
|
||||
u := createUser(ctx, "pw", false)
|
||||
issued, err := svc.Login(ctx, u.UserName, "pw", ClientMeta{Client: "OnlyClient"}, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(issued.Grant.Name).To(Equal("OnlyClient"))
|
||||
})
|
||||
|
||||
It("accepts the username in any case", func() {
|
||||
u := createUser(ctx, "pw", false)
|
||||
issued, err := svc.Login(ctx, strings.ToUpper(u.UserName), "pw", meta, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(issued.User.ID).To(Equal(u.ID))
|
||||
})
|
||||
|
||||
It("stores only known requested scopes", func() {
|
||||
u := createUser(ctx, "pw", false)
|
||||
issued, err := svc.Login(ctx, u.UserName, "pw", meta, []string{"read", "future", "admin"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(issued.Grant.Scopes).To(Equal(model.Scopes{ScopeRead}))
|
||||
})
|
||||
|
||||
It("fails with ErrInvalidAuth for bad credentials", func() {
|
||||
u := createUser(ctx, "pw", false)
|
||||
_, err := svc.Login(ctx, u.UserName, "wrong", meta, nil)
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Setup", func() {
|
||||
It("refuses when users exist", func() {
|
||||
createUser(ctx, "pw", false)
|
||||
_, err := svc.Setup(ctx, "newadmin", "pw", meta, nil)
|
||||
Expect(err).To(MatchError(auth.ErrSetupComplete))
|
||||
})
|
||||
// The empty-database path is covered end to end in server/apiv1, which owns a fresh DB.
|
||||
})
|
||||
|
||||
Describe("Authenticate", func() {
|
||||
It("resolves the secret to its user and the grant's expanded scopes", func() {
|
||||
u := createUser(ctx, "pw", false)
|
||||
issued, p := login(ctx, svc, u, "pw", nil)
|
||||
Expect(p.User.ID).To(Equal(u.ID))
|
||||
Expect(p.GrantID).To(Equal(issued.Grant.ID))
|
||||
Expect(p.Scopes).To(Equal([]string{ScopePassword, ScopeRead}))
|
||||
})
|
||||
|
||||
It("carries only the scopes stored on a narrow grant", func() {
|
||||
u := createUser(ctx, "pw", false)
|
||||
_, p := login(ctx, svc, u, "pw", []string{ScopePassword})
|
||||
Expect(p.Scopes).To(Equal([]string{ScopePassword}))
|
||||
})
|
||||
|
||||
It("records the first use with the client IP", func() {
|
||||
u := createUser(ctx, "pw", false)
|
||||
issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil)
|
||||
_, err := svc.Authenticate(ctx, issued.Secret, "10.0.0.9")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
g, _ := realDS.Grant().Get(ctx, issued.Grant.ID)
|
||||
Expect(g.LastUsedAt).ToNot(BeNil())
|
||||
Expect(g.LastUsedIP).To(Equal("10.0.0.9"))
|
||||
})
|
||||
|
||||
It("records use again only after the touch interval", func() {
|
||||
u := createUser(ctx, "pw", false)
|
||||
issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil)
|
||||
_, err := svc.Authenticate(ctx, issued.Secret, "10.0.0.1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
now = now.Add(touchInterval - time.Second)
|
||||
_, err = svc.Authenticate(ctx, issued.Secret, "10.0.0.2")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
g, _ := realDS.Grant().Get(ctx, issued.Grant.ID)
|
||||
Expect(g.LastUsedIP).To(Equal("10.0.0.1"))
|
||||
|
||||
now = now.Add(2 * time.Second)
|
||||
_, err = svc.Authenticate(ctx, issued.Secret, "10.0.0.3")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
g, _ = realDS.Grant().Get(ctx, issued.Grant.ID)
|
||||
Expect(g.LastUsedIP).To(Equal("10.0.0.3"))
|
||||
Expect(g.LastUsedAt.Equal(now)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("rejects unknown secrets", func() {
|
||||
_, err := svc.Authenticate(ctx, "ndg_unknown", "")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
|
||||
It("deletes and rejects a grant idle for 90 days, including one never used", func() {
|
||||
u := createUser(ctx, "pw", false)
|
||||
issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil)
|
||||
now = now.Add(IdleExpiry + time.Second)
|
||||
_, err := svc.Authenticate(ctx, issued.Secret, "")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
_, err = realDS.Grant().Get(ctx, issued.Grant.ID)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("keeps an idle grant that a concurrent request renewed before the delete ran", func() {
|
||||
u := createUser(ctx, "pw", false)
|
||||
issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil)
|
||||
renewedAt := now.Add(IdleExpiry - time.Minute)
|
||||
racing := New(hookDS{DataStore: realDS, beforeDeleteIdle: func() {
|
||||
Expect(realDS.Grant().Touch(ctx, issued.Grant.ID, "10.0.0.2", renewedAt, renewedAt)).To(Succeed())
|
||||
}})
|
||||
now = now.Add(IdleExpiry + time.Second)
|
||||
racing.SetClock(func() time.Time { return now })
|
||||
|
||||
_, err := racing.Authenticate(ctx, issued.Secret, "")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
g, err := realDS.Grant().Get(ctx, issued.Grant.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(g.LastUsedIP).To(Equal("10.0.0.2"))
|
||||
})
|
||||
|
||||
It("rejects and deletes a grant whose epoch is behind the user's", func() {
|
||||
u := createUser(ctx, "pw", false)
|
||||
issued, _ := svc.Login(ctx, u.UserName, "pw", meta, nil)
|
||||
u.NewPassword = "changed-elsewhere"
|
||||
Expect(realDS.User().Put(ctx, &u)).To(Succeed())
|
||||
_, err := svc.Authenticate(ctx, issued.Secret, "")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
_, err = realDS.Grant().Get(ctx, issued.Grant.ID)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("does not delete a kept grant when the password changed between reading the grant and the user", func() {
|
||||
u := createUser(ctx, "pw", false)
|
||||
issued, p := login(ctx, svc, u, "pw", nil)
|
||||
racing := New(hookDS{DataStore: realDS, afterFind: func() {
|
||||
Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", false)).To(Succeed())
|
||||
}})
|
||||
racing.SetClock(func() time.Time { return now })
|
||||
|
||||
_, err := racing.Authenticate(ctx, issued.Secret, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = realDS.Grant().Get(ctx, p.GrantID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("drops admin from a grant once its user is no longer an admin", func() {
|
||||
saved := KnownScopes
|
||||
KnownScopes = []string{ScopeRead, ScopePassword, ScopeAdmin}
|
||||
DeferCleanup(func() { KnownScopes = saved })
|
||||
u := createUser(ctx, "pw", true)
|
||||
issued, p := login(ctx, svc, u, "pw", nil)
|
||||
Expect(p.Scopes).To(ContainElement(ScopeAdmin))
|
||||
|
||||
u.IsAdmin = false
|
||||
Expect(realDS.User().Put(ctx, &u)).To(Succeed())
|
||||
demoted, err := svc.Authenticate(ctx, issued.Secret, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(demoted.Scopes).To(Equal([]string{ScopePassword, ScopeRead}))
|
||||
})
|
||||
|
||||
It("rejects the secret after its user is deleted, and the grant row is gone", func() {
|
||||
u := createUser(ctx, "pw", false)
|
||||
issued, _ := login(ctx, svc, u, "pw", nil)
|
||||
Expect(realDS.User().Delete(request.WithUser(ctx, model.User{IsAdmin: true}), u.ID)).To(Succeed())
|
||||
_, err := realDS.Grant().Get(ctx, issued.Grant.ID)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
_, err = svc.Authenticate(ctx, issued.Secret, "")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
|
||||
It("leaves a login that raced a password change with a dead grant", func() {
|
||||
u := createUser(ctx, "pw", false)
|
||||
reached, release := make(chan struct{}), make(chan struct{})
|
||||
svc.SetCheckers(func(ds model.DataStore) []CredentialChecker {
|
||||
return []CredentialChecker{pausingChecker{inner: dbChecker{ds: ds}, reached: reached, release: release}}
|
||||
})
|
||||
var issued *Issued
|
||||
var loginErr error
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer GinkgoRecover()
|
||||
defer close(done)
|
||||
issued, loginErr = svc.Login(ctx, u.UserName, "pw", meta, nil)
|
||||
}()
|
||||
<-reached // credentials (and the old epoch) were read
|
||||
u.NewPassword = "changed-meanwhile"
|
||||
Expect(realDS.User().Put(ctx, &u)).To(Succeed())
|
||||
close(release)
|
||||
<-done
|
||||
|
||||
Expect(loginErr).ToNot(HaveOccurred())
|
||||
_, err := svc.Authenticate(ctx, issued.Secret, "")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("grant management", func() {
|
||||
It("lists the user's grants and marks the current one", func() {
|
||||
u := createUser(ctx, "pw", false)
|
||||
first, _ := login(ctx, svc, u, "pw", nil)
|
||||
_, p := login(ctx, svc, u, "pw", nil)
|
||||
grants, total, err := svc.ListGrants(ctx, p, 0, 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(total).To(Equal(int64(2)))
|
||||
Expect(grants).To(HaveLen(2))
|
||||
Expect([]string{grants[0].ID, grants[1].ID}).To(ContainElements(first.Grant.ID, p.GrantID))
|
||||
})
|
||||
|
||||
It("lists only grants on the user's current epoch", func() {
|
||||
u := createUser(ctx, "pw", false)
|
||||
login(ctx, svc, u, "pw", nil)
|
||||
u.NewPassword = "reset-by-admin" // old-UI reset leaves the old grant on the previous epoch
|
||||
Expect(realDS.User().Put(ctx, &u)).To(Succeed())
|
||||
issued, p := login(ctx, svc, u, "reset-by-admin", nil)
|
||||
|
||||
grants, total, err := svc.ListGrants(ctx, p, 0, 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(total).To(Equal(int64(1)))
|
||||
Expect(grants).To(HaveLen(1))
|
||||
Expect(grants[0].ID).To(Equal(issued.Grant.ID))
|
||||
})
|
||||
|
||||
It("logs out, and succeeds again when the grant is already gone", func() {
|
||||
u := createUser(ctx, "pw", false)
|
||||
issued, p := login(ctx, svc, u, "pw", nil)
|
||||
Expect(svc.Logout(ctx, p)).To(Succeed())
|
||||
_, err := svc.Authenticate(ctx, issued.Secret, "")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
|
||||
Expect(svc.Logout(ctx, p)).To(Succeed())
|
||||
})
|
||||
|
||||
It("refuses to revoke another user's grant", func() {
|
||||
alice := createUser(ctx, "pw", false)
|
||||
bob := createUser(ctx, "pw", false)
|
||||
aliceGrant, _ := login(ctx, svc, alice, "pw", nil)
|
||||
_, bobP := login(ctx, svc, bob, "pw", nil)
|
||||
Expect(svc.RevokeGrant(ctx, bobP, aliceGrant.Grant.ID)).To(MatchError(model.ErrNotFound))
|
||||
_, err := svc.Authenticate(ctx, aliceGrant.Secret, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("rejects the secret after its grant is revoked", func() {
|
||||
u := createUser(ctx, "pw", false)
|
||||
other, _ := login(ctx, svc, u, "pw", nil)
|
||||
_, p := login(ctx, svc, u, "pw", nil)
|
||||
Expect(svc.RevokeGrant(ctx, p, other.Grant.ID)).To(Succeed())
|
||||
_, err := svc.Authenticate(ctx, other.Secret, "")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ChangePassword", func() {
|
||||
It("revokes other grants by default and keeps the caller's", func() {
|
||||
u := createUser(ctx, "pw", false)
|
||||
other, _ := login(ctx, svc, u, "pw", nil)
|
||||
mine, p := login(ctx, svc, u, "pw", nil)
|
||||
Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true)).To(Succeed())
|
||||
|
||||
_, err := svc.Authenticate(ctx, mine.Secret, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = svc.Authenticate(ctx, other.Secret, "")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
|
||||
_, err = svc.Login(ctx, u.UserName, "pw2", meta, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("keeps every grant, the caller's included, when revokeOthers is false", func() {
|
||||
u := createUser(ctx, "pw", false)
|
||||
other, _ := login(ctx, svc, u, "pw", nil)
|
||||
mine, p := login(ctx, svc, u, "pw", nil)
|
||||
Expect(svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", false)).To(Succeed())
|
||||
_, err := svc.Authenticate(ctx, other.Secret, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = svc.Authenticate(ctx, mine.Secret, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("rejects a wrong current password without changing anything", func() {
|
||||
u := createUser(ctx, "pw", false)
|
||||
_, p := login(ctx, svc, u, "pw", nil)
|
||||
err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "wrong", "pw2", true)
|
||||
Expect(err).To(MatchError(ErrCurrentPasswordMismatch))
|
||||
_, err = svc.Login(ctx, u.UserName, "pw", meta, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("is forbidden for non-admins when user editing is off", func() {
|
||||
conf.Server.EnableUserEditing = false
|
||||
u := createUser(ctx, "pw", false)
|
||||
_, p := login(ctx, svc, u, "pw", nil)
|
||||
err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true)
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("does not revive grants killed by an earlier reset when keeping grants", func() {
|
||||
u := createUser(ctx, "pw", false)
|
||||
killed, _ := login(ctx, svc, u, "pw", nil)
|
||||
u.NewPassword = "reset-by-admin" // old-UI reset: the killed grant stays on the old epoch until presented
|
||||
Expect(realDS.User().Put(ctx, &u)).To(Succeed())
|
||||
|
||||
_, p2 := login(ctx, svc, u, "reset-by-admin", nil)
|
||||
Expect(svc.ChangePassword(request.WithUser(ctx, p2.User), p2, "reset-by-admin", "pw3", false)).To(Succeed())
|
||||
|
||||
_, err := svc.Authenticate(ctx, killed.Secret, "")
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
|
||||
It("rejects a caller whose grant was revoked before the change ran", func() {
|
||||
u := createUser(ctx, "pw", false)
|
||||
_, p := login(ctx, svc, u, "pw", nil)
|
||||
Expect(realDS.Grant().DeleteForUser(ctx, u.ID, p.GrantID)).To(Succeed())
|
||||
err := svc.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true)
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
_, err = svc.Login(ctx, u.UserName, "pw", meta, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("rejects a caller naming another user's grant", func() {
|
||||
alice := createUser(ctx, "pw", false)
|
||||
bob := createUser(ctx, "pw", false)
|
||||
_, aliceP := login(ctx, svc, alice, "pw", nil)
|
||||
bobGrant, _ := login(ctx, svc, bob, "pw", nil)
|
||||
forged := &Principal{User: aliceP.User, GrantID: bobGrant.Grant.ID}
|
||||
err := svc.ChangePassword(request.WithUser(ctx, alice), forged, "pw", "pw2", true)
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
|
||||
It("rolls back the password and epoch when a grant update fails", func() {
|
||||
u := createUser(ctx, "pw", false)
|
||||
issued, p := login(ctx, svc, u, "pw", nil)
|
||||
failing := New(failingEpochDS{realDS})
|
||||
failing.SetClock(func() time.Time { return now })
|
||||
|
||||
err := failing.ChangePassword(request.WithUser(ctx, p.User), p, "pw", "pw2", true)
|
||||
Expect(err).To(MatchError(ContainSubstring("boom")))
|
||||
|
||||
reloaded, _ := realDS.User().Get(ctx, u.ID)
|
||||
Expect(reloaded.TokenEpoch).To(Equal(u.TokenEpoch))
|
||||
_, err = svc.Login(ctx, u.UserName, "pw", meta, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = svc.Authenticate(ctx, issued.Secret, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PasswordChangeable", func() {
|
||||
It("follows EnableUserEditing for non-admins only", func() {
|
||||
conf.Server.EnableUserEditing = false
|
||||
Expect(PasswordChangeable(model.User{IsAdmin: true})).To(BeTrue())
|
||||
Expect(PasswordChangeable(model.User{})).To(BeFalse())
|
||||
conf.Server.EnableUserEditing = true
|
||||
Expect(PasswordChangeable(model.User{})).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// hookDS runs its optional callbacks inside grant lookups, to land a concurrent change mid-Authenticate.
|
||||
type hookDS struct {
|
||||
model.DataStore
|
||||
afterFind func()
|
||||
beforeDeleteIdle func()
|
||||
}
|
||||
|
||||
func (d hookDS) Grant() model.GrantRepository {
|
||||
return hookGrants{GrantRepository: d.DataStore.Grant(), hooks: d}
|
||||
}
|
||||
|
||||
type hookGrants struct {
|
||||
model.GrantRepository
|
||||
hooks hookDS
|
||||
}
|
||||
|
||||
func (g hookGrants) FindBySecretHash(ctx context.Context, hash string) (*model.Grant, error) {
|
||||
found, err := g.GrantRepository.FindBySecretHash(ctx, hash)
|
||||
if g.hooks.afterFind != nil {
|
||||
g.hooks.afterFind()
|
||||
}
|
||||
return found, err
|
||||
}
|
||||
|
||||
func (g hookGrants) DeleteIdle(ctx context.Context, idleSince time.Time) (int64, error) {
|
||||
if g.hooks.beforeDeleteIdle != nil {
|
||||
g.hooks.beforeDeleteIdle()
|
||||
}
|
||||
return g.GrantRepository.DeleteIdle(ctx, idleSince)
|
||||
}
|
||||
|
||||
type pausingChecker struct {
|
||||
inner CredentialChecker
|
||||
reached, release chan struct{}
|
||||
}
|
||||
|
||||
func (c pausingChecker) Check(ctx context.Context, username, password string) (CredentialResult, error) {
|
||||
res, err := c.inner.Check(ctx, username, password)
|
||||
close(c.reached)
|
||||
<-c.release
|
||||
return res, err
|
||||
}
|
||||
|
||||
// failingEpochDS makes SetEpoch fail inside WithTxImmediate, to prove the whole change rolls back.
|
||||
type failingEpochDS struct{ model.DataStore }
|
||||
|
||||
func (f failingEpochDS) WithTxImmediate(block func(tx model.DataStore) error, scope ...string) error {
|
||||
return f.DataStore.WithTxImmediate(func(tx model.DataStore) error {
|
||||
return block(failingEpochTx{tx})
|
||||
}, scope...)
|
||||
}
|
||||
|
||||
type failingEpochTx struct{ model.DataStore }
|
||||
|
||||
func (f failingEpochTx) Grant() model.GrantRepository { return failingGrants{f.DataStore.Grant()} }
|
||||
|
||||
type failingGrants struct{ model.GrantRepository }
|
||||
|
||||
func (failingGrants) SetEpoch(context.Context, string, int, int, string) error {
|
||||
return errors.New("boom")
|
||||
}
|
||||
|
|
@ -53,7 +53,7 @@ func loadOrCreateSecret(ctx context.Context, ds model.DataStore, key string) str
|
|||
log.Info(ctx, "Creating new JWT secret", "key", key)
|
||||
return createNewSecret(ctx, ds, key)
|
||||
}
|
||||
if secret, err = utils.Decrypt(ctx, getEncKey(), secret); err != nil {
|
||||
if secret, err = utils.Decrypt(ctx, EncryptionKey(), secret); err != nil {
|
||||
log.Error(ctx, "Could not decrypt JWT secret, creating a new one", "key", key, err)
|
||||
return createNewSecret(ctx, ds, key)
|
||||
}
|
||||
|
|
@ -171,11 +171,12 @@ func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
|
|||
|
||||
func createNewSecret(ctx context.Context, ds model.DataStore, key string) string {
|
||||
secret := id.NewRandom()
|
||||
encSecret, err := utils.Encrypt(ctx, getEncKey(), secret)
|
||||
encSecret, err := utils.Encrypt(ctx, EncryptionKey(), secret)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Could not encrypt JWT secret", err)
|
||||
return secret
|
||||
}
|
||||
ctx = log.WithSecrets(ctx, encSecret)
|
||||
if err := ds.Property().Put(ctx, key, encSecret); err != nil {
|
||||
log.Error(ctx, "Could not save JWT secret in DB", err)
|
||||
}
|
||||
|
|
@ -195,7 +196,7 @@ func DecodeAndVerifyToken(tokenStr string) (jwt.Token, error) {
|
|||
return jwtauth.VerifyToken(TokenAuth, tokenStr)
|
||||
}
|
||||
|
||||
func getEncKey() []byte {
|
||||
func EncryptionKey() []byte {
|
||||
key := cmp.Or(
|
||||
conf.Server.PasswordEncryptionKey,
|
||||
consts.DefaultEncryptionKey,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import (
|
|||
)
|
||||
|
||||
func TestAuth(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Auth Test Suite")
|
||||
|
|
|
|||
54
core/auth/first_admin.go
Normal file
54
core/auth/first_admin.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
var ErrSetupComplete = errors.New("setup already complete")
|
||||
|
||||
// CreateFirstAdmin counts and inserts in one locked transaction, so racing setups cannot both win.
|
||||
// then, if not nil, runs in that same transaction with the new user.
|
||||
func CreateFirstAdmin(ctx context.Context, ds model.DataStore, username, password string, then func(tx model.DataStore, u *model.User) error) (*model.User, error) {
|
||||
var created *model.User
|
||||
err := ds.WithTxImmediate(func(tx model.DataStore) error {
|
||||
count, err := tx.User().CountAll(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("counting users: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return ErrSetupComplete
|
||||
}
|
||||
log.Warn(ctx, "Creating initial user", "user", username)
|
||||
u := model.User{
|
||||
ID: id.NewRandom(),
|
||||
UserName: username,
|
||||
Name: cases.Title(language.Und).String(username),
|
||||
NewPassword: password,
|
||||
IsAdmin: true,
|
||||
LastLoginAt: new(time.Now()),
|
||||
}
|
||||
if err := tx.User().Put(ctx, &u); err != nil {
|
||||
return fmt.Errorf("creating initial user: %w", err)
|
||||
}
|
||||
if created, err = tx.User().Get(ctx, u.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if then != nil {
|
||||
return then(tx, created)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
106
core/auth/first_admin_test.go
Normal file
106
core/auth/first_admin_test.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("CreateFirstAdmin", Ordered, func() {
|
||||
var ctx context.Context
|
||||
var ds model.DataStore
|
||||
|
||||
BeforeAll(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DbPath = filepath.Join(GinkgoT().TempDir(), "first-admin.db") + "?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000"
|
||||
DeferCleanup(db.Init(GinkgoT().Context()))
|
||||
ds = persistence.New(db.Db())
|
||||
})
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
_, err := db.Db().ExecContext(ctx, "delete from user")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
create := func(name string) (*model.User, error) {
|
||||
return auth.CreateFirstAdmin(ctx, ds, name, "secret", nil)
|
||||
}
|
||||
|
||||
It("creates an admin with a title-cased name and returns it with its id", func() {
|
||||
u, err := create("john")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(u.ID).ToNot(BeEmpty())
|
||||
Expect(u.IsAdmin).To(BeTrue())
|
||||
Expect(u.Name).To(Equal("John"))
|
||||
|
||||
stored, err := ds.User().FindByUsernameWithPassword(ctx, "john")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(stored.Password).To(Equal("secret"))
|
||||
})
|
||||
|
||||
It("refuses once any user exists", func() {
|
||||
_, err := create("first")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = create("second")
|
||||
Expect(err).To(MatchError(auth.ErrSetupComplete))
|
||||
})
|
||||
|
||||
It("runs then in the same transaction, rolling the user back when it fails", func() {
|
||||
boom := errors.New("boom")
|
||||
var seen string
|
||||
_, err := auth.CreateFirstAdmin(ctx, ds, "john", "secret", func(tx model.DataStore, u *model.User) error {
|
||||
seen = u.ID
|
||||
Expect(tx.User().CountAll(ctx)).To(Equal(int64(1)))
|
||||
return boom
|
||||
})
|
||||
Expect(err).To(MatchError(boom))
|
||||
Expect(seen).ToNot(BeEmpty())
|
||||
Expect(ds.User().CountAll(ctx)).To(BeZero())
|
||||
})
|
||||
|
||||
It("lets exactly one of two concurrent setups win", func() {
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, 2)
|
||||
for i, name := range []string{"racer-a", "racer-b"} {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
_, errs[i] = auth.CreateFirstAdmin(ctx, slowCountDS{ds}, name, "secret", nil)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
Expect(errs).To(ContainElement(BeNil()))
|
||||
Expect(errs).To(ContainElement(MatchError(auth.ErrSetupComplete)))
|
||||
Expect(ds.User().CountAll(ctx)).To(Equal(int64(1)))
|
||||
})
|
||||
})
|
||||
|
||||
type slowCountDS struct{ model.DataStore }
|
||||
|
||||
func (d slowCountDS) User() model.UserRepository { return slowCountUsers{d.DataStore.User()} }
|
||||
|
||||
func (d slowCountDS) WithTxImmediate(block func(tx model.DataStore) error, scope ...string) error {
|
||||
return d.DataStore.WithTxImmediate(func(tx model.DataStore) error { return block(slowCountDS{tx}) }, scope...)
|
||||
}
|
||||
|
||||
type slowCountUsers struct{ model.UserRepository }
|
||||
|
||||
// Holds the transaction open after counting, so an unlocked count would interleave with the other racer.
|
||||
func (u slowCountUsers) CountAll(ctx context.Context, opts ...model.QueryOptions) (int64, error) {
|
||||
n, err := u.UserRepository.CountAll(ctx, opts...)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
return n, err
|
||||
}
|
||||
23
db/migrations/20260926045200_create_api_grant.sql
Normal file
23
db/migrations/20260926045200_create_api_grant.sql
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
create table api_grant (
|
||||
id varchar not null primary key,
|
||||
user_id varchar not null references user(id) on delete cascade,
|
||||
name varchar not null,
|
||||
client varchar not null,
|
||||
client_version varchar not null default '',
|
||||
scopes varchar not null default '',
|
||||
provider varchar not null,
|
||||
secret_hash varchar not null unique,
|
||||
user_epoch integer not null default 0,
|
||||
created_at datetime not null,
|
||||
last_used_at datetime,
|
||||
last_used_ip varchar not null default ''
|
||||
);
|
||||
create index api_grant_user_id on api_grant(user_id);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
drop table api_grant;
|
||||
-- +goose StatementEnd
|
||||
2
go.mod
2
go.mod
|
|
@ -40,6 +40,7 @@ require (
|
|||
github.com/mattn/go-sqlite3 v1.14.52
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/oapi-codegen/runtime v1.7.0
|
||||
github.com/onsi/ginkgo/v2 v2.33.0
|
||||
github.com/onsi/gomega v1.44.0
|
||||
github.com/pelletier/go-toml/v2 v2.4.3
|
||||
|
|
@ -74,6 +75,7 @@ require (
|
|||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.5.0 // indirect
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||
github.com/atombender/go-jsonschema v0.20.0 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
|
|
|
|||
11
go.sum
11
go.sum
|
|
@ -6,14 +6,18 @@ github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAw
|
|||
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
|
||||
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||
github.com/andybalholm/cascadia v1.3.5 h1:RLjq12WJy58dN6eCIQrz0bAGZkztHWsEPFxP53Y7Ms8=
|
||||
github.com/andybalholm/cascadia v1.3.5/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||
github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY=
|
||||
github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.2 h1:eF7W7HWKg3z9NrWV9pTLnNeoXaqq3Tq9DNKXVMfoCnw=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/cespare/reflex v0.3.2 h1:SBN/trM94Ifs/ozz77cR3KxKm4dNE22zfG+0+54y5bQ=
|
||||
|
|
@ -136,6 +140,7 @@ github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a8
|
|||
github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk=
|
||||
github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
|
||||
github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
|
||||
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
||||
github.com/kardianos/service v1.3.0 h1:/LGy+xPP2TM+GLTiCZ2di7cy0Jd/qrawlTUfqKYFdTI=
|
||||
github.com/kardianos/service v1.3.0/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
|
|
@ -188,6 +193,10 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
|
|||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
|
||||
github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY=
|
||||
github.com/oapi-codegen/runtime v1.7.0 h1:t7358VYPvNbWJ9gdAkIK/smVeHpBf6yp8VTsaZsb/7k=
|
||||
github.com/oapi-codegen/runtime v1.7.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
|
||||
github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY=
|
||||
github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU=
|
||||
github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw=
|
||||
|
|
@ -255,6 +264,7 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
|||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
|
|
@ -263,6 +273,7 @@ github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
|||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
|
|
|
|||
39
log/log.go
39
log/log.go
|
|
@ -72,7 +72,10 @@ const (
|
|||
|
||||
type contextKey string
|
||||
|
||||
const loggerCtxKey = contextKey("logger")
|
||||
const (
|
||||
loggerCtxKey = contextKey("logger")
|
||||
secretsCtxKey = contextKey("secrets")
|
||||
)
|
||||
|
||||
type levelPath struct {
|
||||
path string
|
||||
|
|
@ -188,6 +191,34 @@ func NewContext(ctx context.Context, keyValuePairs ...any) context.Context {
|
|||
return ctx
|
||||
}
|
||||
|
||||
// Shorter values could match unrelated log text, or the [REDACTED] marker itself.
|
||||
const minSecretLen = 8
|
||||
|
||||
// WithSecrets returns a context whose log entries have every occurrence of values replaced by
|
||||
// [REDACTED], when redacting is enabled. Values shorter than minSecretLen are ignored.
|
||||
func WithSecrets(ctx context.Context, values ...string) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
secrets := slices.Clone(secretsFrom(ctx))
|
||||
for _, v := range values {
|
||||
if len(v) >= minSecretLen {
|
||||
secrets = append(secrets, v)
|
||||
}
|
||||
}
|
||||
// Longest first, so a secret containing another is not left partly visible.
|
||||
slices.SortStableFunc(secrets, func(a, b string) int { return cmp.Compare(len(b), len(a)) })
|
||||
return context.WithValue(ctx, secretsCtxKey, secrets)
|
||||
}
|
||||
|
||||
func secretsFrom(ctx context.Context) []string {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
secrets, _ := ctx.Value(secretsCtxKey).([]string)
|
||||
return secrets
|
||||
}
|
||||
|
||||
// SetDefaultLogger swaps the process-wide logger and returns the previous one,
|
||||
// so tests can restore the original (with its hooks and formatter) on cleanup.
|
||||
func SetDefaultLogger(l *logrus.Logger) *logrus.Logger {
|
||||
|
|
@ -289,6 +320,12 @@ func parseArgs(args []any) (*logrus.Entry, string) {
|
|||
if err != nil {
|
||||
l = createNewLogger()
|
||||
} else {
|
||||
switch ctx := args[0].(type) {
|
||||
case context.Context:
|
||||
l = l.WithContext(ctx)
|
||||
case *http.Request:
|
||||
l = l.WithContext(ctx.Context())
|
||||
}
|
||||
args = args[1:]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
package log
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -93,9 +96,9 @@ var _ = Describe("Logger", func() {
|
|||
|
||||
It("logs source file and line number, if requested", func() {
|
||||
SetLogSourceLine(true)
|
||||
_, _, line, _ := runtime.Caller(0)
|
||||
Error("A crash happened")
|
||||
// NOTE: This assertion breaks if the line number above changes
|
||||
Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:96"))
|
||||
Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring(fmt.Sprintf("/log/log_test.go:%d", line+1)))
|
||||
Expect(hook.LastEntry().Message).To(Equal("A crash happened"))
|
||||
})
|
||||
|
||||
|
|
@ -109,6 +112,26 @@ var _ = Describe("Logger", func() {
|
|||
Error("Simple Message", "key1", t)
|
||||
Expect(hook.LastEntry().Data["key1"]).To(Equal("nil"))
|
||||
})
|
||||
|
||||
It("passes the call's context to hooks", func() {
|
||||
ctx := WithSecrets(GinkgoT().Context(), "s3cr3t-value")
|
||||
Error(ctx, "Simple Message")
|
||||
Expect(hook.LastEntry().Context).To(Equal(ctx))
|
||||
|
||||
Error(httptest.NewRequest("get", "/", nil).WithContext(ctx), "Simple Message")
|
||||
Expect(hook.LastEntry().Context).To(Equal(ctx))
|
||||
})
|
||||
|
||||
It("redacts the context's secrets when redacting is on", func() {
|
||||
l.AddHook(redacted)
|
||||
ctx := WithSecrets(NewContext(GinkgoT().Context(), "user", "admin"), "s3cr3t-value")
|
||||
|
||||
var buf bytes.Buffer
|
||||
l.SetOutput(&buf)
|
||||
Error(ctx, "Saving s3cr3t-value", "args", map[string]any{"value": "s3cr3t-value"})
|
||||
Expect(buf.String()).ToNot(ContainSubstring("s3cr3t-value"))
|
||||
Expect(buf.String()).To(ContainSubstring("user=admin"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Levels", func() {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
|
@ -35,6 +36,7 @@ func (h *Hook) Fire(e *logrus.Entry) error {
|
|||
if err := h.initRedaction(); err != nil {
|
||||
return err
|
||||
}
|
||||
redactSecrets(e)
|
||||
for _, re := range h.redactionKeys {
|
||||
// Redact based on key matching in Data fields
|
||||
for k, v := range e.Data {
|
||||
|
|
@ -47,7 +49,8 @@ func (h *Hook) Fire(e *logrus.Entry) error {
|
|||
}
|
||||
switch reflect.TypeOf(v).Kind() {
|
||||
case reflect.String:
|
||||
e.Data[k] = re.ReplaceAllString(v.(string), "$1[REDACTED]$2")
|
||||
// Via reflect: named string types (e.g. enums) have Kind String but fail v.(string).
|
||||
e.Data[k] = re.ReplaceAllString(reflect.ValueOf(v).String(), "$1[REDACTED]$2")
|
||||
continue
|
||||
case reflect.Map:
|
||||
s := fmt.Sprintf("%+v", v)
|
||||
|
|
@ -63,6 +66,36 @@ func (h *Hook) Fire(e *logrus.Entry) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// redactSecrets hides the values marked with WithSecrets in the context the entry was logged with.
|
||||
func redactSecrets(e *logrus.Entry) {
|
||||
secrets := secretsFrom(e.Context)
|
||||
if len(secrets) == 0 {
|
||||
return
|
||||
}
|
||||
hide := func(s string) string {
|
||||
for _, secret := range secrets {
|
||||
s = strings.ReplaceAll(s, secret, "[REDACTED]")
|
||||
}
|
||||
return s
|
||||
}
|
||||
e.Message = hide(e.Message)
|
||||
for k, v := range e.Data {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
// fmt.Sprint renders like the text formatter and survives typed-nil errors; []byte is written raw.
|
||||
var s string
|
||||
if b, ok := v.([]byte); ok {
|
||||
s = string(b)
|
||||
} else {
|
||||
s = fmt.Sprint(v)
|
||||
}
|
||||
if hidden := hide(s); hidden != s {
|
||||
e.Data[k] = hidden
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hook) initRedaction() error {
|
||||
if len(h.redactionKeys) == 0 {
|
||||
for _, redactionKey := range h.RedactionList {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package log
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
|
|
@ -157,3 +159,85 @@ func TestEntryMessage(t *testing.T) {
|
|||
assert.Nil(t, err)
|
||||
assert.Equal(t, "Secret Password: [REDACTED]", logEntry.Message)
|
||||
}
|
||||
|
||||
type namedString string
|
||||
|
||||
func TestFireRedactsNamedStringTypes(t *testing.T) {
|
||||
hook := &Hook{RedactionList: []string{"(secret=)[^&]+"}}
|
||||
e := &logrus.Entry{Data: logrus.Fields{"code": namedString("not_found"), "url": namedString("/x?secret=abc")}}
|
||||
|
||||
assert.NotPanics(t, func() { _ = hook.Fire(e) })
|
||||
assert.Equal(t, "not_found", e.Data["code"])
|
||||
assert.Equal(t, "/x?secret=[REDACTED]", e.Data["url"])
|
||||
}
|
||||
|
||||
func TestFireRedactsContextSecrets(t *testing.T) {
|
||||
ctx := WithSecrets(t.Context(), "s3cr3t-value")
|
||||
ctx = WithSecrets(ctx, "", "other-secret")
|
||||
e := &logrus.Entry{
|
||||
Context: ctx,
|
||||
Message: "value s3cr3t-value in message",
|
||||
Data: logrus.Fields{
|
||||
"str": "has s3cr3t-value",
|
||||
"named": namedString("named other-secret"),
|
||||
"args": map[string]any{"p0": "s3cr3t-value", "p1": "plain"},
|
||||
"error": errors.New("failed with other-secret"),
|
||||
"num": 42,
|
||||
"clean": namedString("untouched"),
|
||||
},
|
||||
}
|
||||
|
||||
assert.Nil(t, (&Hook{}).Fire(e))
|
||||
assert.Equal(t, "value [REDACTED] in message", e.Message)
|
||||
assert.Equal(t, "has [REDACTED]", e.Data["str"])
|
||||
assert.Equal(t, "named [REDACTED]", e.Data["named"])
|
||||
assert.Equal(t, "map[p0:[REDACTED] p1:plain]", e.Data["args"])
|
||||
assert.Equal(t, "failed with [REDACTED]", e.Data["error"])
|
||||
assert.Equal(t, 42, e.Data["num"])
|
||||
assert.Equal(t, namedString("untouched"), e.Data["clean"])
|
||||
}
|
||||
|
||||
func TestFireRedactsContextSecretsInAnyValueType(t *testing.T) {
|
||||
ctx := WithSecrets(t.Context(), "s3cr3t-value")
|
||||
var nilErr *url.Error
|
||||
e := &logrus.Entry{
|
||||
Context: ctx,
|
||||
Data: logrus.Fields{
|
||||
"slice": []any{"s3cr3t-value", 1},
|
||||
"struct": struct{ A string }{"s3cr3t-value"},
|
||||
"bytes": []byte("has s3cr3t-value"),
|
||||
"nilErr": nilErr,
|
||||
},
|
||||
}
|
||||
|
||||
assert.NotPanics(t, func() { _ = (&Hook{}).Fire(e) })
|
||||
assert.Equal(t, "[[REDACTED] 1]", e.Data["slice"])
|
||||
assert.Equal(t, "{[REDACTED]}", e.Data["struct"])
|
||||
assert.Equal(t, "has [REDACTED]", e.Data["bytes"])
|
||||
assert.Equal(t, nilErr, e.Data["nilErr"])
|
||||
}
|
||||
|
||||
func TestFireWithoutContextSecretsLeavesEntryUnchanged(t *testing.T) {
|
||||
args := map[string]any{"p0": "value"}
|
||||
e := &logrus.Entry{Context: t.Context(), Message: "value", Data: logrus.Fields{"str": "value", "args": args}}
|
||||
|
||||
assert.Nil(t, (&Hook{}).Fire(e))
|
||||
assert.Equal(t, "value", e.Message)
|
||||
assert.Equal(t, logrus.Fields{"str": "value", "args": args}, e.Data)
|
||||
}
|
||||
|
||||
func TestFireRedactsLongerSecretsFirst(t *testing.T) {
|
||||
ctx := WithSecrets(t.Context(), "abcdefgh", "abcdefghijkl")
|
||||
e := &logrus.Entry{Context: ctx, Message: "abcdefghijkl"}
|
||||
|
||||
assert.Nil(t, (&Hook{}).Fire(e))
|
||||
assert.Equal(t, "[REDACTED]", e.Message)
|
||||
}
|
||||
|
||||
func TestFireIgnoresShortSecrets(t *testing.T) {
|
||||
ctx := WithSecrets(t.Context(), "abc")
|
||||
e := &logrus.Entry{Context: ctx, Message: "abc in UPDATE ... abc"}
|
||||
|
||||
assert.Nil(t, (&Hook{}).Fire(e))
|
||||
assert.Equal(t, "abc in UPDATE ... abc", e.Message)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ type DataStore interface {
|
|||
Plugin() PluginRepository
|
||||
Artwork() ArtworkRepository
|
||||
ArtworkQueue() ArtworkQueueRepository
|
||||
Grant() GrantRepository
|
||||
|
||||
WithTx(block func(tx DataStore) error, scope ...string) error
|
||||
WithTxImmediate(block func(tx DataStore) error, scope ...string) error
|
||||
|
|
|
|||
67
model/grant.go
Normal file
67
model/grant.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Grant struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
UserID string `structs:"user_id" json:"userId"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
Client string `structs:"client" json:"client"`
|
||||
ClientVersion string `structs:"client_version" json:"clientVersion"`
|
||||
Scopes Scopes `structs:"scopes" json:"scopes"`
|
||||
Provider string `structs:"provider" json:"provider"`
|
||||
SecretHash string `structs:"secret_hash" json:"-"`
|
||||
UserEpoch int `structs:"user_epoch" json:"-"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
LastUsedAt *time.Time `structs:"last_used_at" json:"lastUsedAt"`
|
||||
LastUsedIP string `structs:"last_used_ip" json:"lastUsedIp"`
|
||||
}
|
||||
|
||||
func (g Grant) LastActivity() time.Time {
|
||||
if g.LastUsedAt != nil {
|
||||
return *g.LastUsedAt
|
||||
}
|
||||
return g.CreatedAt
|
||||
}
|
||||
|
||||
type Grants []Grant
|
||||
|
||||
// Scopes is stored as a single space-separated column.
|
||||
type Scopes []string
|
||||
|
||||
func (s Scopes) Value() (driver.Value, error) {
|
||||
return strings.Join(s, " "), nil
|
||||
}
|
||||
|
||||
func (s *Scopes) Scan(src any) error {
|
||||
switch v := src.(type) {
|
||||
case string:
|
||||
*s = strings.Fields(v)
|
||||
case []byte:
|
||||
*s = strings.Fields(string(v))
|
||||
case nil:
|
||||
*s = nil
|
||||
default:
|
||||
return fmt.Errorf("cannot scan %T into Scopes", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type GrantRepository interface {
|
||||
Put(ctx context.Context, g *Grant) error
|
||||
Get(ctx context.Context, id string) (*Grant, error)
|
||||
FindBySecretHash(ctx context.Context, hash string) (*Grant, error)
|
||||
GetAllForUser(ctx context.Context, userID string, epoch int, idleSince time.Time, offset, limit int) (Grants, error)
|
||||
CountForUser(ctx context.Context, userID string, epoch int, idleSince time.Time) (int64, error)
|
||||
DeleteForUser(ctx context.Context, userID, id string) error
|
||||
DeleteStaleEpochs(ctx context.Context, userID string, currentEpoch int) error
|
||||
SetEpoch(ctx context.Context, userID string, fromEpoch, toEpoch int, onlyID string) error
|
||||
Touch(ctx context.Context, id, ip string, at, notSince time.Time) error
|
||||
DeleteIdle(ctx context.Context, idleSince time.Time) (int64, error)
|
||||
}
|
||||
24
model/grant_test.go
Normal file
24
model/grant_test.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package model_test
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Grant", func() {
|
||||
Describe("LastActivity", func() {
|
||||
created := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
It("is the creation time for a grant never used", func() {
|
||||
Expect(model.Grant{CreatedAt: created}.LastActivity()).To(Equal(created))
|
||||
})
|
||||
|
||||
It("is the last use once the grant was used", func() {
|
||||
used := created.Add(time.Hour)
|
||||
Expect(model.Grant{CreatedAt: created, LastUsedAt: &used}.LastActivity()).To(Equal(used))
|
||||
})
|
||||
})
|
||||
})
|
||||
114
persistence/grant_repository.go
Normal file
114
persistence/grant_repository.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
type grantRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func NewGrantRepository(db dbx.Builder) model.GrantRepository {
|
||||
r := &grantRepository{}
|
||||
r.db = db
|
||||
r.tableName = "api_grant"
|
||||
return r
|
||||
}
|
||||
|
||||
const grantLastActivity = "COALESCE(last_used_at, created_at)"
|
||||
|
||||
func (r *grantRepository) Put(ctx context.Context, g *model.Grant) error {
|
||||
if g.ID == "" {
|
||||
g.ID = id.NewRandom()
|
||||
}
|
||||
if g.CreatedAt.IsZero() {
|
||||
g.CreatedAt = time.Now()
|
||||
}
|
||||
// Stored as UTC: SQLite compares these timestamps as strings.
|
||||
g.CreatedAt = g.CreatedAt.UTC()
|
||||
if g.LastUsedAt != nil {
|
||||
t := g.LastUsedAt.UTC()
|
||||
g.LastUsedAt = &t
|
||||
}
|
||||
values, err := toSQLArgs(*g)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = r.executeSQL(ctx, Insert(r.tableName).SetMap(values))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *grantRepository) Get(ctx context.Context, id string) (*model.Grant, error) {
|
||||
return r.findOne(ctx, Eq{"id": id})
|
||||
}
|
||||
|
||||
func (r *grantRepository) FindBySecretHash(ctx context.Context, hash string) (*model.Grant, error) {
|
||||
return r.findOne(ctx, Eq{"secret_hash": hash})
|
||||
}
|
||||
|
||||
func (r *grantRepository) findOne(ctx context.Context, cond Sqlizer) (*model.Grant, error) {
|
||||
var g model.Grant
|
||||
if err := r.queryOne(ctx, r.newSelect(ctx).Columns("*").Where(cond), &g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
func activeForUser(userID string, epoch int, idleSince time.Time) Sqlizer {
|
||||
return And{Eq{"user_id": userID, "user_epoch": epoch}, Expr(grantLastActivity+" >= ?", idleSince.UTC())}
|
||||
}
|
||||
|
||||
func (r *grantRepository) GetAllForUser(ctx context.Context, userID string, epoch int, idleSince time.Time, offset, limit int) (model.Grants, error) {
|
||||
sel := r.newSelect(ctx).Columns("*").Where(activeForUser(userID, epoch, idleSince)).
|
||||
OrderBy("last_used_at IS NULL", "last_used_at desc", "created_at desc", "id").
|
||||
Offset(uint64(offset)).Limit(uint64(limit))
|
||||
var res model.Grants
|
||||
err := r.queryAll(ctx, sel, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *grantRepository) CountForUser(ctx context.Context, userID string, epoch int, idleSince time.Time) (int64, error) {
|
||||
return r.count(ctx, Select().Where(activeForUser(userID, epoch, idleSince)))
|
||||
}
|
||||
|
||||
func (r *grantRepository) DeleteForUser(ctx context.Context, userID, id string) error {
|
||||
n, err := r.executeSQL(ctx, Delete(r.tableName).Where(Eq{"id": id, "user_id": userID}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *grantRepository) DeleteStaleEpochs(ctx context.Context, userID string, currentEpoch int) error {
|
||||
return r.delete(ctx, And{Eq{"user_id": userID}, Lt{"user_epoch": currentEpoch}})
|
||||
}
|
||||
|
||||
// SetEpoch only moves grants still on fromEpoch, so grants killed by an earlier change never come back.
|
||||
func (r *grantRepository) SetEpoch(ctx context.Context, userID string, fromEpoch, toEpoch int, onlyID string) error {
|
||||
cond := Eq{"user_id": userID, "user_epoch": fromEpoch}
|
||||
if onlyID != "" {
|
||||
cond["id"] = onlyID
|
||||
}
|
||||
_, err := r.executeSQL(ctx, Update(r.tableName).Set("user_epoch", toEpoch).Where(cond))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *grantRepository) Touch(ctx context.Context, id, ip string, at, notSince time.Time) error {
|
||||
upd := Update(r.tableName).Set("last_used_at", at.UTC()).Set("last_used_ip", ip).
|
||||
Where(And{Eq{"id": id}, Or{Eq{"last_used_at": nil}, Lt{"last_used_at": notSince.UTC()}}})
|
||||
_, err := r.executeSQL(ctx, upd)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *grantRepository) DeleteIdle(ctx context.Context, idleSince time.Time) (int64, error) {
|
||||
return r.executeSQL(ctx, Delete(r.tableName).Where(Expr(grantLastActivity+" < ?", idleSince.UTC())))
|
||||
}
|
||||
206
persistence/grant_repository_test.go
Normal file
206
persistence/grant_repository_test.go
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("GrantRepository", func() {
|
||||
var ctx context.Context
|
||||
var repo model.GrantRepository
|
||||
var now time.Time
|
||||
|
||||
newGrant := func(userID, hash string) *model.Grant {
|
||||
return &model.Grant{UserID: userID, Name: "TV", Client: "TestApp", Scopes: model.Scopes{"all"},
|
||||
Provider: "password", SecretHash: hash, CreatedAt: now}
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = log.NewContext(GinkgoT().Context())
|
||||
repo = NewGrantRepository(GetDBXBuilder())
|
||||
now = time.Now().UTC().Truncate(time.Second)
|
||||
DeferCleanup(func() {
|
||||
_, _ = GetDBXBuilder().NewQuery("delete from api_grant").Execute()
|
||||
})
|
||||
})
|
||||
|
||||
It("stores a grant and finds it by id and by secret hash", func() {
|
||||
g := newGrant(adminUser.ID, "hash-1")
|
||||
g.Scopes = model.Scopes{"read", "password"}
|
||||
Expect(repo.Put(ctx, g)).To(Succeed())
|
||||
Expect(g.ID).ToNot(BeEmpty())
|
||||
|
||||
byID, err := repo.Get(ctx, g.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(byID.Scopes).To(Equal(model.Scopes{"read", "password"}))
|
||||
Expect(byID.LastUsedAt).To(BeNil())
|
||||
Expect(byID.LastUsedIP).To(BeEmpty())
|
||||
|
||||
byHash, err := repo.FindBySecretHash(ctx, "hash-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(byHash.ID).To(Equal(g.ID))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound for unknown ids and hashes", func() {
|
||||
_, err := repo.Get(ctx, "nope")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
_, err = repo.FindBySecretHash(ctx, "nope")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("lists and counts only the user's non-idle grants on the given epoch by lastUsedAt, never-used ones last", func() {
|
||||
old := newGrant(adminUser.ID, "h-old")
|
||||
old.CreatedAt = now.Add(-100 * 24 * time.Hour)
|
||||
usedEarly := newGrant(adminUser.ID, "h-used-early")
|
||||
usedEarly.CreatedAt = now.Add(-10 * time.Hour)
|
||||
earlyUse := now.Add(-5 * time.Hour)
|
||||
usedEarly.LastUsedAt = &earlyUse
|
||||
usedLate := newGrant(adminUser.ID, "h-used-late")
|
||||
usedLate.CreatedAt = now.Add(-10 * time.Hour)
|
||||
lateUse := now.Add(-time.Hour)
|
||||
usedLate.LastUsedAt = &lateUse
|
||||
freshNeverUsed := newGrant(adminUser.ID, "h-fresh") // newer than both uses, but never used
|
||||
other := newGrant(regularUser.ID, "h-other")
|
||||
staleEpoch := newGrant(adminUser.ID, "h-stale-epoch")
|
||||
staleEpoch.UserEpoch = 1
|
||||
for _, g := range []*model.Grant{old, usedEarly, usedLate, freshNeverUsed, other, staleEpoch} {
|
||||
Expect(repo.Put(ctx, g)).To(Succeed())
|
||||
}
|
||||
idleSince := now.Add(-90 * 24 * time.Hour)
|
||||
|
||||
list, err := repo.GetAllForUser(ctx, adminUser.ID, 0, idleSince, 0, 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(list).To(HaveLen(3))
|
||||
Expect([]string{list[0].ID, list[1].ID, list[2].ID}).To(Equal([]string{usedLate.ID, usedEarly.ID, freshNeverUsed.ID}))
|
||||
|
||||
Expect(repo.CountForUser(ctx, adminUser.ID, 0, idleSince)).To(Equal(int64(3)))
|
||||
|
||||
page, err := repo.GetAllForUser(ctx, adminUser.ID, 0, idleSince, 1, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(page).To(HaveLen(1))
|
||||
Expect(page[0].ID).To(Equal(usedEarly.ID))
|
||||
})
|
||||
|
||||
It("deletes a grant only for its owner", func() {
|
||||
g := newGrant(adminUser.ID, "h-own")
|
||||
Expect(repo.Put(ctx, g)).To(Succeed())
|
||||
Expect(repo.DeleteForUser(ctx, regularUser.ID, g.ID)).To(MatchError(model.ErrNotFound))
|
||||
Expect(repo.DeleteForUser(ctx, adminUser.ID, g.ID)).To(Succeed())
|
||||
_, err := repo.Get(ctx, g.ID)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("moves epochs forward", func() {
|
||||
keep := newGrant(adminUser.ID, "h-keep")
|
||||
stay := newGrant(adminUser.ID, "h-stay")
|
||||
Expect(repo.Put(ctx, keep)).To(Succeed())
|
||||
Expect(repo.Put(ctx, stay)).To(Succeed())
|
||||
|
||||
Expect(repo.SetEpoch(ctx, adminUser.ID, 0, 3, keep.ID)).To(Succeed())
|
||||
kept, err := repo.Get(ctx, keep.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(kept.UserEpoch).To(Equal(3))
|
||||
stayed, _ := repo.Get(ctx, stay.ID)
|
||||
Expect(stayed.UserEpoch).To(Equal(0))
|
||||
|
||||
Expect(repo.SetEpoch(ctx, adminUser.ID, 3, 4, "")).To(Succeed())
|
||||
kept, _ = repo.Get(ctx, keep.ID)
|
||||
Expect(kept.UserEpoch).To(Equal(4))
|
||||
})
|
||||
|
||||
It("never moves a grant that is not on fromEpoch", func() {
|
||||
stale := newGrant(adminUser.ID, "h-stale") // left behind by an earlier password change
|
||||
stale.UserEpoch = 1
|
||||
current := newGrant(adminUser.ID, "h-current")
|
||||
current.UserEpoch = 2
|
||||
Expect(repo.Put(ctx, stale)).To(Succeed())
|
||||
Expect(repo.Put(ctx, current)).To(Succeed())
|
||||
|
||||
Expect(repo.SetEpoch(ctx, adminUser.ID, 2, 3, "")).To(Succeed())
|
||||
got, _ := repo.Get(ctx, stale.ID)
|
||||
Expect(got.UserEpoch).To(Equal(1))
|
||||
got, _ = repo.Get(ctx, current.ID)
|
||||
Expect(got.UserEpoch).To(Equal(3))
|
||||
})
|
||||
|
||||
It("deletes only the user's grants on an epoch before the current one", func() {
|
||||
older := newGrant(adminUser.ID, "h-older")
|
||||
older.UserEpoch = 1
|
||||
previous := newGrant(adminUser.ID, "h-previous")
|
||||
previous.UserEpoch = 4
|
||||
current := newGrant(adminUser.ID, "h-current")
|
||||
current.UserEpoch = 5
|
||||
otherUser := newGrant(regularUser.ID, "h-other-user")
|
||||
otherUser.UserEpoch = 1
|
||||
for _, g := range []*model.Grant{older, previous, current, otherUser} {
|
||||
Expect(repo.Put(ctx, g)).To(Succeed())
|
||||
}
|
||||
|
||||
Expect(repo.DeleteStaleEpochs(ctx, adminUser.ID, 5)).To(Succeed())
|
||||
|
||||
for _, g := range []*model.Grant{older, previous} {
|
||||
_, err := repo.Get(ctx, g.ID)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
}
|
||||
for _, g := range []*model.Grant{current, otherUser} {
|
||||
_, err := repo.Get(ctx, g.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
})
|
||||
|
||||
It("touches a never-used grant, then throttles until notSince passes", func() {
|
||||
g := newGrant(adminUser.ID, "h-touch")
|
||||
Expect(repo.Put(ctx, g)).To(Succeed())
|
||||
|
||||
Expect(repo.Touch(ctx, g.ID, "10.0.0.1", now, now.Add(-5*time.Minute))).To(Succeed())
|
||||
got, _ := repo.Get(ctx, g.ID)
|
||||
Expect(got.LastUsedAt).ToNot(BeNil())
|
||||
Expect(got.LastUsedAt.UTC()).To(BeTemporally("==", now))
|
||||
Expect(got.LastUsedIP).To(Equal("10.0.0.1"))
|
||||
|
||||
later := now.Add(time.Minute)
|
||||
Expect(repo.Touch(ctx, g.ID, "10.0.0.2", later, later.Add(-5*time.Minute))).To(Succeed())
|
||||
got, _ = repo.Get(ctx, g.ID)
|
||||
Expect(got.LastUsedIP).To(Equal("10.0.0.1"))
|
||||
|
||||
muchLater := now.Add(6 * time.Minute)
|
||||
Expect(repo.Touch(ctx, g.ID, "10.0.0.3", muchLater, muchLater.Add(-5*time.Minute))).To(Succeed())
|
||||
got, _ = repo.Get(ctx, g.ID)
|
||||
Expect(got.LastUsedIP).To(Equal("10.0.0.3"))
|
||||
})
|
||||
|
||||
It("deletes idle grants, using created_at for never-used ones", func() {
|
||||
idle := newGrant(adminUser.ID, "h-idle")
|
||||
idle.CreatedAt = now.Add(-100 * 24 * time.Hour)
|
||||
usedRecently := newGrant(adminUser.ID, "h-used-recently")
|
||||
usedRecently.CreatedAt = now.Add(-100 * 24 * time.Hour)
|
||||
recentUse := now.Add(-time.Hour)
|
||||
usedRecently.LastUsedAt = &recentUse
|
||||
Expect(repo.Put(ctx, idle)).To(Succeed())
|
||||
Expect(repo.Put(ctx, usedRecently)).To(Succeed())
|
||||
|
||||
n, err := repo.DeleteIdle(ctx, now.Add(-90*24*time.Hour))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(int64(1)))
|
||||
_, err = repo.Get(ctx, usedRecently.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("deletes a user's grants when the user is deleted", func() {
|
||||
users := NewUserRepository(GetDBXBuilder())
|
||||
u := model.User{ID: "grant-owner", UserName: "grant-owner", NewPassword: "pw"}
|
||||
Expect(users.Put(ctx, &u)).To(Succeed())
|
||||
g := newGrant(u.ID, "h-cascade")
|
||||
Expect(repo.Put(ctx, g)).To(Succeed())
|
||||
|
||||
Expect(users.Delete(request.WithUser(ctx, adminUser), u.ID)).To(Succeed())
|
||||
_, err := repo.Get(ctx, g.ID)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
|
|
@ -37,6 +38,7 @@ type SQLStore struct {
|
|||
plugin func() model.PluginRepository
|
||||
artwork func() model.ArtworkRepository
|
||||
artworkQueue func() model.ArtworkQueueRepository
|
||||
grant func() model.GrantRepository
|
||||
}
|
||||
|
||||
// Repositories are built on first use, so a transaction store only pays for the ones its block touches.
|
||||
|
|
@ -64,6 +66,7 @@ func newSQLStore(db dbx.Builder) *SQLStore {
|
|||
plugin: sync.OnceValue(func() model.PluginRepository { return NewPluginRepository(db) }),
|
||||
artwork: sync.OnceValue(func() model.ArtworkRepository { return NewArtworkRepository(db) }),
|
||||
artworkQueue: sync.OnceValue(func() model.ArtworkQueueRepository { return NewArtworkQueueRepository(db) }),
|
||||
grant: sync.OnceValue(func() model.GrantRepository { return NewGrantRepository(db) }),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -155,6 +158,10 @@ func (s *SQLStore) ArtworkQueue() model.ArtworkQueueRepository {
|
|||
return s.artworkQueue()
|
||||
}
|
||||
|
||||
func (s *SQLStore) Grant() model.GrantRepository {
|
||||
return s.grant()
|
||||
}
|
||||
|
||||
func scopeLabel(scope []string) string {
|
||||
if len(scope) > 0 {
|
||||
return scope[0]
|
||||
|
|
@ -271,6 +278,10 @@ func (s *SQLStore) GC(ctx context.Context, libraryIDs ...int) error {
|
|||
trace(ctx, "clean media file bookmarks", func() error { return s.mediaFile().(*mediaFileRepository).cleanBookmarks(ctx) }),
|
||||
trace(ctx, "purge non used tags", func() error { return s.tag().(*tagRepository).purgeUnused(ctx) }),
|
||||
trace(ctx, "remove orphan playlist tracks", func() error { return s.playlist().(*playlistRepository).removeOrphans(ctx) }),
|
||||
trace(ctx, "purge idle API grants", func() error {
|
||||
_, err := s.grant().DeleteIdle(ctx, time.Now().Add(-consts.APIv1GrantIdleExpiry))
|
||||
return err
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tidying up database: %w", err)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -348,6 +350,18 @@ var _ = BeforeSuite(func() {
|
|||
}
|
||||
})
|
||||
|
||||
// captureTraceLogs sends trace logs, SQL included, to a buffer for the rest of the spec.
|
||||
func captureTraceLogs() *bytes.Buffer {
|
||||
buf := &bytes.Buffer{}
|
||||
log.SetOutput(buf)
|
||||
log.SetLevel(log.LevelTrace)
|
||||
DeferCleanup(func() {
|
||||
log.SetOutput(os.Stderr)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
})
|
||||
return buf
|
||||
}
|
||||
|
||||
func GetDBXBuilder() *dbx.DB {
|
||||
return dbx.NewFromDB(db.Db(), db.Dialect)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,4 +33,26 @@ var _ = Describe("Property Repository", func() {
|
|||
It("returns a default value if property does not exist", func() {
|
||||
Expect(pr.DefaultGet(ctx, "2", "default")).To(Equal("default"))
|
||||
})
|
||||
|
||||
It("hides values marked as secrets from the SQL log, but still logs the property id", func() {
|
||||
logs := captureTraceLogs()
|
||||
insertCtx := log.WithSecrets(ctx, "inserted-secret")
|
||||
Expect(pr.Put(insertCtx, "secret-prop", "inserted-secret")).To(Succeed())
|
||||
updateCtx := log.WithSecrets(ctx, "updated-secret")
|
||||
Expect(pr.Put(updateCtx, "secret-prop", "updated-secret")).To(Succeed())
|
||||
|
||||
Expect(logs.String()).To(ContainSubstring("INSERT INTO property"))
|
||||
Expect(logs.String()).To(ContainSubstring("UPDATE property"))
|
||||
Expect(logs.String()).To(ContainSubstring("secret-prop"))
|
||||
Expect(logs.String()).ToNot(ContainSubstring("inserted-secret"))
|
||||
Expect(logs.String()).ToNot(ContainSubstring("updated-secret"))
|
||||
})
|
||||
|
||||
It("logs the values of unmarked property writes", func() {
|
||||
logs := captureTraceLogs()
|
||||
Expect(pr.Put(ctx, "plain-prop", "plain-value")).To(Succeed())
|
||||
|
||||
Expect(logs.String()).To(ContainSubstring("plain-prop"))
|
||||
Expect(logs.String()).To(ContainSubstring("plain-value"))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ func (r *userRepository) Put(ctx context.Context, u *model.User) error {
|
|||
u.UpdatedAt = time.Now()
|
||||
if u.NewPassword != "" {
|
||||
_ = r.encryptPassword(ctx, u)
|
||||
ctx = log.WithSecrets(ctx, u.NewPassword)
|
||||
}
|
||||
values, err := toSQLArgs(*u)
|
||||
if err != nil {
|
||||
|
|
@ -399,6 +400,7 @@ func (r *userRepository) initPasswordEncryptionKey(ctx context.Context) error {
|
|||
|
||||
key := keyTo32Bytes(conf.Server.PasswordEncryptionKey)
|
||||
keySum := fmt.Sprintf("%x", sha256.Sum256(key))
|
||||
ctx = log.WithSecrets(ctx, keySum)
|
||||
|
||||
props := NewPropertyRepository(r.db)
|
||||
savedKeySum, err := props.Get(ctx, consts.PasswordsEncryptedKey)
|
||||
|
|
@ -432,7 +434,8 @@ func (r *userRepository) initPasswordEncryptionKey(ctx context.Context) error {
|
|||
u.NewPassword = u.Password
|
||||
if err := r.encryptPassword(ctx, &u); err == nil {
|
||||
upd := Update(r.tableName).Set("password", u.NewPassword).Where(Eq{"id": u.ID})
|
||||
_, err = r.executeSQL(ctx, upd)
|
||||
userCtx := log.WithSecrets(ctx, u.NewPassword)
|
||||
_, err = r.executeSQL(userCtx, upd)
|
||||
if err != nil {
|
||||
log.Error("Password NOT encrypted! This may cause problems!", "user", u.UserName, "id", u.ID, err)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -2,12 +2,16 @@ package persistence
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
|
|
@ -17,6 +21,7 @@ import (
|
|||
"github.com/navidrome/navidrome/utils/slice"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
var _ = Describe("UserRepository", func() {
|
||||
|
|
@ -74,6 +79,28 @@ var _ = Describe("UserRepository", func() {
|
|||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(actual.Password).To(Equal("newpass"))
|
||||
})
|
||||
It("never logs the stored password, on insert or update, but still logs the user name", func() {
|
||||
logs := captureTraceLogs()
|
||||
storedPassword := func(id string) string {
|
||||
var enc string
|
||||
Expect(GetDBXBuilder().NewQuery("select password from user where id = {:id}").
|
||||
Bind(dbx.Params{"id": id}).Row(&enc)).To(Succeed())
|
||||
return enc
|
||||
}
|
||||
u := model.User{ID: "u-logged", UserName: "logged-user-name", NewPassword: "first-secret"}
|
||||
Expect(repo.Put(ctx, &u)).To(Succeed())
|
||||
inserted := storedPassword(u.ID)
|
||||
u.NewPassword = "second-secret"
|
||||
Expect(repo.Put(ctx, &u)).To(Succeed())
|
||||
updated := storedPassword(u.ID)
|
||||
|
||||
Expect(logs.String()).To(ContainSubstring("INSERT INTO user"))
|
||||
Expect(logs.String()).To(ContainSubstring("UPDATE user"))
|
||||
Expect(logs.String()).To(ContainSubstring("logged-user-name"))
|
||||
for _, secret := range []string{inserted, updated, "first-secret", "second-secret"} {
|
||||
Expect(logs.String()).ToNot(ContainSubstring(secret))
|
||||
}
|
||||
})
|
||||
It("persists and reads back the scrobble filter", func() {
|
||||
usr := model.User{ID: "u-filter", UserName: "u-filter", Name: "Filter User",
|
||||
ScrobbleFilter: `{"all":[{"contains":{"title":"????"}}]}`}
|
||||
|
|
@ -96,6 +123,33 @@ var _ = Describe("UserRepository", func() {
|
|||
})
|
||||
})
|
||||
|
||||
Describe("initPasswordEncryptionKey", func() {
|
||||
It("never logs the encryption key checksum, but still logs its property id", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.PasswordEncryptionKey = "a-new-password-encryption-key"
|
||||
keySum := fmt.Sprintf("%x", sha256.Sum256(keyTo32Bytes(conf.Server.PasswordEncryptionKey)))
|
||||
previousKey := encKey
|
||||
DeferCleanup(func() { encKey = previousKey })
|
||||
tx, err := GetDBXBuilder().Begin()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
DeferCleanup(func() { _ = tx.Rollback() })
|
||||
_, err = tx.NewQuery("delete from user").Execute()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
txRepo := NewUserRepository(tx).(*userRepository)
|
||||
Expect(txRepo.Put(ctx, &model.User{ID: "u-rekey", UserName: "rekeyed-user", NewPassword: "rekeyed-password"})).To(Succeed())
|
||||
|
||||
logs := captureTraceLogs()
|
||||
Expect(txRepo.initPasswordEncryptionKey(ctx)).To(Succeed())
|
||||
|
||||
Expect(logs.String()).To(ContainSubstring("UPDATE user"))
|
||||
Expect(logs.String()).To(ContainSubstring(consts.PasswordsEncryptedKey))
|
||||
Expect(logs.String()).ToNot(ContainSubstring(keySum))
|
||||
var rekeyed string
|
||||
Expect(tx.NewQuery("select password from user where id = 'u-rekey'").Row(&rekeyed)).To(Succeed())
|
||||
Expect(logs.String()).ToNot(ContainSubstring(rekeyed))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("validatePasswordChange", func() {
|
||||
var loggedUser *model.User
|
||||
|
||||
|
|
|
|||
|
|
@ -1,32 +1,53 @@
|
|||
package apiv1
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/navidrome/navidrome/api"
|
||||
"github.com/navidrome/navidrome/core/apiauth"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
const maxBodyBytes = 1 << 20
|
||||
|
||||
type Router struct {
|
||||
http.Handler
|
||||
ds model.DataStore
|
||||
ds model.DataStore
|
||||
auth *apiauth.Service
|
||||
}
|
||||
|
||||
func New(ds model.DataStore) *Router {
|
||||
rt := &Router{ds: ds}
|
||||
rt := &Router{ds: ds, auth: apiauth.New(ds)}
|
||||
rt.Handler = rt.routes()
|
||||
return rt
|
||||
}
|
||||
|
||||
var gateRulesV1 = gateRules{
|
||||
limited: map[string]bool{"login": true, "setupFirstAdmin": true, "changePassword": true},
|
||||
noScope: map[string]bool{"getCapabilities": true},
|
||||
noStore: map[string]bool{"login": true, "setupFirstAdmin": true},
|
||||
}
|
||||
|
||||
func (rt *Router) routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(problemRecoverer, headAsGet(r))
|
||||
doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON())
|
||||
if err != nil {
|
||||
log.Fatal("API v1: cannot load the embedded OpenAPI spec", err)
|
||||
}
|
||||
g, err := newGate(doc, r, rt.auth, gateRulesV1)
|
||||
if err != nil {
|
||||
log.Fatal("API v1: the embedded OpenAPI spec breaks the security rules", err)
|
||||
}
|
||||
r.Use(referenceIDMiddleware, problemRecoverer, headAsGet(r), middleware.RequestSize(maxBodyBytes), g.handler)
|
||||
r.NotFound(func(w http.ResponseWriter, req *http.Request) {
|
||||
writeProblemStatus(w, req, http.StatusNotFound, ProblemCodeNotFound, "no such endpoint")
|
||||
})
|
||||
|
|
@ -40,11 +61,18 @@ func (rt *Router) routes() http.Handler {
|
|||
|
||||
strict := NewStrictHandlerWithOptions(rt, nil, StrictHTTPServerOptions{
|
||||
RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
|
||||
writeProblemStatus(w, req, http.StatusBadRequest, "validation", err.Error())
|
||||
if tooLarge(err) {
|
||||
writeProblem(w, req, ClientError(err, tooLargeDetail))
|
||||
return
|
||||
}
|
||||
writeProblemStatus(w, req, http.StatusBadRequest, ProblemCodeValidation, "request body is not valid JSON")
|
||||
},
|
||||
ResponseErrorHandlerFunc: writeProblem,
|
||||
})
|
||||
HandlerWithOptions(strict, ChiServerOptions{BaseRouter: r, ErrorHandlerFunc: bindingErrorHandler})
|
||||
if err := g.checkRoutes(); err != nil {
|
||||
log.Fatal("API v1: routes and the embedded OpenAPI spec disagree", err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
|
|
@ -90,9 +118,18 @@ func headAsGet(mux chi.Routes) func(http.Handler) http.Handler {
|
|||
}
|
||||
}
|
||||
|
||||
// routeMethod is the method chi dispatches on, which headAsGet sets to GET for a HEAD only GET serves.
|
||||
func routeMethod(req *http.Request) string {
|
||||
if rctx := chi.RouteContext(req.Context()); rctx != nil && rctx.RouteMethod != "" {
|
||||
return rctx.RouteMethod
|
||||
}
|
||||
return req.Method
|
||||
}
|
||||
|
||||
// routePath must pick the same path chi's routeHTTP dispatches on, or the gate could vet a different route.
|
||||
func routePath(req *http.Request) string {
|
||||
if rctx := chi.RouteContext(req.Context()); rctx != nil && rctx.RoutePath != "" {
|
||||
return rctx.RoutePath
|
||||
}
|
||||
return req.URL.Path
|
||||
return cmp.Or(req.URL.RawPath, req.URL.Path, "/")
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -3,7 +3,11 @@ package apiv1
|
|||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/navidrome/navidrome/api"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
|
@ -16,6 +20,40 @@ var _ = Describe("Router", func() {
|
|||
router = New(&tests.MockDataStore{})
|
||||
})
|
||||
|
||||
It("routes every operation in the embedded spec", func() {
|
||||
doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
mux := New(&tests.MockDataStore{}).Handler.(chi.Routes)
|
||||
for path, item := range doc.Paths.Map() {
|
||||
for method := range item.Operations() {
|
||||
Expect(mux.Find(chi.NewRouteContext(), method, path)).To(Equal(path), method+" "+path)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
It("declares Cache-Control no-store on the success responses of every no-store operation", func() {
|
||||
doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
checked := map[string]bool{}
|
||||
for _, item := range doc.Paths.Map() {
|
||||
for _, op := range item.Operations() {
|
||||
if !gateRulesV1.noStore[op.OperationID] {
|
||||
continue
|
||||
}
|
||||
for code, resp := range op.Responses.Map() {
|
||||
if !strings.HasPrefix(code, "2") {
|
||||
continue
|
||||
}
|
||||
h := resp.Value.Headers["Cache-Control"]
|
||||
Expect(h).ToNot(BeNil(), op.OperationID+" "+code)
|
||||
Expect(h.Value.Schema.Value.Enum).To(ConsistOf("no-store"), op.OperationID+" "+code)
|
||||
checked[op.OperationID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
Expect(checked).To(HaveLen(len(gateRulesV1.noStore)))
|
||||
})
|
||||
|
||||
It("returns a 404 problem for unknown paths", func() {
|
||||
w := serve(router, httptest.NewRequest(http.MethodGet, "/api/v1/nope", nil))
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
|
|
@ -67,6 +105,19 @@ var _ = Describe("Router", func() {
|
|||
Expect(p.Detail).To(BeNil())
|
||||
})
|
||||
|
||||
It("tags internal errors with a referenceId that is also on the request's log lines", func() {
|
||||
logs := captureLogs()
|
||||
w := httptest.NewRecorder()
|
||||
h := referenceIDMiddleware(problemRecoverer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
panic("kaboom")
|
||||
})))
|
||||
h.ServeHTTP(w, httptest.NewRequestWithContext(GinkgoT().Context(), http.MethodGet, "/boom", nil))
|
||||
p := decodeProblem(w)
|
||||
Expect(p.ReferenceId).ToNot(BeNil())
|
||||
Expect(*p.ReferenceId).To(MatchRegexp(`^[0-9A-Za-z]{22}$`))
|
||||
Expect(logs.String()).To(ContainSubstring(*p.ReferenceId))
|
||||
})
|
||||
|
||||
It("re-panics http.ErrAbortHandler so the server can drop the connection", func() {
|
||||
Expect(func() {
|
||||
panicking(http.ErrAbortHandler).ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/abort", nil))
|
||||
|
|
|
|||
|
|
@ -2,10 +2,14 @@ package apiv1
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
|
|
@ -14,7 +18,11 @@ import (
|
|||
"github.com/getkin/kin-openapi/routers/gorillamux"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/navidrome/navidrome/api"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
|
@ -29,7 +37,13 @@ func TestAPIv1(t *testing.T) {
|
|||
|
||||
var specRouter routers.Router
|
||||
|
||||
// One database for the suite (db.Db() is a process-wide singleton); each spec clears users and grants.
|
||||
var _ = BeforeSuite(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DbPath = filepath.Join(GinkgoT().TempDir(), "apiv1.db") + "?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000"
|
||||
DeferCleanup(db.Init(GinkgoT().Context()))
|
||||
realDS = persistence.New(db.Db())
|
||||
|
||||
doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
specRouter, err = gorillamux.NewRouter(doc)
|
||||
|
|
@ -46,6 +60,64 @@ func serve(h http.Handler, req *http.Request) *httptest.ResponseRecorder {
|
|||
return w
|
||||
}
|
||||
|
||||
// testClient drives a router end to end through serve, so every response is also checked against the spec.
|
||||
type testClient struct {
|
||||
ctx context.Context
|
||||
router http.Handler
|
||||
}
|
||||
|
||||
func (c testClient) call(method, path, bearer string, body any) *httptest.ResponseRecorder {
|
||||
if body == nil {
|
||||
return c.callRaw(method, path, bearer, "")
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
return c.callRaw(method, path, bearer, string(b))
|
||||
}
|
||||
|
||||
// callRaw sends body verbatim, for JSON a map cannot express, like keys differing only in case.
|
||||
func (c testClient) callRaw(method, path, bearer, body string) *httptest.ResponseRecorder {
|
||||
var req *http.Request
|
||||
if body != "" {
|
||||
req = httptest.NewRequestWithContext(c.ctx, method, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequestWithContext(c.ctx, method, path, nil)
|
||||
}
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
return serve(c.router, req)
|
||||
}
|
||||
|
||||
func (c testClient) setup() GrantCreated {
|
||||
w := c.call(http.MethodPost, "/api/v1/auth/setup", "", creds("admin", "pw"))
|
||||
ExpectWithOffset(1, w.Code).To(Equal(http.StatusCreated), w.Body.String())
|
||||
var gc GrantCreated
|
||||
decodeJSON(w, &gc)
|
||||
return gc
|
||||
}
|
||||
|
||||
// login signs in as the admin created by setup; nil scopes asks for all of them.
|
||||
func (c testClient) login(scopes []string) GrantCreated {
|
||||
body := creds("admin", "pw")
|
||||
if scopes != nil {
|
||||
body["scopes"] = scopes
|
||||
}
|
||||
w := c.call(http.MethodPost, "/api/v1/auth/login", "", body)
|
||||
ExpectWithOffset(1, w.Code).To(Equal(http.StatusOK), w.Body.String())
|
||||
var gc GrantCreated
|
||||
decodeJSON(w, &gc)
|
||||
return gc
|
||||
}
|
||||
|
||||
func creds(user, pw string) map[string]any {
|
||||
return map[string]any{"username": user, "password": pw, "client": "TestApp", "clientVersion": "1.0"}
|
||||
}
|
||||
|
||||
func decodeJSON(w *httptest.ResponseRecorder, v any) {
|
||||
ExpectWithOffset(1, json.Unmarshal(w.Body.Bytes(), v)).To(Succeed(), w.Body.String())
|
||||
}
|
||||
|
||||
func validateAgainstSpec(req *http.Request, w *httptest.ResponseRecorder) {
|
||||
route, pathParams, err := specRouter.FindRoute(req)
|
||||
if errors.Is(err, routers.ErrPathNotFound) || errors.Is(err, routers.ErrMethodNotAllowed) {
|
||||
|
|
|
|||
90
server/apiv1/auth_handlers.go
Normal file
90
server/apiv1/auth_handlers.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
package apiv1
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/core/apiauth"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
const defaultPageSize = 100
|
||||
|
||||
// Login relies on model.ErrInvalidAuth mapping to a detail-less 401, so unknown user and wrong password look the same.
|
||||
func (rt *Router) Login(ctx context.Context, req LoginRequestObject) (LoginResponseObject, error) {
|
||||
b := *req.Body
|
||||
issued, err := rt.auth.Login(ctx, b.Username, b.Password, clientMeta(b), fromScopeRequests(b.Scopes))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Login200JSONResponse{Body: toGrantCreated(issued)}, nil
|
||||
}
|
||||
|
||||
func (rt *Router) SetupFirstAdmin(ctx context.Context, req SetupFirstAdminRequestObject) (SetupFirstAdminResponseObject, error) {
|
||||
b := *req.Body
|
||||
issued, err := rt.auth.Setup(ctx, b.Username, b.Password, clientMeta(b), fromScopeRequests(b.Scopes))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return SetupFirstAdmin201JSONResponse{Body: toGrantCreated(issued)}, nil
|
||||
}
|
||||
|
||||
func (rt *Router) ChangePassword(ctx context.Context, req ChangePasswordRequestObject) (ChangePasswordResponseObject, error) {
|
||||
p, err := principalFrom(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b := *req.Body
|
||||
revoke := true
|
||||
if b.RevokeOtherGrants != nil {
|
||||
revoke = *b.RevokeOtherGrants
|
||||
}
|
||||
err = rt.auth.ChangePassword(ctx, p, b.CurrentPassword, b.NewPassword, revoke)
|
||||
if errors.Is(err, apiauth.ErrCurrentPasswordMismatch) {
|
||||
return nil, validationFailed(ValidationError{Field: "currentPassword", Message: "is incorrect"})
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ChangePassword204Response{}, nil
|
||||
}
|
||||
|
||||
func (rt *Router) ListGrants(ctx context.Context, req ListGrantsRequestObject) (ListGrantsResponseObject, error) {
|
||||
p, err := principalFrom(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
offset := gg.V(req.Params.OffsetParam)
|
||||
limit := cmp.Or(gg.V(req.Params.LimitParam), defaultPageSize)
|
||||
grants, total, err := rt.auth.ListGrants(ctx, p, offset, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := slice.Map(grants, func(g model.Grant) Grant { return toGrant(g, p.GrantID) })
|
||||
return ListGrants200JSONResponse{Items: items, Total: int(total), Offset: offset, Limit: limit}, nil
|
||||
}
|
||||
|
||||
func (rt *Router) RevokeGrant(ctx context.Context, req RevokeGrantRequestObject) (RevokeGrantResponseObject, error) {
|
||||
p, err := principalFrom(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rt.auth.RevokeGrant(ctx, p, req.Id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return RevokeGrant204Response{}, nil
|
||||
}
|
||||
|
||||
func (rt *Router) Logout(ctx context.Context, _ LogoutRequestObject) (LogoutResponseObject, error) {
|
||||
p, err := principalFrom(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rt.auth.Logout(ctx, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Logout200JSONResponse{LogoutUrl: nil}, nil
|
||||
}
|
||||
287
server/apiv1/auth_handlers_test.go
Normal file
287
server/apiv1/auth_handlers_test.go
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
package apiv1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("auth endpoints", func() {
|
||||
var ctx context.Context
|
||||
var api testClient
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.AuthRequestLimit = 0
|
||||
resetDB()
|
||||
api = testClient{ctx: ctx, router: New(realDS)}
|
||||
})
|
||||
|
||||
It("lets exactly one of a v1 setup and a v0 first-admin creation win", func() {
|
||||
var wg sync.WaitGroup
|
||||
var v1Code int
|
||||
var v0Err error
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
v1Code = api.call(http.MethodPost, "/api/v1/auth/setup", "", creds("v1admin", "pw")).Code
|
||||
}()
|
||||
go func() {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
_, v0Err = auth.CreateFirstAdmin(ctx, realDS, "v0admin", "pw", nil) // what v0 /auth/createAdmin runs
|
||||
}()
|
||||
wg.Wait()
|
||||
Expect(realDS.User().CountAll(ctx)).To(Equal(int64(1)))
|
||||
Expect(v1Code).To(Or(Equal(http.StatusCreated), Equal(http.StatusConflict)))
|
||||
Expect(v1Code == http.StatusCreated).ToNot(Equal(v0Err == nil), "exactly one must win")
|
||||
})
|
||||
|
||||
It("sets up the first admin once, then answers 409 setup_complete", func() {
|
||||
gc := api.setup()
|
||||
Expect(gc.Secret).To(HavePrefix("ndg_"))
|
||||
Expect(gc.User.IsAdmin).To(BeTrue())
|
||||
Expect(gc.Grant.Provider).To(Equal("setup"))
|
||||
Expect(gc.Grant.Current).To(BeTrue())
|
||||
|
||||
w := api.call(http.MethodPost, "/api/v1/auth/setup", "", creds("second", "pw"))
|
||||
Expect(w.Code).To(Equal(http.StatusConflict))
|
||||
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeSetupComplete))
|
||||
})
|
||||
|
||||
It("logs in and uses the grant secret on a scoped endpoint", func() {
|
||||
api.setup()
|
||||
w := api.call(http.MethodPost, "/api/v1/auth/login", "", creds("ADMIN", "pw"))
|
||||
Expect(w.Code).To(Equal(http.StatusOK), w.Body.String())
|
||||
var gc GrantCreated
|
||||
decodeJSON(w, &gc)
|
||||
Expect(gc.User.PasswordChangeable).To(BeTrue())
|
||||
|
||||
w = api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil)
|
||||
Expect(w.Code).To(Equal(http.StatusOK), w.Body.String())
|
||||
var list GrantList
|
||||
decodeJSON(w, &list)
|
||||
Expect(list.Total).To(Equal(2))
|
||||
Expect(list.Limit).To(Equal(100))
|
||||
})
|
||||
|
||||
It("fails login the same way for an unknown user and a wrong password, with a Bearer challenge", func() {
|
||||
api.setup()
|
||||
a := api.call(http.MethodPost, "/api/v1/auth/login", "", creds("admin", "wrong"))
|
||||
b := api.call(http.MethodPost, "/api/v1/auth/login", "", creds("ghost", "pw"))
|
||||
Expect(a.Code).To(Equal(http.StatusUnauthorized))
|
||||
Expect(a.Header().Get("WWW-Authenticate")).To(Equal("Bearer"))
|
||||
Expect(a.Body.String()).To(Equal(b.Body.String()))
|
||||
})
|
||||
|
||||
It("treats missing scopes as all scopes, and [] as no scopes", func() {
|
||||
api.setup()
|
||||
all := api.login(nil)
|
||||
Expect(all.Grant.Scopes).To(ConsistOf(ScopeAll))
|
||||
Expect(api.call(http.MethodGet, "/api/v1/auth/grants", all.Secret, nil).Code).To(Equal(http.StatusOK))
|
||||
|
||||
none := api.login([]string{})
|
||||
Expect(none.Grant.Scopes).To(BeEmpty())
|
||||
w := api.call(http.MethodGet, "/api/v1/auth/grants", none.Secret, nil)
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="insufficient_scope", scope="read"`))
|
||||
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInsufficientScope))
|
||||
})
|
||||
|
||||
It("drops unknown requested scopes instead of rejecting them", func() {
|
||||
api.setup()
|
||||
gc := api.login([]string{"read", "playlists:write"})
|
||||
Expect(gc.Grant.Scopes).To(ConsistOf(ScopeRead))
|
||||
})
|
||||
|
||||
It("does not let a grant without read log out or revoke grants", func() {
|
||||
gc := api.setup()
|
||||
narrow := api.login([]string{"password"})
|
||||
Expect(api.call(http.MethodPost, "/api/v1/auth/logout", narrow.Secret, nil).Code).To(Equal(http.StatusForbidden))
|
||||
Expect(api.call(http.MethodDelete, "/api/v1/auth/grants/"+gc.Grant.Id, narrow.Secret, nil).Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
|
||||
It("logs out: the secret stops working and logoutUrl is null", func() {
|
||||
gc := api.setup()
|
||||
w := api.call(http.MethodPost, "/api/v1/auth/logout", gc.Secret, nil)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Body.String()).To(ContainSubstring(`"logoutUrl":null`))
|
||||
|
||||
w = api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil)
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`))
|
||||
})
|
||||
|
||||
It("revokes another grant of the caller, whose secret then stops working", func() {
|
||||
gc := api.setup()
|
||||
other := api.login(nil)
|
||||
Expect(api.call(http.MethodDelete, "/api/v1/auth/grants/"+other.Grant.Id, gc.Secret, nil).Code).To(Equal(http.StatusNoContent))
|
||||
Expect(api.call(http.MethodGet, "/api/v1/auth/grants", other.Secret, nil).Code).To(Equal(http.StatusUnauthorized))
|
||||
Expect(api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil).Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("challenges with invalid_token when the grant is revoked while a password change runs", func() {
|
||||
gc := api.setup()
|
||||
revoking := testClient{ctx: ctx, router: New(beforeTxDS{DataStore: realDS, before: func() {
|
||||
Expect(realDS.Grant().DeleteForUser(ctx, gc.User.Id, gc.Grant.Id)).To(Succeed())
|
||||
}})}
|
||||
|
||||
w := revoking.call(http.MethodPost, "/api/v1/auth/password", gc.Secret, map[string]any{"currentPassword": "pw", "newPassword": "pw2"})
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized), w.Body.String())
|
||||
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`))
|
||||
api.login(nil)
|
||||
})
|
||||
|
||||
It("rejects a case-variant scopes key that would widen an explicit empty subset", func() {
|
||||
api.setup()
|
||||
w := api.callRaw(http.MethodPost, "/api/v1/auth/login", "", `{"username":"admin","password":"pw","client":"c","scopes":[],"Scopes":null}`)
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String())
|
||||
p := decodeProblem(w)
|
||||
Expect(p.Code).To(Equal(ProblemCodeValidation))
|
||||
Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "Scopes", Message: "must match the field name exactly"}))
|
||||
})
|
||||
|
||||
It("rejects a case-variant client key that would skip its length limit", func() {
|
||||
api.setup()
|
||||
body := `{"username":"admin","password":"pw","client":"ok","Client":"` + strings.Repeat("x", 60_000) + `"}`
|
||||
w := api.callRaw(http.MethodPost, "/api/v1/auth/login", "", body)
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String())
|
||||
Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "Client", Message: "must match the field name exactly"}))
|
||||
})
|
||||
|
||||
DescribeTable("rejects a body with data after its JSON value, without echoing it",
|
||||
func(path string, needsSecret bool, body string) {
|
||||
secret := ""
|
||||
if gc := api.setup(); needsSecret {
|
||||
secret = gc.Secret
|
||||
}
|
||||
w := api.callRaw(http.MethodPost, path, secret, body)
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String())
|
||||
p := decodeProblem(w)
|
||||
Expect(p.Code).To(Equal(ProblemCodeValidation))
|
||||
Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "", Message: "must be a single JSON value"}))
|
||||
Expect(w.Body.String()).ToNot(ContainSubstring("hunter2"))
|
||||
},
|
||||
Entry("login with a trailing byte", "/api/v1/auth/login", false, `{"username":"a","password":"hunter2","client":"c"}x`),
|
||||
Entry("login with a second value", "/api/v1/auth/login", false, `{"username":"a","password":"hunter2","client":"c"} {}`),
|
||||
// This schema has a default, which the validator must not fill in by rewriting the body.
|
||||
Entry("password change with a trailing byte", "/api/v1/auth/password", true, `{"currentPassword":"hunter2","newPassword":"pw2"}x`),
|
||||
)
|
||||
|
||||
It("checks the login body even when Content-Type has a repeated parameter", func() {
|
||||
api.setup()
|
||||
body := `{"username":"admin","password":"pw","client":"c","scopes":[],"Scopes":null}`
|
||||
req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/v1/auth/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json; a=1; a=2")
|
||||
w := serve(api.router, req)
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String())
|
||||
Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "Scopes", Message: "must match the field name exactly"}))
|
||||
})
|
||||
|
||||
It("marks responses carrying a grant secret no-store", func() {
|
||||
w := api.call(http.MethodPost, "/api/v1/auth/setup", "", creds("admin", "pw"))
|
||||
Expect(w.Code).To(Equal(http.StatusCreated))
|
||||
Expect(w.Header().Get("Cache-Control")).To(Equal("no-store"))
|
||||
var gc GrantCreated
|
||||
decodeJSON(w, &gc)
|
||||
|
||||
w = api.call(http.MethodPost, "/api/v1/auth/login", "", creds("admin", "pw"))
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Cache-Control")).To(Equal("no-store"))
|
||||
|
||||
w = api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Cache-Control")).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("answers 404 for a grant id the caller does not own, and 400 for an over-long id", func() {
|
||||
gc := api.setup()
|
||||
Expect(api.call(http.MethodDelete, "/api/v1/auth/grants/does-not-exist", gc.Secret, nil).Code).To(Equal(http.StatusNotFound))
|
||||
w := api.call(http.MethodDelete, "/api/v1/auth/grants/"+strings.Repeat("x", 65), gc.Secret, nil)
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "id", Message: "is too long"}))
|
||||
})
|
||||
|
||||
It("changes the password, keeping the caller and revoking the rest", func() {
|
||||
gc := api.setup()
|
||||
other := api.login(nil)
|
||||
|
||||
w := api.call(http.MethodPost, "/api/v1/auth/password", gc.Secret, map[string]any{"currentPassword": "pw", "newPassword": "pw2"})
|
||||
Expect(w.Code).To(Equal(http.StatusNoContent), w.Body.String())
|
||||
|
||||
Expect(api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil).Code).To(Equal(http.StatusOK))
|
||||
Expect(api.call(http.MethodGet, "/api/v1/auth/grants", other.Secret, nil).Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("keeps every grant when revokeOtherGrants is false", func() {
|
||||
gc := api.setup()
|
||||
other := api.login(nil)
|
||||
|
||||
body := map[string]any{"currentPassword": "pw", "newPassword": "pw2", "revokeOtherGrants": false}
|
||||
w := api.call(http.MethodPost, "/api/v1/auth/password", gc.Secret, body)
|
||||
Expect(w.Code).To(Equal(http.StatusNoContent), w.Body.String())
|
||||
|
||||
Expect(api.call(http.MethodGet, "/api/v1/auth/grants", gc.Secret, nil).Code).To(Equal(http.StatusOK))
|
||||
Expect(api.call(http.MethodGet, "/api/v1/auth/grants", other.Secret, nil).Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("reports a wrong current password as a field error", func() {
|
||||
gc := api.setup()
|
||||
w := api.call(http.MethodPost, "/api/v1/auth/password", gc.Secret, map[string]any{"currentPassword": "nope", "newPassword": "pw2"})
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
p := decodeProblem(w)
|
||||
Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "currentPassword", Message: "is incorrect"}))
|
||||
})
|
||||
|
||||
DescribeTable("rejects bad credential bodies with a field error and no echo",
|
||||
func(body map[string]any, field string) {
|
||||
w := api.call(http.MethodPost, "/api/v1/auth/setup", "", body)
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String())
|
||||
p := decodeProblem(w)
|
||||
Expect(p.Code).To(Equal(ProblemCodeValidation))
|
||||
Expect(*p.Errors).To(ContainElement(HaveField("Field", field)))
|
||||
Expect(w.Body.String()).ToNot(ContainSubstring("hunter2"))
|
||||
},
|
||||
Entry("missing client", map[string]any{"username": "a", "password": "hunter2"}, "client"),
|
||||
Entry("empty password", map[string]any{"username": "a", "password": "", "client": "hunter2"}, "password"),
|
||||
Entry("client too long", map[string]any{"username": "a", "password": "hunter2", "client": strings.Repeat("x", 65)}, "client"),
|
||||
Entry("bad scope format", map[string]any{"username": "a", "password": "hunter2", "client": "c", "scopes": []string{"NOT OK"}}, "scopes.0"),
|
||||
)
|
||||
|
||||
DescribeTable("rejects a body over 1 MiB with 413",
|
||||
func(body func(string) io.Reader) {
|
||||
big := `{"username":"a","password":"` + strings.Repeat("a", maxBodyBytes) + `","client":"c"}`
|
||||
req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/v1/auth/login", body(big))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := serve(api.router, req)
|
||||
Expect(w.Code).To(Equal(http.StatusRequestEntityTooLarge))
|
||||
Expect(decodeProblem(w).Code).To(Equal(ProblemCodePayloadTooLarge))
|
||||
},
|
||||
Entry("with a declared length", func(s string) io.Reader { return strings.NewReader(s) }),
|
||||
// io.MultiReader hides the length, so the request has ContentLength -1, like a chunked upload.
|
||||
Entry("with no declared length", func(s string) io.Reader { return io.MultiReader(strings.NewReader(s)) }),
|
||||
)
|
||||
})
|
||||
|
||||
// beforeTxDS calls before as each immediate transaction starts; authentication opens none, so it lands after the gate.
|
||||
type beforeTxDS struct {
|
||||
model.DataStore
|
||||
before func()
|
||||
}
|
||||
|
||||
func (d beforeTxDS) WithTxImmediate(block func(tx model.DataStore) error, scope ...string) error {
|
||||
d.before()
|
||||
return d.DataStore.WithTxImmediate(block, scope...)
|
||||
}
|
||||
13
server/apiv1/db_test.go
Normal file
13
server/apiv1/db_test.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package apiv1
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
var realDS model.DataStore
|
||||
|
||||
func resetDB() {
|
||||
_, _ = db.Db().Exec("delete from api_grant")
|
||||
_, _ = db.Db().Exec("delete from user")
|
||||
}
|
||||
71
server/apiv1/dto.go
Normal file
71
server/apiv1/dto.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
package apiv1
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/core/apiauth"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
func toScopes(in []string) []Scope {
|
||||
return slice.Map(in, func(s string) Scope { return Scope(s) })
|
||||
}
|
||||
|
||||
// fromScopeRequests keeps nil (all scopes) apart from an empty list (no scopes).
|
||||
func fromScopeRequests(in *[]ScopeRequest) []string {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
return append([]string{}, *in...)
|
||||
}
|
||||
|
||||
func nullable(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func toGrant(g model.Grant, currentID string) Grant {
|
||||
return Grant{
|
||||
Id: g.ID,
|
||||
Name: g.Name,
|
||||
Client: g.Client,
|
||||
ClientVersion: nullable(g.ClientVersion),
|
||||
Scopes: toScopes(g.Scopes),
|
||||
Provider: g.Provider,
|
||||
CreatedAt: g.CreatedAt,
|
||||
LastUsedAt: g.LastUsedAt,
|
||||
LastUsedIp: nullable(g.LastUsedIP),
|
||||
Current: g.ID == currentID,
|
||||
}
|
||||
}
|
||||
|
||||
func toGrantCreated(i *apiauth.Issued) GrantCreated {
|
||||
return GrantCreated{
|
||||
Secret: i.Secret,
|
||||
Grant: toGrant(i.Grant, i.Grant.ID),
|
||||
User: AuthUser{
|
||||
Id: i.User.ID,
|
||||
UserName: i.User.UserName,
|
||||
Name: i.User.Name,
|
||||
IsAdmin: i.User.IsAdmin,
|
||||
PasswordChangeable: apiauth.PasswordChangeable(i.User),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func clientMeta(c CredentialsRequest) apiauth.ClientMeta {
|
||||
return apiauth.ClientMeta{Client: c.Client, Name: gg.V(c.Name), ClientVersion: gg.V(c.ClientVersion)}
|
||||
}
|
||||
|
||||
// principalFrom fails closed if the gate did not attach a principal to the context.
|
||||
func principalFrom(ctx context.Context) (*apiauth.Principal, error) {
|
||||
p, ok := apiauth.PrincipalFrom(ctx)
|
||||
if !ok || p == nil {
|
||||
return nil, model.ErrInvalidAuth
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
15
server/apiv1/dto_test.go
Normal file
15
server/apiv1/dto_test.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package apiv1
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/core/apiauth"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("toScopes", func() {
|
||||
It("only produces scopes the spec's Scope enum allows", func() {
|
||||
for _, s := range toScopes(append([]string{apiauth.ScopeAll}, apiauth.KnownScopes...)) {
|
||||
Expect(s.Valid()).To(BeTrue(), "scope %q is missing from the spec's Scope enum", s)
|
||||
}
|
||||
})
|
||||
})
|
||||
422
server/apiv1/gate.go
Normal file
422
server/apiv1/gate.go
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
package apiv1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
"github.com/getkin/kin-openapi/openapi3filter"
|
||||
"github.com/getkin/kin-openapi/routers"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/httprate"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/apiauth"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
)
|
||||
|
||||
type authenticator interface {
|
||||
Authenticate(ctx context.Context, secret, ip string) (*apiauth.Principal, error)
|
||||
}
|
||||
|
||||
type gateOp struct {
|
||||
route *routers.Route
|
||||
public bool
|
||||
scope string
|
||||
limited bool
|
||||
noStore bool
|
||||
}
|
||||
|
||||
func (o *gateOp) id() string { return o.route.Operation.OperationID }
|
||||
|
||||
type opKey struct{ method, path string }
|
||||
|
||||
type gate struct {
|
||||
mux chi.Routes
|
||||
ops map[opKey]*gateOp
|
||||
auth authenticator
|
||||
limiter func(http.Handler) http.Handler
|
||||
}
|
||||
|
||||
// Modules that ride another module's scope; every other module's scope is its own name.
|
||||
var moduleScope = map[string]string{
|
||||
"core": apiauth.ScopeRead,
|
||||
"transcoding": "streaming",
|
||||
"custom-tags": apiauth.ScopeRead,
|
||||
"grouping": apiauth.ScopeRead,
|
||||
"smart-playlists": "playlists:write",
|
||||
}
|
||||
|
||||
type gateRules struct {
|
||||
limited map[string]bool // login-type operations, throttled per client IP
|
||||
noScope map[string]bool // the only bearerAuth operations allowed without x-scope
|
||||
noStore map[string]bool // operations whose responses carry a secret
|
||||
}
|
||||
|
||||
func newGate(doc *openapi3.T, mux chi.Routes, auth authenticator, rules gateRules) (*gate, error) {
|
||||
g := &gate{mux: mux, ops: map[opKey]*gateOp{}, auth: auth}
|
||||
ids := map[string]bool{}
|
||||
for path, item := range doc.Paths.Map() {
|
||||
for method, op := range item.Operations() {
|
||||
gop, err := buildGateOp(doc, path, item, method, op, rules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.ops[opKey{method, path}] = gop
|
||||
ids[op.OperationID] = true
|
||||
}
|
||||
}
|
||||
if err := rules.check(ids); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if conf.Server.AuthRequestLimit > 0 {
|
||||
g.limiter = server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength,
|
||||
// Counts are per node, so X-RateLimit-Remaining would mislead clients of a scaled-out server.
|
||||
httprate.WithResponseHeaders(httprate.ResponseHeaders{RetryAfter: "Retry-After"}),
|
||||
httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblemStatus(w, r, http.StatusTooManyRequests, ProblemCodeRateLimited, "too many requests")
|
||||
}))
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// check fails on a rule naming an operation the spec lacks, so a typo cannot silently disable the rule.
|
||||
func (rules gateRules) check(ids map[string]bool) error {
|
||||
sets := map[string]map[string]bool{"limited": rules.limited, "noScope": rules.noScope, "noStore": rules.noStore}
|
||||
for name, set := range sets {
|
||||
for id := range set {
|
||||
if !ids[id] {
|
||||
return fmt.Errorf("gate rule %s names unknown operation %s", name, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildGateOp enforces the allowed security forms, so a spec edit cannot silently drop a requirement.
|
||||
func buildGateOp(doc *openapi3.T, path string, item *openapi3.PathItem, method string, op *openapi3.Operation, rules gateRules) (*gateOp, error) {
|
||||
id := op.OperationID
|
||||
gop := &gateOp{
|
||||
route: &routers.Route{Spec: doc, Path: path, PathItem: item, Method: method, Operation: op},
|
||||
limited: rules.limited[id],
|
||||
noStore: rules.noStore[id],
|
||||
}
|
||||
if op.Security == nil {
|
||||
return nil, fmt.Errorf("operation %s must declare security explicitly", id)
|
||||
}
|
||||
rawScope, hasScope := op.Extensions["x-scope"]
|
||||
scope, isString := rawScope.(string)
|
||||
if hasScope && (!isString || scope == "") {
|
||||
return nil, fmt.Errorf("operation %s: x-scope must be a non-empty string", id)
|
||||
}
|
||||
module, _ := op.Extensions["x-module"].(string)
|
||||
switch reqs := *op.Security; {
|
||||
case len(reqs) == 0:
|
||||
gop.public = true
|
||||
case len(reqs) == 1 && isScheme(reqs[0], "bearerAuth"):
|
||||
default:
|
||||
return nil, fmt.Errorf("operation %s has a security requirement outside the allowed forms", id)
|
||||
}
|
||||
if !gop.public && scope == "" && !rules.noScope[id] {
|
||||
return nil, fmt.Errorf("operation %s: bearerAuth needs x-scope", id)
|
||||
}
|
||||
if scope != "" {
|
||||
if gop.public {
|
||||
return nil, fmt.Errorf("operation %s: x-scope needs bearerAuth", id)
|
||||
}
|
||||
base := cmp.Or(moduleScope[module], module)
|
||||
if scope != base && scope != base+":write" {
|
||||
return nil, fmt.Errorf("operation %s: x-scope %q does not match module %q", id, scope, module)
|
||||
}
|
||||
if !slices.Contains(apiauth.KnownScopes, scope) {
|
||||
return nil, fmt.Errorf("operation %s: unknown x-scope %q", id, scope)
|
||||
}
|
||||
}
|
||||
gop.scope = scope
|
||||
return gop, nil
|
||||
}
|
||||
|
||||
// isScheme requires the scheme alone with an empty scope list, as OpenAPI 3.0.3 demands for http schemes.
|
||||
func isScheme(req openapi3.SecurityRequirement, name string) bool {
|
||||
scopes, ok := req[name]
|
||||
return ok && len(req) == 1 && len(scopes) == 0
|
||||
}
|
||||
|
||||
// checkRoutes fails when a routed pattern has no spec operation or a spec operation has no route.
|
||||
func (g *gate) checkRoutes() error {
|
||||
err := chi.Walk(g.mux, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
|
||||
if _, ok := g.ops[opKey{method, route}]; !ok {
|
||||
return fmt.Errorf("route %s %s is not in the spec", method, route)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, op := range g.ops {
|
||||
if g.mux.Find(chi.NewRouteContext(), op.route.Method, op.route.Path) != op.route.Path {
|
||||
return fmt.Errorf("spec operation %s (%s %s) has no route", op.id(), op.route.Method, op.route.Path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *gate) handler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
method := routeMethod(r)
|
||||
rctx := chi.NewRouteContext()
|
||||
pattern := g.mux.Find(rctx, method, routePath(r))
|
||||
if pattern == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
op, ok := g.ops[opKey{method, pattern}]
|
||||
if !ok {
|
||||
log.Error(r.Context(), "API v1: routed pattern missing from the spec", "method", method, "pattern", pattern)
|
||||
writeProblemStatus(w, r, http.StatusInternalServerError, ProblemCodeInternal, "")
|
||||
return
|
||||
}
|
||||
if op.noStore {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
}
|
||||
serve := func(w http.ResponseWriter, r *http.Request) {
|
||||
r, ok := g.authorize(w, r, op)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !g.validate(w, r, op, rctx) {
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
if op.limited && g.limiter != nil {
|
||||
g.limiter(http.HandlerFunc(serve)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
serve(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (g *gate) authorize(w http.ResponseWriter, r *http.Request, op *gateOp) (*http.Request, bool) {
|
||||
if op.public {
|
||||
return r, true
|
||||
}
|
||||
secret, ok := bearerToken(r)
|
||||
if !ok {
|
||||
writeProblemStatus(w, r, http.StatusUnauthorized, ProblemCodeUnauthorized, "")
|
||||
return r, false
|
||||
}
|
||||
p, err := g.auth.Authenticate(r.Context(), secret, server.ClientAddr(r))
|
||||
if err != nil {
|
||||
writeProblem(w, r, err)
|
||||
return r, false
|
||||
}
|
||||
if op.scope != "" && !apiauth.Satisfies(p.Scopes, op.scope) {
|
||||
writeProblem(w, r, &scopeError{scope: op.scope})
|
||||
return r, false
|
||||
}
|
||||
ctx := apiauth.WithPrincipal(request.WithUser(r.Context(), p.User), p)
|
||||
return r.WithContext(ctx), true
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) (string, bool) {
|
||||
scheme, token, ok := strings.Cut(strings.TrimSpace(r.Header.Get("Authorization")), " ")
|
||||
token = strings.TrimSpace(token)
|
||||
if !ok || !strings.EqualFold(scheme, "Bearer") || token == "" {
|
||||
return "", false
|
||||
}
|
||||
return token, true
|
||||
}
|
||||
|
||||
// SkipSettingDefaults: the handlers apply defaults themselves, and the validator must not rewrite the body.
|
||||
var validationOptions = &openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc, MultiError: true, SkipSettingDefaults: true}
|
||||
|
||||
func (g *gate) validate(w http.ResponseWriter, r *http.Request, op *gateOp, rctx *chi.Context) bool {
|
||||
params := make(map[string]string, len(rctx.URLParams.Keys))
|
||||
for i, k := range rctx.URLParams.Keys {
|
||||
params[k] = rctx.URLParams.Values[i]
|
||||
}
|
||||
body, err := readBody(r, op.route.Operation)
|
||||
if err == nil {
|
||||
err = openapi3filter.ValidateRequest(r.Context(), &openapi3filter.RequestValidationInput{
|
||||
Request: r, PathParams: params, Route: op.route, Options: validationOptions,
|
||||
})
|
||||
if body != nil {
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
}
|
||||
}
|
||||
if tooLarge(err) {
|
||||
writeProblem(w, r, ClientError(err, tooLargeDetail))
|
||||
return false
|
||||
}
|
||||
var fields []ValidationError
|
||||
if err != nil {
|
||||
fields = sanitizeValidation(err)
|
||||
} else if fields = jsonBodyFields(body, op.route.Operation); len(fields) == 0 {
|
||||
return true
|
||||
}
|
||||
log.Debug(r.Context(), "API v1: request failed validation", "operation", op.id(), "errors", fields)
|
||||
writeProblemStatus(w, r, http.StatusBadRequest, ProblemCodeValidation, "the request does not match the API schema", fields...)
|
||||
return false
|
||||
}
|
||||
|
||||
// readBody reads a declared body once, so the validator, the JSON checks and the handler all see the same bytes.
|
||||
func readBody(r *http.Request, op *openapi3.Operation) ([]byte, error) {
|
||||
if op.RequestBody == nil || r.Body == nil || r.Body == http.NoBody {
|
||||
return nil, nil
|
||||
}
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(data))
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// jsonBodyFields checks what kin-openapi misses in a JSON body: data after the first value, which Go's decoder
|
||||
// ignores, and keys that only case-fold to a declared property, which encoding/json decodes into that property.
|
||||
func jsonBodyFields(data []byte, op *openapi3.Operation) []ValidationError {
|
||||
if op.RequestBody == nil || op.RequestBody.Value == nil || len(bytes.TrimSpace(data)) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Keyed on the spec, not the request's Content-Type: the handlers decode JSON whatever the header says.
|
||||
media := op.RequestBody.Value.Content.Get("application/json")
|
||||
if media == nil || media.Schema == nil {
|
||||
return nil
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
var body any
|
||||
if err := dec.Decode(&body); err != nil {
|
||||
return []ValidationError{{Field: "", Message: "must be a single JSON value"}}
|
||||
}
|
||||
if _, err := dec.Token(); !errors.Is(err, io.EOF) {
|
||||
return []ValidationError{{Field: "", Message: "must be a single JSON value"}}
|
||||
}
|
||||
var out []ValidationError
|
||||
collectCaseAliases(body, media.Schema.Value, "", &out)
|
||||
return out
|
||||
}
|
||||
|
||||
func collectCaseAliases(v any, schema *openapi3.Schema, path string, out *[]ValidationError) {
|
||||
if schema == nil {
|
||||
return
|
||||
}
|
||||
switch v := v.(type) {
|
||||
case map[string]any:
|
||||
for _, key := range slices.Sorted(maps.Keys(v)) {
|
||||
field := joinField(path, key)
|
||||
if prop, ok := schema.Properties[key]; ok {
|
||||
if prop != nil {
|
||||
collectCaseAliases(v[key], prop.Value, field, out)
|
||||
}
|
||||
continue
|
||||
}
|
||||
for name := range schema.Properties {
|
||||
if strings.EqualFold(key, name) {
|
||||
*out = append(*out, ValidationError{Field: field, Message: "must match the field name exactly"})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
if schema.Items == nil {
|
||||
return
|
||||
}
|
||||
for i, item := range v {
|
||||
collectCaseAliases(item, schema.Items.Value, joinField(path, strconv.Itoa(i)), out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func joinField(path, name string) string {
|
||||
if path == "" {
|
||||
return name
|
||||
}
|
||||
return path + "." + name
|
||||
}
|
||||
|
||||
var missingProperty = regexp.MustCompile(`property "([^"]+)" is missing`)
|
||||
|
||||
// sanitizeValidation keeps only field paths and fixed messages: kin-openapi errors can embed the submitted value.
|
||||
// It walks wrappers by concrete type, not errors.As, because MultiError.As would skip the RequestError that names the parameter.
|
||||
func sanitizeValidation(err error) []ValidationError {
|
||||
var out []ValidationError
|
||||
var walk func(err error, param string)
|
||||
walk = func(err error, param string) {
|
||||
switch e := err.(type) { //nolint:errorlint
|
||||
case openapi3.MultiError:
|
||||
for _, child := range e {
|
||||
walk(child, param)
|
||||
}
|
||||
case *openapi3filter.RequestError:
|
||||
if e.Parameter != nil {
|
||||
param = e.Parameter.Name
|
||||
}
|
||||
switch {
|
||||
case errors.Is(e.Err, openapi3filter.ErrInvalidRequired), errors.Is(e.Err, openapi3filter.ErrInvalidEmptyValue):
|
||||
out = append(out, ValidationError{Field: param, Message: "is required"})
|
||||
case e.Err != nil:
|
||||
walk(e.Err, param)
|
||||
default:
|
||||
out = append(out, ValidationError{Field: param, Message: "is invalid"})
|
||||
}
|
||||
case *openapi3.SchemaError:
|
||||
field := strings.Join(e.JSONPointer(), ".")
|
||||
if field == "" && e.SchemaField == "required" {
|
||||
if m := missingProperty.FindStringSubmatch(e.Reason); m != nil {
|
||||
field = m[1]
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case param != "" && field != "":
|
||||
field = param + "." + field
|
||||
case field == "":
|
||||
field = param
|
||||
}
|
||||
out = append(out, ValidationError{Field: field, Message: schemaMessage(e.SchemaField)})
|
||||
default:
|
||||
if inner := errors.Unwrap(err); inner != nil {
|
||||
walk(inner, param)
|
||||
return
|
||||
}
|
||||
out = append(out, ValidationError{Field: param, Message: "is invalid"})
|
||||
}
|
||||
}
|
||||
walk(err, "")
|
||||
return out
|
||||
}
|
||||
|
||||
func schemaMessage(keyword string) string {
|
||||
switch keyword {
|
||||
case "required":
|
||||
return "is required"
|
||||
case "maxLength", "maxItems":
|
||||
return "is too long"
|
||||
case "minLength", "minItems":
|
||||
return "is too short"
|
||||
case "maximum", "exclusiveMaximum":
|
||||
return "is too large"
|
||||
case "minimum", "exclusiveMinimum":
|
||||
return "is too small"
|
||||
case "pattern", "format":
|
||||
return "has an invalid format"
|
||||
case "enum":
|
||||
return "is not an allowed value"
|
||||
case "type", "nullable":
|
||||
return "has the wrong type"
|
||||
default:
|
||||
return "is invalid"
|
||||
}
|
||||
}
|
||||
406
server/apiv1/gate_test.go
Normal file
406
server/apiv1/gate_test.go
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
package apiv1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/apiauth"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
const gateSpec = `
|
||||
openapi: 3.0.3
|
||||
info: {title: t, version: "1"}
|
||||
paths:
|
||||
/open:
|
||||
get: {operationId: open, x-module: core, security: [], responses: {'200': {description: ok}}}
|
||||
/things/{id}:
|
||||
get:
|
||||
operationId: getThing
|
||||
x-module: core
|
||||
x-scope: read
|
||||
security: [{bearerAuth: []}]
|
||||
parameters: [{name: id, in: path, required: true, schema: {type: string, maxLength: 3}}]
|
||||
responses: {'200': {description: ok}}
|
||||
/things:
|
||||
post:
|
||||
operationId: createThing
|
||||
x-module: password
|
||||
x-scope: password
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [name]
|
||||
properties:
|
||||
name: {type: string, maxLength: 5}
|
||||
tags: {type: array, items: {type: object, properties: {label: {type: string, maxLength: 5}}}}
|
||||
responses: {'200': {description: ok}}
|
||||
/caps:
|
||||
get: {operationId: caps, x-module: core, security: [{bearerAuth: []}], responses: {'200': {description: ok}}}
|
||||
/limited:
|
||||
post: {operationId: limited, x-module: core, security: [], responses: {'200': {description: ok}}}
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth: {type: http, scheme: bearer}
|
||||
`
|
||||
|
||||
type fakeAuth struct {
|
||||
principal *apiauth.Principal
|
||||
err error
|
||||
gotSecret string
|
||||
gotIP string
|
||||
}
|
||||
|
||||
func (f *fakeAuth) Authenticate(_ context.Context, secret, ip string) (*apiauth.Principal, error) {
|
||||
f.gotSecret, f.gotIP = secret, ip
|
||||
return f.principal, f.err
|
||||
}
|
||||
|
||||
var testGateRules = gateRules{
|
||||
limited: map[string]bool{"limited": true},
|
||||
noScope: map[string]bool{"caps": true},
|
||||
noStore: map[string]bool{"caps": true},
|
||||
}
|
||||
|
||||
var _ = Describe("spec gate", func() {
|
||||
var ctx context.Context
|
||||
var fa *fakeAuth
|
||||
var mux *chi.Mux
|
||||
var g *gate
|
||||
var reached string
|
||||
|
||||
build := func(spec string) (*chi.Mux, error) {
|
||||
doc, err := openapi3.NewLoader().LoadFromData([]byte(spec))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
m := chi.NewRouter()
|
||||
g, err = newGate(doc, m, fa, testGateRules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.Use(headAsGet(m), g.handler)
|
||||
ok := func(name string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
reached = name
|
||||
if p, found := apiauth.PrincipalFrom(r.Context()); found {
|
||||
w.Header().Set("X-User", p.User.ID)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
m.Get("/open", ok("open"))
|
||||
m.Get("/things/{id}", ok("getThing"))
|
||||
m.Post("/things", ok("createThing"))
|
||||
m.Get("/caps", ok("caps"))
|
||||
m.Post("/limited", ok("limited"))
|
||||
return m, nil
|
||||
}
|
||||
|
||||
do := func(method, path, auth, body string) *httptest.ResponseRecorder {
|
||||
var req *http.Request
|
||||
if body != "" {
|
||||
req = httptest.NewRequestWithContext(ctx, method, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequestWithContext(ctx, method, path, nil)
|
||||
}
|
||||
if auth != "" {
|
||||
req.Header.Set("Authorization", auth)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
reached = ""
|
||||
fa = &fakeAuth{principal: &apiauth.Principal{User: model.User{ID: "u1"}, GrantID: "g1", Scopes: []string{"read"}}}
|
||||
var err error
|
||||
mux, err = build(gateSpec)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("lets public operations through without a credential", func() {
|
||||
Expect(do(http.MethodGet, "/open", "", "").Code).To(Equal(http.StatusOK))
|
||||
Expect(reached).To(Equal("open"))
|
||||
})
|
||||
|
||||
It("requires a grant secret, with a Bearer challenge", func() {
|
||||
w := do(http.MethodGet, "/things/1", "", "")
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
Expect(w.Header().Get("WWW-Authenticate")).To(Equal("Bearer"))
|
||||
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeUnauthorized))
|
||||
Expect(reached).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("accepts the Bearer scheme in any case and trims spaces", func() {
|
||||
w := do(http.MethodGet, "/things/1", "bearer ndg_secret ", "")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(fa.gotSecret).To(Equal("ndg_secret"))
|
||||
Expect(w.Header().Get("X-User")).To(Equal("u1"))
|
||||
})
|
||||
|
||||
It("maps auth failures to unauthorized with invalid_token", func() {
|
||||
fa.err = model.ErrInvalidAuth
|
||||
w := do(http.MethodGet, "/things/1", "Bearer x", "")
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`))
|
||||
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeUnauthorized))
|
||||
})
|
||||
|
||||
It("rejects a grant without the operation's scope", func() {
|
||||
w := do(http.MethodPost, "/things", "Bearer x", `{"name":"a"}`)
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="insufficient_scope", scope="password"`))
|
||||
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInsufficientScope))
|
||||
})
|
||||
|
||||
It("lets any valid grant through an operation with no x-scope", func() {
|
||||
fa.principal.Scopes = nil
|
||||
Expect(do(http.MethodGet, "/caps", "Bearer x", "").Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("passes the full client address to the authenticator, not the rate-limit /64", func() {
|
||||
req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/things/1", nil)
|
||||
req.RemoteAddr = "[2001:db8:1:2:3:4:5:6]:4321"
|
||||
req.Header.Set("Authorization", "Bearer x")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(fa.gotIP).To(Equal("2001:db8:1:2:3:4:5:6"))
|
||||
})
|
||||
|
||||
It("marks only the listed operations' responses no-store, errors included", func() {
|
||||
Expect(do(http.MethodGet, "/caps", "Bearer ndg_secret", "").Header().Get("Cache-Control")).To(Equal("no-store"))
|
||||
fa.err = model.ErrInvalidAuth
|
||||
Expect(do(http.MethodGet, "/caps", "Bearer ndg_secret", "").Header().Get("Cache-Control")).To(Equal("no-store"))
|
||||
fa.err = nil
|
||||
Expect(do(http.MethodGet, "/things/1", "Bearer x", "").Header().Get("Cache-Control")).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("checks HEAD on a protected GET", func() {
|
||||
w := do(http.MethodHead, "/things/1", "", "")
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("looks routes up on the raw path, as chi dispatches them", func() {
|
||||
w := do(http.MethodGet, "/things/a%2Fb", "", "")
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
Expect(reached).To(BeEmpty())
|
||||
|
||||
root := chi.NewRouter()
|
||||
root.Mount("/music/api/v1", mux)
|
||||
w = httptest.NewRecorder()
|
||||
root.ServeHTTP(w, httptest.NewRequestWithContext(ctx, http.MethodGet, "/music/api/v1/things/a%2Fb", nil))
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
Expect(reached).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("works when mounted under a base path", func() {
|
||||
root := chi.NewRouter()
|
||||
root.Mount("/music/api/v1", mux)
|
||||
req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/music/api/v1/things/1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
root.ServeHTTP(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("authenticates before validating", func() {
|
||||
w := do(http.MethodPost, "/things", "", `{"name":"far-too-long"}`)
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("returns and logs sanitised validation errors that never echo the value", func() {
|
||||
logs := captureLogs()
|
||||
fa.principal.Scopes = []string{"password"}
|
||||
w := do(http.MethodPost, "/things", "Bearer x", `{"name":"hunter2-secret"}`)
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
p := decodeProblem(w)
|
||||
Expect(p.Code).To(Equal(ProblemCodeValidation))
|
||||
Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "name", Message: "is too long"}))
|
||||
Expect(w.Body.String()).ToNot(ContainSubstring("hunter2"))
|
||||
Expect(logs.String()).To(ContainSubstring("failed validation"))
|
||||
Expect(logs.String()).ToNot(ContainSubstring("hunter2"))
|
||||
})
|
||||
|
||||
It("reports a missing required body field by name", func() {
|
||||
fa.principal.Scopes = []string{"password"}
|
||||
w := do(http.MethodPost, "/things", "Bearer x", `{}`)
|
||||
Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "name", Message: "is required"}))
|
||||
})
|
||||
|
||||
DescribeTable("rejects a case variant of a declared body field, which Go would decode into it",
|
||||
func(body, field string) {
|
||||
fa.principal.Scopes = []string{"password"}
|
||||
w := do(http.MethodPost, "/things", "Bearer x", body)
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String())
|
||||
p := decodeProblem(w)
|
||||
Expect(p.Code).To(Equal(ProblemCodeValidation))
|
||||
Expect(*p.Errors).To(ConsistOf(ValidationError{Field: field, Message: "must match the field name exactly"}))
|
||||
Expect(reached).To(BeEmpty())
|
||||
},
|
||||
Entry("top level", `{"name":"ok","NAME":"much-too-long"}`, "NAME"),
|
||||
Entry("inside array items", `{"name":"ok","tags":[{"label":"a"},{"label":"b","Label":"much-too-long"}]}`, "tags.1.Label"),
|
||||
Entry("Unicode case folding", "{\"name\":\"ok\",\"tag\u017f\":null}", "tag\u017f"),
|
||||
)
|
||||
|
||||
DescribeTable("rejects data after the first JSON value, which Go's decoder would ignore",
|
||||
func(body string) {
|
||||
fa.principal.Scopes = []string{"password"}
|
||||
w := do(http.MethodPost, "/things", "Bearer x", body)
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String())
|
||||
p := decodeProblem(w)
|
||||
Expect(p.Code).To(Equal(ProblemCodeValidation))
|
||||
Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "", Message: "must be a single JSON value"}))
|
||||
Expect(reached).To(BeEmpty())
|
||||
},
|
||||
Entry("garbage", `{"name":"ok","NAME":"much-too-long"}x`),
|
||||
Entry("a second value", `{"name":"ok"} {"NAME":"much-too-long"}`),
|
||||
Entry("a stray bracket", `{"name":"ok"}]`),
|
||||
)
|
||||
|
||||
DescribeTable("checks the body whatever parameters the Content-Type carries",
|
||||
func(contentType string) {
|
||||
fa.principal.Scopes = []string{"password"}
|
||||
req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/things", strings.NewReader(`{"name":"ok","NAME":"much-too-long"}`))
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
req.Header.Set("Authorization", "Bearer x")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest), w.Body.String())
|
||||
Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "NAME", Message: "must match the field name exactly"}))
|
||||
Expect(reached).To(BeEmpty())
|
||||
},
|
||||
Entry("repeated parameter", "application/json; a=1; a=2"),
|
||||
Entry("repeated charset", "application/json; charset=utf-8; CHARSET=latin1"),
|
||||
)
|
||||
|
||||
It("accepts trailing whitespace after the JSON value", func() {
|
||||
fa.principal.Scopes = []string{"password"}
|
||||
Expect(do(http.MethodPost, "/things", "Bearer x", "{\"name\":\"ok\"}\n \t").Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("allows unknown body fields that do not collide with a declared one", func() {
|
||||
fa.principal.Scopes = []string{"password"}
|
||||
w := do(http.MethodPost, "/things", "Bearer x", `{"name":"ok","extra":{"Name":"x"},"tags":[{"label":"a","other":1}]}`)
|
||||
Expect(w.Code).To(Equal(http.StatusOK), w.Body.String())
|
||||
Expect(reached).To(Equal("createThing"))
|
||||
})
|
||||
|
||||
It("validates path parameters", func() {
|
||||
w := do(http.MethodGet, "/things/toolong", "Bearer x", "")
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(*decodeProblem(w).Errors).To(ConsistOf(ValidationError{Field: "id", Message: "is too long"}))
|
||||
})
|
||||
|
||||
It("passes unknown paths through to the router's 404", func() {
|
||||
Expect(do(http.MethodGet, "/nope", "", "").Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
|
||||
It("fails closed for a routed pattern the spec does not know", func() {
|
||||
mux.Get("/extra", func(w http.ResponseWriter, r *http.Request) { reached = "extra" })
|
||||
w := do(http.MethodGet, "/extra", "", "")
|
||||
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||
Expect(reached).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("rate-limits the listed operations with a 429 problem", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.AuthRequestLimit = 1
|
||||
var err error
|
||||
mux, err = build(gateSpec)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
w := do(http.MethodPost, "/limited", "", "")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
expectNoXRateLimitHeaders(w)
|
||||
w = do(http.MethodPost, "/limited", "", "")
|
||||
Expect(w.Code).To(Equal(http.StatusTooManyRequests))
|
||||
Expect(w.Header().Get("Retry-After")).ToNot(BeEmpty())
|
||||
expectNoXRateLimitHeaders(w)
|
||||
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeRateLimited))
|
||||
})
|
||||
|
||||
DescribeTable("refuses specs that break the security rules",
|
||||
func(bad string) {
|
||||
_, err := build(bad)
|
||||
Expect(err).To(HaveOccurred())
|
||||
},
|
||||
Entry("missing security", strings.Replace(gateSpec, "operationId: open, x-module: core, security: [],", "operationId: open, x-module: core,", 1)),
|
||||
Entry("scope not matching module", strings.Replace(gateSpec, "x-scope: read", "x-scope: password", 1)),
|
||||
Entry("unknown scope", strings.Replace(gateSpec, "x-scope: read", "x-scope: bogus", 1)),
|
||||
Entry("bearer without x-scope outside the allowlist", strings.Replace(gateSpec, " x-scope: read\n", "", 1)),
|
||||
Entry("a scheme other than bearerAuth", strings.Replace(
|
||||
strings.Replace(gateSpec, " bearerAuth: {type: http, scheme: bearer}\n", " bearerAuth: {type: http, scheme: bearer}\n grantAuth: {type: http, scheme: bearer}\n", 1),
|
||||
"operationId: caps, x-module: core, security: [{bearerAuth: []}]", "operationId: caps, x-module: core, security: [{grantAuth: []}]", 1)),
|
||||
Entry("an undeclared scheme", strings.Replace(gateSpec, "operationId: limited, x-module: core, security: []", "operationId: limited, x-module: core, security: [{grantAuth: []}]", 1)),
|
||||
Entry("non-empty scope list on a bearer scheme", strings.Replace(gateSpec, "operationId: caps, x-module: core, security: [{bearerAuth: []}]", "operationId: caps, x-module: core, security: [{bearerAuth: [read]}]", 1)),
|
||||
Entry("x-scope on a public operation", strings.Replace(gateSpec, "operationId: open, x-module: core, security: [],", "operationId: open, x-module: core, x-scope: read, security: [],", 1)),
|
||||
Entry("x-scope that is not a string", strings.Replace(gateSpec, "x-scope: read", "x-scope: [read]", 1)),
|
||||
Entry("x-scope not in KnownScopes", strings.Replace(gateSpec, "x-module: password\n x-scope: password", "x-module: admin\n x-scope: admin", 1)),
|
||||
)
|
||||
|
||||
DescribeTable("refuses rules that name an operation missing from the spec",
|
||||
func(set func(*gateRules) *map[string]bool) {
|
||||
doc, err := openapi3.NewLoader().LoadFromData([]byte(gateSpec))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
rules := testGateRules
|
||||
m := set(&rules)
|
||||
*m = maps.Clone(*m)
|
||||
(*m)["typo"] = true
|
||||
_, err = newGate(doc, chi.NewRouter(), fa, rules)
|
||||
Expect(err).To(MatchError(ContainSubstring("typo")))
|
||||
},
|
||||
Entry("limited", func(r *gateRules) *map[string]bool { return &r.limited }),
|
||||
Entry("noScope", func(r *gateRules) *map[string]bool { return &r.noScope }),
|
||||
Entry("noStore", func(r *gateRules) *map[string]bool { return &r.noStore }),
|
||||
)
|
||||
|
||||
It("checks routes against the spec in both directions", func() {
|
||||
Expect(g.checkRoutes()).To(Succeed())
|
||||
|
||||
mux.Get("/extra", func(http.ResponseWriter, *http.Request) {})
|
||||
Expect(g.checkRoutes()).To(MatchError(ContainSubstring("GET /extra is not in the spec")))
|
||||
|
||||
extraOp := strings.Replace(gateSpec, "components:", ` /unrouted:
|
||||
get: {operationId: unrouted, x-module: core, security: [], responses: {'200': {description: ok}}}
|
||||
components:`, 1)
|
||||
_, err := build(extraOp)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(g.checkRoutes()).To(MatchError(ContainSubstring("unrouted")))
|
||||
})
|
||||
})
|
||||
|
||||
// captureLogs sends debug logs to a buffer for the rest of the spec.
|
||||
func captureLogs() *bytes.Buffer {
|
||||
buf := &bytes.Buffer{}
|
||||
log.SetOutput(buf)
|
||||
log.SetLevel(log.LevelDebug)
|
||||
DeferCleanup(func() {
|
||||
log.SetOutput(os.Stderr)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
})
|
||||
return buf
|
||||
}
|
||||
|
||||
func expectNoXRateLimitHeaders(w *httptest.ResponseRecorder) {
|
||||
GinkgoHelper()
|
||||
for _, h := range []string{"X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Increment", "X-RateLimit-Reset"} {
|
||||
Expect(w.Header().Values(h)).To(BeEmpty(), h)
|
||||
}
|
||||
}
|
||||
12
server/apiv1/oapi-codegen-overlay.yaml
Normal file
12
server/apiv1/oapi-codegen-overlay.yaml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
overlay: 1.0.0
|
||||
info:
|
||||
title: Go type names for the API v1 server
|
||||
version: 1.0.0
|
||||
actions:
|
||||
# Ginkgo's dot-imported Offset would clash with a generated Offset type in this package's tests.
|
||||
- target: $.components.parameters.offset
|
||||
update:
|
||||
x-go-name: OffsetParam
|
||||
- target: $.components.parameters.limit
|
||||
update:
|
||||
x-go-name: LimitParam
|
||||
|
|
@ -8,5 +8,7 @@ output-options:
|
|||
exclude-operation-ids:
|
||||
- getOpenAPISpecJSON
|
||||
- getOpenAPISpecYAML
|
||||
overlay:
|
||||
path: server/apiv1/oapi-codegen-overlay.yaml
|
||||
compatibility:
|
||||
always-prefix-enum-values: true
|
||||
|
|
|
|||
|
|
@ -3,26 +3,89 @@ package apiv1
|
|||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/navidrome/navidrome/core/apiauth"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
const problemContentType = "application/problem+json"
|
||||
|
||||
type clientError struct {
|
||||
err error
|
||||
detail string
|
||||
}
|
||||
|
||||
func (e *clientError) Error() string { return e.detail }
|
||||
func (e *clientError) Unwrap() error { return e.err }
|
||||
|
||||
// ClientError marks detail as safe to show clients; err still decides the status and code.
|
||||
func ClientError(err error, detail string) error {
|
||||
return &clientError{err: err, detail: detail}
|
||||
}
|
||||
|
||||
// scopeError names the scope an operation requires, for the insufficient_scope challenge.
|
||||
type scopeError struct {
|
||||
scope string
|
||||
}
|
||||
|
||||
func (e *scopeError) Error() string { return "insufficient scope" }
|
||||
|
||||
const tooLargeDetail = "request body too large"
|
||||
|
||||
func tooLarge(err error) bool {
|
||||
return errors.As(err, new(*http.MaxBytesError))
|
||||
}
|
||||
|
||||
type fieldErrors struct {
|
||||
fields []ValidationError
|
||||
}
|
||||
|
||||
func (e *fieldErrors) Error() string { return "validation failed" }
|
||||
func (e *fieldErrors) Unwrap() error { return model.ErrValidation }
|
||||
|
||||
func validationFailed(fields ...ValidationError) error {
|
||||
return &fieldErrors{fields: fields}
|
||||
}
|
||||
|
||||
func writeProblem(w http.ResponseWriter, r *http.Request, err error) {
|
||||
status, code := classifyError(err)
|
||||
detail := err.Error()
|
||||
if status == http.StatusInternalServerError {
|
||||
log.Error(r.Context(), "API v1: unexpected error", "path", r.URL.Path, err)
|
||||
detail = ""
|
||||
writeProblemStatus(w, r, status, code, "")
|
||||
return
|
||||
}
|
||||
log.Debug(r.Context(), "API v1: request failed", "path", r.URL.Path, "status", status, "code", code, err)
|
||||
var se *scopeError
|
||||
if errors.As(err, &se) {
|
||||
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer error="insufficient_scope", scope=%q`, se.scope))
|
||||
}
|
||||
var detail string
|
||||
var ce *clientError
|
||||
if errors.As(err, &ce) {
|
||||
detail = ce.detail
|
||||
}
|
||||
var fe *fieldErrors
|
||||
if errors.As(err, &fe) {
|
||||
writeProblemStatus(w, r, status, code, detail, fe.fields...)
|
||||
return
|
||||
}
|
||||
writeProblemStatus(w, r, status, code, detail)
|
||||
}
|
||||
|
||||
func classifyError(err error) (int, ProblemCode) {
|
||||
switch {
|
||||
case tooLarge(err):
|
||||
return http.StatusRequestEntityTooLarge, ProblemCodePayloadTooLarge
|
||||
case errors.As(err, new(*scopeError)):
|
||||
return http.StatusForbidden, ProblemCodeInsufficientScope
|
||||
case errors.Is(err, auth.ErrSetupComplete):
|
||||
return http.StatusConflict, ProblemCodeSetupComplete
|
||||
case errors.Is(err, apiauth.ErrPasswordManagedExternally):
|
||||
return http.StatusConflict, ProblemCodePasswordManagedExternally
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
return http.StatusNotFound, ProblemCodeNotFound
|
||||
case errors.Is(err, model.ErrNotAuthorized):
|
||||
|
|
@ -45,6 +108,19 @@ func writeProblemStatus(w http.ResponseWriter, r *http.Request, status int, code
|
|||
if len(fieldErrors) > 0 {
|
||||
p.Errors = &fieldErrors
|
||||
}
|
||||
if status == http.StatusInternalServerError {
|
||||
if ref := referenceIDFrom(r.Context()); ref != "" {
|
||||
p.ReferenceId = &ref
|
||||
}
|
||||
}
|
||||
// Every 401 carries a Bearer challenge; callers may set a more specific one first.
|
||||
if status == http.StatusUnauthorized && w.Header().Get("WWW-Authenticate") == "" {
|
||||
challenge := "Bearer"
|
||||
if _, sent := bearerToken(r); sent {
|
||||
challenge = `Bearer error="invalid_token"`
|
||||
}
|
||||
w.Header().Set("WWW-Authenticate", challenge)
|
||||
}
|
||||
w.Header().Set("Content-Type", problemContentType)
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(p); err != nil {
|
||||
|
|
@ -53,20 +129,20 @@ func writeProblemStatus(w http.ResponseWriter, r *http.Request, status int, code
|
|||
}
|
||||
|
||||
func bindingErrorHandler(w http.ResponseWriter, r *http.Request, err error) {
|
||||
var fieldErrors []ValidationError
|
||||
var fieldErrs []ValidationError
|
||||
var required *RequiredParamError
|
||||
var invalid *InvalidParamFormatError
|
||||
var tooMany *TooManyValuesForParamError
|
||||
var unmarshal *UnmarshalingParamError
|
||||
switch {
|
||||
case errors.As(err, &required):
|
||||
fieldErrors = append(fieldErrors, ValidationError{Field: required.ParamName, Message: "is required"})
|
||||
fieldErrs = append(fieldErrs, ValidationError{Field: required.ParamName, Message: "is required"})
|
||||
case errors.As(err, &invalid):
|
||||
fieldErrors = append(fieldErrors, ValidationError{Field: invalid.ParamName, Message: invalid.Err.Error()})
|
||||
fieldErrs = append(fieldErrs, ValidationError{Field: invalid.ParamName, Message: "has an invalid value"})
|
||||
case errors.As(err, &tooMany):
|
||||
fieldErrors = append(fieldErrors, ValidationError{Field: tooMany.ParamName, Message: "expected a single value"})
|
||||
fieldErrs = append(fieldErrs, ValidationError{Field: tooMany.ParamName, Message: "expected a single value"})
|
||||
case errors.As(err, &unmarshal):
|
||||
fieldErrors = append(fieldErrors, ValidationError{Field: unmarshal.ParamName, Message: unmarshal.Err.Error()})
|
||||
fieldErrs = append(fieldErrs, ValidationError{Field: unmarshal.ParamName, Message: "has an invalid value"})
|
||||
}
|
||||
writeProblemStatus(w, r, http.StatusBadRequest, ProblemCodeValidation, err.Error(), fieldErrors...)
|
||||
writeProblemStatus(w, r, http.StatusBadRequest, ProblemCodeValidation, "invalid request parameters", fieldErrs...)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
package apiv1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/navidrome/navidrome/core/apiauth"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
|
@ -19,12 +22,14 @@ func decodeProblem(w *httptest.ResponseRecorder) Problem {
|
|||
}
|
||||
|
||||
var _ = Describe("problem", func() {
|
||||
var ctx context.Context
|
||||
var w *httptest.ResponseRecorder
|
||||
var r *http.Request
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
w = httptest.NewRecorder()
|
||||
r = httptest.NewRequest(http.MethodGet, "/api/v1/server", nil)
|
||||
r = httptest.NewRequestWithContext(ctx, http.MethodGet, "/api/v1/server", nil)
|
||||
})
|
||||
|
||||
Describe("writeProblem", func() {
|
||||
|
|
@ -46,20 +51,68 @@ var _ = Describe("problem", func() {
|
|||
Entry("expired", model.ErrExpired, http.StatusUnauthorized, ProblemCodeUnauthorized),
|
||||
Entry("validation", model.ErrValidation, http.StatusBadRequest, ProblemCodeValidation),
|
||||
Entry("not available", model.ErrNotAvailable, http.StatusServiceUnavailable, ProblemCodeUnavailable),
|
||||
Entry("insufficient scope", &scopeError{scope: "read"}, http.StatusForbidden, ProblemCodeInsufficientScope),
|
||||
Entry("setup complete", auth.ErrSetupComplete, http.StatusConflict, ProblemCodeSetupComplete),
|
||||
Entry("password managed externally", apiauth.ErrPasswordManagedExternally, http.StatusConflict, ProblemCodePasswordManagedExternally),
|
||||
Entry("unknown", errors.New("boom"), http.StatusInternalServerError, ProblemCodeInternal),
|
||||
)
|
||||
|
||||
DescribeTable("keeps the wrapping context as detail for client errors",
|
||||
func(err error) {
|
||||
writeProblem(w, r, err)
|
||||
p := decodeProblem(w)
|
||||
Expect(p.Status).To(Equal(http.StatusNotFound))
|
||||
Expect(p.Detail).ToNot(BeNil())
|
||||
Expect(*p.Detail).To(ContainSubstring("album 123"))
|
||||
},
|
||||
Entry("fmt.Errorf %w", fmt.Errorf("album 123: %w", model.ErrNotFound)),
|
||||
Entry("errors.Join", errors.Join(errors.New("album 123"), model.ErrNotFound)),
|
||||
)
|
||||
It("shows detail only for errors marked as client-facing", func() {
|
||||
writeProblem(w, r, fmt.Errorf("album 123: %w", model.ErrNotFound))
|
||||
Expect(decodeProblem(w).Detail).To(BeNil())
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
writeProblem(w, r, ClientError(model.ErrNotFound, "album not found"))
|
||||
p := decodeProblem(w)
|
||||
Expect(p.Code).To(Equal(ProblemCodeNotFound))
|
||||
Expect(*p.Detail).To(Equal("album not found"))
|
||||
})
|
||||
|
||||
It("writes field errors from validationFailed", func() {
|
||||
writeProblem(w, r, validationFailed(ValidationError{Field: "currentPassword", Message: "is incorrect"}))
|
||||
p := decodeProblem(w)
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(p.Code).To(Equal(ProblemCodeValidation))
|
||||
Expect(*p.Errors).To(ConsistOf(ValidationError{Field: "currentPassword", Message: "is incorrect"}))
|
||||
})
|
||||
|
||||
It("writes and logs a problem with debug logging on", func() {
|
||||
logs := captureLogs()
|
||||
writeProblem(w, r, model.ErrNotFound)
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
Expect(logs.String()).To(ContainSubstring("code=not_found"))
|
||||
})
|
||||
|
||||
It("adds a Bearer challenge to every 401 unless one is already set", func() {
|
||||
writeProblem(w, r, model.ErrInvalidAuth)
|
||||
Expect(w.Header().Get("WWW-Authenticate")).To(Equal("Bearer"))
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
|
||||
writeProblem(w, r, model.ErrInvalidAuth)
|
||||
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`))
|
||||
})
|
||||
|
||||
It("challenges a 401 with invalid_token when the request carried a bearer token", func() {
|
||||
r.Header.Set("Authorization", "Bearer tok")
|
||||
writeProblem(w, r, model.ErrInvalidAuth)
|
||||
Expect(w.Header().Get("WWW-Authenticate")).To(Equal(`Bearer error="invalid_token"`))
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
r.Header.Set("Authorization", "Basic dXNlcjpwdw==")
|
||||
writeProblem(w, r, model.ErrInvalidAuth)
|
||||
Expect(w.Header().Get("WWW-Authenticate")).To(Equal("Bearer"))
|
||||
})
|
||||
|
||||
It("adds the request's referenceId to internal errors only", func() {
|
||||
r = r.WithContext(withReferenceID(r.Context(), "ref-123"))
|
||||
writeProblem(w, r, errors.New("boom"))
|
||||
Expect(*decodeProblem(w).ReferenceId).To(Equal("ref-123"))
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
writeProblem(w, r, model.ErrNotFound)
|
||||
Expect(decodeProblem(w).ReferenceId).To(BeNil())
|
||||
})
|
||||
|
||||
It("hides details for internal errors", func() {
|
||||
writeProblem(w, r, errors.New("db password is hunter2"))
|
||||
|
|
@ -95,14 +148,19 @@ var _ = Describe("problem", func() {
|
|||
Expect(p.Errors).ToNot(BeNil())
|
||||
Expect(*p.Errors).To(HaveLen(1))
|
||||
Expect((*p.Errors)[0].Field).To(Equal(field))
|
||||
Expect((*p.Errors)[0].Message).To(ContainSubstring(message))
|
||||
Expect((*p.Errors)[0].Message).To(Equal(message))
|
||||
},
|
||||
Entry("required", &RequiredParamError{ParamName: "limit"}, "limit", "is required"),
|
||||
Entry("invalid format", &InvalidParamFormatError{ParamName: "offset", Err: errors.New("not a number")}, "offset", "not a number"),
|
||||
Entry("too many values", &TooManyValuesForParamError{ParamName: "sort", Count: 2}, "sort", "single value"),
|
||||
Entry("unmarshaling", &UnmarshalingParamError{ParamName: "ids", Err: errors.New("bad json")}, "ids", "bad json"),
|
||||
Entry("invalid format", &InvalidParamFormatError{ParamName: "offset", Err: errors.New(`parsing "abc": invalid syntax`)}, "offset", "has an invalid value"),
|
||||
Entry("too many values", &TooManyValuesForParamError{ParamName: "sort", Count: 2}, "sort", "expected a single value"),
|
||||
Entry("unmarshaling", &UnmarshalingParamError{ParamName: "ids", Err: errors.New("bad json")}, "ids", "has an invalid value"),
|
||||
)
|
||||
|
||||
It("never echoes the submitted value", func() {
|
||||
bindingErrorHandler(w, r, &InvalidParamFormatError{ParamName: "offset", Err: errors.New(`parsing "hunter2": invalid syntax`)})
|
||||
Expect(w.Body.String()).ToNot(ContainSubstring("hunter2"))
|
||||
})
|
||||
|
||||
It("still returns a validation problem for unknown binding errors", func() {
|
||||
bindingErrorHandler(w, r, errors.New("weird"))
|
||||
p := decodeProblem(w)
|
||||
|
|
|
|||
29
server/apiv1/reference.go
Normal file
29
server/apiv1/reference.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package apiv1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
)
|
||||
|
||||
type referenceIDKey struct{}
|
||||
|
||||
func withReferenceID(ctx context.Context, ref string) context.Context {
|
||||
return context.WithValue(ctx, referenceIDKey{}, ref)
|
||||
}
|
||||
|
||||
func referenceIDFrom(ctx context.Context) string {
|
||||
ref, _ := ctx.Value(referenceIDKey{}).(string)
|
||||
return ref
|
||||
}
|
||||
|
||||
// referenceIDMiddleware tags every log line of the request with an id that 500 problems also carry.
|
||||
func referenceIDMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ref := id.NewRandom()
|
||||
ctx := log.NewContext(withReferenceID(r.Context(), ref), "referenceId", ref)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
|
@ -18,6 +18,13 @@ func (rt *Router) GetServerInfo(ctx context.Context, _ GetServerInfoRequestObjec
|
|||
ServerVersion: consts.Version,
|
||||
SpecVersion: api.SpecVersion(),
|
||||
SetupRequired: count == 0,
|
||||
LoginMethods: []ServerInfoLoginMethods{ServerInfoLoginMethodsPassword},
|
||||
LoginMethods: LoginMethods{Password: &PasswordLoginMethod{}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (rt *Router) GetCapabilities(context.Context, GetCapabilitiesRequestObject) (GetCapabilitiesResponseObject, error) {
|
||||
return GetCapabilities200JSONResponse{
|
||||
Core: &CoreCapability{Version: 1},
|
||||
Password: &PasswordCapability{Version: 1},
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"net/http/httptest"
|
||||
|
||||
"github.com/navidrome/navidrome/api"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
|
|
@ -43,7 +44,8 @@ var _ = Describe("GET /server", func() {
|
|||
Expect(info.ServerVersion).To(Equal(consts.Version))
|
||||
Expect(info.SpecVersion).To(Equal(api.SpecVersion()))
|
||||
Expect(info.SetupRequired).To(BeTrue())
|
||||
Expect(info.LoginMethods).To(ConsistOf(ServerInfoLoginMethodsPassword))
|
||||
Expect(info.LoginMethods.Password).ToNot(BeNil())
|
||||
Expect(w.Body.String()).To(ContainSubstring(`"loginMethods":{"password":{}}`))
|
||||
})
|
||||
|
||||
It("reports setupRequired=false once a user exists", func() {
|
||||
|
|
@ -59,3 +61,33 @@ var _ = Describe("GET /server", func() {
|
|||
Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInternal))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("GET /capabilities", func() {
|
||||
var ctx context.Context
|
||||
var api testClient
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
resetDB()
|
||||
api = testClient{ctx: ctx, router: New(realDS)}
|
||||
})
|
||||
|
||||
It("needs a grant", func() {
|
||||
w := api.call(http.MethodGet, "/api/v1/capabilities", "", nil)
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("lists core and password for any valid grant, even one with no scopes", func() {
|
||||
api.setup()
|
||||
gc := api.login([]string{})
|
||||
Expect(gc.Grant.Scopes).To(BeEmpty())
|
||||
|
||||
w := api.call(http.MethodGet, "/api/v1/capabilities", gc.Secret, nil)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var caps Capabilities
|
||||
decodeJSON(w, &caps)
|
||||
Expect(caps.Core.Version).To(Equal(1))
|
||||
Expect(caps.Password.Version).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
|
@ -13,7 +13,6 @@ import (
|
|||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
|
|
@ -26,8 +25,6 @@ import (
|
|||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/gravatar"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -127,16 +124,11 @@ func createAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request
|
|||
_ = rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error())
|
||||
return
|
||||
}
|
||||
c, err := ds.User().CountAll(r.Context())
|
||||
if err != nil {
|
||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if c > 0 {
|
||||
_, err = auth.CreateFirstAdmin(r.Context(), ds, username, password, nil)
|
||||
if errors.Is(err, auth.ErrSetupComplete) {
|
||||
_ = rest.RespondWithError(w, http.StatusForbidden, "Cannot create another first admin")
|
||||
return
|
||||
}
|
||||
err = createAdminUser(r.Context(), ds, username, password)
|
||||
if err != nil {
|
||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
|
|
@ -145,26 +137,6 @@ func createAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request
|
|||
}
|
||||
}
|
||||
|
||||
func createAdminUser(ctx context.Context, ds model.DataStore, username, password string) error {
|
||||
log.Warn(ctx, "Creating initial user", "user", username)
|
||||
caser := cases.Title(language.Und)
|
||||
initialUser := model.User{
|
||||
ID: id.NewRandom(),
|
||||
UserName: username,
|
||||
Name: caser.String(username),
|
||||
Email: "",
|
||||
NewPassword: password,
|
||||
IsAdmin: true,
|
||||
LastLoginAt: new(time.Now()),
|
||||
}
|
||||
err := ds.User().Put(ctx, &initialUser)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Could not create initial user", "user", initialUser.UserName, err)
|
||||
return fmt.Errorf("creating initial user: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateLogin(ctx context.Context, userRepo model.UserRepository, userName, password string) (*model.User, error) {
|
||||
u, err := userRepo.FindByUsernameWithPassword(ctx, userName)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
|
|
|
|||
|
|
@ -74,14 +74,27 @@ var _ = Describe("Auth", func() {
|
|||
})
|
||||
})
|
||||
|
||||
Describe("createAdminUser", func() {
|
||||
Describe("CreateFirstAdmin", func() {
|
||||
It("returns the error when the user cannot be saved", func() {
|
||||
ds = &tests.MockDataStore{MockedUser: &tests.MockedUserRepo{Error: errors.New("db is down")}}
|
||||
err := createAdminUser(context.Background(), ds, "johndoe", "secret")
|
||||
failing := dsWithFailingPut(errors.New("db is down"))
|
||||
_, err := auth.CreateFirstAdmin(ctx, failing, "johndoe", "secret", nil)
|
||||
Expect(err).To(MatchError(ContainSubstring("db is down")))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("createAdmin when a user already exists", func() {
|
||||
It("responds 403", func() {
|
||||
req = httptest.NewRequest("POST", "/createAdmin", strings.NewReader(`{"username":"another", "password":"secret"}`))
|
||||
resp = httptest.NewRecorder()
|
||||
Expect(ds.User().Put(ctx, &model.User{UserName: "johndoe", NewPassword: "secret"})).To(Succeed())
|
||||
|
||||
createAdmin(ds)(resp, req)
|
||||
|
||||
Expect(resp.Code).To(Equal(http.StatusForbidden))
|
||||
Expect(resp.Body.String()).To(ContainSubstring("Cannot create another first admin"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("createAdmin when the user cannot be stored", func() {
|
||||
It("responds 500 rather than falling through to login", func() {
|
||||
failing := dsWithFailingPut(errors.New("db is down"))
|
||||
|
|
|
|||
|
|
@ -234,16 +234,21 @@ func trustedProxyPrefixes(list string) []string {
|
|||
|
||||
// ClientIPRateLimiter returns a rate limiter keyed by ClientIP, so spoofed forwarding headers
|
||||
// cannot be rotated for a fresh bucket.
|
||||
func ClientIPRateLimiter(requestLimit int, windowLength time.Duration) func(http.Handler) http.Handler {
|
||||
func ClientIPRateLimiter(requestLimit int, windowLength time.Duration, opts ...httprate.Option) func(http.Handler) http.Handler {
|
||||
return httprate.LimitBy(requestLimit, windowLength, func(r *http.Request) (string, error) {
|
||||
return ClientIP(r), nil
|
||||
})
|
||||
}, opts...)
|
||||
}
|
||||
|
||||
// ClientIP returns the canonical client IP resolved by realIPMiddleware, for keying rate limits. The
|
||||
// peer address fallback degrades a missing middleware to per-peer limiting, not one shared bucket.
|
||||
func ClientIP(r *http.Request) string {
|
||||
return httprate.CanonicalizeIP(cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r)))
|
||||
return httprate.CanonicalizeIP(ClientAddr(r))
|
||||
}
|
||||
|
||||
// ClientAddr returns the client IP resolved by realIPMiddleware unmasked, for recording who made a request.
|
||||
func ClientAddr(r *http.Request) string {
|
||||
return cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r))
|
||||
}
|
||||
|
||||
// reqToCtx creates a middleware that updates the request's context with a value computed from the request. A given key
|
||||
|
|
|
|||
|
|
@ -494,6 +494,35 @@ var _ = Describe("middlewares", func() {
|
|||
})
|
||||
})
|
||||
|
||||
Describe("ClientAddr", func() {
|
||||
var ctx context.Context
|
||||
var addr, ip string
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
conf.Server.ExtAuth.TrustedSources = "10.0.0.0/8"
|
||||
})
|
||||
call := func(h http.Handler, peer, xff string) {
|
||||
r := httptest.NewRequestWithContext(ctx, "POST", "/auth/login", nil)
|
||||
r.RemoteAddr = peer
|
||||
r.Header.Set("X-Forwarded-For", xff)
|
||||
h.ServeHTTP(httptest.NewRecorder(), r)
|
||||
}
|
||||
capture := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
addr, ip = ClientAddr(r), ClientIP(r)
|
||||
})
|
||||
|
||||
It("returns the full resolved IPv6 address, while ClientIP keeps the /64 for rate limiting", func() {
|
||||
call(realIPMiddleware(capture), "10.0.0.1:1234", "2001:db8:1:2:3:4:5:6")
|
||||
Expect(addr).To(Equal("2001:db8:1:2:3:4:5:6"))
|
||||
Expect(ip).To(Equal("2001:db8:1:2::"))
|
||||
})
|
||||
|
||||
It("falls back to the peer host without the middleware", func() {
|
||||
call(capture, "[2001:db8:1:2:3:4:5:6]:1234", "")
|
||||
Expect(addr).To(Equal("2001:db8:1:2:3:4:5:6"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ClientIPRateLimiter", func() {
|
||||
var handler http.Handler
|
||||
JustBeforeEach(func() {
|
||||
|
|
@ -520,6 +549,12 @@ var _ = Describe("middlewares", func() {
|
|||
Entry("True-Client-IP", "True-Client-IP"),
|
||||
)
|
||||
|
||||
It("sends the X-RateLimit headers by default", func() {
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, httptest.NewRequestWithContext(GinkgoT().Context(), "POST", "/auth/login", nil))
|
||||
Expect(w.Header().Get("X-RateLimit-Limit")).To(Equal("2"))
|
||||
})
|
||||
|
||||
Context("behind a trusted proxy", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ExtAuth.TrustedSources = "10.0.0.0/8"
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ type MockDataStore struct {
|
|||
MockedPlugin model.PluginRepository
|
||||
MockedArtwork model.ArtworkRepository
|
||||
MockedArtworkQueue model.ArtworkQueueRepository
|
||||
MockedGrant model.GrantRepository
|
||||
scrobbleBufferMu sync.Mutex
|
||||
repoMu sync.Mutex
|
||||
|
||||
|
|
@ -321,6 +322,19 @@ func (db *MockDataStore) ArtworkQueue() model.ArtworkQueueRepository {
|
|||
return db.MockedArtworkQueue
|
||||
}
|
||||
|
||||
func (db *MockDataStore) Grant() model.GrantRepository {
|
||||
db.repoMu.Lock()
|
||||
defer db.repoMu.Unlock()
|
||||
if db.MockedGrant != nil {
|
||||
return db.MockedGrant
|
||||
}
|
||||
if db.RealDS != nil {
|
||||
return db.RealDS.Grant()
|
||||
}
|
||||
db.MockedGrant = &MockedGrantRepo{}
|
||||
return db.MockedGrant
|
||||
}
|
||||
|
||||
func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error {
|
||||
return block(db)
|
||||
}
|
||||
|
|
|
|||
8
tests/mock_grant_repo.go
Normal file
8
tests/mock_grant_repo.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package tests
|
||||
|
||||
import "github.com/navidrome/navidrome/model"
|
||||
|
||||
// MockedGrantRepo exists so MockDataStore satisfies DataStore; auth tests use a real database.
|
||||
type MockedGrantRepo struct {
|
||||
model.GrantRepository
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue