fix: remove old comments (#4292)

* fix: quotation marks

* diceware passphrase generation

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: Roland Tannous <rolandtannous@gravityq.ai>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Wasim Yousef Said 2026-03-14 13:50:13 +01:00 committed by GitHub
commit 629199e3a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 660 additions and 260 deletions

View file

@ -10,17 +10,23 @@ from .authentication import (
create_refresh_token,
refresh_access_token,
get_current_subject,
get_current_subject_allow_password_change,
reload_secret,
)
from .storage import (
DEFAULT_ADMIN_USERNAME,
clear_bootstrap_password,
generate_bootstrap_password,
get_bootstrap_password,
is_initialized,
create_initial_user,
ensure_default_admin,
get_jwt_secret,
get_user_and_secret,
load_jwt_secret,
save_setup_token,
consume_setup_token,
has_pending_setup_token,
requires_password_change,
save_refresh_token,
update_password,
verify_refresh_token,
revoke_user_refresh_tokens,
)
@ -31,15 +37,21 @@ __all__ = [
"create_refresh_token",
"refresh_access_token",
"get_current_subject",
"get_current_subject_allow_password_change",
"reload_secret",
"DEFAULT_ADMIN_USERNAME",
"clear_bootstrap_password",
"generate_bootstrap_password",
"get_bootstrap_password",
"is_initialized",
"create_initial_user",
"ensure_default_admin",
"get_jwt_secret",
"get_user_and_secret",
"load_jwt_secret",
"save_setup_token",
"consume_setup_token",
"has_pending_setup_token",
"requires_password_change",
"save_refresh_token",
"update_password",
"verify_refresh_token",
"revoke_user_refresh_tokens",
"hash_password",

View file

@ -3,30 +3,50 @@
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional
from typing import Optional, Tuple
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
import jwt
from .storage import load_jwt_secret, save_refresh_token, verify_refresh_token
from .storage import (
get_jwt_secret,
get_user_and_secret,
load_jwt_secret,
save_refresh_token,
verify_refresh_token,
)
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60
REFRESH_TOKEN_EXPIRE_DAYS = 7
# Load stable secret from SQLite (set during first-time setup)
# This will raise RuntimeError if auth hasn't been initialized yet
try:
SECRET_KEY = load_jwt_secret()
except RuntimeError:
# Fallback: use a temporary secret until setup is complete
# This allows the app to start, but protected routes will fail until setup
SECRET_KEY = secrets.token_urlsafe(64)
security = HTTPBearer() # Reads Authorization: Bearer <token>
def _get_secret_for_subject(subject: str) -> str:
secret = get_jwt_secret(subject)
if secret is None:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Invalid or expired token",
)
return secret
def _decode_subject_without_verification(token: str) -> Optional[str]:
try:
payload = jwt.decode(
token,
options = {"verify_signature": False, "verify_exp": False},
)
except jwt.InvalidTokenError:
return None
subject = payload.get("sub")
return subject if isinstance(subject, str) else None
def create_access_token(
subject: str,
expires_delta: Optional[timedelta] = None,
@ -34,14 +54,18 @@ def create_access_token(
"""
Create a signed JWT for the given subject (e.g. username).
Tokens are valid across restarts because SECRET_KEY is stored in SQLite.
Tokens are valid across restarts because the signing secret is stored in SQLite.
"""
to_encode = {"sub": subject}
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes = ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm = ALGORITHM)
return jwt.encode(
to_encode,
_get_secret_for_subject(subject),
algorithm = ALGORITHM,
)
def create_refresh_token(subject: str) -> str:
@ -56,7 +80,7 @@ def create_refresh_token(subject: str) -> str:
return token
def refresh_access_token(refresh_token: str) -> Optional[str]:
def refresh_access_token(refresh_token: str) -> Tuple[Optional[str], Optional[str]]:
"""
Validate a refresh token and issue a new access token.
@ -65,22 +89,43 @@ def refresh_access_token(refresh_token: str) -> Optional[str]:
"""
username = verify_refresh_token(refresh_token)
if username is None:
return None
return create_access_token(subject = username)
return None, None
return create_access_token(subject = username), username
def reload_secret() -> None:
"""
Reload the JWT secret from SQLite.
Keep legacy API compatibility for callers expecting auth storage init.
Call this after setup to ensure new tokens use the persistent secret.
Auth now resolves the current signing secret directly from SQLite.
"""
global SECRET_KEY
SECRET_KEY = load_jwt_secret()
load_jwt_secret()
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(
credentials,
allow_password_change = False,
)
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(
credentials,
allow_password_change = True,
)
async def _get_current_subject(
credentials: HTTPAuthorizationCredentials,
*,
allow_password_change: bool,
) -> str:
"""
FastAPI dependency to validate the JWT and return the subject.
@ -92,14 +137,33 @@ async def get_current_subject(
...
"""
token = credentials.credentials
subject = _decode_subject_without_verification(token)
if subject is None:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Invalid token payload",
)
record = get_user_and_secret(subject)
if record is None:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Invalid or expired token",
)
_salt, _pwd_hash, jwt_secret, must_change_password = record
try:
payload = jwt.decode(token, SECRET_KEY, algorithms = [ALGORITHM])
subject: Optional[str] = payload.get("sub")
if subject is None:
payload = jwt.decode(token, jwt_secret, algorithms = [ALGORITHM])
if payload.get("sub") != subject:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Invalid token payload",
)
if must_change_password and not allow_password_change:
raise HTTPException(
status_code = status.HTTP_403_FORBIDDEN,
detail = "Password change required",
)
return subject
except jwt.InvalidTokenError:
raise HTTPException(

View file

@ -6,6 +6,7 @@ SQLite storage for authentication data (user credentials + JWT secret).
"""
import hashlib
import secrets
import sqlite3
from datetime import datetime, timezone
from typing import Optional, Tuple
@ -13,10 +14,65 @@ from typing import Optional, Tuple
from utils.paths import auth_db_path, ensure_dir
DB_PATH = auth_db_path()
DEFAULT_ADMIN_USERNAME = "unsloth"
# Plaintext bootstrap password file — lives beside auth.db, deleted on
# first password change so the credential never lingers on disk.
_BOOTSTRAP_PW_PATH = DB_PATH.parent / ".bootstrap_password"
# In-process cache so we don't re-read the file on every HTML serve.
_bootstrap_password: Optional[str] = None
def generate_bootstrap_password() -> str:
"""Generate a 4-word diceware passphrase and persist it to disk.
The passphrase is written to ``_BOOTSTRAP_PW_PATH`` so that it
survives server restarts (the DB only stores the *hash*). On
subsequent calls / restarts, the persisted value is returned.
"""
global _bootstrap_password
# 1. Already cached in this process?
if _bootstrap_password is not None:
return _bootstrap_password
# 2. Already persisted from a previous run?
if _BOOTSTRAP_PW_PATH.is_file():
_bootstrap_password = _BOOTSTRAP_PW_PATH.read_text().strip()
if _bootstrap_password:
return _bootstrap_password
# 3. First-ever startup — generate a fresh passphrase.
import diceware
_bootstrap_password = diceware.get_passphrase(
options = diceware.handle_options(args = ["-n", "4", "-d", "", "-c"])
)
# Persist so the *same* passphrase is used if the server restarts
# before the user changes the password.
ensure_dir(_BOOTSTRAP_PW_PATH.parent)
_BOOTSTRAP_PW_PATH.write_text(_bootstrap_password)
return _bootstrap_password
def get_bootstrap_password() -> Optional[str]:
"""Return the cached bootstrap password, or None if not yet generated."""
return _bootstrap_password
def clear_bootstrap_password() -> None:
"""Delete the persisted bootstrap password file (called after password change)."""
global _bootstrap_password
_bootstrap_password = None
if _BOOTSTRAP_PW_PATH.is_file():
_BOOTSTRAP_PW_PATH.unlink(missing_ok = True)
def _hash_token(token: str) -> str:
"""SHA-256 hash a setup token for safe storage."""
"""SHA-256 hash helper used for refresh token storage."""
return hashlib.sha256(token.encode("utf-8")).hexdigest()
@ -32,15 +88,8 @@ def get_connection() -> sqlite3.Connection:
username TEXT UNIQUE NOT NULL,
password_salt TEXT NOT NULL,
password_hash TEXT NOT NULL,
jwt_secret TEXT NOT NULL
);
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS setup_tokens (
id INTEGER PRIMARY KEY,
token_hash TEXT NOT NULL
jwt_secret TEXT NOT NULL,
must_change_password INTEGER NOT NULL DEFAULT 0
);
"""
)
@ -54,12 +103,17 @@ def get_connection() -> sqlite3.Connection:
);
"""
)
columns = {row["name"] for row in conn.execute("PRAGMA table_info(auth_user)")}
if "must_change_password" not in columns:
conn.execute(
"ALTER TABLE auth_user ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0"
)
conn.commit()
return conn
def is_initialized() -> bool:
"""Check if auth has been set up (user exists in DB)."""
"""Check if auth is ready for login (at least one user exists in DB)."""
conn = get_connection()
cur = conn.execute("SELECT COUNT(*) AS c FROM auth_user")
row = cur.fetchone()
@ -67,7 +121,13 @@ def is_initialized() -> bool:
return bool(row["c"])
def create_initial_user(username: str, password: str, jwt_secret: str) -> None:
def create_initial_user(
username: str,
password: str,
jwt_secret: str,
*,
must_change_password: bool = False,
) -> None:
"""
Create the initial admin user in the database.
@ -80,10 +140,16 @@ def create_initial_user(username: str, password: str, jwt_secret: str) -> None:
try:
conn.execute(
"""
INSERT INTO auth_user (username, password_salt, password_hash, jwt_secret)
VALUES (?, ?, ?, ?)
INSERT INTO auth_user (
username,
password_salt,
password_hash,
jwt_secret,
must_change_password
)
VALUES (?, ?, ?, ?, ?)
""",
(username, salt, pwd_hash, jwt_secret),
(username, salt, pwd_hash, jwt_secret, int(must_change_password)),
)
conn.commit()
finally:
@ -94,7 +160,7 @@ def delete_user(username: str) -> None:
"""
Delete a user from the database.
Used for rollback when setup fails after user creation.
Used for rollback when user creation fails partway through bootstrap.
"""
conn = get_connection()
try:
@ -104,17 +170,18 @@ def delete_user(username: str) -> None:
conn.close()
def get_user_and_secret(username: str) -> Optional[Tuple[str, str, str]]:
def get_user_and_secret(username: str) -> Optional[Tuple[str, str, str, bool]]:
"""
Get user's password salt, hash, and JWT secret.
Returns (password_salt, password_hash, jwt_secret) or None if user not found.
Returns (password_salt, password_hash, jwt_secret, must_change_password)
or None if user not found.
"""
conn = get_connection()
try:
cur = conn.execute(
"""
SELECT password_salt, password_hash, jwt_secret
SELECT password_salt, password_hash, jwt_secret, must_change_password
FROM auth_user
WHERE username = ?
""",
@ -123,7 +190,40 @@ def get_user_and_secret(username: str) -> Optional[Tuple[str, str, str]]:
row = cur.fetchone()
if not row:
return None
return row["password_salt"], row["password_hash"], row["jwt_secret"]
return (
row["password_salt"],
row["password_hash"],
row["jwt_secret"],
bool(row["must_change_password"]),
)
finally:
conn.close()
def get_jwt_secret(username: str) -> Optional[str]:
"""Return the current JWT signing secret for a user."""
conn = get_connection()
try:
cur = conn.execute(
"SELECT jwt_secret FROM auth_user WHERE username = ?",
(username,),
)
row = cur.fetchone()
return row["jwt_secret"] if row else None
finally:
conn.close()
def requires_password_change(username: str) -> bool:
"""Return whether the user must change the seeded default password."""
conn = get_connection()
try:
cur = conn.execute(
"SELECT must_change_password FROM auth_user WHERE username = ?",
(username,),
)
row = cur.fetchone()
return bool(row and row["must_change_password"])
finally:
conn.close()
@ -132,7 +232,7 @@ def load_jwt_secret() -> str:
"""
Load the JWT secret from the database.
Raises RuntimeError if auth is not initialized.
Raises RuntimeError if no auth user has been created yet.
"""
conn = get_connection()
try:
@ -140,56 +240,52 @@ def load_jwt_secret() -> str:
row = cur.fetchone()
if not row:
raise RuntimeError(
"Auth is not initialized. Please set up a password first."
"Auth is not initialized. Wait for the seeded admin bootstrap to complete."
)
return row["jwt_secret"]
finally:
conn.close()
def save_setup_token(token: str) -> None:
def ensure_default_admin() -> bool:
"""Seed the default admin account on first startup.
Uses a randomly generated diceware passphrase as the bootstrap password.
Returns True when the default admin was created in this call.
"""
Store a hashed setup token, replacing any existing one.
"""
token_hash = _hash_token(token)
conn = get_connection()
bootstrap_pw = generate_bootstrap_password()
try:
conn.execute("DELETE FROM setup_tokens")
conn.execute("INSERT INTO setup_tokens (token_hash) VALUES (?)", (token_hash,))
conn.commit()
finally:
conn.close()
def consume_setup_token(token: str) -> bool:
"""
Verify a setup token and delete it if valid.
Returns True if the token was valid (and is now consumed), False otherwise.
"""
token_hash = _hash_token(token)
conn = get_connection()
try:
cur = conn.execute(
"SELECT id FROM setup_tokens WHERE token_hash = ?", (token_hash,)
create_initial_user(
username = DEFAULT_ADMIN_USERNAME,
password = bootstrap_pw,
jwt_secret = secrets.token_urlsafe(64),
must_change_password = True,
)
row = cur.fetchone()
if row is None:
return False
conn.execute("DELETE FROM setup_tokens WHERE id = ?", (row["id"],))
conn.commit()
return True
finally:
conn.close()
except sqlite3.IntegrityError:
return False
def has_pending_setup_token() -> bool:
"""Check if a setup token is waiting to be consumed."""
def update_password(username: str, new_password: str) -> bool:
"""Update password, clear first-login requirement, rotate JWT secret."""
from .hashing import hash_password
salt, pwd_hash = hash_password(new_password)
jwt_secret = secrets.token_urlsafe(64)
conn = get_connection()
try:
cur = conn.execute("SELECT COUNT(*) AS c FROM setup_tokens")
row = cur.fetchone()
return bool(row["c"])
cursor = conn.execute(
"""
UPDATE auth_user
SET password_salt = ?, password_hash = ?, jwt_secret = ?, must_change_password = 0
WHERE username = ?
""",
(salt, pwd_hash, jwt_secret, username),
)
conn.commit()
if cursor.rowcount > 0:
clear_bootstrap_password()
return cursor.rowcount > 0
finally:
conn.close()

View file

@ -10,7 +10,6 @@ import os
# Suppress annoying C-level dependency warnings globally
os.environ["PYTHONWARNINGS"] = "ignore"
import secrets
import shutil
import warnings
from contextlib import asynccontextmanager
@ -48,7 +47,7 @@ from utils.cache_cleanup import clear_unsloth_compiled_cache
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup: detect hardware, print setup token if needed. Shutdown: clean up compiled cache."""
"""Startup: detect hardware, seed default admin if needed. Shutdown: clean up compiled cache."""
# Clean up any stale compiled cache from previous runs
clear_unsloth_compiled_cache()
@ -75,15 +74,19 @@ async def lifespan(app: FastAPI):
threading.Thread(target = _precache, daemon = True).start()
if not storage.is_initialized():
setup_token = secrets.token_urlsafe(32)
storage.save_setup_token(setup_token)
if storage.ensure_default_admin():
bootstrap_pw = storage.get_bootstrap_password()
app.state.bootstrap_password = bootstrap_pw
print("\n" + "=" * 60)
print("FIRST-TIME SETUP REQUIRED")
print("Use this one-time setup token to create your admin account:\n")
print(f" {setup_token}\n")
print("This token can only be used once.")
print("DEFAULT ADMIN ACCOUNT CREATED")
print(
"Sign in with the seeded credentials and change the password immediately:\n"
)
print(f" username: {storage.DEFAULT_ADMIN_USERNAME}")
print(f" password: {bootstrap_pw}\n")
print("=" * 60 + "\n")
else:
app.state.bootstrap_password = storage.get_bootstrap_password()
yield
# Cleanup
_hw_module.DEVICE = None
@ -199,6 +202,34 @@ async def get_hardware_info():
# ============ Serve Frontend (Optional) ============
def _inject_bootstrap(html_bytes: bytes, app: FastAPI) -> bytes:
"""Inject bootstrap credentials into HTML when password change is required.
The script tag is only injected while the default admin account still
has ``must_change_password=True``. Once the user changes the password
the HTML is served clean no credentials leak.
"""
import json as _json
if not storage.requires_password_change(storage.DEFAULT_ADMIN_USERNAME):
return html_bytes
bootstrap_pw = getattr(app.state, "bootstrap_password", None)
if not bootstrap_pw:
return html_bytes
payload = _json.dumps(
{
"username": storage.DEFAULT_ADMIN_USERNAME,
"password": bootstrap_pw,
}
)
tag = f"<script>window.__UNSLOTH_BOOTSTRAP__={payload}</script>"
html = html_bytes.decode("utf-8")
html = html.replace("</head>", f"{tag}</head>", 1)
return html.encode("utf-8")
def setup_frontend(app: FastAPI, build_path: Path):
"""Mount frontend static files (optional)"""
if not build_path.exists():
@ -212,6 +243,7 @@ def setup_frontend(app: FastAPI, build_path: Path):
@app.get("/")
async def serve_root():
content = (build_path / "index.html").read_bytes()
content = _inject_bootstrap(content, app)
return Response(
content = content,
media_type = "text/html",
@ -234,6 +266,7 @@ def setup_frontend(app: FastAPI, build_path: Path):
# Serve index.html as bytes — avoids Content-Length mismatch
content = (build_path / "index.html").read_bytes()
content = _inject_bootstrap(content, app)
return Response(
content = content,
media_type = "text/html",

View file

@ -23,10 +23,10 @@ from .models import (
ModelListResponse,
)
from .auth import (
AuthSetupRequest,
AuthLoginRequest,
RefreshTokenRequest,
AuthStatusResponse,
ChangePasswordRequest,
)
from .export import (
LoadCheckpointRequest,
@ -79,10 +79,10 @@ __all__ = [
"LoRAScanResponse",
"ModelListResponse",
# Auth schemas
"AuthSetupRequest",
"AuthLoginRequest",
"RefreshTokenRequest",
"AuthStatusResponse",
"ChangePasswordRequest",
# Export schemas
"CheckpointInfo",
"ModelCheckpoints",

View file

@ -8,18 +8,6 @@ Pydantic schemas for Authentication API
from pydantic import BaseModel, Field
class AuthSetupRequest(BaseModel):
"""First-time setup: create the initial admin user + password."""
setup_token: str = Field(
..., description = "One-time setup token printed to the server console"
)
username: str = Field(..., description = "Admin username")
password: str = Field(
..., min_length = 8, description = "Admin password (minimum 8 characters)"
)
class AuthLoginRequest(BaseModel):
"""Login payload: username/password to obtain a JWT."""
@ -36,6 +24,24 @@ class RefreshTokenRequest(BaseModel):
class AuthStatusResponse(BaseModel):
"""Indicate whether auth has been initialized."""
"""Indicate whether the seeded admin auth flow is ready."""
initialized: bool = Field(..., description = "True if auth setup has been completed")
initialized: bool = Field(
..., description = "True if the auth database contains a login user"
)
default_username: str = Field(..., description = "Default seeded admin username")
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)"
)

View file

@ -10,8 +10,18 @@ from pydantic import BaseModel, Field
class Token(BaseModel):
"""Authentication token model with access and refresh tokens."""
"""Authentication response model for session credentials."""
access_token: str = Field(..., description = "JWT access token (60 min expiry)")
refresh_token: str = Field(..., description = "Opaque refresh token (7 day expiry)")
token_type: str = Field(..., description = "Token type, always 'bearer'")
access_token: str = Field(
..., description = "Session access credential used for authenticated API requests"
)
refresh_token: str = Field(
...,
description = "Session refresh credential used to renew an expired access credential",
)
token_type: str = Field(
..., description = "Credential type for the Authorization header, always 'bearer'"
)
must_change_password: bool = Field(
..., description = "True when the user must change the seeded default password"
)

View file

@ -13,3 +13,4 @@ addict
gradio>=4.0.0
huggingface-hub==0.36.2
structlog>=24.1.0
diceware

View file

@ -5,22 +5,22 @@
Authentication API routes
"""
from fastapi import APIRouter, HTTPException, status
import secrets
from fastapi import APIRouter, Depends, HTTPException, status
from models.auth import (
AuthSetupRequest,
AuthLoginRequest,
RefreshTokenRequest,
AuthStatusResponse,
ChangePasswordRequest,
)
from models.users import Token
from auth import storage, hashing
from auth.authentication import (
create_access_token,
create_refresh_token,
get_current_subject,
get_current_subject_allow_password_change,
refresh_access_token,
reload_secret,
)
router = APIRouter()
@ -31,63 +31,17 @@ async def auth_status() -> AuthStatusResponse:
"""
Check whether auth has already been initialized.
- initialized = False -> frontend should show "Set admin password" screen.
- initialized = True -> frontend should show normal login.
- initialized = False -> frontend should wait for the seeded admin bootstrap.
- initialized = True -> frontend should show login or force the first password change.
"""
return AuthStatusResponse(initialized = storage.is_initialized())
@router.post("/setup", response_model = Token, status_code = status.HTTP_201_CREATED)
async def setup_auth(payload: AuthSetupRequest) -> Token:
"""
First-time setup: create the admin user and a JWT secret.
Requires a valid setup token (printed to the server console on startup).
Can only be called once. Subsequent calls will return 400.
"""
if storage.is_initialized():
raise HTTPException(
status_code = status.HTTP_400_BAD_REQUEST,
detail = "Auth is already initialized.",
return AuthStatusResponse(
initialized = storage.is_initialized(),
default_username = storage.DEFAULT_ADMIN_USERNAME,
requires_password_change = storage.requires_password_change(
storage.DEFAULT_ADMIN_USERNAME
)
# Validate the one-time setup token
if not storage.consume_setup_token(payload.setup_token):
raise HTTPException(
status_code = status.HTTP_403_FORBIDDEN,
detail = "Invalid or expired setup token.",
)
# Generate a strong random JWT secret for this installation
jwt_secret = secrets.token_urlsafe(64)
# Create user + generate tokens atomically — rollback if anything fails
try:
storage.create_initial_user(
username = payload.username,
password = payload.password,
jwt_secret = jwt_secret,
)
# Reload JWT secret from DB (so authentication.py picks it up)
reload_secret()
# Issue access + refresh tokens for the new user
access_token = create_access_token(subject = payload.username)
refresh_token = create_refresh_token(subject = payload.username)
except Exception as e:
# Rollback: remove the user row so setup can be retried
storage.delete_user(payload.username)
raise HTTPException(
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR,
detail = f"Setup failed (rolled back): {str(e)}",
)
return Token(
access_token = access_token,
refresh_token = refresh_token,
token_type = "bearer",
if storage.is_initialized()
else True,
)
@ -103,7 +57,7 @@ async def login(payload: AuthLoginRequest) -> Token:
detail = "Incorrect username or password",
)
salt, pwd_hash, _jwt_secret = record
salt, pwd_hash, _jwt_secret, must_change_password = record
if not hashing.verify_password(payload.password, salt, pwd_hash):
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
@ -116,6 +70,7 @@ async def login(payload: AuthLoginRequest) -> Token:
access_token = access_token,
refresh_token = refresh_token,
token_type = "bearer",
must_change_password = must_change_password,
)
@ -126,8 +81,8 @@ async def refresh(payload: RefreshTokenRequest) -> Token:
The refresh token itself is reusable until it expires (7 days).
"""
new_access_token = refresh_access_token(payload.refresh_token)
if new_access_token is None:
new_access_token, username = refresh_access_token(payload.refresh_token)
if new_access_token is None or username is None:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Invalid or expired refresh token",
@ -137,4 +92,42 @@ async def refresh(payload: RefreshTokenRequest) -> Token:
access_token = new_access_token,
refresh_token = payload.refresh_token,
token_type = "bearer",
must_change_password = storage.requires_password_change(username),
)
@router.post("/change-password", response_model = Token)
async def change_password(
payload: ChangePasswordRequest,
current_subject: str = Depends(get_current_subject_allow_password_change),
) -> Token:
"""Allow the authenticated user to replace the default password."""
record = storage.get_user_and_secret(current_subject)
if record is None:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "User session is invalid",
)
salt, pwd_hash, _jwt_secret, _must_change_password = record
if not hashing.verify_password(payload.current_password, salt, pwd_hash):
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Current password is incorrect",
)
if payload.current_password == payload.new_password:
raise HTTPException(
status_code = status.HTTP_400_BAD_REQUEST,
detail = "New password must be different from the current password",
)
storage.update_password(current_subject, payload.new_password)
storage.revoke_user_refresh_tokens(current_subject)
access_token = create_access_token(subject = current_subject)
refresh_token = create_refresh_token(subject = current_subject)
return Token(
access_token = access_token,
refresh_token = refresh_token,
token_type = "bearer",
must_change_password = False,
)

View file

@ -6,6 +6,7 @@ import {
getPostAuthRoute,
hasAuthToken,
hasRefreshToken,
mustChangePassword,
refreshSession,
} from "@/features/auth";
@ -26,13 +27,44 @@ async function checkAuthInitialized(): Promise<boolean> {
}
}
async function checkPasswordChangeRequired(): Promise<boolean> {
try {
const res = await fetch("/api/auth/status");
if (!res.ok) return mustChangePassword();
const data = (await res.json()) as { requires_password_change: boolean };
return data.requires_password_change || mustChangePassword();
} catch {
return mustChangePassword();
}
}
export async function requireAuth(): Promise<void> {
if (await hasActiveSession()) return;
if (await hasActiveSession()) {
if (await checkPasswordChangeRequired()) {
throw redirect({ to: "/change-password" });
}
return;
}
const requiresPasswordChange = await checkPasswordChangeRequired();
if (requiresPasswordChange) throw redirect({ to: "/change-password" });
const initialized = await checkAuthInitialized();
throw redirect({ to: initialized ? "/login" : "/signup" });
throw redirect({ to: initialized ? "/login" : "/change-password" });
}
export async function requireGuest(): Promise<void> {
if (!(await hasActiveSession())) return;
throw redirect({ to: getPostAuthRoute() });
}
export async function requirePasswordChangeFlow(): Promise<void> {
const requiresPasswordChange = await checkPasswordChangeRequired();
if (requiresPasswordChange) return;
if (await hasActiveSession()) {
throw redirect({ to: getPostAuthRoute() });
}
const initialized = await checkAuthInitialized();
throw redirect({ to: initialized ? "/login" : "/change-password" });
}

