Add admin login session flow
build / build (push) Successful in 58s

This commit is contained in:
2026-07-09 00:22:27 +08:00
parent b146ccb7ac
commit c3af3caa5b
3 changed files with 166 additions and 18 deletions
+7 -2
View File
@@ -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 参数脱敏。
+2
View File
@@ -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"),
+157 -16
View File
@@ -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(`<!doctype html>
<div class="muted">保存位置:<br><code>{{.CredentialsPath}}</code></div>
</section>
<section class="login-card">
{{if not .AdminMode}}
<h2>管理登录</h2>
<p>请输入服务环境变量 <code>ZHANLU_LOGIN_PASSWORD</code> 配置的管理密码。</p>
<form id="password-form">
<label>登录密码<input name="password" type="password" autocomplete="current-password" placeholder="请输入服务访问密码" required></label>
<button class="login-button" type="submit">进入登录管理</button>
</form>
<div class="status" id="status">需要登录后才能管理湛卢凭据。</div>
{{else}}
<h2>手机号验证码登录</h2>
<p>手机号和一次性 secret 会按插件逻辑用 RSA 加密后提交到移动云公网接口。</p>
<form id="phone-form">
@@ -702,13 +808,16 @@ var loginTemplate = template.Must(template.New("login").Parse(`<!doctype html>
<button class="login-button" type="submit">登录并保存凭据</button>
</form>
<div class="status" id="status">正在检查登录状态...</div>
<div class="muted">凭据保存到 JSON;验证码本身不会保存。</div>
<div class="muted">凭据保存到 JSON;验证码本身不会保存。{{if .PasswordEnabled}} <button id="logout-button" type="button">退出管理登录</button>{{end}}</div>
{{end}}
</section>
</main>
<script>
const statusEl = document.getElementById('status');
const form = document.getElementById('phone-form');
const passwordForm = document.getElementById('password-form');
const codeButton = document.getElementById('code-button');
const logoutButton = document.getElementById('logout-button');
let secret = '';
let countdown = 0;
let countdownTimer = null;
@@ -733,11 +842,43 @@ var loginTemplate = template.Must(template.New("login").Parse(`<!doctype html>
}, 1000);
}
fetch('/api/credentials').then(r => r.json()).then(data => {
statusEl.textContent = data.configured ? ('已登录:' + (data.access_key || '')) : '当前未登录';
});
if (passwordForm) {
passwordForm.addEventListener('submit', async (event) => {
event.preventDefault();
const password = passwordForm.password.value;
if (!password) {
setStatus('请输入登录密码');
return;
}
setStatus('正在登录...');
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
const data = await res.json();
if (!res.ok || !data.ok) {
setStatus(data.error || '登录失败');
return;
}
window.location.href = '/admin/login';
});
}
codeButton.addEventListener('click', async () => {
if (logoutButton) {
logoutButton.addEventListener('click', async () => {
await fetch('/api/logout', { method: 'POST' });
window.location.href = '/login';
});
}
if (form) {
fetch('/api/credentials').then(r => r.json()).then(data => {
statusEl.textContent = data.configured ? ('已登录:' + (data.access_key || '')) : '当前未登录';
});
}
codeButton && codeButton.addEventListener('click', async () => {
const telephone = form.telephone.value.trim();
if (!/^1[3-9]\d{9}$/.test(telephone)) {
setStatus('请输入有效的 11 位手机号');
@@ -761,7 +902,7 @@ var loginTemplate = template.Must(template.New("login").Parse(`<!doctype html>
startCountdown();
});
form.addEventListener('submit', async (event) => {
form && form.addEventListener('submit', async (event) => {
event.preventDefault();
const telephone = form.telephone.value.trim();
const code = form.code.value.trim();