From 39fb220cf2565d5b7f34ca2d34aca9239cf35c49 Mon Sep 17 00:00:00 2001 From: ViperEkura <3081035982@qq.com> Date: Thu, 26 Mar 2026 20:47:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 22 ++- backend/models.py | 41 ++++- backend/routes/__init__.py | 7 +- backend/routes/auth.py | 259 ++++++++++++++++++++++++++++++++ backend/routes/conversations.py | 33 ++-- backend/routes/messages.py | 25 +-- backend/routes/projects.py | 60 +++----- backend/routes/stats.py | 6 +- backend/services/chat.py | 8 +- backend/utils/helpers.py | 18 ++- docs/Design.md | 102 +++++++++++-- pyproject.toml | 1 + 12 files changed, 492 insertions(+), 90 deletions(-) create mode 100644 backend/routes/auth.py diff --git a/README.md b/README.md index 1213fb5..163c2df 100644 --- a/README.md +++ b/README.md @@ -31,14 +31,16 @@ pip install -e . backend_port: 3000 frontend_port: 4000 -# LLM API -api_key: {{your-api-key}} -api_url: https://open.bigmodel.cn/api/paas/v4/chat/completions +# LLM API (global defaults, can be overridden per model) +default_api_key: {{your-api-key}} +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: - id: 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 name: GLM-4 Plus @@ -47,6 +49,14 @@ default_model: glm-5 # Workspace root directory 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 db_type: sqlite db_sqlite_file: nano_claw.db @@ -72,6 +82,7 @@ npm run dev backend/ ├── models.py # SQLAlchemy 数据模型 ├── routes/ # API 路由 +│ ├── auth.py # 认证(登录/注册/JWT) │ ├── conversations.py │ ├── messages.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/:id/messages` | 消息列表 | | `POST` | `/api/conversations/:id/messages` | 发送消息(SSE 流式) | diff --git a/backend/models.py b/backend/models.py index 3f05f1b..e7d4e39 100644 --- a/backend/models.py +++ b/backend/models.py @@ -21,12 +21,49 @@ class User(db.Model): id = db.Column(db.Integer, primary_key=True, autoincrement=True) username = db.Column(db.String(50), unique=True, nullable=False) - password = db.Column(db.String(255), nullable=True) # Allow NULL for third-party login - phone = db.Column(db.String(20)) + password_hash = db.Column(db.String(255), nullable=True) # NULL for API-key-only auth + 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", cascade="all, delete-orphan", 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): diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py index aa91f5d..e7820ce 100644 --- a/backend/routes/__init__.py +++ b/backend/routes/__init__.py @@ -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.stats import bp as stats_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.config import MODEL_CONFIG @@ -15,8 +16,12 @@ def register_routes(app: Flask): # Initialize GLM client with per-model config glm_client = GLMClient(MODEL_CONFIG) init_chat_service(glm_client) - + + # Initialize authentication system (reads auth_mode from config.yml) + init_auth(app) + # Register blueprints + app.register_blueprint(auth_bp) app.register_blueprint(conversations_bp) app.register_blueprint(messages_bp) app.register_blueprint(models_bp) diff --git a/backend/routes/auth.py b/backend/routes/auth.py new file mode 100644 index 0000000..ecb1870 --- /dev/null +++ b/backend/routes/auth.py @@ -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"]}) diff --git a/backend/routes/conversations.py b/backend/routes/conversations.py index b88603a..d84c30f 100644 --- a/backend/routes/conversations.py +++ b/backend/routes/conversations.py @@ -1,10 +1,10 @@ """Conversation API routes""" import uuid from datetime import datetime, timezone -from flask import Blueprint, request +from flask import Blueprint, request, g from backend import db 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 bp = Blueprint("conversations", __name__) @@ -23,15 +23,16 @@ def _conv_to_dict(conv, **extra): @bp.route("/api/conversations", methods=["GET", "POST"]) def conversation_list(): """List or create conversations""" + user = g.current_user + if request.method == "POST": d = request.json or {} - user = get_or_create_default_user() # Validate project_id if provided project_id = d.get("project_id") if 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") conv = Conversation( @@ -48,23 +49,22 @@ def conversation_list(): db.session.add(conv) db.session.commit() return ok(_conv_to_dict(conv)) - + # GET - list conversations cursor = request.args.get("cursor") limit = min(int(request.args.get("limit", 20)), 100) project_id = request.args.get("project_id") - user = get_or_create_default_user() q = Conversation.query.filter_by(user_id=user.id) - + # Filter by project if specified if project_id: q = q.filter_by(project_id=project_id) - + if cursor: q = q.filter(Conversation.updated_at < ( db.session.query(Conversation.updated_at).filter_by(id=cursor).scalar() or datetime.now(timezone.utc))) rows = q.order_by(Conversation.updated_at.desc()).limit(limit + 1).all() - + items = [_conv_to_dict(r, message_count=r.messages.count()) for r in rows[:limit]] return ok({ "items": items, @@ -76,32 +76,33 @@ def conversation_list(): @bp.route("/api/conversations/", methods=["GET", "PATCH", "DELETE"]) def conversation_detail(conv_id): """Get, update or delete a conversation""" + user = g.current_user 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") - + if request.method == "GET": return ok(_conv_to_dict(conv)) - + if request.method == "DELETE": db.session.delete(conv) db.session.commit() return ok(message="deleted") - + # PATCH - update conversation d = request.json or {} for k in ("title", "model", "system_prompt", "temperature", "max_tokens", "thinking_enabled"): if k in d: setattr(conv, k, d[k]) - + # Support updating project_id if "project_id" in d: project_id = d["project_id"] if 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") conv.project_id = project_id or None - + db.session.commit() return ok(_conv_to_dict(conv)) diff --git a/backend/routes/messages.py b/backend/routes/messages.py index 8a81c03..ff43b4c 100644 --- a/backend/routes/messages.py +++ b/backend/routes/messages.py @@ -2,13 +2,12 @@ import json import uuid from datetime import datetime, timezone -from flask import Blueprint, request +from flask import Blueprint, request, g from backend import db from backend.models import Conversation, Message from backend.utils.helpers import ok, err, message_to_dict from backend.services.chat import ChatService - bp = Blueprint("messages", __name__) # ChatService will be injected during registration @@ -21,13 +20,21 @@ def init_chat_service(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//messages", methods=["GET", "POST"]) def message_list(conv_id): """List or create messages""" - conv = db.session.get(Conversation, conv_id) + conv = _get_conv(conv_id) if not conv: return err(404, "conversation not found") - + if request.method == "GET": cursor = request.args.get("cursor") limit = min(int(request.args.get("limit", 50)), 100) @@ -36,14 +43,14 @@ def message_list(conv_id): q = q.filter(Message.created_at < ( db.session.query(Message.created_at).filter_by(id=cursor).scalar() or datetime.now(timezone.utc))) rows = q.order_by(Message.created_at.asc()).limit(limit + 1).all() - + items = [message_to_dict(r) for r in rows[:limit]] return ok({ "items": items, "next_cursor": items[-1]["id"] if len(rows) > limit else None, "has_more": len(rows) > limit, }) - + # POST - create message and get AI response d = request.json or {} text = (d.get("text") or "").strip() @@ -68,14 +75,14 @@ def message_list(conv_id): tools_enabled = d.get("tools_enabled", True) project_id = d.get("project_id") or conv.project_id - + return _chat_service.stream_response(conv, tools_enabled, project_id) @bp.route("/api/conversations//messages/", methods=["DELETE"]) def delete_message(conv_id, msg_id): """Delete a message""" - conv = db.session.get(Conversation, conv_id) + conv = _get_conv(conv_id) if not conv: return err(404, "conversation not found") msg = db.session.get(Message, msg_id) @@ -89,7 +96,7 @@ def delete_message(conv_id, msg_id): @bp.route("/api/conversations//regenerate/", methods=["POST"]) def regenerate_message(conv_id, msg_id): """Regenerate an assistant message""" - conv = db.session.get(Conversation, conv_id) + conv = _get_conv(conv_id) if not conv: return err(404, "conversation not found") diff --git a/backend/routes/projects.py b/backend/routes/projects.py index 3c835d0..acfa0fe 100644 --- a/backend/routes/projects.py +++ b/backend/routes/projects.py @@ -2,10 +2,10 @@ import os import uuid import shutil -from flask import Blueprint, request +from flask import Blueprint, request, g 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.workspace import ( create_project_directory, @@ -20,13 +20,9 @@ bp = Blueprint("projects", __name__) @bp.route("/api/projects", methods=["GET"]) def list_projects(): - """List all projects for a user""" - user_id = request.args.get("user_id", type=int) - - 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() + """List all projects for current user""" + user = g.current_user + projects = Project.query.filter_by(user_id=user.id).order_by(Project.updated_at.desc()).all() return ok({ "projects": [ @@ -48,49 +44,41 @@ def list_projects(): @bp.route("/api/projects", methods=["POST"]) def create_project(): """Create a new project""" + user = g.current_user data = request.get_json() - + if not data: return err(400, "No data provided") - - user_id = data.get("user_id") + name = data.get("name", "").strip() description = data.get("description", "") - - if not user_id: - return err(400, "Missing user_id") - + if not name: 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 - 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: return err(400, f"Project '{name}' already exists") - + # Create project directory 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: return err(500, f"Failed to create project directory: {str(e)}") - + # Create project record project = Project( id=str(uuid.uuid4()), - user_id=user_id, + user_id=user.id, name=name, path=relative_path, description=description ) - + db.session.add(project) db.session.commit() - + return ok({ "id": project.id, "name": project.name, @@ -202,15 +190,12 @@ def delete_project(project_id): @bp.route("/api/projects/upload", methods=["POST"]) def upload_project_folder(): """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() description = request.form.get("description", "") files = request.files.getlist("files") - if not user_id: - return err(400, "Missing user_id") - if not files: return err(400, "No files uploaded") @@ -218,19 +203,14 @@ def upload_project_folder(): # Use first file's top-level folder name 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 - 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: return err(400, f"Project '{project_name}' already exists") # Create project directory first 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: return err(500, f"Failed to create project directory: {str(e)}") diff --git a/backend/routes/stats.py b/backend/routes/stats.py index 5454cf8..47a7f9c 100644 --- a/backend/routes/stats.py +++ b/backend/routes/stats.py @@ -1,9 +1,9 @@ """Token statistics API routes""" from datetime import date, timedelta -from flask import Blueprint, request +from flask import Blueprint, request, g from sqlalchemy import func 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__) @@ -11,7 +11,7 @@ bp = Blueprint("stats", __name__) @bp.route("/api/stats/tokens", methods=["GET"]) def token_stats(): """Get token usage statistics""" - user = get_or_create_default_user() + user = g.current_user period = request.args.get("period", "daily") today = date.today() diff --git a/backend/services/chat.py b/backend/services/chat.py index 0c6f4c2..9858793 100644 --- a/backend/services/chat.py +++ b/backend/services/chat.py @@ -1,12 +1,11 @@ """Chat completion service""" import json import uuid -from flask import current_app, Response +from flask import current_app, g, Response from backend import db from backend.models import Conversation, Message from backend.tools import registry, ToolExecutor from backend.utils.helpers import ( - get_or_create_default_user, record_token_usage, build_messages, ) @@ -205,8 +204,9 @@ class ChatService: db.session.add(msg) db.session.commit() - user = get_or_create_default_user() - record_token_usage(user.id, conv_model, prompt_tokens, token_count) + user = g.get("current_user") + if user: + record_token_usage(user.id, conv_model, prompt_tokens, token_count) # Check if we need to set title (first message in conversation) conv = db.session.get(Conversation, conv_id) diff --git a/backend/utils/helpers.py b/backend/utils/helpers.py index c849c70..f523d7a 100644 --- a/backend/utils/helpers.py +++ b/backend/utils/helpers.py @@ -7,8 +7,24 @@ from backend import db 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: - """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() if not user: user = User(username="default", password=None) diff --git a/docs/Design.md b/docs/Design.md index f22f8bf..5ca0f80 100644 --- a/docs/Design.md +++ b/docs/Design.md @@ -42,6 +42,7 @@ backend/ │ ├── routes/ # API 路由 │ ├── __init__.py +│ ├── auth.py # 认证(登录/注册/JWT) │ ├── conversations.py # 会话 CRUD │ ├── messages.py # 消息 CRUD + 聊天 │ ├── models.py # 模型列表 @@ -89,10 +90,18 @@ classDiagram class User { +Integer id +String username - +String password - +String phone + +String password_hash + +String email + +String avatar + +String role + +Boolean is_active + +DateTime created_at + +DateTime last_login_at +relationship conversations +relationship projects + +to_dict() dict + +check_password(str) bool + +password(str)$ # property setter, 自动 hash } class Project { @@ -352,6 +361,16 @@ def process_tool_calls(self, tool_calls, context=None): ## 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(用户) -| 字段 | 类型 | 说明 | -|------|------|------| -| `id` | Integer | 自增主键 | -| `username` | String(50) | 用户名(唯一) | -| `password` | String(255) | 密码(可为空,支持第三方登录) | -| `phone` | String(20) | 手机号 | +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `id` | Integer | - | 自增主键 | +| `username` | String(50) | - | 用户名(唯一) | +| `password_hash` | String(255) | null | 密码哈希(可为空,支持 API-key-only 认证) | +| `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(项目) @@ -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 为空时不发送) +- 收到 401 时自动清除 token + +切换到多用户模式时,只需补充登录/注册页面 UI。 + +--- | Code | 说明 | |------|------| | `0` | 成功 | | `400` | 请求参数错误 | +| `401` | 未认证(多用户模式下缺少或无效 token) | +| `403` | 禁止访问(账户禁用、单用户模式下注册等) | | `404` | 资源不存在 | +| `409` | 资源冲突(用户名/邮箱已存在) | | `500` | 服务器错误 | 错误响应: @@ -749,6 +828,11 @@ default_model: glm-5 # 工作区根目录 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_host: localhost diff --git a/pyproject.toml b/pyproject.toml index d75aef2..b7fe0d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "beautifulsoup4>=4.12", "httpx>=0.25", "lxml>=5.0", + "PyJWT>=2.8", ] [build-system]