4fbf3ac966
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>
166 lines
4.6 KiB
Go
166 lines
4.6 KiB
Go
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)
|
|
}
|
|
}
|