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