- 配置版本:fsutil 版本化备份(内容相同自动去重),主 nginx.conf 与 VPS vhost 的版本列表/逐行差异/一键回滚,回滚先校验语法与 nginx -t 再写回 - agent 0.12.2:写 vhost 前留版本;nginx -t 校验失败改为恢复上一个可用版本(此前直接删文件),新增域名 vhost 版本端点,双架构二进制已重编 - 通知:补企业微信 / 钉钉 / 飞书机器人,切换预设时服务地址跟随该预设默认值 - 阈值告警:新增 internal/monitor 巡检(CPU/内存/磁盘持续超阈值、隧道连续 N 次未注册),默认关闭,带静默期与恢复通知 - API Token:api_tokens 表 + Authorization Bearer 认证,只读/读写两种范围;令牌管理与改密/退出仅限登录会话
84 lines
2.7 KiB
Go
84 lines
2.7 KiB
Go
package api
|
||
|
||
import (
|
||
"context"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"github.com/fonu/fonu/internal/auth"
|
||
)
|
||
|
||
type contextKey string
|
||
|
||
const sessionContextKey contextKey = "session_id"
|
||
|
||
func SessionMiddleware(authSvc *auth.Service) func(http.Handler) http.Handler {
|
||
return func(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
cookie, err := r.Cookie("fonu_session")
|
||
if err == nil && cookie.Value != "" {
|
||
ctx := context.WithValue(r.Context(), sessionContextKey, cookie.Value)
|
||
r = r.WithContext(ctx)
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
}
|
||
|
||
// RequireAuth 接受两种凭据:浏览器的会话 cookie,以及 Authorization: Bearer <api token>。
|
||
// 令牌分读写两种范围:read 只允许 GET/HEAD,write 与会话同权。
|
||
func RequireAuth(authSvc *auth.Service) func(http.Handler) http.Handler {
|
||
return func(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if token := bearerToken(r); token != "" {
|
||
scope, err := authSvc.ValidateAPIToken(r.Context(), token)
|
||
if err != nil {
|
||
writeError(r, w, http.StatusUnauthorized, "API Token 无效或已过期")
|
||
return
|
||
}
|
||
// 账号与令牌管理只允许浏览器会话:否则一个 write 令牌可以自己给自己提权
|
||
if tokenDeniedPath(r.URL.Path) {
|
||
writeError(r, w, http.StatusForbidden, "该操作需要使用登录会话")
|
||
return
|
||
}
|
||
if scope != auth.TokenScopeWrite && r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||
writeError(r, w, http.StatusForbidden, "该 Token 只读,不能执行写操作")
|
||
return
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
|
||
sessionID, _ := r.Context().Value(sessionContextKey).(string)
|
||
if err := authSvc.ValidateSession(r.Context(), sessionID); err != nil {
|
||
writeError(r, w, http.StatusUnauthorized, "未登录或会话已过期")
|
||
return
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
}
|
||
|
||
// bearerToken 取出 Authorization: Bearer 里的明文令牌;没有或格式不对返回空串。
|
||
func bearerToken(r *http.Request) string {
|
||
header := strings.TrimSpace(r.Header.Get("Authorization"))
|
||
const prefix = "Bearer "
|
||
if len(header) <= len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(header[len(prefix):])
|
||
}
|
||
|
||
// tokenDeniedPath 是只允许浏览器会话的路径(账号与令牌管理)。
|
||
func tokenDeniedPath(path string) bool {
|
||
if path == "/api/auth/password" || path == "/api/auth/logout" {
|
||
return true
|
||
}
|
||
return strings.HasPrefix(path, "/api/settings/tokens")
|
||
}
|
||
|
||
func sessionIDFromContext(ctx context.Context) string {
|
||
v, _ := ctx.Value(sessionContextKey).(string)
|
||
return v
|
||
}
|