mirror of
https://github.com/apidoorman/doorman.git
synced 2026-02-11 03:58:39 -06:00
fixes and auth not required
This commit is contained in:
@@ -33,6 +33,9 @@ class CreateApiModel(BaseModel):
|
||||
|
||||
# Public access
|
||||
api_public: Optional[bool] = Field(False, description="If true, this API can be called without authentication or subscription")
|
||||
|
||||
# Auth requirement (non-public)
|
||||
api_auth_required: Optional[bool] = Field(True, description="If true (default), JWT auth is required for this API when not public. If false, requests may be unauthenticated but must meet other checks as configured.")
|
||||
|
||||
api_id: Optional[str] = Field(None, description="Unique identifier for the API, auto-generated", example=None)
|
||||
api_path: Optional[str] = Field(None, description="Unique path for the API, auto-generated", example=None)
|
||||
|
||||
@@ -35,5 +35,8 @@ class UpdateApiModel(BaseModel):
|
||||
# Public access
|
||||
api_public: Optional[bool] = Field(None, description="If true, this API can be called without authentication or subscription")
|
||||
|
||||
# Auth requirement (non-public)
|
||||
api_auth_required: Optional[bool] = Field(None, description="If true (default), JWT auth is required for this API when not public. If false, requests may be unauthenticated but must meet other checks as configured.")
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@@ -116,20 +116,27 @@ async def gateway(request: Request, path: str):
|
||||
request_id = str(uuid.uuid4())
|
||||
start_time = time.time() * 1000
|
||||
try:
|
||||
# Identify API, check if public to bypass auth/subscription/group/limits
|
||||
# Identify API, check if public or auth_required to decide gates
|
||||
parts = [p for p in (path or '').split('/') if p]
|
||||
api_public = False
|
||||
api_auth_required = True
|
||||
if len(parts) >= 2 and parts[1].startswith('v') and parts[1][1:].isdigit():
|
||||
api_key = doorman_cache.get_cache('api_id_cache', f"/{parts[0]}/{parts[1]}")
|
||||
api = await api_util.get_api(api_key, f"/{parts[0]}/{parts[1]}")
|
||||
api_public = bool(api.get('api_public')) if api else False
|
||||
api_auth_required = bool(api.get('api_auth_required')) if api and api.get('api_auth_required') is not None else True
|
||||
username = None
|
||||
if not api_public:
|
||||
await subscription_required(request)
|
||||
await group_required(request)
|
||||
await limit_and_throttle(request)
|
||||
payload = await auth_required(request)
|
||||
username = payload.get("sub")
|
||||
if api_auth_required:
|
||||
await subscription_required(request)
|
||||
await group_required(request)
|
||||
await limit_and_throttle(request)
|
||||
payload = await auth_required(request)
|
||||
username = payload.get("sub")
|
||||
else:
|
||||
# Unauthenticated mode: require at least client-key for routing if present; skip auth/sub/group/limits
|
||||
# You can add custom checks here (e.g., IP allowlist) if needed
|
||||
pass
|
||||
logger.info(f"{request_id} | Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S:%f')[:-3]}ms")
|
||||
logger.info(f"{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}")
|
||||
logger.info(f"{request_id} | Endpoint: {request.method} {str(request.url.path)}")
|
||||
@@ -196,17 +203,22 @@ async def soap_gateway(request: Request, path: str):
|
||||
try:
|
||||
parts = [p for p in (path or '').split('/') if p]
|
||||
api_public = False
|
||||
api_auth_required = True
|
||||
if len(parts) >= 2 and parts[1].startswith('v') and parts[1][1:].isdigit():
|
||||
api_key = doorman_cache.get_cache('api_id_cache', f"/{parts[0]}/{parts[1]}")
|
||||
api = await api_util.get_api(api_key, f"/{parts[0]}/{parts[1]}")
|
||||
api_public = bool(api.get('api_public')) if api else False
|
||||
api_auth_required = bool(api.get('api_auth_required')) if api and api.get('api_auth_required') is not None else True
|
||||
username = None
|
||||
if not api_public:
|
||||
await subscription_required(request)
|
||||
await group_required(request)
|
||||
await limit_and_throttle(request)
|
||||
payload = await auth_required(request)
|
||||
username = payload.get("sub")
|
||||
if api_auth_required:
|
||||
await subscription_required(request)
|
||||
await group_required(request)
|
||||
await limit_and_throttle(request)
|
||||
payload = await auth_required(request)
|
||||
username = payload.get("sub")
|
||||
else:
|
||||
pass
|
||||
logger.info(f"{request_id} | Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S:%f')[:-3]}ms")
|
||||
logger.info(f"{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}")
|
||||
logger.info(f"{request_id} | Endpoint: {request.method} {str(request.url.path)}")
|
||||
@@ -271,18 +283,22 @@ async def graphql_gateway(request: Request, path: str):
|
||||
try:
|
||||
if not request.headers.get('X-API-Version'):
|
||||
raise HTTPException(status_code=400, detail="X-API-Version header is required")
|
||||
# Determine public visibility for this API
|
||||
# Determine public visibility and auth requirement for this API
|
||||
api_name = re.sub(r"^.*/", "",request.url.path)
|
||||
api_key = doorman_cache.get_cache('api_id_cache', api_name + '/' + request.headers.get('X-API-Version', 'v0'))
|
||||
api = await api_util.get_api(api_key, api_name + '/' + request.headers.get('X-API-Version', 'v0'))
|
||||
api_public = bool(api.get('api_public')) if api else False
|
||||
api_auth_required = bool(api.get('api_auth_required')) if api and api.get('api_auth_required') is not None else True
|
||||
username = None
|
||||
if not api_public:
|
||||
await subscription_required(request)
|
||||
await group_required(request)
|
||||
await limit_and_throttle(request)
|
||||
payload = await auth_required(request)
|
||||
username = payload.get("sub")
|
||||
if api_auth_required:
|
||||
await subscription_required(request)
|
||||
await group_required(request)
|
||||
await limit_and_throttle(request)
|
||||
payload = await auth_required(request)
|
||||
username = payload.get("sub")
|
||||
else:
|
||||
pass
|
||||
logger.info(f"{request_id} | Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S:%f')[:-3]}ms")
|
||||
logger.info(f"{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}")
|
||||
logger.info(f"{request_id} | Endpoint: {request.method} {str(request.url.path)}")
|
||||
|
||||
@@ -93,3 +93,37 @@ async def test_public_api_bypasses_credits_check(client, authed_client):
|
||||
r = await client.get(f"/api/rest/{name}/{ver}/ping")
|
||||
# Should not 401 due to credits check since API is public
|
||||
assert r.status_code != 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_not_required_but_not_public(client, authed_client):
|
||||
name, ver = "noauthsub", "v1"
|
||||
# Create API with auth not required but not public
|
||||
cr = await authed_client.post(
|
||||
"/platform/api",
|
||||
json={
|
||||
"api_name": name,
|
||||
"api_version": ver,
|
||||
"api_description": "Auth not required",
|
||||
"api_servers": ["http://upstream.invalid"],
|
||||
"api_type": "REST",
|
||||
"api_public": False,
|
||||
"api_auth_required": False,
|
||||
},
|
||||
)
|
||||
assert cr.status_code in (200, 201), cr.text
|
||||
ce = await authed_client.post(
|
||||
"/platform/endpoint",
|
||||
json={
|
||||
"api_name": name,
|
||||
"api_version": ver,
|
||||
"endpoint_method": "GET",
|
||||
"endpoint_uri": "/ping",
|
||||
"endpoint_description": "ping",
|
||||
},
|
||||
)
|
||||
assert ce.status_code in (200, 201), ce.text
|
||||
|
||||
# Can call without auth, but not public; our current behavior allows it without JWT
|
||||
r = await client.get(f"/api/rest/{name}/{ver}/ping")
|
||||
assert r.status_code in (200, 400, 404, 429, 500)
|
||||
|
||||
@@ -6,6 +6,7 @@ import Link from 'next/link'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import Layout from '@/components/Layout'
|
||||
import { fetchJson, getCookie } from '@/utils/http'
|
||||
import { putJson } from '@/utils/api'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
import { SERVER_URL } from '@/utils/config'
|
||||
import InfoTooltip from '@/components/InfoTooltip'
|
||||
@@ -52,6 +53,8 @@ interface UpdateApiData {
|
||||
api_credits_enabled?: boolean
|
||||
api_credit_group?: string
|
||||
api_public?: boolean
|
||||
api_auth_required?: boolean
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
const ApiDetailPage = () => {
|
||||
@@ -170,7 +173,9 @@ const ApiDetailPage = () => {
|
||||
api_allowed_headers: [...(parsedApi.api_allowed_headers || [])],
|
||||
api_credits_enabled: parsedApi.api_credits_enabled,
|
||||
api_credit_group: parsedApi.api_credit_group,
|
||||
api_public: (parsedApi as any).api_public
|
||||
api_public: (parsedApi as any).api_public,
|
||||
api_auth_required: (parsedApi as any).api_auth_required,
|
||||
active: (parsedApi as any).active
|
||||
})
|
||||
setLoading(false)
|
||||
} catch (err) {
|
||||
@@ -203,7 +208,9 @@ const ApiDetailPage = () => {
|
||||
api_allowed_headers: [...(found.api_allowed_headers || [])],
|
||||
api_credits_enabled: found.api_credits_enabled,
|
||||
api_credit_group: found.api_credit_group,
|
||||
api_public: (found as any).api_public
|
||||
api_public: (found as any).api_public,
|
||||
api_auth_required: (found as any).api_auth_required,
|
||||
active: (found as any).active
|
||||
})
|
||||
setError(null)
|
||||
} else {
|
||||
@@ -294,28 +301,22 @@ const ApiDetailPage = () => {
|
||||
|
||||
const targetName = (api?.['api_name'] as string) || ''
|
||||
const targetVersion = (api?.['api_version'] as string) || ''
|
||||
const response = await fetch(`${SERVER_URL}/platform/api/${encodeURIComponent(targetName)}/${encodeURIComponent(targetVersion)}`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(editData)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.detail || 'Failed to update API')
|
||||
}
|
||||
await putJson(`${SERVER_URL}/platform/api/${encodeURIComponent(targetName)}/${encodeURIComponent(targetVersion)}`, editData)
|
||||
|
||||
// Refresh from server to get the latest canonical data
|
||||
if (!api) throw new Error('API context missing for refresh')
|
||||
const name = (api as any).api_name as string
|
||||
const version = (api as any).api_version as string
|
||||
const refreshedApi = await fetchJson(`${SERVER_URL}/platform/api/${encodeURIComponent(name)}/${encodeURIComponent(version)}`)
|
||||
setApi(refreshedApi)
|
||||
sessionStorage.setItem('selectedApi', JSON.stringify(refreshedApi))
|
||||
try {
|
||||
if (!api) throw new Error('API context missing for refresh')
|
||||
const name = (api as any).api_name as string
|
||||
const version = (api as any).api_version as string
|
||||
const refreshedApi = await fetchJson(`${SERVER_URL}/platform/api/${encodeURIComponent(name)}/${encodeURIComponent(version)}`)
|
||||
setApi(refreshedApi)
|
||||
sessionStorage.setItem('selectedApi', JSON.stringify(refreshedApi))
|
||||
} catch (e) {
|
||||
// Fallback: optimistically merge editData into current API to avoid a confusing error on first save
|
||||
const merged = { ...(api as any), ...(editData as any) }
|
||||
setApi(merged as any)
|
||||
sessionStorage.setItem('selectedApi', JSON.stringify(merged))
|
||||
}
|
||||
// Persist current protobuf preference
|
||||
try {
|
||||
const { setUseProtobuf } = await import('@/utils/proto')
|
||||
@@ -791,6 +792,26 @@ const ApiDetailPage = () => {
|
||||
)}
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1">Use with care. Authentication, subscriptions, and group checks are skipped.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Auth Required</label>
|
||||
{isEditing ? (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!(editData as any).api_auth_required}
|
||||
onChange={(e) => handleInputChange('api_auth_required' as any, e.target.checked)}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label className="ml-2 text-sm text-gray-700 dark:text-gray-300">Require platform auth (JWT) for this API</label>
|
||||
</div>
|
||||
) : (
|
||||
<span className={`badge ${((api as any).api_auth_required ?? true) ? 'badge-primary' : 'badge-secondary'}`}>
|
||||
{((api as any).api_auth_required ?? true) ? 'Auth Required' : 'No Auth'}
|
||||
</span>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">If disabled (and not public), unauthenticated requests are accepted. Subscription/group checks don’t apply without an authenticated user.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Credits Enabled
|
||||
|
||||
@@ -28,6 +28,7 @@ const AddApiPage = () => {
|
||||
api_credits_enabled: false,
|
||||
api_credit_group: '',
|
||||
active: true,
|
||||
api_auth_required: true,
|
||||
// Frontend-only preference; stored in localStorage per API
|
||||
use_protobuf: false,
|
||||
// kept for future use; backend ignores unknown fields
|
||||
@@ -300,6 +301,26 @@ const AddApiPage = () => {
|
||||
<FormHelp docHref="/docs/using-fields.html#api-config">Set credits, auth header mapping, and validations.</FormHelp>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Auth Required
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="api_auth_required"
|
||||
name="api_auth_required"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
checked={(formData as any).api_auth_required}
|
||||
onChange={handleChange}
|
||||
disabled={loading}
|
||||
/>
|
||||
<label htmlFor="api_auth_required" className="ml-2 block text-sm text-gray-700 dark:text-gray-300">
|
||||
Require platform auth (JWT) for this API
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Disable to accept unauthenticated requests. Not public — but subscription/group checks are skipped without auth.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
|
||||
@@ -5,24 +5,37 @@ export function getCookie(name: string): string | null {
|
||||
}
|
||||
|
||||
export async function fetchJson<T = any>(url: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
...(init.headers as any)
|
||||
}
|
||||
const csrf = getCookie('csrf_token')
|
||||
if (csrf) headers['X-CSRF-Token'] = csrf
|
||||
const attempt = async (): Promise<T> => {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
...(init.headers as any)
|
||||
}
|
||||
const csrf = getCookie('csrf_token')
|
||||
if (csrf) headers['X-CSRF-Token'] = csrf
|
||||
|
||||
const resp = await fetch(url, {
|
||||
credentials: 'include',
|
||||
...init,
|
||||
headers
|
||||
})
|
||||
const data = await resp.json().catch(() => ({}))
|
||||
const unwrapped = (data && typeof data === 'object' && 'response' in data) ? data.response : data
|
||||
if (!resp.ok) {
|
||||
const msg = (unwrapped && (unwrapped.error_message || unwrapped.message)) || resp.statusText
|
||||
throw new Error(msg)
|
||||
const resp = await fetch(url, {
|
||||
credentials: 'include',
|
||||
...init,
|
||||
headers
|
||||
})
|
||||
const data = await resp.json().catch(() => ({}))
|
||||
const unwrapped = (data && typeof data === 'object' && 'response' in data) ? (data as any).response : data
|
||||
if (!resp.ok) {
|
||||
const msg = (unwrapped && (unwrapped.error_message || unwrapped.message)) || resp.statusText
|
||||
throw new Error(msg)
|
||||
}
|
||||
return unwrapped as T
|
||||
}
|
||||
|
||||
// Try once; on transient failure for GET, retry quickly once
|
||||
try {
|
||||
return await attempt()
|
||||
} catch (e) {
|
||||
const method = (init.method || 'GET').toString().toUpperCase()
|
||||
if (method === 'GET') {
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
return await attempt()
|
||||
}
|
||||
throw e
|
||||
}
|
||||
return unwrapped as T
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user