feat(auth): implement WebSocket authentication for admin access to logs
This commit is contained in:
+124
-47
@@ -100,62 +100,58 @@ func GetUserLevelFromRequest(r *http.Request) string {
|
||||
return tokenInfo.Level
|
||||
}
|
||||
|
||||
// Auth is the central authentication and authorization function.
|
||||
// It validates the request method, X-Timestamp header (30min tolerance),
|
||||
// X-Token header, and permission level. Returns true if the request is authorized.
|
||||
// checkTimestamp validates a Unix timestamp in seconds against the server clock
|
||||
// (30 minute tolerance per agent.md). The check is skipped entirely in debug
|
||||
// mode. It returns 0 when the timestamp is acceptable, otherwise the HTTP status
|
||||
// and message to reject the request with.
|
||||
func checkTimestamp(timestamp string) (int, string) {
|
||||
if config.IsDebugMode() {
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
if timestamp == "" {
|
||||
return http.StatusUnauthorized, "missing timestamp"
|
||||
}
|
||||
|
||||
ts, err := strconv.ParseInt(timestamp, 10, 64)
|
||||
if err != nil {
|
||||
return http.StatusUnauthorized, "invalid timestamp"
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
diff := now - ts
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff > 1800 {
|
||||
return http.StatusUnauthorized, "request expired"
|
||||
}
|
||||
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
// checkPermission validates a session token against the required permission
|
||||
// level and refreshes the token's idle timer on success.
|
||||
//
|
||||
// Permission levels: "None" (public, no token required), "bot", "admin".
|
||||
// When level is "bot", both "bot" and "admin" tokens are accepted.
|
||||
// When level is "admin", only "admin" tokens are accepted.
|
||||
func Auth(w http.ResponseWriter, r *http.Request, targetMethod string, targetLevel string) bool {
|
||||
// Validate HTTP method.
|
||||
if r.Method != targetMethod {
|
||||
SendErrorResponse(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate X-Timestamp.
|
||||
timestamp := r.Header.Get("X-Timestamp")
|
||||
if !config.IsDebugMode() {
|
||||
if timestamp == "" {
|
||||
SendErrorResponse(w, http.StatusUnauthorized, "missing timestamp")
|
||||
return false
|
||||
}
|
||||
|
||||
ts, err := strconv.ParseInt(timestamp, 10, 64)
|
||||
if err != nil {
|
||||
SendErrorResponse(w, http.StatusUnauthorized, "invalid timestamp")
|
||||
return false
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
diff := now - ts
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
// 30 minute tolerance per agent.md.
|
||||
if diff > 1800 {
|
||||
SendErrorResponse(w, http.StatusUnauthorized, "request expired")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// It returns 0 when the token is authorized, otherwise the HTTP status and
|
||||
// message to reject the request with.
|
||||
func checkPermission(token string, targetLevel string) (int, string) {
|
||||
// Public endpoints require no token.
|
||||
if targetLevel == "None" {
|
||||
return true
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
// Validate X-Token.
|
||||
token := r.Header.Get("X-Token")
|
||||
if token == "" {
|
||||
SendErrorResponse(w, http.StatusUnauthorized, "missing token")
|
||||
return false
|
||||
return http.StatusUnauthorized, "missing token"
|
||||
}
|
||||
|
||||
tokenInfo, exists := GetTokenInfo(token)
|
||||
if !exists {
|
||||
SendErrorResponse(w, http.StatusUnauthorized, "invalid token")
|
||||
return false
|
||||
return http.StatusUnauthorized, "invalid token"
|
||||
}
|
||||
|
||||
// Check permission level.
|
||||
@@ -164,21 +160,102 @@ func Auth(w http.ResponseWriter, r *http.Request, targetMethod string, targetLev
|
||||
switch targetLevel {
|
||||
case "admin":
|
||||
if tokenInfo.Level != "admin" {
|
||||
SendErrorResponse(w, http.StatusForbidden, "permission denied")
|
||||
return false
|
||||
return http.StatusForbidden, "permission denied"
|
||||
}
|
||||
case "bot":
|
||||
if tokenInfo.Level != "bot" && tokenInfo.Level != "admin" {
|
||||
SendErrorResponse(w, http.StatusForbidden, "permission denied")
|
||||
return false
|
||||
return http.StatusForbidden, "permission denied"
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh token last access time.
|
||||
RefreshToken(token)
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
// Auth is the central authentication and authorization function.
|
||||
// It validates the request method, X-Timestamp header (30min tolerance),
|
||||
// X-Token header, and permission level. Returns true if the request is authorized.
|
||||
//
|
||||
// Permission levels: "None" (public, no token required), "bot", "admin".
|
||||
// When level is "bot", both "bot" and "admin" tokens are accepted.
|
||||
// When level is "admin", only "admin" tokens are accepted.
|
||||
//
|
||||
// WebSocket upgrades cannot carry custom headers from a browser; those endpoints
|
||||
// use WebSocketAuthMiddleware instead, which also accepts the credentials as
|
||||
// query parameters.
|
||||
func Auth(w http.ResponseWriter, r *http.Request, targetMethod string, targetLevel string) bool {
|
||||
// Validate HTTP method.
|
||||
if r.Method != targetMethod {
|
||||
SendErrorResponse(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate X-Timestamp.
|
||||
if status, message := checkTimestamp(r.Header.Get("X-Timestamp")); status != 0 {
|
||||
SendErrorResponse(w, status, message)
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate X-Token and its permission level.
|
||||
if status, message := checkPermission(r.Header.Get("X-Token"), targetLevel); status != 0 {
|
||||
SendErrorResponse(w, status, message)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// AuthWS gates a WebSocket endpoint behind the given permission
|
||||
// level ("bot" or "admin"), authenticating the upgrade request before the
|
||||
// connection is handed to the handler. Unauthorized requests are answered with
|
||||
// the standard JSON error response and are never upgraded.
|
||||
//
|
||||
// A browser cannot set custom headers on a WebSocket handshake, so the session
|
||||
// token and timestamp are read from the X-Token / X-Timestamp headers when
|
||||
// present and otherwise from the "token" and "timestamp" query parameters:
|
||||
//
|
||||
// ws://host/api/system/getLogs?token=<token>×tamp=<unix seconds>
|
||||
//
|
||||
// The timestamp is only checked at handshake time, so a long-lived connection
|
||||
// stays open past its tolerance window. Because a query string commonly ends up
|
||||
// in proxy and access logs, a token-carrying URL should be treated as a secret.
|
||||
func AuthWS(targetLevel string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// An upgrade request is always a GET.
|
||||
if r.Method != http.MethodGet {
|
||||
SendErrorResponse(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
// Headers win over query parameters so programmatic clients can keep
|
||||
// the credentials out of the URL.
|
||||
token := r.Header.Get("X-Token")
|
||||
timestamp := r.Header.Get("X-Timestamp")
|
||||
query := r.URL.Query()
|
||||
if token == "" {
|
||||
token = query.Get("token")
|
||||
}
|
||||
if timestamp == "" {
|
||||
timestamp = query.Get("timestamp")
|
||||
}
|
||||
|
||||
if status, message := checkTimestamp(timestamp); status != 0 {
|
||||
SendErrorResponse(w, status, message)
|
||||
return
|
||||
}
|
||||
if status, message := checkPermission(token, targetLevel); status != 0 {
|
||||
SendErrorResponse(w, status, message)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CleanExpiredTokens removes tokens that have been idle for over 1 hour.
|
||||
func CleanExpiredTokens() {
|
||||
tokenStoreLock.Lock()
|
||||
|
||||
Reference in New Issue
Block a user