Files
orchestra/internal/authn/http.go
T
kami 4fbf3ac966 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>
2026-08-26 23:42:26 +04:00

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)
}
}