diff --git a/README.md b/README.md index 89d53eb..97c70c3 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,10 @@ go run ./cmd/zhanlu-proxy http://127.0.0.1:8080 ``` -打开登录页: +打开首页会自动跳转到管理登录页: ```text -http://127.0.0.1:8080/login +http://127.0.0.1:8080/ ``` ## systemd 服务示例 @@ -52,6 +52,7 @@ ZHANLU_UPSTREAM_PATH=/api/acepilot/zhanlu/aiDeveloper/chat ZHANLU_MODELS=glm47,minimax-m25 ZHANLU_DEFAULT_MODEL=minimax-m25 ZHANLU_UPSTREAM_TIMEOUT=300s +ZHANLU_LOGIN_PASSWORD=change-this-login-password OPENAI_COMPAT_API_KEY=change-this-local-secret ``` @@ -97,6 +98,8 @@ journalctl -u zhanlu-proxy -f 打开 `/login` 后输入手机号并点击“获取验证码”。实现按插件默认登录分支工作: +如果设置了 `ZHANLU_LOGIN_PASSWORD`,`/login` 只负责管理密码登录。密码正确后服务会设置 HttpOnly 会话 Cookie,并跳转到 `/admin/login`。`/admin/login` 才是手机号验证码登录湛卢的页面,之后才能查看凭据状态、获取短信验证码、保存凭据或使用备用 SSO 管理接口。 + - 生成 16 位一次性 `secret`。 - 使用插件内置 RSA 公钥加密手机号和 `secret`。 - 调用公网接口 `/api/query/acepilot-h5/manager/code/getAuthCode` 发送验证码。 @@ -191,6 +194,7 @@ curl http://127.0.0.1:8080/v1/models ` | `ZHANLU_DEFAULT_MODEL` | `minimax-m25` | 请求未传 `model` 时使用的默认模型 | | `ZHANLU_UPSTREAM_TIMEOUT` | `300s` | 上游请求超时 | | `ZHANLU_STREAM_IDLE_TIMEOUT` | `300s` | 预留的流式空闲超时配置 | +| `ZHANLU_LOGIN_PASSWORD` | 空 | `/login` 管理页面密码;设置后登录成功跳转到 `/admin/login` 管理湛卢凭据 | | `ZHANLU_DEBUG` | `false` | 调试模式,错误信息更详细但会脱敏敏感 query | | `OPENAI_COMPAT_API_KEY` | 空 | 本地 OpenAI 兼容接口鉴权 key | @@ -207,6 +211,7 @@ curl http://127.0.0.1:8080/v1/models ` - `credentials.json` 包含明文 `AccessKey`、`SecretKey`、`Token`,请不要提交到仓库。 - 默认保存在当前执行目录的 `credentials.json`。 +- 建议设置 `ZHANLU_LOGIN_PASSWORD`,避免公网暴露的 `/admin/login` 被直接访问。 - 错误响应默认不会返回签名 URL,避免泄露 `AccessKey`、`authorization`、`Signature`。 - `ZHANLU_DEBUG=true` 时会返回更详细错误,但仍会对敏感 query 参数脱敏。 diff --git a/internal/config/config.go b/internal/config/config.go index 1cb5d01..bb84b16 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,7 @@ type Config struct { Models []string DefaultModel string OpenAIAPIKey string + LoginPassword string UpstreamTimeout time.Duration StreamIdleTimout time.Duration Debug bool @@ -41,6 +42,7 @@ func Load() (Config, error) { PhonePublicKeyPEM: getenv("ZHANLU_PHONE_PUBLIC_KEY_PEM", defaultPhonePublicKeyPEM), DefaultModel: getenv("ZHANLU_DEFAULT_MODEL", "minimax-m25"), OpenAIAPIKey: os.Getenv("OPENAI_COMPAT_API_KEY"), + LoginPassword: os.Getenv("ZHANLU_LOGIN_PASSWORD"), UpstreamTimeout: durationEnv("ZHANLU_UPSTREAM_TIMEOUT", 300*time.Second), StreamIdleTimout: durationEnv("ZHANLU_STREAM_IDLE_TIMEOUT", 300*time.Second), Debug: strings.EqualFold(os.Getenv("ZHANLU_DEBUG"), "true"), diff --git a/internal/server/server.go b/internal/server/server.go index 6445164..82a46a2 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2,6 +2,9 @@ package server import ( "bufio" + crand "crypto/rand" + "crypto/subtle" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -21,26 +24,34 @@ import ( ) type Server struct { - cfg config.Config - mux *http.ServeMux + cfg config.Config + mux *http.ServeMux + loginSession string } func New(cfg config.Config) http.Handler { s := &Server{cfg: cfg, mux: http.NewServeMux()} + if cfg.LoginPassword != "" { + s.loginSession = randomSessionToken() + } s.routes() return s.mux } func (s *Server) routes() { + s.mux.HandleFunc("GET /", s.index) s.mux.HandleFunc("GET /healthz", s.healthz) s.mux.HandleFunc("GET /login", s.loginPage) - s.mux.HandleFunc("GET /auth/start", s.startSSO) - s.mux.HandleFunc("GET /auth/callback", s.ssoCallback) - s.mux.HandleFunc("POST /api/auth/code", s.requestPhoneCode) - s.mux.HandleFunc("POST /api/auth/login", s.loginWithPhoneCode) - s.mux.HandleFunc("GET /api/credentials", s.getCredentials) - s.mux.HandleFunc("POST /api/credentials", s.saveCredentials) - s.mux.HandleFunc("POST /api/sso/exchange", s.exchangeSSOCode) + s.mux.HandleFunc("GET /admin/login", s.adminLoginPage) + s.mux.HandleFunc("POST /api/login", s.passwordLogin) + s.mux.HandleFunc("POST /api/logout", s.passwordLogout) + s.mux.HandleFunc("GET /auth/start", s.withLoginSession(s.startSSO)) + s.mux.HandleFunc("GET /auth/callback", s.withLoginSession(s.ssoCallback)) + s.mux.HandleFunc("POST /api/auth/code", s.withLoginSession(s.requestPhoneCode)) + s.mux.HandleFunc("POST /api/auth/login", s.withLoginSession(s.loginWithPhoneCode)) + s.mux.HandleFunc("GET /api/credentials", s.withLoginSession(s.getCredentials)) + s.mux.HandleFunc("POST /api/credentials", s.withLoginSession(s.saveCredentials)) + s.mux.HandleFunc("POST /api/sso/exchange", s.withLoginSession(s.exchangeSSOCode)) s.mux.HandleFunc("GET /v1/models", s.withAPIKey(s.models)) s.mux.HandleFunc("POST /v1/chat/completions", s.withAPIKey(s.chatCompletions)) } @@ -49,6 +60,14 @@ func (s *Server) healthz(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } +func (s *Server) index(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + http.Redirect(w, r, "/login", http.StatusFound) +} + func (s *Server) withAPIKey(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if s.cfg.OpenAIAPIKey != "" { @@ -63,8 +82,48 @@ func (s *Server) withAPIKey(next http.HandlerFunc) http.HandlerFunc { } func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) { + if s.hasLoginSession(r) { + http.Redirect(w, r, "/admin/login", http.StatusFound) + return + } + if s.cfg.LoginPassword == "" { + http.Redirect(w, r, "/admin/login", http.StatusFound) + return + } w.Header().Set("Content-Type", "text/html; charset=utf-8") - _ = loginTemplate.Execute(w, map[string]any{"CredentialsPath": s.cfg.CredentialsPath, "SSOBaseURL": s.cfg.SSOBaseURL}) + _ = loginTemplate.Execute(w, map[string]any{"CredentialsPath": s.cfg.CredentialsPath, "SSOBaseURL": s.cfg.SSOBaseURL, "PasswordEnabled": true, "AdminMode": false}) +} + +func (s *Server) adminLoginPage(w http.ResponseWriter, r *http.Request) { + if !s.hasLoginSession(r) { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = loginTemplate.Execute(w, map[string]any{"CredentialsPath": s.cfg.CredentialsPath, "SSOBaseURL": s.cfg.SSOBaseURL, "PasswordEnabled": s.cfg.LoginPassword != "", "AdminMode": true}) +} + +func (s *Server) passwordLogin(w http.ResponseWriter, r *http.Request) { + var in struct { + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()}) + return + } + if !s.validLoginPassword(in.Password) { + writeJSON(w, http.StatusUnauthorized, map[string]any{"ok": false, "error": "登录密码无效"}) + return + } + if s.cfg.LoginPassword != "" { + http.SetCookie(w, &http.Cookie{Name: loginSessionCookieName, Value: s.loginSession, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int((24 * time.Hour).Seconds())}) + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) passwordLogout(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{Name: loginSessionCookieName, Value: "", Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: -1}) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } func (s *Server) startSSO(w http.ResponseWriter, r *http.Request) { @@ -223,6 +282,44 @@ func (s *Server) loginWithPhoneCode(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": s.cfg.CredentialsPath, "access_key": mask(creds.AccessKey)}) } +func (s *Server) validLoginPassword(password string) bool { + if s.cfg.LoginPassword == "" { + return true + } + return subtle.ConstantTimeCompare([]byte(password), []byte(s.cfg.LoginPassword)) == 1 +} + +func (s *Server) withLoginSession(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !s.hasLoginSession(r) { + writeJSON(w, http.StatusUnauthorized, map[string]any{"ok": false, "error": "请先登录管理页面"}) + return + } + next(w, r) + } +} + +func (s *Server) hasLoginSession(r *http.Request) bool { + if s.cfg.LoginPassword == "" { + return true + } + c, err := r.Cookie(loginSessionCookieName) + if err != nil { + return false + } + return subtle.ConstantTimeCompare([]byte(c.Value), []byte(s.loginSession)) == 1 +} + +func randomSessionToken() string { + b := make([]byte, 32) + if _, err := crand.Read(b); err != nil { + return randomRequestID() + } + return hex.EncodeToString(b) +} + +const loginSessionCookieName = "zhanlu_proxy_session" + type phoneAPIResponse struct { State string `json:"state"` ErrorMessage string `json:"errorMessage"` @@ -689,6 +786,15 @@ var loginTemplate = template.Must(template.New("login").Parse(`
保存位置:
{{.CredentialsPath}}
+ {{if not .AdminMode}} +

管理登录

+

请输入服务环境变量 ZHANLU_LOGIN_PASSWORD 配置的管理密码。

+
+ + +
+
需要登录后才能管理湛卢凭据。
+ {{else}}

手机号验证码登录

手机号和一次性 secret 会按插件逻辑用 RSA 加密后提交到移动云公网接口。

@@ -702,13 +808,16 @@ var loginTemplate = template.Must(template.New("login").Parse(`
正在检查登录状态...
-
凭据保存到 JSON;验证码本身不会保存。
+
凭据保存到 JSON;验证码本身不会保存。{{if .PasswordEnabled}} {{end}}
+ {{end}}