feat: 增加用户认证系统

This commit is contained in:
ViperEkura 2026-03-26 20:47:51 +08:00
parent 55e28b8d3b
commit 39fb220cf2
12 changed files with 492 additions and 90 deletions

View File

@ -31,14 +31,16 @@ pip install -e .
backend_port: 3000 backend_port: 3000
frontend_port: 4000 frontend_port: 4000
# LLM API # LLM API (global defaults, can be overridden per model)
api_key: {{your-api-key}} default_api_key: {{your-api-key}}
api_url: https://open.bigmodel.cn/api/paas/v4/chat/completions default_api_url: https://open.bigmodel.cn/api/paas/v4/chat/completions
# Available models # Available models (each model can optionally specify its own api_key and api_url)
models: models:
- id: glm-5 - id: glm-5
name: GLM-5 name: GLM-5
# api_key: xxx # Optional, falls back to default_api_key
# api_url: xxx # Optional, falls back to default_api_url
- id: glm-4-plus - id: glm-4-plus
name: GLM-4 Plus name: GLM-4 Plus
@ -47,6 +49,14 @@ default_model: glm-5
# Workspace root directory # Workspace root directory
workspace_root: ./workspaces workspace_root: ./workspaces
# Authentication
# "single": Single-user mode - no login required, auto-creates default user
# "multi": Multi-user mode - requires JWT, users must register/login
auth_mode: single
# JWT secret (only used in multi-user mode, change for production!)
jwt_secret: nano-claw-default-secret-change-in-production
# Database Configuration # Database Configuration
db_type: sqlite db_type: sqlite
db_sqlite_file: nano_claw.db db_sqlite_file: nano_claw.db
@ -72,6 +82,7 @@ npm run dev
backend/ backend/
├── models.py # SQLAlchemy 数据模型 ├── models.py # SQLAlchemy 数据模型
├── routes/ # API 路由 ├── routes/ # API 路由
│ ├── auth.py # 认证(登录/注册/JWT
│ ├── conversations.py │ ├── conversations.py
│ ├── messages.py │ ├── messages.py
│ ├── projects.py # 项目管理 │ ├── projects.py # 项目管理
@ -117,7 +128,8 @@ frontend/
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|------|------|------| |------|------|------|
| `POST` | `/api/conversations` | 创建会话 | | `POST` | `/api/auth/login` | 用户登录 |
| `POST` | `/api/auth/register` | 用户注册 |
| `GET` | `/api/conversations` | 会话列表 | | `GET` | `/api/conversations` | 会话列表 |
| `GET` | `/api/conversations/:id/messages` | 消息列表 | | `GET` | `/api/conversations/:id/messages` | 消息列表 |
| `POST` | `/api/conversations/:id/messages` | 发送消息SSE 流式) | | `POST` | `/api/conversations/:id/messages` | 发送消息SSE 流式) |

View File