View file

@ -11,14 +11,14 @@ import { Route as gridTestRoute } from "./routes/grid-test";
import { Route as indexRoute } from "./routes/index";
import { Route as loginRoute } from "./routes/login";
import { Route as onboardingRoute } from "./routes/onboarding";
import { Route as signupRoute } from "./routes/signup";
import { Route as changePasswordRoute } from "./routes/change-password";
import { Route as studioRoute } from "./routes/studio";
const routeTree = rootRoute.addChildren([
indexRoute,
onboardingRoute,
loginRoute,
signupRoute,
changePasswordRoute,
gridTestRoute,
studioRoute,
chatRoute,

View file

@ -15,7 +15,7 @@ export const Route = createRootRoute({
component: RootLayout,
});
const HIDDEN_NAVBAR_ROUTES = ["/onboarding", "/login", "/signup"];
const HIDDEN_NAVBAR_ROUTES = ["/onboarding", "/login", "/change-password"];
function RootLayout() {
const pathname = useRouterState({ select: (s) => s.location.pathname });

View file

@ -3,18 +3,18 @@
import { createRoute } from "@tanstack/react-router";
import { lazy } from "react";
import { requireGuest } from "../auth-guards";
import { requirePasswordChangeFlow } from "../auth-guards";
import { Route as rootRoute } from "./__root";
const SignupPage = lazy(() =>
const ChangePasswordPage = lazy(() =>
import("@/features/auth").then((m) => ({
default: m.SignupPage,
default: m.ChangePasswordPage,
})),
);
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/signup",
beforeLoad: () => requireGuest(),
component: SignupPage,
path: "/change-password",
beforeLoad: () => requirePasswordChangeFlow(),
component: ChangePasswordPage,
});

View file

@ -5,16 +5,29 @@ import {
clearAuthTokens,
getAuthToken,
getRefreshToken,
mustChangePassword,
storeAuthTokens,
} from "./session";
type RefreshResponse = {
access_token: string;
refresh_token: string;
must_change_password: boolean;
};
let isRedirecting = false;
async function isPasswordChangeRequiredResponse(response: Response): Promise<boolean> {
if (response.status !== 403) return false;
try {
const payload = (await response.clone().json()) as { detail?: string };
return payload.detail === "Password change required";
} catch {
return false;
}
}
async function redirectToAuth(): Promise<void> {
if (isRedirecting) return;
isRedirecting = true;
@ -23,8 +36,8 @@ async function redirectToAuth(): Promise<void> {
try {
const res = await fetch("/api/auth/status");
if (res.ok) {
const data = (await res.json()) as { initialized: boolean };
if (!data.initialized) target = "/signup";
const data = (await res.json()) as { requires_password_change: boolean };
if (data.requires_password_change || mustChangePassword()) target = "/change-password";
}
} catch {
// Fall through to /login on error
@ -50,7 +63,11 @@ export async function refreshSession(): Promise<boolean> {
}
const payload = (await response.json()) as RefreshResponse;
storeAuthTokens(payload.access_token, payload.refresh_token);
storeAuthTokens(
payload.access_token,
payload.refresh_token,
payload.must_change_password,
);
return true;
} catch {
return false;
@ -68,6 +85,10 @@ export async function authFetch(
}
const response = await fetch(input, { ...init, headers });
if (await isPasswordChangeRequiredResponse(response)) {
void redirectToAuth();
return response;
}
if (response.status !== 401) return response;
const refreshed = await refreshSession();
@ -77,6 +98,11 @@ export async function authFetch(
return response;
}
if (mustChangePassword()) {
void redirectToAuth();
return response;
}
const retryHeaders = new Headers(init?.headers);
const newToken = getAuthToken();
if (newToken) {

View file

@ -5,7 +5,7 @@ import { LightRays } from "@/components/ui/light-rays";
import { Card } from "@/components/ui/card";
import { AuthForm } from "./components/auth-form";
export function SignupPage() {
export function ChangePasswordPage() {
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4 py-8 sm:px-6 sm:py-10 md:px-10">
<LightRays
@ -17,7 +17,7 @@ export function SignupPage() {
style={{ opacity: 0.4 }}
/>
<Card className="relative z-10 w-full max-w-sm px-5 py-6 shadow-border ring-1 ring-border sm:px-6 sm:py-8">
<AuthForm mode="signup" />
<AuthForm mode="change-password" />
</Card>
</div>
);

View file

@ -10,38 +10,79 @@ import { useEffect, useState } from "react";
import type { FormEvent } from "react";
import type { ReactElement } from "react";
import { refreshSession } from "../api";
// Bootstrap credentials injected into index.html by the backend
// (only present while default admin must_change_password is true)
declare global {
interface Window {
__UNSLOTH_BOOTSTRAP__?: { username: string; password: string };
}
}
import {
clearAuthTokens,
getAuthToken,
getPostAuthRoute,
hasAuthToken,
hasRefreshToken,
mustChangePassword,
resetOnboardingDone,
setMustChangePassword,
storeAuthTokens,
} from "../session";
type AuthMode = "login" | "signup";
type AuthMode = "login" | "change-password";
type AuthStatusResponse = {
initialized: boolean;
default_username: string;
requires_password_change: boolean;
};
type TokenResponse = {
access_token: string;
refresh_token: string;
must_change_password: boolean;
};
async function loginWithPassword(
username: string,
password: string,
): Promise<TokenResponse> {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: username.trim(),
password,
}),
});
if (!response.ok) {
const errorPayload = (await response.json().catch(() => null)) as { detail?: string } | null;
throw new Error(errorPayload?.detail ?? "Login failed.");
}
return (await response.json()) as TokenResponse;
}
type AuthFormProps = {
mode: AuthMode;
};
export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
const navigate = useNavigate();
const isLoginMode = mode === "login";
const [showPassword, setShowPassword] = useState(false);
const [username, setUsername] = useState("admin");
const [setupToken, setSetupToken] = useState("");
const [username, setUsername] = useState("unsloth");
const [password, setPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [loading, setLoading] = useState(false);
const [statusLoading, setStatusLoading] = useState(true);
const [initialized, setInitialized] = useState<boolean | null>(null);
const [requiresPasswordChange, setRequiresPasswordChange] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
@ -68,12 +109,13 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
const result = (await response.json()) as AuthStatusResponse;
if (!canceled) {
setInitialized(result.initialized);
// Auto-redirect to the correct page based on init state
if (mode === "login" && result.initialized === false) {
navigate({ to: "/signup" });
setUsername(result.default_username);
setRequiresPasswordChange(result.requires_password_change);
if (mode === "login" && result.requires_password_change) {
navigate({ to: "/change-password" });
return;
}
if (mode === "signup" && result.initialized === true) {
if (mode === "change-password" && !result.requires_password_change && !mustChangePassword()) {
navigate({ to: "/login" });
return;
}
@ -94,62 +136,120 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
};
}, [navigate]);
const blockedByState =
(mode === "login" && initialized === false) ||
(mode === "signup" && initialized === true);
// Seed password from bootstrap credentials injected into HTML
useEffect(() => {
const bootstrap = window.__UNSLOTH_BOOTSTRAP__;
if (bootstrap) {
if (!isLoginMode && !password) {
setPassword(bootstrap.password);
}
if (bootstrap.username) {
setUsername(bootstrap.username);
}
}
}, []);
const blockedByState =
initialized === false ||
(mode === "login" && requiresPasswordChange) ||
(mode === "change-password" && !requiresPasswordChange && !mustChangePassword());
const isLoginMode = mode === "login";
let helperText: string | null = null;
if (isLoginMode && initialized === false) {
helperText = "Auth not initialized. go setup first.";
} else if (!isLoginMode && initialized === true) {
helperText = "Auth already initialized. use login.";
if (initialized === false) {
helperText = "Auth is still bootstrapping the default admin account.";
} else if (isLoginMode && requiresPasswordChange) {
helperText = "Sign in once with the seeded credentials to change the password.";
} else if (!isLoginMode && !requiresPasswordChange && !mustChangePassword()) {
helperText = "Password already updated. Use the login screen.";
}
const title = isLoginMode ? "Welcome back" : "Welcome to Unsloth Studio!";
const title = isLoginMode ? "Welcome back" : "Update your admin password";
const subtitle = isLoginMode
? "Sign in to continue"
: "Create first admin account";
const submitLabel = isLoginMode ? "Login" : "Create account";
const switchText = isLoginMode ? "Need setup first? " : "Already initialized? ";
const switchLinkTo = isLoginMode ? "/signup" : "/login";
const switchLinkText = isLoginMode ? "Setup account" : "Login";
? "Sign in with the seeded admin account"
: "Use the default admin credentials, then choose a new password";
const submitLabel = isLoginMode ? "Login" : "Change password";
const showSwitchLink = !isLoginMode;
const switchText = "Password already changed? ";
const switchLinkTo = "/login";
const switchLinkText = "Back to login";
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (!isLoginMode && !setupToken.trim()) {
setError("Setup token required.");
if (!isLoginMode && newPassword.length < 8) {
setError("New password must be at least 8 characters.");
return;
}
if (!isLoginMode && password === newPassword) {
setError("New password must be different from the default password.");
return;
}
setLoading(true);
try {
const endpoint = isLoginMode ? "/api/auth/login" : "/api/auth/setup";
const payload: { username: string; password: string; setup_token?: string } = {
username: username.trim(),
password,
};
if (!isLoginMode) {
payload.setup_token = setupToken.trim();
}
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
let message = "Auth failed.";
const errorPayload = (await response
.json()
.catch(() => null)) as { detail?: string } | null;
if (errorPayload?.detail) message = errorPayload.detail;
throw new Error(message);
}
const token = (await response.json()) as TokenResponse;
let token: TokenResponse;
if (!isLoginMode) resetOnboardingDone();
storeAuthTokens(token.access_token, token.refresh_token);
if (isLoginMode) {
token = await loginWithPassword(username, password);
} else {
let accessToken = getAuthToken();
if (hasRefreshToken()) {
const refreshed = await refreshSession();
accessToken = getAuthToken();
if (!refreshed) {
clearAuthTokens();
accessToken = null;
}
}
if (!accessToken) {
const bootstrapToken = await loginWithPassword(username, password);
storeAuthTokens(
bootstrapToken.access_token,
bootstrapToken.refresh_token,
bootstrapToken.must_change_password,
);
setMustChangePassword(bootstrapToken.must_change_password);
accessToken = bootstrapToken.access_token;
}
const response = await fetch("/api/auth/change-password", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
current_password: password,
new_password: newPassword,
}),
});
if (!response.ok) {
let message = "Password update failed.";
const errorPayload = (await response
.json()
.catch(() => null)) as { detail?: string } | null;
if (errorPayload?.detail) message = errorPayload.detail;
throw new Error(message);
}
token = (await response.json()) as TokenResponse;
}
if (!isLoginMode) {
resetOnboardingDone();
setRequiresPasswordChange(false);
setMustChangePassword(false);
} else {
setMustChangePassword(token.must_change_password);
}
storeAuthTokens(
token.access_token,
token.refresh_token,
token.must_change_password,
);
navigate({ to: getPostAuthRoute() });
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Auth failed.");
@ -177,23 +277,22 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
<Input
id="username"
autoComplete="username"
placeholder="admin"
placeholder="unsloth"
value={username}
onChange={(event) => setUsername(event.target.value)}
required
disabled={!isLoginMode}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password">{isLoginMode ? "Password" : "Current password"}</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
className="pr-10"
autoComplete={
mode === "login" ? "current-password" : "new-password"
}
autoComplete={isLoginMode ? "current-password" : "current-password"}
value={password}
onChange={(event) => setPassword(event.target.value)}
minLength={8}
@ -213,22 +312,24 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
)}
</Button>
</div>
{!isLoginMode && (
<p className="text-xs text-muted-foreground">Must be at least 8 characters</p>
{isLoginMode ? null : (
<p className="text-xs text-muted-foreground">Confirm the seeded password before choosing a new one.</p>
)}
</div>
{!isLoginMode && (
<div className="space-y-2">
<Label htmlFor="setup-token">Setup token</Label>
<Label htmlFor="new-password">New password</Label>
<Input
id="setup-token"
autoComplete="off"
placeholder="Paste token from backend console"
value={setupToken}
onChange={(event) => setSetupToken(event.target.value)}
id="new-password"
type="password"
autoComplete="new-password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
minLength={8}
required
/>
<p className="text-xs text-muted-foreground">Must be at least 8 characters and different from the default password.</p>
</div>
)}
@ -240,18 +341,26 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
<Button
type="submit"
className="w-full"
disabled={loading || statusLoading || blockedByState || (!isLoginMode && password.length < 8)}
disabled={
loading ||
statusLoading ||
blockedByState ||
password.length < 8 ||
(!isLoginMode && newPassword.length < 8)
}
>
{loading ? "Please wait..." : submitLabel}
</Button>
</form>
<p className="text-center text-sm text-muted-foreground">
{switchText}
<Link to={switchLinkTo} className="text-primary hover:underline">
{switchLinkText}
</Link>
</p>
{showSwitchLink && (
<p className="text-center text-sm text-muted-foreground">
{switchText}
<Link to={switchLinkTo} className="text-primary hover:underline">
{switchLinkText}
</Link>
</p>
)}
</div>
);
}

View file

@ -2,7 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export { LoginPage } from "./login-page";
export { SignupPage } from "./signup-page";
export { ChangePasswordPage } from "./change-password-page";
export { authFetch, refreshSession } from "./api";
export {
getPostAuthRoute,
@ -10,4 +10,7 @@ export {
hasRefreshToken,
isOnboardingDone,
markOnboardingDone,
mustChangePassword,
resetOnboardingDone,
setMustChangePassword,
} from "./session";

View file

@ -4,8 +4,9 @@
export const AUTH_TOKEN_KEY = "unsloth_auth_token";
export const AUTH_REFRESH_TOKEN_KEY = "unsloth_auth_refresh_token";
export const ONBOARDING_DONE_KEY = "unsloth_onboarding_done";
export const AUTH_MUST_CHANGE_PASSWORD_KEY = "unsloth_auth_must_change_password";
type PostAuthRoute = "/onboarding" | "/studio";
type PostAuthRoute = "/onboarding" | "/studio" | "/change-password";
function canUseStorage(): boolean {
return typeof window !== "undefined";
@ -34,16 +35,29 @@ export function getRefreshToken(): string | null {
export function storeAuthTokens(
accessToken: string,
refreshToken: string,
mustChangePassword = false,
): void {
if (!canUseStorage()) return;
localStorage.setItem(AUTH_TOKEN_KEY, accessToken);
localStorage.setItem(AUTH_REFRESH_TOKEN_KEY, refreshToken);
localStorage.setItem(AUTH_MUST_CHANGE_PASSWORD_KEY, String(mustChangePassword));
}
export function clearAuthTokens(): void {
if (!canUseStorage()) return;
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(AUTH_REFRESH_TOKEN_KEY);
localStorage.removeItem(AUTH_MUST_CHANGE_PASSWORD_KEY);
}
export function mustChangePassword(): boolean {
if (!canUseStorage()) return false;
return localStorage.getItem(AUTH_MUST_CHANGE_PASSWORD_KEY) === "true";
}
export function setMustChangePassword(required: boolean): void {
if (!canUseStorage()) return;
localStorage.setItem(AUTH_MUST_CHANGE_PASSWORD_KEY, String(required));
}
export function isOnboardingDone(): boolean {
@ -62,5 +76,6 @@ export function resetOnboardingDone(): void {
}
export function getPostAuthRoute(): PostAuthRoute {
if (mustChangePassword()) return "/change-password";
return isOnboardingDone() ? "/studio" : "/onboarding";
}