Report queued tasks the scheduling pass never considers
A task in retry backoff was filtered out before the candidate loop, so it recorded no rejection at all: queued, apparently assignable, and silent. That is the exact shape that made F5 take a live session to diagnose. It now reports "retry backoff until <time>". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
package authn
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"orchestra/internal/authz"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxLoginBody = 8 << 10
|
||||
|
||||
type HTTP struct {
|
||||
Users *Store
|
||||
Sessions *authz.Sessions
|
||||
SecureCookie bool
|
||||
}
|
||||
|
||||
func (h HTTP) cookie(value string, maxAge int) *http.Cookie {
|
||||
cookie := &http.Cookie{
|
||||
Name: authz.SessionCookie,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
MaxAge: maxAge,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Secure: h.SecureCookie,
|
||||
}
|
||||
if maxAge < 0 {
|
||||
cookie.Expires = time.Unix(1, 0)
|
||||
}
|
||||
return cookie
|
||||
}
|
||||
|
||||
func decode(w http.ResponseWriter, r *http.Request, dst any) error {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxLoginBody)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err == nil {
|
||||
return errors.New("multiple JSON values")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func (h HTTP) sessionUser(r *http.Request) (User, bool) {
|
||||
if h.Users == nil || h.Sessions == nil {
|
||||
return User{}, false
|
||||
}
|
||||
cookie, err := r.Cookie(authz.SessionCookie)
|
||||
if err != nil {
|
||||
return User{}, false
|
||||
}
|
||||
username, ok := h.Sessions.Username(cookie.Value)
|
||||
if !ok {
|
||||
return User{}, false
|
||||
}
|
||||
user, err := h.Users.User(username)
|
||||
return user, err == nil
|
||||
}
|
||||
|
||||
func (h HTTP) Session(w http.ResponseWriter, r *http.Request) {
|
||||
if h.Users == nil || h.Sessions == nil {
|
||||
http.Error(w, "login unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
user, ok := h.sessionUser(r)
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, user)
|
||||
case http.MethodPost:
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := decode(w, r, &body); err != nil {
|
||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
user, err := h.Users.Authenticate(body.Username, body.Password)
|
||||
if err != nil {
|
||||
// Credential failures are deliberately indistinguishable. Database
|
||||
// failures are not exposed either, but they remain a server error.
|
||||
if errors.Is(err, ErrInvalidCredentials) {
|
||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||
} else {
|
||||
http.Error(w, "login unavailable", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
value, err := h.Sessions.IssueFor(user.Username)
|
||||
if err != nil {
|
||||
http.Error(w, "session unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, h.cookie(value, int(h.Sessions.Duration().Seconds())))
|
||||
writeJSON(w, http.StatusOK, user)
|
||||
case http.MethodDelete:
|
||||
if cookie, err := r.Cookie(authz.SessionCookie); err == nil {
|
||||
h.Sessions.Revoke(cookie.Value)
|
||||
}
|
||||
http.SetCookie(w, h.cookie("", -1))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST, DELETE")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (h HTTP) Account(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := h.sessionUser(r)
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, user)
|
||||
case http.MethodPut:
|
||||
var body struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
Username string `json:"username"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
if err := decode(w, r, &body); err != nil || body.CurrentPassword == "" {
|
||||
http.Error(w, "current password is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
updated, err := h.Users.Update(user.Username, body.CurrentPassword, body.Username, body.NewPassword)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidCredentials):
|
||||
http.Error(w, "current password is incorrect", http.StatusUnauthorized)
|
||||
case errors.Is(err, ErrUsernameExists):
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
default:
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Credential changes revoke every browser holding this identity. The
|
||||
// response carries the updated display name, then the UI signs in again.
|
||||
h.Sessions.RevokeUser(user.Username)
|
||||
http.SetCookie(w, h.cookie("", -1))
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, PUT")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user