@ -21,12 +21,49 @@ class User(db.Model):
id = db.Column(db.Integer, primary_key=True, autoincrement=True) id = db.Column(db.Integer, primary_key=True, autoincrement=True)
username = db.Column(db.String(50), unique=True, nullable=False) username = db.Column(db.String(50), unique=True, nullable=False)
password = db.Column(db.String(255), nullable=True) # Allow NULL for third-party login password_hash = db.Column(db.String(255), nullable=True) # NULL for API-key-only auth
phone = db.Column(db.String(20)) email = db.Column(db.String(120), unique=True, nullable=True)
avatar = db.Column(db.String(512), nullable=True)
role = db.Column(db.String(20), nullable=False, default="user") # user, admin
is_active = db.Column(db.Boolean, nullable=False, default=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
last_login_at = db.Column(db.DateTime, nullable=True)
conversations = db.relationship("Conversation", backref="user", lazy="dynamic", conversations = db.relationship("Conversation", backref="user", lazy="dynamic",
cascade="all, delete-orphan", cascade="all, delete-orphan",
order_by="Conversation.updated_at.desc()") order_by="Conversation.updated_at.desc()")
projects = db.relationship("Project", backref="user", lazy="dynamic",
cascade="all, delete-orphan")
@property
def password(self):
raise AttributeError("password is not readable")
@password.setter
def password(self, plain):
if plain:
from werkzeug.security import generate_password_hash
self.password_hash = generate_password_hash(plain)
else:
self.password_hash = None
def check_password(self, plain):
if not self.password_hash:
return False
from werkzeug.security import check_password_hash
return check_password_hash(self.password_hash, plain)
def to_dict(self):
return {
"id": self.id,
"username": self.username,
"email": self.email,
"avatar": self.avatar,
"role": self.role,
"is_active": self.is_active,
"created_at": self.created_at.isoformat() if self.created_at else None,
"last_login_at": self.last_login_at.isoformat() if self.last_login_at else None,
}
class Conversation(db.Model): class Conversation(db.Model):

View File

@ -6,6 +6,7 @@ from backend.routes.models import bp as models_bp
from backend.routes.tools import bp as tools_bp from backend.routes.tools import bp as tools_bp
from backend.routes.stats import bp as stats_bp from backend.routes.stats import bp as stats_bp
from backend.routes.projects import bp as projects_bp from backend.routes.projects import bp as projects_bp
from backend.routes.auth import bp as auth_bp, init_auth
from backend.services.glm_client import GLMClient from backend.services.glm_client import GLMClient
from backend.config import MODEL_CONFIG from backend.config import MODEL_CONFIG
@ -16,7 +17,11 @@ def register_routes(app: Flask):
glm_client = GLMClient(MODEL_CONFIG) glm_client = GLMClient(MODEL_CONFIG)
init_chat_service(glm_client) init_chat_service(glm_client)
# Initialize authentication system (reads auth_mode from config.yml)
init_auth(app)
# Register blueprints # Register blueprints
app.register_blueprint(auth_bp)
app.register_blueprint(conversations_bp) app.register_blueprint(conversations_bp)
app.register_blueprint(messages_bp) app.register_blueprint(messages_bp)
app.register_blueprint(models_bp) app.register_blueprint(models_bp)

259
backend/routes/auth.py Normal file
View File

@ -0,0 +1,259 @@
"""Authentication module - supports both single-user and multi-user modes.
Single-user mode (auth_mode: "single"):
- Auto-creates a default user on first startup
- All requests are authenticated as the default user (no token needed)
- Login endpoint returns a convenience token but it's optional
Multi-user mode (auth_mode: "multi"):
- Requires JWT token for all API requests (except login/register)
- Users must register and login to get a token
- Supports admin/user roles
"""
import time
import jwt
from datetime import datetime, timezone
from functools import wraps
from flask import Blueprint, request, g, current_app
from backend import db
from backend.models import User
from backend.utils.helpers import ok, err
bp = Blueprint("auth", __name__)
# Routes that don't require authentication
PUBLIC_ROUTES = {
"POST:/api/auth/login",
"POST:/api/auth/register",
"GET:/api/models",
"GET:/api/tools",
}
def get_auth_config():
"""Get auth configuration from app config."""
return current_app.config.get("AUTH_CONFIG", {
"mode": "single", # "single" or "multi"
"jwt_secret": "nano-claw-default-secret-change-in-production",
"jwt_expiry": 7 * 24 * 3600, # 7 days in seconds
})
def generate_token(user):
"""Generate a JWT token for a user."""
cfg = get_auth_config()
payload = {
"user_id": user.id,
"username": user.username,
"role": user.role,
"exp": int(time.time()) + cfg["jwt_expiry"],
}
return jwt.encode(payload, cfg["jwt_secret"], algorithm="HS256")
def _resolve_user():
"""Resolve the current user from request context.
In single-user mode: auto-creates and returns the default user.
In multi-user mode: validates the JWT token from Authorization header.
Returns None if authentication fails in multi-user mode.
"""
cfg = get_auth_config()
if cfg["mode"] == "single":
user = User.query.filter_by(username="default").first()
if not user:
user = User(username="default", role="admin")
db.session.add(user)
db.session.commit()
return user
# Multi-user mode: validate JWT
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return None
token = auth_header[7:]
try:
payload = jwt.decode(token, cfg["jwt_secret"], algorithms=["HS256"])
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
return None
user = db.session.get(User, payload.get("user_id"))
if not user or not user.is_active:
return None
return user
def init_auth(app):
"""Register authentication hooks on the Flask app."""
cfg_path = app.config.get("AUTH_CONFIG_PATH")
if cfg_path:
from backend import load_config
full_cfg = load_config()
auth_mode = full_cfg.get("auth_mode", "single")
jwt_secret = full_cfg.get("jwt_secret", "nano-claw-default-secret-change-in-production")
else:
auth_mode = "single"
jwt_secret = "nano-claw-default-secret-change-in-production"
app.config["AUTH_CONFIG"] = {
"mode": auth_mode,
"jwt_secret": jwt_secret,
"jwt_expiry": 7 * 24 * 3600,
}
@app.before_request
def before_request_auth():
"""Authenticate user before each request."""
method_path = f"{request.method}:{request.path}"
# Skip auth for public routes
if method_path in PUBLIC_ROUTES:
return None
# Skip auth for static files
if request.path.startswith("/static"):
return None
# In single-user mode, always set the default user
cfg = get_auth_config()
if cfg["mode"] == "single":
g.current_user = _resolve_user()
return None
# Multi-user mode: validate token
user = _resolve_user()
if not user:
return err(401, "Unauthorized - please login")
g.current_user = user
# Update last_login_at (debounced: at most once per hour)
if (not user.last_login_at or
(datetime.now(timezone.utc) - user.last_login_at).total_seconds() > 3600):
user.last_login_at = datetime.now(timezone.utc)
db.session.commit()
return None
# --- Auth API Routes ---
@bp.route("/api/auth/login", methods=["POST"])
def login():
"""User login - returns JWT token."""
cfg = get_auth_config()
# Single-user mode: just return the default user's token
if cfg["mode"] == "single":
user = User.query.filter_by(username="default").first()
if not user:
return err(500, "Default user not initialized")
return ok({
"token": generate_token(user),
"user": user.to_dict(),
})
# Multi-user mode: validate credentials
data = request.get_json(silent=True) or {}
username = data.get("username", "").strip()
password = data.get("password", "")
if not username or not password:
return err(400, "Username and password are required")
user = User.query.filter_by(username=username).first()
if not user or not user.check_password(password):
return err(401, "Invalid username or password")
if not user.is_active:
return err(403, "Account is disabled")
user.last_login_at = datetime.now(timezone.utc)
db.session.commit()
return ok({
"token": generate_token(user),
"user": user.to_dict(),
})
@bp.route("/api/auth/register", methods=["POST"])
def register():
"""User registration - only available in multi-user mode."""
cfg = get_auth_config()
if cfg["mode"] == "single":
return err(403, "Registration is disabled in single-user mode")
data = request.get_json(silent=True) or {}
username = data.get("username", "").strip()
password = data.get("password", "")
email = data.get("email", "").strip() or None
if not username or not password:
return err(400, "Username and password are required")
if len(username) < 2 or len(username) > 50:
return err(400, "Username must be 2-50 characters")
if len(password) < 4:
return err(400, "Password must be at least 4 characters")
if email and "@" not in email:
return err(400, "Invalid email format")
if User.query.filter_by(username=username).first():
return err(409, f"Username '{username}' already exists")
if email and User.query.filter_by(email=email).first():
return err(409, "Email already registered")
user = User(username=username, password=password, email=email)
db.session.add(user)
db.session.commit()
return ok({
"token": generate_token(user),
"user": user.to_dict(),
})
@bp.route("/api/auth/profile", methods=["GET"])
def get_profile():
"""Get current user profile."""
user = getattr(g, "current_user", None)
if not user:
return err(401, "Not authenticated")
return ok(user.to_dict())
@bp.route("/api/auth/profile", methods=["PATCH"])
def update_profile():
"""Update current user profile."""
user = getattr(g, "current_user", None)
if not user:
return err(401, "Not authenticated")
data = request.get_json(silent=True) or {}
if "email" in data:
new_email = data["email"].strip() or None
if new_email and new_email != user.email:
if User.query.filter_by(email=new_email).first():
return err(409, "Email already registered")
user.email = new_email
if "avatar" in data:
user.avatar = data["avatar"]
if "password" in data:
new_password = data["password"]
if len(new_password) < 4:
return err(400, "Password must be at least 4 characters")
user.password = new_password
db.session.commit()
return ok(user.to_dict())
@bp.route("/api/auth/mode", methods=["GET"])
def get_auth_mode():
"""Get current authentication mode (public endpoint)."""
cfg = get_auth_config()
return ok({"mode": cfg["mode"]})

View File

@ -1,10 +1,10 @@
"""Conversation API routes""" """Conversation API routes"""
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from flask import Blueprint, request from flask import Blueprint, request, g
from backend import db from backend import db
from backend.models import Conversation, Project from backend.models import Conversation, Project
from backend.utils.helpers import ok, err, to_dict, get_or_create_default_user from backend.utils.helpers import ok, err, to_dict
from backend.config import DEFAULT_MODEL from backend.config import DEFAULT_MODEL
bp = Blueprint("conversations", __name__) bp = Blueprint("conversations", __name__)
@ -23,15 +23,16 @@ def _conv_to_dict(conv, **extra):
@bp.route("/api/conversations", methods=["GET", "POST"]) @bp.route("/api/conversations", methods=["GET", "POST"])
def conversation_list(): def conversation_list():
"""List or create conversations""" """List or create conversations"""
user = g.current_user
if request.method == "POST": if request.method == "POST":
d = request.json or {} d = request.json or {}
user = get_or_create_default_user()
# Validate project_id if provided # Validate project_id if provided
project_id = d.get("project_id") project_id = d.get("project_id")
if project_id: if project_id:
project = db.session.get(Project, project_id) project = db.session.get(Project, project_id)
if not project: if not project or project.user_id != user.id:
return err(404, "Project not found") return err(404, "Project not found")
conv = Conversation( conv = Conversation(
@ -53,7 +54,6 @@ def conversation_list():
cursor = request.args.get("cursor") cursor = request.args.get("cursor")
limit = min(int(request.args.get("limit", 20)), 100) limit = min(int(request.args.get("limit", 20)), 100)
project_id = request.args.get("project_id") project_id = request.args.get("project_id")
user = get_or_create_default_user()
q = Conversation.query.filter_by(user_id=user.id) q = Conversation.query.filter_by(user_id=user.id)
# Filter by project if specified # Filter by project if specified
@ -76,8 +76,9 @@ def conversation_list():
@bp.route("/api/conversations/<conv_id>", methods=["GET", "PATCH", "DELETE"]) @bp.route("/api/conversations/<conv_id>", methods=["GET", "PATCH", "DELETE"])
def conversation_detail(conv_id): def conversation_detail(conv_id):
"""Get, update or delete a conversation""" """Get, update or delete a conversation"""
user = g.current_user
conv = db.session.get(Conversation, conv_id) conv = db.session.get(Conversation, conv_id)
if not conv: if not conv or conv.user_id != user.id:
return err(404, "conversation not found") return err(404, "conversation not found")
if request.method == "GET": if request.method == "GET":
@ -99,7 +100,7 @@ def conversation_detail(conv_id):
project_id = d["project_id"] project_id = d["project_id"]
if project_id: if project_id:
project = db.session.get(Project, project_id) project = db.session.get(Project, project_id)
if not project: if not project or project.user_id != user.id:
return err(404, "Project not found") return err(404, "Project not found")
conv.project_id = project_id or None conv.project_id = project_id or None

View File

@ -2,13 +2,12 @@
import json import json
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from flask import Blueprint, request from flask import Blueprint, request, g
from backend import db from backend import db
from backend.models import Conversation, Message from backend.models import Conversation, Message
from backend.utils.helpers import ok, err, message_to_dict from backend.utils.helpers import ok, err, message_to_dict
from backend.services.chat import ChatService from backend.services.chat import ChatService
bp = Blueprint("messages", __name__) bp = Blueprint("messages", __name__)
# ChatService will be injected during registration # ChatService will be injected during registration
@ -21,10 +20,18 @@ def init_chat_service(glm_client):
_chat_service = ChatService(glm_client) _chat_service = ChatService(glm_client)
def _get_conv(conv_id):
"""Get conversation with ownership check."""
conv = db.session.get(Conversation, conv_id)
if not conv or conv.user_id != g.current_user.id:
return None
return conv
@bp.route("/api/conversations/<conv_id>/messages", methods=["GET", "POST"]) @bp.route("/api/conversations/<conv_id>/messages", methods=["GET", "POST"])
def message_list(conv_id): def message_list(conv_id):
"""List or create messages""" """List or create messages"""
conv = db.session.get(Conversation, conv_id) conv = _get_conv(conv_id)
if not conv: if not conv:
return err(404, "conversation not found") return err(404, "conversation not found")
@ -75,7 +82,7 @@ def message_list(conv_id):
@bp.route("/api/conversations/<conv_id>/messages/<msg_id>", methods=["DELETE"]) @bp.route("/api/conversations/<conv_id>/messages/<msg_id>", methods=["DELETE"])
def delete_message(conv_id, msg_id): def delete_message(conv_id, msg_id):
"""Delete a message""" """Delete a message"""
conv = db.session.get(Conversation, conv_id) conv = _get_conv(conv_id)
if not conv: if not conv:
return err(404, "conversation not found") return err(404, "conversation not found")
msg = db.session.get(Message, msg_id) msg = db.session.get(Message, msg_id)
@ -89,7 +96,7 @@ def delete_message(conv_id, msg_id):
@bp.route("/api/conversations/<conv_id>/regenerate/<msg_id>", methods=["POST"]) @bp.route("/api/conversations/<conv_id>/regenerate/<msg_id>", methods=["POST"])
def regenerate_message(conv_id, msg_id): def regenerate_message(conv_id, msg_id):
"""Regenerate an assistant message""" """Regenerate an assistant message"""
conv = db.session.get(Conversation, conv_id) conv = _get_conv(conv_id)
if not conv: if not conv:
return err(404, "conversation not found") return err(404, "conversation not found")

View File

@ -2,10 +2,10 @@
import os import os
import uuid import uuid
import shutil import shutil
from flask import Blueprint, request from flask import Blueprint, request, g
from backend import db from backend import db
from backend.models import Project, User from backend.models import Project
from backend.utils.helpers import ok, err from backend.utils.helpers import ok, err
from backend.utils.workspace import ( from backend.utils.workspace import (
create_project_directory, create_project_directory,
@ -20,13 +20,9 @@ bp = Blueprint("projects", __name__)
@bp.route("/api/projects", methods=["GET"]) @bp.route("/api/projects", methods=["GET"])
def list_projects(): def list_projects():
"""List all projects for a user""" """List all projects for current user"""
user_id = request.args.get("user_id", type=int) user = g.current_user
projects = Project.query.filter_by(user_id=user.id).order_by(Project.updated_at.desc()).all()
if not user_id:
return err(400, "Missing user_id parameter")
projects = Project.query.filter_by(user_id=user_id).order_by(Project.updated_at.desc()).all()
return ok({ return ok({
"projects": [ "projects": [
@ -48,41 +44,33 @@ def list_projects():
@bp.route("/api/projects", methods=["POST"]) @bp.route("/api/projects", methods=["POST"])
def create_project(): def create_project():
"""Create a new project""" """Create a new project"""
user = g.current_user
data = request.get_json() data = request.get_json()
if not data: if not data:
return err(400, "No data provided") return err(400, "No data provided")
user_id = data.get("user_id")
name = data.get("name", "").strip() name = data.get("name", "").strip()
description = data.get("description", "") description = data.get("description", "")
if not user_id:
return err(400, "Missing user_id")
if not name: if not name:
return err(400, "Project name is required") return err(400, "Project name is required")
# Check if user exists
user = User.query.get(user_id)
if not user:
return err(404, "User not found")
# Check if project name already exists for this user # Check if project name already exists for this user
existing = Project.query.filter_by(user_id=user_id, name=name).first() existing = Project.query.filter_by(user_id=user.id, name=name).first()
if existing: if existing:
return err(400, f"Project '{name}' already exists") return err(400, f"Project '{name}' already exists")
# Create project directory # Create project directory
try: try:
relative_path, absolute_path = create_project_directory(name, user_id) relative_path, absolute_path = create_project_directory(name, user.id)
except Exception as e: except Exception as e:
return err(500, f"Failed to create project directory: {str(e)}") return err(500, f"Failed to create project directory: {str(e)}")
# Create project record # Create project record
project = Project( project = Project(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
user_id=user_id, user_id=user.id,
name=name, name=name,
path=relative_path, path=relative_path,
description=description description=description
@ -202,15 +190,12 @@ def delete_project(project_id):
@bp.route("/api/projects/upload", methods=["POST"]) @bp.route("/api/projects/upload", methods=["POST"])
def upload_project_folder(): def upload_project_folder():
"""Upload a folder as a new project via file upload""" """Upload a folder as a new project via file upload"""
user_id = request.form.get("user_id", type=int) user = g.current_user
project_name = request.form.get("name", "").strip() project_name = request.form.get("name", "").strip()
description = request.form.get("description", "") description = request.form.get("description", "")
files = request.files.getlist("files") files = request.files.getlist("files")
if not user_id:
return err(400, "Missing user_id")
if not files: if not files:
return err(400, "No files uploaded") return err(400, "No files uploaded")
@ -218,19 +203,14 @@ def upload_project_folder():
# Use first file's top-level folder name # Use first file's top-level folder name
project_name = files[0].filename.split("/")[0] if files[0].filename else "untitled" project_name = files[0].filename.split("/")[0] if files[0].filename else "untitled"
# Check if user exists
user = User.query.get(user_id)
if not user:
return err(404, "User not found")
# Check if project name already exists # Check if project name already exists
existing = Project.query.filter_by(user_id=user_id, name=project_name).first() existing = Project.query.filter_by(user_id=user.id, name=project_name).first()
if existing: if existing:
return err(400, f"Project '{project_name}' already exists") return err(400, f"Project '{project_name}' already exists")
# Create project directory first # Create project directory first
try: try:
relative_path, absolute_path = create_project_directory(project_name, user_id) relative_path, absolute_path = create_project_directory(project_name, user.id)
except Exception as e: except Exception as e:
return err(500, f"Failed to create project directory: {str(e)}") return err(500, f"Failed to create project directory: {str(e)}")

View File

@ -1,9 +1,9 @@
"""Token statistics API routes""" """Token statistics API routes"""
from datetime import date, timedelta from datetime import date, timedelta
from flask import Blueprint, request from flask import Blueprint, request, g
from sqlalchemy import func from sqlalchemy import func
from backend.models import TokenUsage from backend.models import TokenUsage
from backend.utils.helpers import ok, err, get_or_create_default_user from backend.utils.helpers import ok, err
bp = Blueprint("stats", __name__) bp = Blueprint("stats", __name__)
@ -11,7 +11,7 @@ bp = Blueprint("stats", __name__)
@bp.route("/api/stats/tokens", methods=["GET"]) @bp.route("/api/stats/tokens", methods=["GET"])
def token_stats(): def token_stats():
"""Get token usage statistics""" """Get token usage statistics"""
user = get_or_create_default_user() user = g.current_user
period = request.args.get("period", "daily") period = request.args.get("period", "daily")
today = date.today() today = date.today()

View File

@ -1,12 +1,11 @@
"""Chat completion service""" """Chat completion service"""
import json import json
import uuid import uuid
from flask import current_app, Response from flask import current_app, g, Response
from backend import db from backend import db
from backend.models import Conversation, Message from backend.models import Conversation, Message
from backend.tools import registry, ToolExecutor from backend.tools import registry, ToolExecutor
from backend.utils.helpers import ( from backend.utils.helpers import (
get_or_create_default_user,
record_token_usage, record_token_usage,
build_messages, build_messages,
) )
@ -205,8 +204,9 @@ class ChatService:
db.session.add(msg) db.session.add(msg)
db.session.commit() db.session.commit()
user = get_or_create_default_user() user = g.get("current_user")
record_token_usage(user.id, conv_model, prompt_tokens, token_count) if user:
record_token_usage(user.id, conv_model, prompt_tokens, token_count)
# Check if we need to set title (first message in conversation) # Check if we need to set title (first message in conversation)
conv = db.session.get(Conversation, conv_id) conv = db.session.get(Conversation, conv_id)

View File

@ -7,8 +7,24 @@ from backend import db
from backend.models import Message, TokenUsage, User from backend.models import Message, TokenUsage, User
def get_current_user():
"""Get the current authenticated user from request context (g.current_user)."""
from flask import g
return getattr(g, "current_user", None)
def get_or_create_default_user() -> User: def get_or_create_default_user() -> User:
"""Get or create default user""" """Get or create default user.
.. deprecated::
Use g.current_user instead. This is kept for backward compatibility
and will be removed in a future version.
"""
from flask import g
user = getattr(g, "current_user", None)
if user:
return user
# Fallback: look up or create default user (should not happen with auth middleware)
user = User.query.filter_by(username="default").first() user = User.query.filter_by(username="default").first()
if not user: if not user:
user = User(username="default", password=None) user = User(username="default", password=None)

View File

@ -42,6 +42,7 @@ backend/
├── routes/ # API 路由 ├── routes/ # API 路由
│ ├── __init__.py │ ├── __init__.py
│ ├── auth.py # 认证(登录/注册/JWT
│ ├── conversations.py # 会话 CRUD │ ├── conversations.py # 会话 CRUD
│ ├── messages.py # 消息 CRUD + 聊天 │ ├── messages.py # 消息 CRUD + 聊天
│ ├── models.py # 模型列表 │ ├── models.py # 模型列表
@ -89,10 +90,18 @@ classDiagram
class User { class User {
+Integer id +Integer id
+String username +String username
+String password +String password_hash
+String phone +String email
+String avatar
+String role
+Boolean is_active
+DateTime created_at
+DateTime last_login_at
+relationship conversations +relationship conversations
+relationship projects +relationship projects
+to_dict() dict
+check_password(str) bool
+password(str)$ # property setter, 自动 hash
} }
class Project { class Project {
@ -352,6 +361,16 @@ def process_tool_calls(self, tool_calls, context=None):
## API 总览 ## API 总览
### 认证
| 方法 | 路径 | 说明 |
|------|------|------|
| `GET` | `/api/auth/mode` | 获取当前认证模式(公开端点) |
| `POST` | `/api/auth/login` | 用户登录,返回 JWT token |
| `POST` | `/api/auth/register` | 用户注册(仅多用户模式可用) |
| `GET` | `/api/auth/profile` | 获取当前用户信息 |
| `PATCH` | `/api/auth/profile` | 更新当前用户信息 |
### 会话管理 ### 会话管理
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
@ -441,12 +460,19 @@ def process_tool_calls(self, tool_calls, context=None):
### User用户 ### User用户
| 字段 | 类型 | 说明 | | 字段 | 类型 | 默认值 | 说明 |
|------|------|------| |------|------|--------|------|
| `id` | Integer | 自增主键 | | `id` | Integer | - | 自增主键 |
| `username` | String(50) | 用户名(唯一) | | `username` | String(50) | - | 用户名(唯一) |
| `password` | String(255) | 密码(可为空,支持第三方登录) | | `password_hash` | String(255) | null | 密码哈希(可为空,支持 API-key-only 认证) |
| `phone` | String(20) | 手机号 | | `email` | String(120) | null | 邮箱(唯一) |
| `avatar` | String(512) | null | 头像 URL |
| `role` | String(20) | "user" | 角色:`user` / `admin` |
| `is_active` | Boolean | true | 是否激活 |
| `created_at` | DateTime | now | 创建时间 |
| `last_login_at` | DateTime | null | 最后登录时间 |
`password` 通过 property setter 自动调用 `werkzeug``generate_password_hash` 存储,通过 `check_password()` 方法验证。
### Project项目 ### Project项目
@ -527,13 +553,66 @@ GET /api/conversations?limit=20&cursor=conv_abc123
--- ---
## 错误码 ## 认证机制
### 概述
系统支持**单用户模式**和**多用户模式**,通过 `config.yml` 中的 `auth_mode` 切换。
### 单用户模式(`auth_mode: single`,默认)
- **无需登录**,前端不需要传 token
- 后端自动创建一个 `username="default"`、`role="admin"` 的用户
- 每次请求通过 `before_request` 钩子自动将 `g.current_user` 设为该默认用户
- 所有路由从 `g.current_user` 获取当前用户,无需前端传递 `user_id`
### 多用户模式(`auth_mode: multi`
- 除公开端点外,所有请求必须在 `Authorization` 头中携带 JWT token
- 用户通过 `/api/auth/register` 注册、`/api/auth/login` 登录获取 token
- Token 有效期 7 天,过期需重新登录
- 用户只能访问自己的数据(对话、项目、统计等)
### 认证流程
```
单用户模式:
请求 → before_request → 查找/创建 default 用户 → g.current_user → 路由处理
多用户模式:
请求 → before_request → 提取 Authorization header → 验证 JWT → 查找用户 → g.current_user → 路由处理
↓ 失败
返回 401
```
### 公开端点(无需认证)
| 端点 | 说明 |
|------|------|
| `POST /api/auth/login` | 登录 |
| `POST /api/auth/register` | 注册 |
| `GET /api/models` | 模型列表 |
| `GET /api/tools` | 工具列表 |
### 前端适配
前端 API 层(`frontend/src/api/index.js`)已预留 token 管理:
- `getToken()` / `setToken(token)` / `clearToken()`
- 所有请求自动附带 `Authorization: Bearer <token>`token 为空时不发送)
- 收到 401 时自动清除 token
切换到多用户模式时,只需补充登录/注册页面 UI。
---
| Code | 说明 | | Code | 说明 |
|------|------| |------|------|
| `0` | 成功 | | `0` | 成功 |
| `400` | 请求参数错误 | | `400` | 请求参数错误 |
| `401` | 未认证(多用户模式下缺少或无效 token |
| `403` | 禁止访问(账户禁用、单用户模式下注册等) |
| `404` | 资源不存在 | | `404` | 资源不存在 |
| `409` | 资源冲突(用户名/邮箱已存在) |
| `500` | 服务器错误 | | `500` | 服务器错误 |
错误响应: 错误响应:
@ -749,6 +828,11 @@ default_model: glm-5
# 工作区根目录 # 工作区根目录
workspace_root: ./workspaces workspace_root: ./workspaces
# 认证模式single单用户无需登录 / multi多用户需要 JWT
auth_mode: single
# JWT 密钥(仅多用户模式使用,生产环境请替换为随机值)
jwt_secret: nano-claw-default-secret-change-in-production
# 数据库 # 数据库
db_type: mysql # mysql, sqlite, postgresql db_type: mysql # mysql, sqlite, postgresql
db_host: localhost db_host: localhost

View File

@ -15,6 +15,7 @@ dependencies = [
"beautifulsoup4>=4.12", "beautifulsoup4>=4.12",
"httpx>=0.25", "httpx>=0.25",
"lxml>=5.0", "lxml>=5.0",
"PyJWT>=2.8",
] ]
[build-system] [build-system]