A pass over the auth surface found a cluster of related issues that this
commit closes together.
Login (routes/auth.py):
- Add an in-memory per-IP login rate limiter. Five failed POSTs to
/api/auth/login inside a 60s window produce 429 with Retry-After.
A successful login clears the bucket. Previously 30 wrong passwords
in under one second was accepted as 30x 401, which combined with
the (now fixed) admin-username leak from /api/auth/status made
brute-force trivial against a small password.
Logout (routes/auth.py):
- New POST /api/auth/logout returns 204 and calls
storage.revoke_user_refresh_tokens(subject) so the refresh token
is no longer valid. Previously POST /api/auth/logout returned 405
and there was no way to invalidate refresh tokens short of
changing the password. Frontend session.ts already calls
clearAuthTokens() to drop localStorage; the new endpoint lets the
client also tell the server to revoke server-side state.
Refresh-token rotation (routes/auth.py + auth/storage.py):
- New storage.consume_refresh_token(token) atomically validates +
deletes a refresh token, returning (username, is_desktop). The
/api/auth/refresh handler now mints both a new access AND a new
refresh token; the supplied token becomes invalid. Replaying a
consumed refresh returns 401 "Invalid or expired refresh token".
The previous refresh_access_token helper is left in place for
callers that intentionally want the non-rotating shape; nothing
in the route layer uses it now.
/api/auth/status no longer leaks default_username (models/auth.py +
routes/auth.py):
- AuthStatusResponse.default_username becomes Optional[str] with a
None default; the handler always returns None. The frontend already
hardcodes HIDDEN_LOGIN_USERNAME = "unsloth" (auth-form.tsx:82), so
no UI change is required.
window.__UNSLOTH_BOOTSTRAP__ no longer auto-injects (main.py):
- _inject_bootstrap is now opt-in via the
UNSLOTH_STUDIO_INJECT_BOOTSTRAP env var. The previous default
(inject whenever requires_password_change is true) embedded the
plaintext bootstrap password into the first-boot HTML for any
caller that hit /, /change-password, or any unknown SPA path.
Browser extensions and any XSS payload on the page could read it
trivially. With the new gate the bootstrap password lives only in
the auth/.bootstrap_password file (mode 0o600) where it has always
been; users typing it into a current-password field is the right
UX. routes/auth.py:change_password also clears
app.state.bootstrap_password defensively.
Security headers + server fingerprint (main.py + run.py):
- New SecurityHeadersMiddleware adds Content-Security-Policy,
X-Frame-Options: DENY, X-Content-Type-Options: nosniff,
Referrer-Policy: no-referrer,
Permissions-Policy: camera=(), microphone=(), geolocation=(),
interest-cohort=(), and stamps server: unsloth-studio so the
generic uvicorn banner no longer fingerprints the stack. The
uvicorn.Config gains server_header=False so it stops emitting its
own Server header.
/api/health minimisation (main.py):
- Unauthenticated GET /api/health returns just
{"status":"healthy","timestamp":...} so load-balancer liveness
probes keep working without leaking version, device_type,
chat_only, desktop_protocol_version, or studio_root_id to
arbitrary callers. A request that presents a valid Bearer token
still gets the full diagnostic payload so internal launchers and
sibling-Studio detection (which compares studio_root_id) keep
working.
Verification:
- 30 wrong-password POSTs to /api/auth/login -> first 5 = 401, 6th
through 30th = 429.
- POST /api/auth/logout with a fresh token -> 204. The matching
refresh token then fails 401.
- Login -> R1; /api/auth/refresh with R1 -> new access + R2 (R2 !=
R1); /api/auth/refresh with R1 again -> 401; /api/auth/refresh
with R2 -> still succeeds once and rotates again.
- curl /api/auth/status -> default_username: null.
- curl http://127.0.0.1/ does not contain __UNSLOTH_BOOTSTRAP__.
- curl -I / shows CSP, X-Frame-Options: DENY,
X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer,
Permissions-Policy, and server: unsloth-studio.
- curl /api/health unauthenticated -> {status, timestamp} only.
curl with Authorization: Bearer <valid> -> full payload.
- Existing /api/system, /api/models/list, /api/train/status,
/api/inference/status, /api/auth/api-keys, login flow, SPA root
all still return 200 after the changes (regression smoke).
104 lines
3.1 KiB
Python
104 lines
3.1 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""
|
|
Pydantic schemas for Authentication API
|
|
"""
|
|
|
|
from typing import Optional
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
class AuthLoginRequest(BaseModel):
|
|
"""Login payload: username/password to obtain a JWT."""
|
|
|
|
username: str = Field(..., description = "Username")
|
|
password: str = Field(..., description = "Password")
|
|
|
|
|
|
class DesktopLoginRequest(BaseModel):
|
|
"""Desktop-only local secret exchange payload."""
|
|
|
|
secret: str = Field(..., description = "Desktop local auth secret")
|
|
|
|
|
|
class RefreshTokenRequest(BaseModel):
|
|
"""Refresh token payload to obtain new access + refresh tokens."""
|
|
|
|
refresh_token: str = Field(
|
|
..., description = "Refresh token from a previous login or refresh"
|
|
)
|
|
|
|
|
|
class AuthStatusResponse(BaseModel):
|
|
"""Indicate whether the seeded admin auth flow is ready."""
|
|
|
|
initialized: bool = Field(
|
|
..., description = "True if the auth database contains a login user"
|
|
)
|
|
default_username: Optional[str] = Field(
|
|
None,
|
|
description = (
|
|
"Default seeded admin username. Returned as None to "
|
|
"unauthenticated callers so /api/auth/status no longer "
|
|
"leaks the admin name (finding 3.3). The frontend hardcodes "
|
|
"the admin name so this is safe to omit."
|
|
),
|
|
)
|
|
requires_password_change: bool = Field(
|
|
...,
|
|
description = "True if the seeded admin must still change the default password",
|
|
)
|
|
|
|
|
|
class ChangePasswordRequest(BaseModel):
|
|
"""Change the current user's password, typically on first login."""
|
|
|
|
current_password: str = Field(
|
|
..., min_length = 8, description = "Existing password for the authenticated user"
|
|
)
|
|
new_password: str = Field(
|
|
..., min_length = 8, description = "Replacement password (minimum 8 characters)"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# API key schemas
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class CreateApiKeyRequest(BaseModel):
|
|
"""Request body to create a new API key."""
|
|
|
|
name: str = Field(..., description = "Human-readable label for this key")
|
|
expires_in_days: Optional[int] = Field(
|
|
None, description = "Number of days until the key expires (None = never)"
|
|
)
|
|
|
|
|
|
class ApiKeyResponse(BaseModel):
|
|
"""Public representation of an API key (never contains the raw key)."""
|
|
|
|
id: int
|
|
name: str
|
|
key_prefix: str = Field(
|
|
..., description = "First 8 characters after sk-unsloth- for display"
|
|
)
|
|
created_at: str
|
|
last_used_at: Optional[str] = None
|
|
expires_at: Optional[str] = None
|
|
is_active: bool
|
|
|
|
|
|
class CreateApiKeyResponse(BaseModel):
|
|
"""Returned once when a key is created -- ``key`` is never shown again."""
|
|
|
|
key: str = Field(..., description = "Full API key (shown once)")
|
|
api_key: ApiKeyResponse
|
|
|
|
|
|
class ApiKeyListResponse(BaseModel):
|
|
"""List of API keys for the authenticated user."""
|
|
|
|
api_keys: list[ApiKeyResponse]
|