diff --git a/studio/backend/auth/__init__.py b/studio/backend/auth/__init__.py index 8e489f2a9f..b3e1a8a9c0 100644 --- a/studio/backend/auth/__init__.py +++ b/studio/backend/auth/__init__.py @@ -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", diff --git a/studio/backend/auth/authentication.py b/studio/backend/auth/authentication.py index c41f60bea8..b39f915764 100644 --- a/studio/backend/auth/authentication.py +++ b/studio/backend/auth/authentication.py @@ -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 +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( diff --git a/studio/backend/auth/storage.py b/studio/backend/auth/storage.py index d246f18bf1..1395574cce 100644 --- a/studio/backend/auth/storage.py +++ b/studio/backend/auth/storage.py @@ -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() diff --git a/studio/backend/main.py b/studio/backend/main.py index 7fb2757a66..f2cb7d72af 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -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"" + html = html_bytes.decode("utf-8") + html = html.replace("", f"{tag}", 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", diff --git a/studio/backend/models/__init__.py b/studio/backend/models/__init__.py index 4a53418439..11cf215f54 100644 --- a/studio/backend/models/__init__.py +++ b/studio/backend/models/__init__.py @@ -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", diff --git a/studio/backend/models/auth.py b/studio/backend/models/auth.py index c12d15617e..73d21130ae 100644 --- a/studio/backend/models/auth.py +++ b/studio/backend/models/auth.py @@ -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)" + ) diff --git a/studio/backend/models/users.py b/studio/backend/models/users.py index 8e982ed9f9..6a28d3ea55 100644 --- a/studio/backend/models/users.py +++ b/studio/backend/models/users.py @@ -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" + ) diff --git a/studio/backend/requirements/studio.txt b/studio/backend/requirements/studio.txt index 04592147b9..7eccaa0faf 100644 --- a/studio/backend/requirements/studio.txt +++ b/studio/backend/requirements/studio.txt @@ -13,3 +13,4 @@ addict gradio>=4.0.0 huggingface-hub==0.36.2 structlog>=24.1.0 +diceware diff --git a/studio/backend/routes/auth.py b/studio/backend/routes/auth.py index 83cc41e83b..4b586d1432 100644 --- a/studio/backend/routes/auth.py +++ b/studio/backend/routes/auth.py @@ -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, ) diff --git a/studio/frontend/src/app/auth-guards.ts b/studio/frontend/src/app/auth-guards.ts index 8ec1c92666..1dcdfcb143 100644 --- a/studio/frontend/src/app/auth-guards.ts +++ b/studio/frontend/src/app/auth-guards.ts @@ -6,6 +6,7 @@ import { getPostAuthRoute, hasAuthToken, hasRefreshToken, + mustChangePassword, refreshSession, } from "@/features/auth"; @@ -26,13 +27,44 @@ async function checkAuthInitialized(): Promise { } } +async function checkPasswordChangeRequired(): Promise { + 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 { - 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 { if (!(await hasActiveSession())) return; throw redirect({ to: getPostAuthRoute() }); } + +export async function requirePasswordChangeFlow(): Promise { + 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" }); +} diff --git a/studio/frontend/src/app/router.tsx b/studio/frontend/src/app/router.tsx index d4046559a3..13ff8a5cbe 100644 --- a/studio/frontend/src/app/router.tsx +++ b/studio/frontend/src/app/router.tsx @@ -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, diff --git a/studio/frontend/src/app/routes/__root.tsx b/studio/frontend/src/app/routes/__root.tsx index 903e618d3e..a58343d0c5 100644 --- a/studio/frontend/src/app/routes/__root.tsx +++ b/studio/frontend/src/app/routes/__root.tsx @@ -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 }); diff --git a/studio/frontend/src/app/routes/signup.tsx b/studio/frontend/src/app/routes/change-password.tsx similarity index 62% rename from studio/frontend/src/app/routes/signup.tsx rename to studio/frontend/src/app/routes/change-password.tsx index 8c93f1f09b..61b5194160 100644 --- a/studio/frontend/src/app/routes/signup.tsx +++ b/studio/frontend/src/app/routes/change-password.tsx @@ -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, }); diff --git a/studio/frontend/src/features/auth/api.ts b/studio/frontend/src/features/auth/api.ts index 751c7fd3e4..e2e4932936 100644 --- a/studio/frontend/src/features/auth/api.ts +++ b/studio/frontend/src/features/auth/api.ts @@ -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 { + 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 { if (isRedirecting) return; isRedirecting = true; @@ -23,8 +36,8 @@ async function redirectToAuth(): Promise { 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 { } 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) { diff --git a/studio/frontend/src/features/auth/signup-page.tsx b/studio/frontend/src/features/auth/change-password-page.tsx similarity index 90% rename from studio/frontend/src/features/auth/signup-page.tsx rename to studio/frontend/src/features/auth/change-password-page.tsx index 3163ca179f..dd4ba711e0 100644 --- a/studio/frontend/src/features/auth/signup-page.tsx +++ b/studio/frontend/src/features/auth/change-password-page.tsx @@ -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 (
- +
); diff --git a/studio/frontend/src/features/auth/components/auth-form.tsx b/studio/frontend/src/features/auth/components/auth-form.tsx index a06ceb91da..d51fc2814c 100644 --- a/studio/frontend/src/features/auth/components/auth-form.tsx +++ b/studio/frontend/src/features/auth/components/auth-form.tsx @@ -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 { + 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(null); + const [requiresPasswordChange, setRequiresPasswordChange] = useState(false); const [error, setError] = useState(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) { 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 { setUsername(event.target.value)} required + disabled={!isLoginMode} />
- +
setPassword(event.target.value)} minLength={8} @@ -213,22 +312,24 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null { )}
- {!isLoginMode && ( -

Must be at least 8 characters

+ {isLoginMode ? null : ( +

Confirm the seeded password before choosing a new one.

)}
{!isLoginMode && (
- + setSetupToken(event.target.value)} + id="new-password" + type="password" + autoComplete="new-password" + value={newPassword} + onChange={(event) => setNewPassword(event.target.value)} + minLength={8} required /> +

Must be at least 8 characters and different from the default password.

)} @@ -240,18 +341,26 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null { -

- {switchText} - - {switchLinkText} - -

+ {showSwitchLink && ( +

+ {switchText} + + {switchLinkText} + +

+ )} ); } diff --git a/studio/frontend/src/features/auth/index.ts b/studio/frontend/src/features/auth/index.ts index 79631b69cb..75db92432c 100644 --- a/studio/frontend/src/features/auth/index.ts +++ b/studio/frontend/src/features/auth/index.ts @@ -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"; diff --git a/studio/frontend/src/features/auth/session.ts b/studio/frontend/src/features/auth/session.ts index df8e30bbce..5ee2a6b13b 100644 --- a/studio/frontend/src/features/auth/session.ts +++ b/studio/frontend/src/features/auth/session.ts @@ -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"; }