Studio: reset-password rotates the credential in place instead of deleting auth.db (#7573)
* reset-password: rotate the admin credential in place instead of deleting auth.db * reset-password: fix the CI callers and error handling for the in-place rotation * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * reset-password: narrow the CI change to the jobs that read .bootstrap_password * reset-password: stop over-claiming what the reset revokes and when it takes effect * auth: bind token issuance to the credential version that was verified * auth: bind credential-creating writes to the version the request authenticated with * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * auth: bind the change-password and workflow-key writes to their own credential version * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * auth: read the credential version inside the transaction that validated it * data-recipe: answer 401 when a reset revokes the credential mid job start * Fix lint blocker and Windows path assertion for PR #7573 Drop the now-unused validate_api_key import from studio/backend/auth/authentication.py. Every call site moved to validate_api_key_with_credential, so the Source lint job's import-hoist gate flagged it as a blocker. The wrapper itself stays in storage.py; test_api_key_expiry.py still exercises it. Make test_run_reexec_forwards_resolved_frontend_on_public_launch compare against str(Path(...)) instead of a POSIX literal. _find_frontend_dist returns a Path, so on Windows the forwarded value is \fake\studio\frontend\dist and the assertion could never pass there. Pre-existing, surfaced by running unsloth_cli/tests on Windows. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han <danielhanchen@gmail.com>
This commit is contained in:
parent
5cebc46124
commit
52609fb890
21 changed files with 806 additions and 295 deletions
|
|
@ -17,7 +17,8 @@ if [ -n "${STUDIO_PERMISSION_FRONTEND:-}" ]; then
|
|||
fi
|
||||
|
||||
mkdir -p "$artifact_dir"
|
||||
unsloth studio reset-password
|
||||
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
|
||||
rm -rf "$studio_home/auth"
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$port" "$@" \
|
||||
>"$server_log" 2>&1 &
|
||||
studio_pid=$!
|
||||
|
|
|
|||
3
.github/workflows/studio-api-smoke.yml
vendored
3
.github/workflows/studio-api-smoke.yml
vendored
|
|
@ -113,7 +113,8 @@ jobs:
|
|||
|
||||
- name: Reset auth + boot Unsloth (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
|
|||
7
.github/workflows/studio-inference-smoke.yml
vendored
7
.github/workflows/studio-inference-smoke.yml
vendored
|
|
@ -127,7 +127,8 @@ jobs:
|
|||
|
||||
- name: Reset auth + boot Unsloth (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
@ -400,7 +401,7 @@ jobs:
|
|||
# tool_policy=None so each request's `enable_tools` field is
|
||||
# honoured.
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
@ -978,7 +979,7 @@ jobs:
|
|||
# response_format requests aren't routed through the agentic
|
||||
# tool loop.
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
|
|||
3
.github/workflows/studio-mac-api-smoke.yml
vendored
3
.github/workflows/studio-mac-api-smoke.yml
vendored
|
|
@ -101,7 +101,8 @@ jobs:
|
|||
|
||||
- name: Reset auth + boot Unsloth (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
|
|||
|
|
@ -126,7 +126,8 @@ jobs:
|
|||
|
||||
- name: Reset auth + boot Unsloth (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
@ -386,7 +387,7 @@ jobs:
|
|||
# tool_policy=None so each request's `enable_tools` field is
|
||||
# honoured.
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
@ -831,7 +832,7 @@ jobs:
|
|||
# response_format requests aren't routed through the agentic
|
||||
# tool loop.
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
|
|||
11
.github/workflows/studio-mac-ui-smoke.yml
vendored
11
.github/workflows/studio-mac-ui-smoke.yml
vendored
|
|
@ -146,7 +146,8 @@ jobs:
|
|||
|
||||
- name: Reset auth + boot Unsloth
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
@ -190,7 +191,7 @@ jobs:
|
|||
# runner's kernel briefly runs out of socket buffers, and (3) a
|
||||
# goto 'interrupted by another navigation' when the SPA auth
|
||||
# guard redirects mid-navigation. The retry FULLY resets Unsloth
|
||||
# (kill, reset-password, reboot, wait /api/health, re-export
|
||||
# (kill, wipe auth, reboot, wait /api/health, re-export
|
||||
# bootstrap pw) before re-running the script. A real test failure
|
||||
# (assertion / timeout) does NOT match any pattern so it bypasses
|
||||
# retry and surfaces immediately.
|
||||
|
|
@ -213,7 +214,7 @@ jobs:
|
|||
echo "::warning::Playwright flake on attempt ${attempt}; resetting Unsloth and retrying..."
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> "logs/studio_retry_${attempt}.log" 2>&1 &
|
||||
STUDIO_PID=$!
|
||||
|
|
@ -251,7 +252,7 @@ jobs:
|
|||
|
||||
- name: Reset auth + boot Unsloth for extra UI tests (port 18897)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \
|
||||
> logs/studio_extra.log 2>&1 &
|
||||
|
|
@ -308,7 +309,7 @@ jobs:
|
|||
echo "::warning::Playwright flake on attempt ${attempt}; resetting Unsloth and retrying..."
|
||||
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \
|
||||
> "logs/studio_extra_retry_${attempt}.log" 2>&1 &
|
||||
STUDIO_EXTRA_PID=$!
|
||||
|
|
|
|||
9
.github/workflows/studio-ui-smoke.yml
vendored
9
.github/workflows/studio-ui-smoke.yml
vendored
|
|
@ -115,7 +115,8 @@ jobs:
|
|||
|
||||
- name: Reset auth + boot Unsloth
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
@ -193,7 +194,7 @@ jobs:
|
|||
# warm install we already did) so this adds little wall time.
|
||||
- name: Reset auth + boot Unsloth for extra UI tests (port 18894)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18894 \
|
||||
> logs/studio_extra.log 2>&1 &
|
||||
|
|
@ -253,7 +254,7 @@ jobs:
|
|||
# (RAG embedder + llama.cpp probe) stay hidden from the picker.
|
||||
- name: Reset auth + boot Unsloth for model-config tests (port 18898)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18898 \
|
||||
> logs/studio_modelcfg.log 2>&1 &
|
||||
|
|
@ -299,7 +300,7 @@ jobs:
|
|||
# earlier UI tests. No GGUF -- the bug surface is the composer.
|
||||
- name: Reset auth + boot Unsloth for IME / i18n tests (port 18896)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18896 \
|
||||
> logs/studio_ime.log 2>&1 &
|
||||
|
|
|
|||
|
|
@ -179,7 +179,8 @@ jobs:
|
|||
|
||||
- name: Reset auth + boot Unsloth (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
|
|||
|
|
@ -229,7 +229,8 @@ jobs:
|
|||
|
||||
- name: Reset auth + boot Unsloth (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
@ -573,7 +574,7 @@ jobs:
|
|||
|
||||
- name: Reset auth + boot Unsloth (API-only, default tool policy)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
@ -1074,7 +1075,7 @@ jobs:
|
|||
|
||||
- name: Reset auth + boot Unsloth (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
@ -1546,7 +1547,7 @@ jobs:
|
|||
|
||||
- name: Reset auth + boot Unsloth (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
|
|||
|
|
@ -297,7 +297,8 @@ jobs:
|
|||
|
||||
- name: Reset auth + boot Unsloth
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
|
|
@ -352,7 +353,7 @@ jobs:
|
|||
|
||||
- name: Reset auth + boot Unsloth for extra UI tests (port 18897)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
rm -rf ~/.unsloth/studio/auth
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \
|
||||
> logs/studio_extra.log 2>&1 &
|
||||
|
|
|
|||
|
|
@ -11,11 +11,12 @@ import jwt
|
|||
|
||||
from .storage import (
|
||||
API_KEY_PREFIX,
|
||||
credential_generation,
|
||||
get_jwt_secret,
|
||||
get_user_and_secret,
|
||||
load_jwt_secret,
|
||||
save_refresh_token,
|
||||
validate_api_key,
|
||||
validate_api_key_with_credential,
|
||||
verify_refresh_token,
|
||||
)
|
||||
|
||||
|
|
@ -54,11 +55,14 @@ def create_access_token(
|
|||
expires_delta: Optional[timedelta] = None,
|
||||
*,
|
||||
desktop: bool = False,
|
||||
secret: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Create a signed JWT for the given subject (e.g. username).
|
||||
|
||||
Valid across restarts: the signing secret is stored in SQLite.
|
||||
Valid across restarts: the signing secret is stored in SQLite. Callers that
|
||||
already verified a credential pass ``secret`` so a rotation landing mid-request
|
||||
cannot sign the token with the credential that just replaced it.
|
||||
"""
|
||||
to_encode = {"sub": subject}
|
||||
if desktop:
|
||||
|
|
@ -69,7 +73,7 @@ def create_access_token(
|
|||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(
|
||||
to_encode,
|
||||
_get_secret_for_subject(subject),
|
||||
secret if secret is not None else _get_secret_for_subject(subject),
|
||||
algorithm = ALGORITHM,
|
||||
)
|
||||
|
||||
|
|
@ -96,15 +100,28 @@ def is_desktop_access_token(token: str) -> bool:
|
|||
return payload.get("sub") == subject and payload.get("desktop") is True
|
||||
|
||||
|
||||
def create_refresh_token(subject: str, *, desktop: bool = False) -> str:
|
||||
def create_refresh_token(
|
||||
subject: str,
|
||||
*,
|
||||
desktop: bool = False,
|
||||
secret: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Create a random refresh token, store its hash in SQLite, and return it.
|
||||
|
||||
Refresh tokens are opaque (not JWTs); expire after REFRESH_TOKEN_EXPIRE_DAYS.
|
||||
``secret`` stamps the token with the credential version the caller verified,
|
||||
so a rotation cannot leave a token minted from the replaced credential valid.
|
||||
"""
|
||||
token = secrets.token_urlsafe(48)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days = REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
save_refresh_token(token, subject, expires_at.isoformat(), is_desktop = desktop)
|
||||
save_refresh_token(
|
||||
token,
|
||||
subject,
|
||||
expires_at.isoformat(),
|
||||
is_desktop = desktop,
|
||||
secret_gen = credential_generation(secret) if secret is not None else None,
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
|
|
@ -137,7 +154,22 @@ def reload_secret() -> None:
|
|||
|
||||
async def get_current_subject(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
|
||||
"""Validate JWT and require the password-change flow to be completed."""
|
||||
return await _get_current_subject(
|
||||
subject, _generation = await _get_current_credential(
|
||||
credentials,
|
||||
allow_password_change = False,
|
||||
)
|
||||
return subject
|
||||
|
||||
|
||||
async def get_current_credential(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
) -> Tuple[str, Optional[str]]:
|
||||
"""As get_current_subject, but also returns the credential generation.
|
||||
|
||||
For routes that persist a new credential and must not do so on behalf of one
|
||||
a concurrent reset has revoked.
|
||||
"""
|
||||
return await _get_current_credential(
|
||||
credentials,
|
||||
allow_password_change = False,
|
||||
)
|
||||
|
|
@ -158,10 +190,11 @@ async def get_current_subject_allow_password_change(
|
|||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
) -> str:
|
||||
"""Validate JWT but allow access to the password-change endpoint."""
|
||||
return await _get_current_subject(
|
||||
subject, _generation = await _get_current_credential(
|
||||
credentials,
|
||||
allow_password_change = True,
|
||||
)
|
||||
return subject
|
||||
|
||||
|
||||
# The literal the examples ship with; pasted unedited more often than a revoked key.
|
||||
|
|
@ -179,21 +212,27 @@ def _invalid_api_key_detail(token: str) -> str:
|
|||
return "Invalid or expired API key"
|
||||
|
||||
|
||||
async def _get_current_subject(
|
||||
async def _get_current_credential(
|
||||
credentials: HTTPAuthorizationCredentials, *, allow_password_change: bool
|
||||
) -> str:
|
||||
"""FastAPI dependency: validate the JWT and return the subject. Use on protected routes."""
|
||||
) -> Tuple[str, Optional[str]]:
|
||||
"""Validate the bearer and return ``(subject, credential generation)``.
|
||||
|
||||
The generation is the credential version this request actually authenticated
|
||||
against. Routes that persist new credentials must bind their write to it, or
|
||||
a reset landing mid-request would bless what it just revoked.
|
||||
"""
|
||||
token = credentials.credentials
|
||||
|
||||
# --- API key path (sk-unsloth-...) ---
|
||||
if token.startswith(API_KEY_PREFIX):
|
||||
username = validate_api_key(token)
|
||||
if username is None:
|
||||
verified = validate_api_key_with_credential(token)
|
||||
if verified is None:
|
||||
raise HTTPException(
|
||||
status_code = status.HTTP_401_UNAUTHORIZED,
|
||||
detail = _invalid_api_key_detail(token),
|
||||
)
|
||||
return username
|
||||
username, secret = verified
|
||||
return username, credential_generation(secret)
|
||||
|
||||
# --- JWT path ---
|
||||
subject = _decode_subject_without_verification(token)
|
||||
|
|
@ -224,7 +263,7 @@ async def _get_current_subject(
|
|||
status_code = status.HTTP_403_FORBIDDEN,
|
||||
detail = "Password change required",
|
||||
)
|
||||
return subject
|
||||
return subject, credential_generation(jwt_secret)
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(
|
||||
status_code = status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ def clear_bootstrap_password() -> None:
|
|||
# Removal failed (Windows AV, read-only auth dir). The hash is already
|
||||
# committed, so don't fail the change -- but truncate the file so its
|
||||
# stale plaintext can't be re-seeded by generate_bootstrap_password()
|
||||
# if a later reset-password deletes auth.db and re-validates it.
|
||||
# if auth.db is ever recreated.
|
||||
try:
|
||||
_BOOTSTRAP_PW_PATH.write_text("", encoding = "utf-8")
|
||||
cleared = True
|
||||
|
|
@ -221,6 +221,31 @@ def _hash_token(token: str) -> str:
|
|||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class CredentialRotated(Exception):
|
||||
"""A password reset revoked the credential this request authenticated with."""
|
||||
|
||||
|
||||
def credential_generation(jwt_secret: str) -> str:
|
||||
"""Marker for the credential version a refresh token was issued under.
|
||||
|
||||
Every password change rotates ``jwt_secret``, so a token stamped with the
|
||||
previous one is rejected even if it was inserted after the revoking DELETE.
|
||||
"""
|
||||
return hashlib.sha256(jwt_secret.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _current_secret(conn: sqlite3.Connection, username: str) -> Optional[str]:
|
||||
row = conn.execute(
|
||||
"SELECT jwt_secret FROM auth_user WHERE username = ?", (username,)
|
||||
).fetchone()
|
||||
return row["jwt_secret"] if row else None
|
||||
|
||||
|
||||
def _current_generation(conn: sqlite3.Connection, username: str) -> Optional[str]:
|
||||
secret = _current_secret(conn, username)
|
||||
return credential_generation(secret) if secret is not None else None
|
||||
|
||||
|
||||
def get_connection() -> sqlite3.Connection:
|
||||
"""Get a connection to the auth database, creating tables if needed."""
|
||||
ensure_dir(DB_PATH.parent)
|
||||
|
|
@ -264,7 +289,8 @@ def get_connection() -> sqlite3.Connection:
|
|||
token_hash TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
is_desktop INTEGER NOT NULL DEFAULT 0
|
||||
is_desktop INTEGER NOT NULL DEFAULT 0,
|
||||
secret_gen TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
|
@ -303,6 +329,8 @@ def get_connection() -> sqlite3.Connection:
|
|||
refresh_columns = {row["name"] for row in conn.execute("PRAGMA table_info(refresh_tokens)")}
|
||||
if "is_desktop" not in refresh_columns:
|
||||
conn.execute("ALTER TABLE refresh_tokens ADD COLUMN is_desktop INTEGER NOT NULL DEFAULT 0")
|
||||
if "secret_gen" not in refresh_columns:
|
||||
conn.execute("ALTER TABLE refresh_tokens ADD COLUMN secret_gen TEXT")
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
|
@ -676,12 +704,22 @@ def update_password(
|
|||
new_password: str,
|
||||
*,
|
||||
revoke_refresh_tokens: bool = False,
|
||||
) -> bool:
|
||||
expect_password_hash: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Update password, clear first-login requirement, rotate JWT secret.
|
||||
|
||||
Returns the new JWT secret, or None when nothing was updated. Callers that
|
||||
mint tokens for the caller must sign with the returned secret: re-reading it
|
||||
would pick up a reset that landed between this commit and the mint.
|
||||
|
||||
``revoke_refresh_tokens`` deletes the user's refresh tokens in the SAME
|
||||
transaction: a separate delete could fail after the password commit and
|
||||
leave a pre-change token still able to mint access tokens.
|
||||
|
||||
``expect_password_hash`` makes the write conditional on the credential the
|
||||
caller verified still being current, so a request that checked the old
|
||||
password cannot overwrite a reset that landed while it was in flight.
|
||||
Returns False when the credential moved underneath it.
|
||||
"""
|
||||
from .hashing import hash_password
|
||||
|
||||
|
|
@ -689,6 +727,7 @@ def update_password(
|
|||
jwt_secret = secrets.token_urlsafe(64)
|
||||
conn = get_connection()
|
||||
try:
|
||||
if expect_password_hash is None:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
UPDATE auth_user
|
||||
|
|
@ -697,13 +736,23 @@ def update_password(
|
|||
""",
|
||||
(salt, pwd_hash, jwt_secret, username),
|
||||
)
|
||||
else:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
UPDATE auth_user
|
||||
SET password_salt = ?, password_hash = ?, jwt_secret = ?, must_change_password = 0
|
||||
WHERE username = ? AND password_hash = ?
|
||||
""",
|
||||
(salt, pwd_hash, jwt_secret, username, expect_password_hash),
|
||||
)
|
||||
if revoke_refresh_tokens and cursor.rowcount > 0:
|
||||
conn.execute("DELETE FROM refresh_tokens WHERE username = ?", (username,))
|
||||
conn.commit()
|
||||
if cursor.rowcount > 0:
|
||||
clear_bootstrap_password()
|
||||
clear_desktop_secret()
|
||||
return cursor.rowcount > 0
|
||||
return jwt_secret
|
||||
return None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
|
@ -714,35 +763,49 @@ def save_refresh_token(
|
|||
expires_at: str,
|
||||
*,
|
||||
is_desktop: bool = False,
|
||||
secret_gen: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Store a hashed refresh token with its associated username and expiry.
|
||||
|
||||
``secret_gen`` binds the token to a credential version; it defaults to the
|
||||
current one, and callers that already verified a credential must pass the
|
||||
version they verified rather than let this re-read a rotated one.
|
||||
"""
|
||||
token_hash = _hash_token(token)
|
||||
conn = get_connection()
|
||||
try:
|
||||
if secret_gen is None:
|
||||
secret_gen = _current_generation(conn, username)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO refresh_tokens (token_hash, username, expires_at, is_desktop)
|
||||
VALUES (?, ?, ?, ?)
|
||||
INSERT INTO refresh_tokens (token_hash, username, expires_at, is_desktop, secret_gen)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(token_hash, username, expires_at, int(is_desktop)),
|
||||
(token_hash, username, expires_at, int(is_desktop), secret_gen),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def consume_refresh_token(token: str) -> Optional[Tuple[str, bool]]:
|
||||
def consume_refresh_token(token: str) -> Optional[Tuple[str, bool, str]]:
|
||||
"""Atomically validate-and-delete a refresh token for single-use rotation.
|
||||
|
||||
DELETE RETURNING fuses validate and delete into one statement so two
|
||||
concurrent refresh requests cannot both consume the same token.
|
||||
concurrent refresh requests cannot both consume the same token. Returns
|
||||
``(username, is_desktop, jwt_secret)``; the caller must mint the replacement
|
||||
tokens against that secret so a rotation landing mid-refresh cannot issue a
|
||||
post-rotation session from a pre-rotation token.
|
||||
"""
|
||||
token_hash = _hash_token(token)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
conn = get_connection()
|
||||
try:
|
||||
# One transaction with the delete: an unstamped legacy row has no
|
||||
# generation to compare, so reading the credential after committing would
|
||||
# hand a reset's new secret to a token issued before it.
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
conn.execute(
|
||||
"DELETE FROM refresh_tokens WHERE expires_at < ?",
|
||||
(now,),
|
||||
|
|
@ -751,15 +814,21 @@ def consume_refresh_token(token: str) -> Optional[Tuple[str, bool]]:
|
|||
"""
|
||||
DELETE FROM refresh_tokens
|
||||
WHERE token_hash = ? AND expires_at >= ?
|
||||
RETURNING username, is_desktop
|
||||
RETURNING username, is_desktop, secret_gen
|
||||
""",
|
||||
(token_hash, now),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
if row is None:
|
||||
conn.commit()
|
||||
return None
|
||||
return row["username"], bool(row["is_desktop"])
|
||||
secret = _current_secret(conn, row["username"])
|
||||
conn.commit()
|
||||
if secret is None:
|
||||
return None
|
||||
if row["secret_gen"] is not None and row["secret_gen"] != credential_generation(secret):
|
||||
return None
|
||||
return row["username"], bool(row["is_desktop"]), secret
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
|
@ -783,7 +852,7 @@ def verify_refresh_token(token: str) -> Optional[Tuple[str, bool]]:
|
|||
|
||||
cur = conn.execute(
|
||||
"""
|
||||
SELECT id, username, expires_at, is_desktop FROM refresh_tokens
|
||||
SELECT id, username, expires_at, is_desktop, secret_gen FROM refresh_tokens
|
||||
WHERE token_hash = ?
|
||||
""",
|
||||
(token_hash,),
|
||||
|
|
@ -792,6 +861,13 @@ def verify_refresh_token(token: str) -> Optional[Tuple[str, bool]]:
|
|||
if row is None:
|
||||
return None
|
||||
|
||||
if row["secret_gen"] is not None and row["secret_gen"] != _current_generation(
|
||||
conn, row["username"]
|
||||
):
|
||||
conn.execute("DELETE FROM refresh_tokens WHERE id = ?", (row["id"],))
|
||||
conn.commit()
|
||||
return None
|
||||
|
||||
# Check expiry
|
||||
expires_at = datetime.fromisoformat(row["expires_at"])
|
||||
if datetime.now(timezone.utc) > expires_at:
|
||||
|
|
@ -836,30 +912,41 @@ def create_desktop_secret() -> str:
|
|||
conn.close()
|
||||
|
||||
|
||||
def validate_desktop_secret(raw_secret: str) -> Optional[str]:
|
||||
"""Return the real admin username when the desktop secret matches."""
|
||||
def validate_desktop_secret_with_credential(raw_secret: str) -> Optional[Tuple[str, str]]:
|
||||
"""Validate the desktop secret and return ``(username, jwt_secret)``.
|
||||
|
||||
Both reads share one transaction so the returned secret is the credential
|
||||
version the desktop secret was checked against; a reset landing mid-request
|
||||
then invalidates the tokens minted from it rather than blessing them.
|
||||
"""
|
||||
if not raw_secret.startswith(DESKTOP_SECRET_PREFIX):
|
||||
return None
|
||||
if get_user_and_secret(DEFAULT_ADMIN_USERNAME) is None:
|
||||
return None
|
||||
|
||||
secret_hash = _pbkdf2_desktop_secret(raw_secret)
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
conn.execute("BEGIN")
|
||||
row = conn.execute(
|
||||
"SELECT value FROM app_secrets WHERE key = ?",
|
||||
(_DESKTOP_SECRET_HASH_KEY,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
).fetchone()
|
||||
if row is None or not secrets.compare_digest(row["value"], secret_hash):
|
||||
return None
|
||||
if not secrets.compare_digest(row["value"], secret_hash):
|
||||
jwt_secret = _current_secret(conn, DEFAULT_ADMIN_USERNAME)
|
||||
if jwt_secret is None:
|
||||
return None
|
||||
return DEFAULT_ADMIN_USERNAME
|
||||
return DEFAULT_ADMIN_USERNAME, jwt_secret
|
||||
finally:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
|
||||
|
||||
def validate_desktop_secret(raw_secret: str) -> Optional[str]:
|
||||
"""Return the real admin username when the desktop secret matches."""
|
||||
verified = validate_desktop_secret_with_credential(raw_secret)
|
||||
return verified[0] if verified else None
|
||||
|
||||
|
||||
def clear_desktop_secret() -> None:
|
||||
"""Remove backend-side desktop auth state."""
|
||||
conn = get_connection()
|
||||
|
|
@ -885,6 +972,7 @@ def create_api_key(
|
|||
name: str,
|
||||
expires_at: Optional[str] = None,
|
||||
internal: bool = False,
|
||||
expect_gen: Optional[str] = None,
|
||||
) -> Tuple[str, dict]:
|
||||
"""Create a new API key for *username*.
|
||||
|
||||
|
|
@ -893,6 +981,10 @@ def create_api_key(
|
|||
|
||||
Pass ``internal=True`` for keys minted by workflows (e.g. data-recipe
|
||||
runs) that should not appear in user-facing key listings.
|
||||
|
||||
``expect_gen`` ties the insert to the credential generation the request
|
||||
authenticated under, so a session revoked by a concurrent password reset
|
||||
cannot mint a key that outlives it. Raises ``CredentialRotated`` if it moved.
|
||||
"""
|
||||
raw_key = API_KEY_PREFIX + secrets.token_hex(16)
|
||||
key_hash = _pbkdf2_api_key(raw_key)
|
||||
|
|
@ -901,6 +993,12 @@ def create_api_key(
|
|||
|
||||
conn = get_connection()
|
||||
try:
|
||||
if expect_gen is not None:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
if _current_generation(conn, username) != expect_gen:
|
||||
raise CredentialRotated(
|
||||
"The credential this request authenticated with was revoked."
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO api_keys (username, key_prefix, key_hash, name, created_at, expires_at, is_internal)
|
||||
|
|
@ -989,15 +1087,25 @@ def revoke_internal_api_key(key_id: int) -> bool:
|
|||
|
||||
|
||||
def validate_api_key(raw_key: str) -> Optional[str]:
|
||||
"""Validate *raw_key* and return the owning username, or ``None``.
|
||||
"""Validate *raw_key* and return the owning username, or ``None``."""
|
||||
verified = validate_api_key_with_credential(raw_key)
|
||||
return verified[0] if verified else None
|
||||
|
||||
Also updates ``last_used_at`` on success.
|
||||
|
||||
def validate_api_key_with_credential(raw_key: str) -> Optional[Tuple[str, str]]:
|
||||
"""Validate *raw_key* and return ``(username, jwt_secret)``, or ``None``.
|
||||
|
||||
Also updates ``last_used_at`` on success. The key check and the credential
|
||||
read share one write transaction, so the returned version is the one the key
|
||||
was actually valid under: a reset committing right after cannot have its new
|
||||
generation handed to a request the key it revoked authenticated.
|
||||
"""
|
||||
cache_id = _api_key_cache_id(raw_key)
|
||||
cached_hash = _api_key_hash_cache.get(cache_id)
|
||||
key_hash = cached_hash if cached_hash is not None else _pbkdf2_api_key(raw_key)
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
cur = conn.execute(
|
||||
"SELECT id, username, is_active, expires_at FROM api_keys WHERE key_hash = ?",
|
||||
(key_hash,),
|
||||
|
|
@ -1017,11 +1125,15 @@ def validate_api_key(raw_key: str) -> Optional[str]:
|
|||
expires = datetime.fromisoformat(row["expires_at"])
|
||||
if datetime.now(timezone.utc) > expires:
|
||||
return None
|
||||
secret = _current_secret(conn, row["username"])
|
||||
if secret is None:
|
||||
return None
|
||||
conn.execute(
|
||||
"UPDATE api_keys SET last_used_at = ? WHERE id = ?",
|
||||
(datetime.now(timezone.utc).isoformat(), row["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
return row["username"]
|
||||
return row["username"], secret
|
||||
finally:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ from auth import storage, hashing
|
|||
from auth.authentication import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
get_current_credential,
|
||||
get_current_subject,
|
||||
get_current_subject_allow_password_change,
|
||||
refresh_access_token,
|
||||
|
|
@ -399,7 +400,7 @@ async def login(payload: AuthLoginRequest, request: Request) -> Token:
|
|||
detail = f"Incorrect password. To reset it, run this in your terminal: {_reset_password_command()}",
|
||||
)
|
||||
|
||||
salt, pwd_hash, _jwt_secret, must_change_password = record
|
||||
salt, pwd_hash, jwt_secret, must_change_password = record
|
||||
if not hashing.verify_password(payload.password, salt, pwd_hash):
|
||||
_record_login_failure(key)
|
||||
raise HTTPException(
|
||||
|
|
@ -409,8 +410,10 @@ async def login(payload: AuthLoginRequest, request: Request) -> Token:
|
|||
|
||||
_clear_login_bucket(key)
|
||||
_clear_login_bucket(unknown_key)
|
||||
access_token = create_access_token(subject = payload.username)
|
||||
refresh_token = create_refresh_token(subject = payload.username)
|
||||
# Issue against the credential version just verified, not whatever is in the DB
|
||||
# now: a concurrent reset-password must not hand this login a post-reset session.
|
||||
access_token = create_access_token(subject = payload.username, secret = jwt_secret)
|
||||
refresh_token = create_refresh_token(subject = payload.username, secret = jwt_secret)
|
||||
return Token(
|
||||
access_token = access_token,
|
||||
refresh_token = refresh_token,
|
||||
|
|
@ -438,16 +441,17 @@ async def logout(
|
|||
@router.post("/desktop-login", response_model = Token)
|
||||
async def desktop_login(payload: DesktopLoginRequest) -> Token:
|
||||
"""Exchange a local desktop secret for normal admin-subject tokens."""
|
||||
username = storage.validate_desktop_secret(payload.secret)
|
||||
if username is None:
|
||||
verified = storage.validate_desktop_secret_with_credential(payload.secret)
|
||||
if verified is None:
|
||||
raise HTTPException(
|
||||
status_code = status.HTTP_401_UNAUTHORIZED,
|
||||
detail = "Desktop authentication failed",
|
||||
)
|
||||
username, jwt_secret = verified
|
||||
|
||||
return Token(
|
||||
access_token = create_access_token(subject = username, desktop = True),
|
||||
refresh_token = create_refresh_token(subject = username, desktop = True),
|
||||
access_token = create_access_token(subject = username, desktop = True, secret = jwt_secret),
|
||||
refresh_token = create_refresh_token(subject = username, desktop = True, secret = jwt_secret),
|
||||
token_type = "bearer",
|
||||
must_change_password = False,
|
||||
)
|
||||
|
|
@ -462,9 +466,11 @@ async def refresh(payload: RefreshTokenRequest) -> Token:
|
|||
status_code = status.HTTP_401_UNAUTHORIZED,
|
||||
detail = "Invalid or expired refresh token",
|
||||
)
|
||||
username, is_desktop = consumed
|
||||
new_access_token = create_access_token(subject = username, desktop = is_desktop)
|
||||
new_refresh_token = create_refresh_token(subject = username, desktop = is_desktop)
|
||||
username, is_desktop, jwt_secret = consumed
|
||||
new_access_token = create_access_token(subject = username, desktop = is_desktop, secret = jwt_secret)
|
||||
new_refresh_token = create_refresh_token(
|
||||
subject = username, desktop = is_desktop, secret = jwt_secret
|
||||
)
|
||||
|
||||
return Token(
|
||||
access_token = new_access_token,
|
||||
|
|
@ -507,13 +513,25 @@ async def change_password(
|
|||
|
||||
# Single transaction: a separate refresh-token purge could fail after the
|
||||
# password commit, leaving pre-change tokens able to mint access tokens.
|
||||
storage.update_password(current_subject, payload.new_password, revoke_refresh_tokens = True)
|
||||
# Conditional on the hash just verified: a reset-password that landed while
|
||||
# this request was in flight must not be overwritten by it.
|
||||
new_secret = storage.update_password(
|
||||
current_subject,
|
||||
payload.new_password,
|
||||
revoke_refresh_tokens = True,
|
||||
expect_password_hash = pwd_hash,
|
||||
)
|
||||
if new_secret is None:
|
||||
raise HTTPException(
|
||||
status_code = status.HTTP_409_CONFLICT,
|
||||
detail = "The password changed while this request was in flight. Sign in again.",
|
||||
)
|
||||
try:
|
||||
request.app.state.bootstrap_password = None
|
||||
except AttributeError:
|
||||
pass
|
||||
access_token = create_access_token(subject = current_subject)
|
||||
refresh_token = create_refresh_token(subject = current_subject)
|
||||
access_token = create_access_token(subject = current_subject, secret = new_secret)
|
||||
refresh_token = create_refresh_token(subject = current_subject, secret = new_secret)
|
||||
return Token(
|
||||
access_token = access_token,
|
||||
refresh_token = refresh_token,
|
||||
|
|
@ -541,19 +559,27 @@ def _row_to_api_key_response(row: dict) -> ApiKeyResponse:
|
|||
|
||||
@router.post("/api-keys", response_model = CreateApiKeyResponse)
|
||||
async def create_api_key(
|
||||
payload: CreateApiKeyRequest, current_subject: str = Depends(get_current_subject)
|
||||
payload: CreateApiKeyRequest, credential: tuple = Depends(get_current_credential)
|
||||
) -> CreateApiKeyResponse:
|
||||
"""Create a new API key. The raw key is returned once and cannot be retrieved later."""
|
||||
current_subject, generation = credential
|
||||
expires_at = None
|
||||
if payload.expires_in_days is not None:
|
||||
expires_at = (
|
||||
datetime.now(timezone.utc) + timedelta(days = payload.expires_in_days)
|
||||
).isoformat()
|
||||
|
||||
try:
|
||||
raw_key, row = storage.create_api_key(
|
||||
username = current_subject,
|
||||
name = payload.name,
|
||||
expires_at = expires_at,
|
||||
expect_gen = generation,
|
||||
)
|
||||
except storage.CredentialRotated:
|
||||
raise HTTPException(
|
||||
status_code = status.HTTP_401_UNAUTHORIZED,
|
||||
detail = "Invalid or expired token",
|
||||
)
|
||||
return CreateApiKeyResponse(
|
||||
key = raw_key,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ from datetime import datetime, timedelta, timezone
|
|||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
|
||||
from auth.authentication import get_current_credential
|
||||
from auth.storage import CredentialRotated
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
|
|
@ -257,7 +260,11 @@ def _inject_local_structured_response_format(
|
|||
model_configs.extend(new_configs)
|
||||
|
||||
|
||||
def _inject_local_providers(recipe: dict[str, Any], request: Request) -> Optional[int]:
|
||||
def _inject_local_providers(
|
||||
recipe: dict[str, Any],
|
||||
request: Request,
|
||||
expect_gen: Optional[str] = None,
|
||||
) -> Optional[int]:
|
||||
"""Mutate recipe in-place: point is_local providers at this server and mint
|
||||
a short-lived internal sk-unsloth-* key for workflow auth.
|
||||
|
||||
|
|
@ -313,6 +320,7 @@ def _inject_local_providers(recipe: dict[str, Any], request: Request) -> Optiona
|
|||
name = "data-recipe workflow",
|
||||
expires_at = expires_at,
|
||||
internal = True,
|
||||
expect_gen = expect_gen,
|
||||
)
|
||||
internal_key_id = int(row["id"])
|
||||
|
||||
|
|
@ -375,7 +383,11 @@ def _normalize_run_name(value: Any) -> str | None:
|
|||
|
||||
|
||||
@router.post("/jobs", response_class = JSONResponse, response_model = JobCreateResponse)
|
||||
def create_job(payload: RecipePayload, request: Request):
|
||||
def create_job(
|
||||
payload: RecipePayload,
|
||||
request: Request,
|
||||
credential: tuple = Depends(get_current_credential),
|
||||
):
|
||||
recipe = payload.recipe
|
||||
if not recipe.get("columns"):
|
||||
raise HTTPException(status_code = 400, detail = "Recipe must include columns.")
|
||||
|
|
@ -406,7 +418,11 @@ def create_job(payload: RecipePayload, request: Request):
|
|||
) from exc
|
||||
|
||||
try:
|
||||
internal_api_key_id = _inject_local_providers(recipe, request)
|
||||
internal_api_key_id = _inject_local_providers(recipe, request, credential[1])
|
||||
except CredentialRotated as exc:
|
||||
# A reset-password landed after this request authenticated; the workflow key
|
||||
# is refused, so answer like any other revoked credential rather than 500.
|
||||
raise HTTPException(status_code = 401, detail = "Invalid or expired token") from exc
|
||||
except ValueError as exc:
|
||||
raise log_and_http_error(
|
||||
exc,
|
||||
|
|
|
|||
|
|
@ -1328,7 +1328,8 @@ def _apply_supplied_password(password_value: "Optional[str]") -> None:
|
|||
if not _auth_storage.requires_password_change(_admin):
|
||||
print(
|
||||
"Error: an Unsloth admin password is already set; --password only sets "
|
||||
"the initial password. Run `unsloth studio reset-password` first.",
|
||||
"the initial password. Change it in the UI, or run `unsloth studio "
|
||||
"reset-password` for a new one.",
|
||||
file = sys.stderr,
|
||||
flush = True,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -67,9 +67,11 @@ def test_rejects_password_containing_spaces(_user):
|
|||
|
||||
|
||||
def test_allows_password_without_spaces(_user, monkeypatch):
|
||||
monkeypatch.setattr(auth_routes.storage, "update_password", lambda *args, **kwargs: True)
|
||||
monkeypatch.setattr(auth_routes, "create_access_token", lambda subject: "at")
|
||||
monkeypatch.setattr(auth_routes, "create_refresh_token", lambda subject: "rt")
|
||||
monkeypatch.setattr(
|
||||
auth_routes.storage, "update_password", lambda *args, **kwargs: "rotated-secret"
|
||||
)
|
||||
monkeypatch.setattr(auth_routes, "create_access_token", lambda subject, **kwargs: "at")
|
||||
monkeypatch.setattr(auth_routes, "create_refresh_token", lambda subject, **kwargs: "rt")
|
||||
token = _change("correct-horse-battery")
|
||||
assert token.access_token == "at"
|
||||
assert token.must_change_password is False
|
||||
|
|
|
|||
255
studio/backend/tests/test_credential_rotation_race.py
Normal file
255
studio/backend/tests/test_credential_rotation_race.py
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""A password rotation must not leave a session minted from the replaced credential.
|
||||
|
||||
`unsloth studio reset-password` rotates in place against a live server, so a login
|
||||
can verify the old password, have the rotation land, and only then mint its tokens.
|
||||
Issuance is bound to the credential version that was verified, so such a login gets
|
||||
tokens that are already dead rather than a session that outlives the reset.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
|
||||
from auth import hashing, storage
|
||||
from auth.authentication import ALGORITHM, create_access_token, create_refresh_token
|
||||
|
||||
|
||||
@pytest.fixture(autouse = True)
|
||||
def isolated_auth_db(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(storage, "DB_PATH", tmp_path / "auth.db")
|
||||
monkeypatch.setattr(storage, "_BOOTSTRAP_PW_PATH", tmp_path / ".bootstrap_password")
|
||||
monkeypatch.setattr(storage, "_bootstrap_password", None)
|
||||
monkeypatch.setattr(storage, "_api_key_pbkdf2_salt_cache", None)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin():
|
||||
storage.create_initial_user(
|
||||
username = storage.DEFAULT_ADMIN_USERNAME,
|
||||
password = "old-password-123",
|
||||
jwt_secret = secrets.token_urlsafe(64),
|
||||
)
|
||||
return storage.DEFAULT_ADMIN_USERNAME
|
||||
|
||||
|
||||
def _verified_secret(username):
|
||||
return storage.get_user_and_secret(username)[2]
|
||||
|
||||
|
||||
def test_access_token_from_the_replaced_credential_is_rejected(admin):
|
||||
secret = _verified_secret(admin)
|
||||
|
||||
storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True)
|
||||
token = create_access_token(subject = admin, secret = secret)
|
||||
|
||||
with pytest.raises(jwt.InvalidTokenError):
|
||||
jwt.decode(token, storage.get_jwt_secret(admin), algorithms = [ALGORITHM])
|
||||
|
||||
|
||||
def test_refresh_token_from_the_replaced_credential_is_rejected(admin):
|
||||
secret = _verified_secret(admin)
|
||||
|
||||
# Inserted AFTER the rotation's DELETE, so revocation alone cannot catch it.
|
||||
storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True)
|
||||
token = create_refresh_token(subject = admin, secret = secret)
|
||||
|
||||
assert storage.verify_refresh_token(token) is None
|
||||
assert storage.consume_refresh_token(token) is None
|
||||
|
||||
|
||||
def test_a_rejected_refresh_token_is_dropped(admin):
|
||||
secret = _verified_secret(admin)
|
||||
storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True)
|
||||
token = create_refresh_token(subject = admin, secret = secret)
|
||||
|
||||
storage.verify_refresh_token(token)
|
||||
|
||||
conn = storage.get_connection()
|
||||
try:
|
||||
assert conn.execute("SELECT COUNT(*) AS c FROM refresh_tokens").fetchone()["c"] == 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_tokens_from_the_current_credential_still_work(admin):
|
||||
secret = _verified_secret(admin)
|
||||
|
||||
access = create_access_token(subject = admin, secret = secret)
|
||||
refresh = create_refresh_token(subject = admin, secret = secret)
|
||||
|
||||
jwt.decode(access, storage.get_jwt_secret(admin), algorithms = [ALGORITHM])
|
||||
assert storage.verify_refresh_token(refresh) == (admin, False)
|
||||
|
||||
|
||||
def test_refresh_cannot_outlive_a_rotation_it_raced(admin):
|
||||
# /refresh consumes, then mints. A rotation landing in between must not let
|
||||
# the replacement pair be signed with the credential that just replaced it.
|
||||
secret = _verified_secret(admin)
|
||||
token = create_refresh_token(subject = admin, secret = secret)
|
||||
consumed = storage.consume_refresh_token(token)
|
||||
assert consumed is not None
|
||||
_username, _is_desktop, consumed_secret = consumed
|
||||
|
||||
storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True)
|
||||
access = create_access_token(subject = admin, secret = consumed_secret)
|
||||
refresh = create_refresh_token(subject = admin, secret = consumed_secret)
|
||||
|
||||
with pytest.raises(jwt.InvalidTokenError):
|
||||
jwt.decode(access, storage.get_jwt_secret(admin), algorithms = [ALGORITHM])
|
||||
assert storage.verify_refresh_token(refresh) is None
|
||||
|
||||
|
||||
def test_desktop_login_cannot_outlive_a_rotation_it_raced(admin):
|
||||
# The reset deletes the desktop secret, so a desktop-login that validated it
|
||||
# just beforehand must not mint a session that survives.
|
||||
raw = storage.create_desktop_secret()
|
||||
verified = storage.validate_desktop_secret_with_credential(raw)
|
||||
assert verified is not None
|
||||
_username, verified_secret = verified
|
||||
|
||||
storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True)
|
||||
access = create_access_token(subject = admin, desktop = True, secret = verified_secret)
|
||||
refresh = create_refresh_token(subject = admin, desktop = True, secret = verified_secret)
|
||||
|
||||
with pytest.raises(jwt.InvalidTokenError):
|
||||
jwt.decode(access, storage.get_jwt_secret(admin), algorithms = [ALGORITHM])
|
||||
assert storage.verify_refresh_token(refresh) is None
|
||||
|
||||
|
||||
def test_change_password_cannot_overwrite_a_rotation_it_raced(admin):
|
||||
# A change-password that verified the old hash must not clobber a reset that
|
||||
# committed while it was in flight.
|
||||
_salt, verified_hash, _secret, _must_change = storage.get_user_and_secret(admin)
|
||||
|
||||
storage.update_password(admin, "reset-by-the-cli-789", revoke_refresh_tokens = True)
|
||||
|
||||
assert not storage.update_password(
|
||||
admin,
|
||||
"attacker-chosen-000",
|
||||
revoke_refresh_tokens = True,
|
||||
expect_password_hash = verified_hash,
|
||||
)
|
||||
salt, pwd_hash, _s, _m = storage.get_user_and_secret(admin)
|
||||
assert hashing.verify_password("reset-by-the-cli-789", salt, pwd_hash)
|
||||
|
||||
|
||||
def test_api_key_creation_from_a_revoked_credential_is_refused(admin):
|
||||
generation = storage.credential_generation(_verified_secret(admin))
|
||||
|
||||
storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True)
|
||||
|
||||
with pytest.raises(storage.CredentialRotated):
|
||||
storage.create_api_key(username = admin, name = "k", expect_gen = generation)
|
||||
conn = storage.get_connection()
|
||||
try:
|
||||
assert conn.execute("SELECT COUNT(*) AS c FROM api_keys").fetchone()["c"] == 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_api_key_creation_under_the_current_credential_still_works(admin):
|
||||
generation = storage.credential_generation(_verified_secret(admin))
|
||||
|
||||
raw_key, _row = storage.create_api_key(username = admin, name = "k", expect_gen = generation)
|
||||
|
||||
assert storage.validate_api_key(raw_key) == admin
|
||||
|
||||
|
||||
def test_change_password_tokens_are_bound_to_its_own_write(admin):
|
||||
# The tokens returned to a successful change-password must be signed with the
|
||||
# secret that write produced, not whatever a later reset put in the DB.
|
||||
_salt, verified_hash, _secret, _must = storage.get_user_and_secret(admin)
|
||||
new_secret = storage.update_password(
|
||||
admin,
|
||||
"chosen-by-the-user",
|
||||
revoke_refresh_tokens = True,
|
||||
expect_password_hash = verified_hash,
|
||||
)
|
||||
assert new_secret is not None
|
||||
|
||||
storage.update_password(admin, "reset-by-the-cli-789", revoke_refresh_tokens = True)
|
||||
access = create_access_token(subject = admin, secret = new_secret)
|
||||
refresh = create_refresh_token(subject = admin, secret = new_secret)
|
||||
|
||||
with pytest.raises(jwt.InvalidTokenError):
|
||||
jwt.decode(access, storage.get_jwt_secret(admin), algorithms = [ALGORITHM])
|
||||
assert storage.verify_refresh_token(refresh) is None
|
||||
|
||||
|
||||
def test_internal_api_key_minting_honours_the_request_generation(admin):
|
||||
generation = storage.credential_generation(_verified_secret(admin))
|
||||
storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True)
|
||||
|
||||
with pytest.raises(storage.CredentialRotated):
|
||||
storage.create_api_key(
|
||||
username = admin,
|
||||
name = "data-recipe workflow",
|
||||
internal = True,
|
||||
expect_gen = generation,
|
||||
)
|
||||
|
||||
|
||||
def test_api_key_auth_reports_the_version_the_key_was_valid_under(admin):
|
||||
# The generation must come from the same transaction as the key check, or a
|
||||
# revoked key could hand a route the post-reset generation and mint again.
|
||||
raw, _row = storage.create_api_key(username = admin, name = "agent")
|
||||
verified = storage.validate_api_key_with_credential(raw)
|
||||
assert verified is not None
|
||||
_user, secret = verified
|
||||
generation = storage.credential_generation(secret)
|
||||
|
||||
storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True)
|
||||
conn = storage.get_connection()
|
||||
try:
|
||||
conn.execute("DELETE FROM api_keys")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
assert storage.validate_api_key(raw) is None
|
||||
with pytest.raises(storage.CredentialRotated):
|
||||
storage.create_api_key(username = admin, name = "after", expect_gen = generation)
|
||||
|
||||
|
||||
def test_consuming_a_legacy_token_reports_the_pre_reset_credential(admin):
|
||||
# An unstamped row has no generation to compare, so consume must read the
|
||||
# credential inside the delete transaction rather than after committing it.
|
||||
token = secrets.token_urlsafe(48)
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(days = 7)).isoformat()
|
||||
storage.save_refresh_token(token, admin, expires_at, secret_gen = None)
|
||||
conn = storage.get_connection()
|
||||
try:
|
||||
conn.execute("UPDATE refresh_tokens SET secret_gen = NULL")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
consumed = storage.consume_refresh_token(token)
|
||||
assert consumed is not None
|
||||
_username, _is_desktop, consumed_secret = consumed
|
||||
|
||||
storage.update_password(admin, "new-password-456", revoke_refresh_tokens = True)
|
||||
access = create_access_token(subject = admin, secret = consumed_secret)
|
||||
with pytest.raises(jwt.InvalidTokenError):
|
||||
jwt.decode(access, storage.get_jwt_secret(admin), algorithms = [ALGORITHM])
|
||||
|
||||
|
||||
def test_unstamped_legacy_tokens_still_verify(admin):
|
||||
# Rows written before the secret_gen column existed must not log users out.
|
||||
token = secrets.token_urlsafe(48)
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(days = 7)).isoformat()
|
||||
storage.save_refresh_token(token, admin, expires_at, secret_gen = None)
|
||||
conn = storage.get_connection()
|
||||
try:
|
||||
conn.execute("UPDATE refresh_tokens SET secret_gen = NULL")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
assert storage.verify_refresh_token(token) == (admin, False)
|
||||
|
|
@ -445,7 +445,7 @@ def test_consume_refresh_token_second_call_returns_none():
|
|||
storage.save_refresh_token(raw, storage.DEFAULT_ADMIN_USERNAME, expires)
|
||||
|
||||
first = storage.consume_refresh_token(raw)
|
||||
assert first == (storage.DEFAULT_ADMIN_USERNAME, False)
|
||||
assert first[:2] == (storage.DEFAULT_ADMIN_USERNAME, False)
|
||||
second = storage.consume_refresh_token(raw)
|
||||
assert second is None
|
||||
|
||||
|
|
@ -474,7 +474,7 @@ def test_consume_refresh_token_concurrent_only_one_succeeds(tmp_path, monkeypatc
|
|||
|
||||
successes = [r for r in results if r is not None]
|
||||
assert len(successes) == 1, f"expected exactly one consumer to win, got {len(successes)}"
|
||||
assert successes[0] == (storage.DEFAULT_ADMIN_USERNAME, False)
|
||||
assert successes[0][:2] == (storage.DEFAULT_ADMIN_USERNAME, False)
|
||||
|
||||
|
||||
def test_consume_refresh_token_expired_returns_none():
|
||||
|
|
@ -548,6 +548,28 @@ def test_local_recipe_token_authenticates_as_admin_for_web_user(loaded_local_mod
|
|||
assert asyncio.run(get_current_subject(credentials)) == storage.DEFAULT_ADMIN_USERNAME
|
||||
|
||||
|
||||
def test_rotated_credential_job_start_is_401_not_500(loaded_local_model):
|
||||
# A reset-password landing mid-request makes the workflow-key mint refuse.
|
||||
# That must reach the client as a revoked credential, not an unhandled error.
|
||||
from fastapi import HTTPException
|
||||
|
||||
seed_user()
|
||||
jobs_route = data_recipe_jobs_module()
|
||||
stale_gen = storage.credential_generation(secrets.token_urlsafe(64))
|
||||
|
||||
with pytest.raises(storage.CredentialRotated):
|
||||
jobs_route._inject_local_providers(local_recipe(), local_recipe_request("t"), stale_gen)
|
||||
|
||||
def _boom(*_a, **_k):
|
||||
raise storage.CredentialRotated("revoked")
|
||||
|
||||
jobs_route._inject_local_providers = _boom
|
||||
payload = SimpleNamespace(recipe = local_recipe(), run = {})
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
jobs_route.create_job(payload, local_recipe_request("t"), ("unsloth", stale_gen))
|
||||
assert excinfo.value.status_code == 401
|
||||
|
||||
|
||||
def test_desktop_login_rejects_invalid_secret():
|
||||
seed_user(must_change_password = False)
|
||||
client = auth_client()
|
||||
|
|
@ -580,18 +602,31 @@ def test_reset_password_removes_desktop_secret_files(tmp_path, monkeypatch):
|
|||
from unsloth_cli.commands import studio as studio_cli
|
||||
|
||||
auth_dir = tmp_path / "auth"
|
||||
auth_dir.mkdir()
|
||||
(auth_dir / "auth.db").write_text("db")
|
||||
(auth_dir / ".bootstrap_password").write_text("boot")
|
||||
(auth_dir / ".desktop_secret").write_text("new")
|
||||
monkeypatch.setattr(studio_cli, "STUDIO_HOME", tmp_path)
|
||||
secret = studio_cli._create_desktop_secret_in_cli()
|
||||
studio_cli._write_auth_secret(auth_dir / studio_cli.DESKTOP_SECRET_FILE, secret)
|
||||
(auth_dir / studio_cli.BOOTSTRAP_PASSWORD_FILE).write_text("boot")
|
||||
|
||||
result = CliRunner().invoke(studio_cli.studio_app, ["reset-password"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert not (auth_dir / "auth.db").exists()
|
||||
assert not (auth_dir / ".bootstrap_password").exists()
|
||||
assert not (auth_dir / ".desktop_secret").exists()
|
||||
assert result.exit_code == 0, result.output
|
||||
# The DB survives on purpose: a running server keeps serving from its admin row.
|
||||
assert (auth_dir / "auth.db").exists()
|
||||
assert not (auth_dir / studio_cli.BOOTSTRAP_PASSWORD_FILE).exists()
|
||||
assert not (auth_dir / studio_cli.DESKTOP_SECRET_FILE).exists()
|
||||
|
||||
conn = studio_cli._connect_auth_db()
|
||||
try:
|
||||
surviving = conn.execute(
|
||||
"SELECT COUNT(*) FROM app_secrets WHERE key IN (?, ?)",
|
||||
(
|
||||
studio_cli.DESKTOP_SECRET_HASH_KEY,
|
||||
studio_cli.DESKTOP_SECRET_CREATED_AT_KEY,
|
||||
),
|
||||
).fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
assert surviving == 0
|
||||
|
||||
|
||||
def test_reset_password_removes_desktop_secret_files_without_db(tmp_path, monkeypatch):
|
||||
|
|
@ -846,7 +881,7 @@ def test_update_password_clears_desktop_secret():
|
|||
assert storage.validate_desktop_secret(raw) == storage.DEFAULT_ADMIN_USERNAME
|
||||
|
||||
changed = storage.update_password(storage.DEFAULT_ADMIN_USERNAME, "new-admin-password")
|
||||
assert changed is True
|
||||
assert changed
|
||||
assert storage.validate_desktop_secret(raw) is None
|
||||
|
||||
|
||||
|
|
@ -855,7 +890,7 @@ def test_update_password_on_unknown_user_leaves_desktop_secret_intact():
|
|||
raw = storage.create_desktop_secret()
|
||||
|
||||
changed = storage.update_password("not-a-user", "irrelevant")
|
||||
assert changed is False
|
||||
assert not changed
|
||||
assert storage.validate_desktop_secret(raw) == storage.DEFAULT_ADMIN_USERNAME
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -247,8 +247,8 @@ def test_lifespan_honors_bootstrap_suppression_in_source():
|
|||
def test_clear_bootstrap_password_truncates_when_unlink_fails(monkeypatch, tmp_path):
|
||||
# If the file cannot be unlinked (Windows AV / read-only auth dir), clear must
|
||||
# truncate it so its stale plaintext cannot be re-seeded by
|
||||
# generate_bootstrap_password() after a later reset-password deletes auth.db,
|
||||
# which would re-validate the revoked bootstrap password.
|
||||
# generate_bootstrap_password() if auth.db is ever recreated, which would
|
||||
# re-validate the revoked bootstrap password.
|
||||
import pathlib
|
||||
|
||||
pw_path = tmp_path / ".bootstrap_password"
|
||||
|
|
|
|||
|
|
@ -505,6 +505,8 @@ def _connect_auth_db() -> sqlite3.Connection:
|
|||
auth_dir = STUDIO_HOME / "auth"
|
||||
auth_dir.mkdir(parents = True, exist_ok = True)
|
||||
conn = sqlite3.connect(auth_dir / "auth.db")
|
||||
# A live server writes this DB while the CLI runs; the default lock wait is zero.
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
# Mirror backend storage.get_connection: this path can create auth/ and
|
||||
# auth.db (the pre-exposure gate writes here first), and sqlite3.connect
|
||||
# makes the DB 0644 under a 022 umask. Keep both private.
|
||||
|
|
@ -532,7 +534,8 @@ def _connect_auth_db() -> sqlite3.Connection:
|
|||
token_hash TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
is_desktop INTEGER NOT NULL DEFAULT 0
|
||||
is_desktop INTEGER NOT NULL DEFAULT 0,
|
||||
secret_gen TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
|
@ -567,6 +570,8 @@ def _connect_auth_db() -> sqlite3.Connection:
|
|||
refresh_columns = {row[1] for row in conn.execute("PRAGMA table_info(refresh_tokens)")}
|
||||
if "is_desktop" not in refresh_columns:
|
||||
conn.execute("ALTER TABLE refresh_tokens ADD COLUMN is_desktop INTEGER NOT NULL DEFAULT 0")
|
||||
if "secret_gen" not in refresh_columns:
|
||||
conn.execute("ALTER TABLE refresh_tokens ADD COLUMN secret_gen TEXT")
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
|
@ -700,12 +705,30 @@ def _bootstrap_deadline_active() -> bool:
|
|||
return True
|
||||
|
||||
|
||||
def _cli_update_password(conn: sqlite3.Connection, username: str, new_password: str) -> None:
|
||||
def _generate_reset_password() -> str:
|
||||
"""Readable 4-word passphrase; the user has to type this one back in."""
|
||||
try:
|
||||
import diceware
|
||||
return diceware.get_passphrase(
|
||||
options = diceware.handle_options(args = ["-n", "4", "-d", "", "-c"])
|
||||
)
|
||||
except Exception:
|
||||
return secrets.token_urlsafe(24)
|
||||
|
||||
|
||||
def _cli_update_password(
|
||||
conn: sqlite3.Connection,
|
||||
username: str,
|
||||
new_password: str,
|
||||
*,
|
||||
revoke_api_keys: bool = False,
|
||||
) -> None:
|
||||
"""CLI mirror of backend update_password + change-password route effects.
|
||||
|
||||
One transaction: rehash, rotate the JWT secret, clear must_change_password,
|
||||
revoke refresh tokens (PR #6651 finding), and drop the desktop secret. File
|
||||
cleanup happens after commit; a failed unlink must not roll the change back.
|
||||
revoke refresh tokens (PR #6651 finding), drop the desktop secret, and (for a
|
||||
reset) the API keys the old credential could have minted. File cleanup happens
|
||||
after commit; a failed unlink must not roll the change back.
|
||||
"""
|
||||
password_salt, password_hash = _hash_password(new_password)
|
||||
with conn:
|
||||
|
|
@ -722,6 +745,8 @@ def _cli_update_password(conn: sqlite3.Connection, username: str, new_password:
|
|||
"DELETE FROM app_secrets WHERE key IN (?, ?)",
|
||||
(DESKTOP_SECRET_HASH_KEY, DESKTOP_SECRET_CREATED_AT_KEY),
|
||||
)
|
||||
if revoke_api_keys:
|
||||
conn.execute("DELETE FROM api_keys")
|
||||
for stale in (BOOTSTRAP_PASSWORD_FILE, DESKTOP_SECRET_FILE):
|
||||
stale_path = STUDIO_HOME / "auth" / stale
|
||||
try:
|
||||
|
|
@ -731,8 +756,8 @@ def _cli_update_password(conn: sqlite3.Connection, username: str, new_password:
|
|||
# change back. But a locked-yet-writable file (Windows AV, read-only
|
||||
# auth dir) must be truncated: otherwise its stale plaintext survives
|
||||
# and generate_bootstrap_password() would re-validate this revoked
|
||||
# credential after a later reset-password deletes auth.db. Mirrors
|
||||
# backend clear_bootstrap_password().
|
||||
# credential if auth.db is ever recreated. Mirrors backend
|
||||
# clear_bootstrap_password().
|
||||
try:
|
||||
stale_path.write_text("", encoding = "utf-8")
|
||||
cleared = True
|
||||
|
|
@ -790,8 +815,8 @@ def _apply_supplied_password_before_launch(supplied_password: "str | None") -> N
|
|||
if not row[2]:
|
||||
typer.echo(
|
||||
"Error: an Unsloth admin password is already set; --password only sets "
|
||||
"the initial password. Run `unsloth studio reset-password` first "
|
||||
"(or change it in the UI).",
|
||||
"the initial password. Change it in the UI, or run `unsloth studio "
|
||||
"reset-password` for a new one.",
|
||||
err = True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
|
@ -2893,59 +2918,33 @@ def provision_desktop_auth():
|
|||
def reset_password():
|
||||
"""Reset the Unsloth admin password.
|
||||
|
||||
Deletes the auth database so that a fresh admin account with a new
|
||||
random password is created on the next server start. The Unsloth
|
||||
server must be restarted after running this command.
|
||||
Rotates the credential in place: a running Unsloth accepts the new password on
|
||||
its next request, so there is nothing to restart. Shared /p preview links are
|
||||
not revoked -- rotate those in Settings if the old password leaked.
|
||||
"""
|
||||
auth_dir = STUDIO_HOME / "auth"
|
||||
db_file = auth_dir / "auth.db"
|
||||
stale_files = [
|
||||
auth_dir / BOOTSTRAP_PASSWORD_FILE,
|
||||
auth_dir / DESKTOP_SECRET_FILE,
|
||||
]
|
||||
had_db = db_file.exists()
|
||||
|
||||
# Delete auth.db FIRST and prove it is gone before touching the seeded
|
||||
# credential files. If it cannot be removed (a running Unsloth or Windows
|
||||
# holds it open, or a read-only auth dir), abort with the credential files
|
||||
# untouched: deleting them while an un-resettable DB (must_change_password=1)
|
||||
# survives would lock a forgotten-password reset out of any recovery
|
||||
# credential. Failing here leaves a consistent, still-recoverable state.
|
||||
new_password = _generate_reset_password()
|
||||
try:
|
||||
db_file.unlink(missing_ok = True)
|
||||
except OSError as exc:
|
||||
conn = _connect_auth_db()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
typer.echo(
|
||||
f"Error: could not delete the auth database ({exc}). Stop any running "
|
||||
"Unsloth and retry; no credential files were changed.",
|
||||
f"Error: could not open the auth database ({exc}). Check that "
|
||||
f"{STUDIO_HOME / 'auth'} is writable; if auth.db itself is unreadable, stop "
|
||||
"Unsloth, delete it, and start again to re-seed.",
|
||||
err = True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# The DB is gone, so the next start re-seeds. Invalidate the seeded plaintext
|
||||
# credential files so that re-seed generates a FRESH password instead of
|
||||
# reusing a stale one: unlink only ignores FileNotFoundError, so a
|
||||
# locked/undeletable file (Windows AV, read-only dir) would otherwise survive
|
||||
# and generate_bootstrap_password() would read it back and re-validate the
|
||||
# credential this reset revoked. Truncate on unlink failure; if a file can be
|
||||
# neither removed nor truncated, fail closed -- the DB is already gone, so a
|
||||
# surviving plaintext would be reused, and the user must remove it manually.
|
||||
for path in stale_files:
|
||||
try:
|
||||
path.unlink(missing_ok = True)
|
||||
except OSError:
|
||||
try:
|
||||
path.write_text("", encoding = "utf-8")
|
||||
except OSError as exc:
|
||||
typer.echo(
|
||||
f"Error: could not remove or clear {path.name} ({exc}); delete "
|
||||
"it manually before restarting Unsloth or the old password may "
|
||||
"be reused.",
|
||||
err = True,
|
||||
)
|
||||
_ensure_cli_default_admin(conn)
|
||||
_cli_update_password(conn, DEFAULT_ADMIN_USERNAME, new_password, revoke_api_keys = True)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
typer.echo(f"Error: could not reset the password ({exc}).", err = True)
|
||||
raise typer.Exit(1)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if not had_db:
|
||||
typer.echo("No auth database found -- nothing to reset.")
|
||||
raise typer.Exit(0)
|
||||
|
||||
typer.echo("Auth database deleted. Restart Unsloth Studio to get a new password.")
|
||||
typer.echo(f"New password for '{DEFAULT_ADMIN_USERNAME}': {new_password}")
|
||||
typer.echo(
|
||||
"Sessions and API keys revoked. A running Unsloth takes it on the next request, "
|
||||
"though repeated failed logins can hold the rate limit shut for up to a minute."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -963,7 +963,9 @@ def test_run_reexec_forwards_resolved_frontend_on_public_launch(monkeypatch, tmp
|
|||
|
||||
exec_argv = [argv for kind, argv in events if kind == "exec"][0]
|
||||
assert "--frontend" in exec_argv, exec_argv
|
||||
assert exec_argv[exec_argv.index("--frontend") + 1] == "/fake/studio/frontend/dist", exec_argv
|
||||
# str(Path(...)), not the literal: Windows renders it with backslashes.
|
||||
expected_dist = str(Path("/fake/studio/frontend/dist"))
|
||||
assert exec_argv[exec_argv.index("--frontend") + 1] == expected_dist, exec_argv
|
||||
|
||||
|
||||
def test_run_non_tty_persists_seeded_admin_on_fresh_home(monkeypatch, tmp_path):
|
||||
|
|
@ -1038,50 +1040,173 @@ def test_bootstrap_deadline_active_mirrors_backend_parsing(monkeypatch, raw, exp
|
|||
assert studio_mod._bootstrap_deadline_active() is expected
|
||||
|
||||
|
||||
def test_reset_password_truncates_locked_bootstrap_after_db_delete(monkeypatch, tmp_path):
|
||||
# reset-password deletes auth.db first, then invalidates the seeded credential
|
||||
# files. A locked/undeletable .bootstrap_password must be truncated so its
|
||||
# stale plaintext cannot be re-seeded (generate_bootstrap_password reuses a
|
||||
# non-empty file), while the reset still succeeds.
|
||||
import pathlib
|
||||
|
||||
studio_mod = _studio()
|
||||
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
||||
_seed_auth(studio_mod)
|
||||
auth_dir = tmp_path / "auth"
|
||||
bootstrap_file = auth_dir / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
||||
db_file = auth_dir / "auth.db"
|
||||
assert bootstrap_file.exists() and db_file.exists()
|
||||
assert bootstrap_file.read_text().strip()
|
||||
|
||||
_real_unlink = pathlib.Path.unlink
|
||||
|
||||
def _boom_unlink(self, *a, **k):
|
||||
if self.name == studio_mod.BOOTSTRAP_PASSWORD_FILE:
|
||||
raise OSError("locked")
|
||||
return _real_unlink(self, *a, **k)
|
||||
|
||||
monkeypatch.setattr(pathlib.Path, "unlink", _boom_unlink)
|
||||
|
||||
def _reset_password_cli(studio_mod):
|
||||
import typer as _typer
|
||||
|
||||
app = _typer.Typer()
|
||||
app.command()(studio_mod.reset_password)
|
||||
result = CliRunner().invoke(app, [], catch_exceptions = True)
|
||||
return CliRunner().invoke(app, [], catch_exceptions = True)
|
||||
|
||||
|
||||
def _password_works(studio_mod, candidate):
|
||||
conn = studio_mod._connect_auth_db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT password_salt, password_hash FROM auth_user WHERE username = ?",
|
||||
(studio_mod.DEFAULT_ADMIN_USERNAME,),
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
return studio_mod._pbkdf2_hex(candidate, row[0].encode("utf-8")) == row[1]
|
||||
|
||||
|
||||
def _printed_password(result):
|
||||
line = next(l for l in result.output.splitlines() if l.startswith("New password for"))
|
||||
return line.split(": ", 1)[1].strip()
|
||||
|
||||
|
||||
def test_reset_password_rotates_in_place_without_deleting_the_db(monkeypatch, tmp_path):
|
||||
# The DB survives, so a running server keeps its admin row and the new password.
|
||||
studio_mod = _studio()
|
||||
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
||||
_seed_auth(studio_mod)
|
||||
db_file = tmp_path / "auth" / "auth.db"
|
||||
before = _auth_state(studio_mod)
|
||||
|
||||
result = _reset_password_cli(studio_mod)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert not db_file.exists()
|
||||
# The locked file survives, but truncated -- no reusable plaintext.
|
||||
assert bootstrap_file.exists()
|
||||
assert bootstrap_file.read_text() == ""
|
||||
assert db_file.exists()
|
||||
after = _auth_state(studio_mod)
|
||||
assert after["password_hash"] != before["password_hash"]
|
||||
assert after["jwt_secret"] != before["jwt_secret"]
|
||||
assert _password_works(studio_mod, _printed_password(result))
|
||||
|
||||
|
||||
def test_reset_password_waits_out_a_concurrent_writer(monkeypatch, tmp_path):
|
||||
# The CLI now writes while the server does; without a busy_timeout this fails.
|
||||
import threading
|
||||
import time
|
||||
|
||||
studio_mod = _studio()
|
||||
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
||||
_seed_auth(studio_mod)
|
||||
released = threading.Event()
|
||||
|
||||
def hold_write_lock():
|
||||
conn = sqlite3.connect(_auth_db(tmp_path))
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
conn.execute(
|
||||
"INSERT INTO refresh_tokens (token_hash, username, expires_at) "
|
||||
"VALUES ('held', 'unsloth', '2099-01-01T00:00:00')"
|
||||
)
|
||||
time.sleep(0.5)
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
released.set()
|
||||
|
||||
holder = threading.Thread(target = hold_write_lock)
|
||||
holder.start()
|
||||
time.sleep(0.1)
|
||||
result = _reset_password_cli(studio_mod)
|
||||
holder.join()
|
||||
|
||||
assert released.is_set()
|
||||
assert result.exit_code == 0, result.output
|
||||
assert _password_works(studio_mod, _printed_password(result))
|
||||
|
||||
|
||||
def test_reset_password_revokes_sessions_and_api_keys(monkeypatch, tmp_path):
|
||||
# Deleting auth.db used to drop these implicitly.
|
||||
studio_mod = _studio()
|
||||
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
||||
_seed_auth(studio_mod)
|
||||
conn = studio_mod._connect_auth_db()
|
||||
conn.execute(
|
||||
"INSERT INTO api_keys (username, key_prefix, key_hash, name, created_at) "
|
||||
"VALUES (?, 'sk-x', 'hash', 'k', '2026-01-01T00:00:00')",
|
||||
(studio_mod.DEFAULT_ADMIN_USERNAME,),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
assert _reset_password_cli(studio_mod).exit_code == 0
|
||||
|
||||
conn = studio_mod._connect_auth_db()
|
||||
try:
|
||||
assert conn.execute("SELECT COUNT(*) FROM api_keys").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT COUNT(*) FROM refresh_tokens").fetchone()[0] == 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_reset_password_leaves_the_account_ready_to_log_in(monkeypatch, tmp_path):
|
||||
# must_change_password stays 0 on purpose: at 1 a running server injects its
|
||||
# startup-cached (now wrong) bootstrap password into the login page.
|
||||
studio_mod = _studio()
|
||||
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
||||
_seed_auth(studio_mod)
|
||||
|
||||
assert _reset_password_cli(studio_mod).exit_code == 0
|
||||
|
||||
assert _auth_state(studio_mod)["must_change_password"] == 0
|
||||
assert not (tmp_path / "auth" / studio_mod.BOOTSTRAP_PASSWORD_FILE).exists()
|
||||
|
||||
|
||||
def test_reset_password_seeds_the_admin_when_no_db_exists(monkeypatch, tmp_path):
|
||||
studio_mod = _studio()
|
||||
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
||||
|
||||
result = _reset_password_cli(studio_mod)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert _password_works(studio_mod, _printed_password(result))
|
||||
|
||||
|
||||
def test_reset_password_reports_an_unwritable_auth_dir(monkeypatch, tmp_path):
|
||||
# _connect_auth_db creates auth/ before it opens SQLite, so a read-only Unsloth
|
||||
# home raises OSError, not sqlite3.Error.
|
||||
import pathlib
|
||||
|
||||
studio_mod = _studio()
|
||||
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
||||
|
||||
def _boom_mkdir(self, *a, **k):
|
||||
raise PermissionError("read-only")
|
||||
|
||||
monkeypatch.setattr(pathlib.Path, "mkdir", _boom_mkdir)
|
||||
|
||||
result = _reset_password_cli(studio_mod)
|
||||
|
||||
assert result.exit_code == 1, result.output
|
||||
assert not isinstance(result.exception, OSError)
|
||||
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
||||
assert "could not open the auth database" in combined.lower()
|
||||
|
||||
|
||||
def test_reset_password_reports_an_unreadable_db(monkeypatch, tmp_path):
|
||||
# Deleting a corrupt DB here would revive the bug: a running server would be
|
||||
# left with no admin row, rejecting the correct password until restarted.
|
||||
studio_mod = _studio()
|
||||
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
||||
auth_dir = tmp_path / "auth"
|
||||
auth_dir.mkdir()
|
||||
(auth_dir / "auth.db").write_text("not a database")
|
||||
|
||||
result = _reset_password_cli(studio_mod)
|
||||
|
||||
assert result.exit_code == 1, result.output
|
||||
assert (auth_dir / "auth.db").exists()
|
||||
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
||||
assert "could not open the auth database" in combined.lower()
|
||||
|
||||
|
||||
def test_cli_update_password_truncates_locked_bootstrap_after_change(monkeypatch, tmp_path):
|
||||
# After a CLI/interactive password change the seeded .bootstrap_password is
|
||||
# deleted. If it cannot be unlinked but is still writable (locked file /
|
||||
# read-only dir), it must be TRUNCATED so its stale plaintext cannot be
|
||||
# re-seeded by generate_bootstrap_password() after a later reset-password
|
||||
# deletes auth.db. The change is already committed, so it must NOT roll back.
|
||||
# re-seeded by generate_bootstrap_password() if auth.db is ever recreated. The
|
||||
# change is already committed, so it must NOT roll back.
|
||||
import pathlib
|
||||
|
||||
studio_mod = _studio()
|
||||
|
|
@ -1109,88 +1234,6 @@ def test_cli_update_password_truncates_locked_bootstrap_after_change(monkeypatch
|
|||
assert bootstrap_file.read_text() == ""
|
||||
|
||||
|
||||
def test_reset_password_fails_closed_when_db_cannot_be_deleted(monkeypatch, tmp_path):
|
||||
# If auth.db cannot be removed (running Unsloth / Windows lock, read-only dir),
|
||||
# reset must abort BEFORE touching the credential files -- deleting them while
|
||||
# an un-resettable must_change_password=1 DB survives would lock a
|
||||
# forgotten-password reset out with no recovery credential.
|
||||
import pathlib
|
||||
|
||||
studio_mod = _studio()
|
||||
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
||||
_seed_auth(studio_mod)
|
||||
auth_dir = tmp_path / "auth"
|
||||
bootstrap_file = auth_dir / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
||||
db_file = auth_dir / "auth.db"
|
||||
assert bootstrap_file.exists() and db_file.exists()
|
||||
|
||||
_real_unlink = pathlib.Path.unlink
|
||||
|
||||
def _boom_unlink(self, *a, **k):
|
||||
if self.name == "auth.db":
|
||||
raise OSError("database is locked")
|
||||
return _real_unlink(self, *a, **k)
|
||||
|
||||
monkeypatch.setattr(pathlib.Path, "unlink", _boom_unlink)
|
||||
|
||||
import typer as _typer
|
||||
|
||||
app = _typer.Typer()
|
||||
app.command()(studio_mod.reset_password)
|
||||
result = CliRunner().invoke(app, [], catch_exceptions = True)
|
||||
|
||||
assert result.exit_code == 1, result.output
|
||||
# DB still there; credential files untouched (no lockout, no half-done reset).
|
||||
assert db_file.exists()
|
||||
assert bootstrap_file.exists()
|
||||
assert bootstrap_file.read_text().strip()
|
||||
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
||||
assert "could not delete the auth database" in combined.lower()
|
||||
|
||||
|
||||
def test_reset_password_fails_closed_when_credential_cannot_be_invalidated(monkeypatch, tmp_path):
|
||||
# If a seeded credential file can be neither unlinked nor truncated, reset must
|
||||
# fail closed: auth.db is already gone, so a surviving plaintext would be
|
||||
# re-seeded and re-validate the revoked password.
|
||||
import pathlib
|
||||
|
||||
studio_mod = _studio()
|
||||
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
||||
_seed_auth(studio_mod)
|
||||
auth_dir = tmp_path / "auth"
|
||||
bootstrap_file = auth_dir / studio_mod.BOOTSTRAP_PASSWORD_FILE
|
||||
db_file = auth_dir / "auth.db"
|
||||
assert bootstrap_file.exists() and db_file.exists()
|
||||
|
||||
_real_unlink = pathlib.Path.unlink
|
||||
_real_write_text = pathlib.Path.write_text
|
||||
|
||||
def _boom_unlink(self, *a, **k):
|
||||
if self.name == studio_mod.BOOTSTRAP_PASSWORD_FILE:
|
||||
raise OSError("locked")
|
||||
return _real_unlink(self, *a, **k)
|
||||
|
||||
def _boom_write_text(self, *a, **k):
|
||||
if self.name == studio_mod.BOOTSTRAP_PASSWORD_FILE:
|
||||
raise OSError("read-only")
|
||||
return _real_write_text(self, *a, **k)
|
||||
|
||||
monkeypatch.setattr(pathlib.Path, "unlink", _boom_unlink)
|
||||
monkeypatch.setattr(pathlib.Path, "write_text", _boom_write_text)
|
||||
|
||||
import typer as _typer
|
||||
|
||||
app = _typer.Typer()
|
||||
app.command()(studio_mod.reset_password)
|
||||
result = CliRunner().invoke(app, [], catch_exceptions = True)
|
||||
|
||||
assert result.exit_code == 1, result.output
|
||||
# auth.db was deleted first; the un-invalidatable file is reported for manual removal.
|
||||
assert not db_file.exists()
|
||||
combined = (result.output or "") + (getattr(result, "stderr", "") or "")
|
||||
assert "delete it manually" in combined.lower()
|
||||
|
||||
|
||||
def test_connect_auth_db_creates_private_files(monkeypatch, tmp_path):
|
||||
# Fresh install: the CLI gate writes the password hash + JWT secret before
|
||||
# the backend ever runs, so this path must apply the same 0700/0600 modes
|
||||
|
|
@ -1405,30 +1448,3 @@ def test_studio_default_password_applies_on_headless_wildcard_no_tunnel(monkeypa
|
|||
assert after["must_change_password"] == 0
|
||||
assert after["password_hash"] != before["password_hash"]
|
||||
assert "--password" not in _exec_argv(events)
|
||||
|
||||
|
||||
def test_reset_password_then_password_roundtrip(monkeypatch, tmp_path):
|
||||
# After reset-password wipes the DB, the next start re-seeds a fresh admin
|
||||
# that again requires a change, so --password can set a new initial password.
|
||||
import typer
|
||||
|
||||
studio_mod = _studio()
|
||||
monkeypatch.setattr(studio_mod, "STUDIO_HOME", tmp_path)
|
||||
_seed_auth(studio_mod)
|
||||
conn = studio_mod._connect_auth_db()
|
||||
studio_mod._cli_update_password(conn, studio_mod.DEFAULT_ADMIN_USERNAME, "first-password-1")
|
||||
conn.close()
|
||||
assert _auth_state(studio_mod)["must_change_password"] == 0
|
||||
|
||||
# reset-password deletes the auth DB + seeded credential files.
|
||||
try:
|
||||
studio_mod.reset_password()
|
||||
except typer.Exit:
|
||||
pass
|
||||
assert not (tmp_path / "auth" / "auth.db").exists()
|
||||
|
||||
# A restart re-seeds (ensure_default_admin, must_change=1); --password sets anew.
|
||||
events = _install_prompt_env(monkeypatch, tmp_path, interactive = True)
|
||||
_invoke_studio_default(monkeypatch, events, ["--secure", "--password", "second-password-2"])
|
||||
assert [kind for kind, _ in events] == ["exec"], events
|
||||
assert _auth_state(studio_mod)["must_change_password"] == 0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue