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
+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();