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