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:
parent
b20b3b80df
commit
629199e3a6
18 changed files with 660 additions and 260 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,3 +13,4 @@ addict
|
|||
gradio>=4.0.0
|
||||
huggingface-hub==0.36.2
|
||||
structlog>=24.1.0
|
||||
diceware
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue