Why: Historical full sessions could reach self-profile actions, and a lost recovery response had no safe idempotent retry contract. Evidence: SELF receipts /tmp/iam-u3-full-password-profile.json, /tmp/iam-u3-recovery-idempotent-sqlite.json, /tmp/iam-u3-recovery-idempotent-pg.json, /tmp/iam-u3-account-sse-pass.json, /tmp/iam-u3-backend-idempotent-all.json, and /tmp/iam-u3-backend-idempotent-pg.json. Impact: Profile actions require a password-authenticated full cookie; duplicate owner confirmation or rotation returns status without plaintext; an active SSE stream stops before its next business frame after disable. Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery
129 lines
3.7 KiB
Go
129 lines
3.7 KiB
Go
package server
|
|
|
|
import (
|
|
"net/http"
|
|
)
|
|
|
|
func (s *Server) createAdminRecoveryProposal(w http.ResponseWriter, r *http.Request) {
|
|
actor, ok := s.requireAdmin(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req struct {
|
|
ExpectedStateVersion int64 `json:"expectedStateVersion"`
|
|
IdempotencyKey string `json:"idempotencyKey"`
|
|
}
|
|
if err := decodeStrictJSON(r, &req); err != nil {
|
|
http.Error(w, "invalid recovery proposal", http.StatusBadRequest)
|
|
return
|
|
}
|
|
proposal, err := s.st.CreateRecoveryProposal(actor, r.PathValue("userId"), req.ExpectedStateVersion, req.IdempotencyKey)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
writeJSON(w, http.StatusOK, proposal)
|
|
}
|
|
|
|
func (s *Server) getAdminRecoveryProposal(w http.ResponseWriter, r *http.Request) {
|
|
actor, ok := s.requireAdmin(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
proposal, err := s.st.GetRecoveryProposalForAdmin(actor, r.PathValue("userId"), r.PathValue("id"))
|
|
writeJSONOr(w, proposal, err)
|
|
}
|
|
|
|
func (s *Server) listAdminRecoveryProposals(w http.ResponseWriter, r *http.Request) {
|
|
actor, ok := s.requireAdmin(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
proposals, err := s.st.ListRecoveryProposalsForAdmin(actor, r.PathValue("userId"))
|
|
writeJSONOr(w, map[string]any{"proposals": proposals}, err)
|
|
}
|
|
|
|
func (s *Server) revokeAdminRecoveryProposal(w http.ResponseWriter, r *http.Request) {
|
|
actor, ok := s.requireAdmin(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req struct {
|
|
ExpectedVersion int64 `json:"expectedVersion"`
|
|
}
|
|
if err := decodeStrictJSON(r, &req); err != nil {
|
|
http.Error(w, "invalid recovery revocation", http.StatusBadRequest)
|
|
return
|
|
}
|
|
proposal, err := s.st.RevokeRecoveryProposal(actor, r.PathValue("userId"), r.PathValue("id"), req.ExpectedVersion)
|
|
writeJSONOr(w, proposal, err)
|
|
}
|
|
|
|
func (s *Server) listMyRecoveryProposals(w http.ResponseWriter, r *http.Request) {
|
|
owner, ok := s.requireFullPasswordSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
proposals, err := s.st.ListOwnerRecoveryProposals(owner)
|
|
writeJSONOr(w, map[string]any{"proposals": proposals}, err)
|
|
}
|
|
|
|
func (s *Server) getMyRecoveryProposal(w http.ResponseWriter, r *http.Request) {
|
|
owner, ok := s.requireFullPasswordSession(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
proposal, err := s.st.GetOwnerRecoveryProposal(owner, r.PathValue("id"))
|
|
writeJSONOr(w, proposal, err)
|
|
}
|
|
|
|
func (s *Server) confirmRecoveryProposal(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := s.requireFullPasswordSession(w, r); !ok {
|
|
return
|
|
}
|
|
var req struct {
|
|
ExpectedVersion int64 `json:"expectedVersion"`
|
|
}
|
|
if err := decodeStrictJSON(r, &req); err != nil {
|
|
http.Error(w, "invalid recovery confirmation", http.StatusBadRequest)
|
|
return
|
|
}
|
|
plain, proposal, err := s.st.ConfirmRecoveryProposal(sessionToken(r), r.PathValue("id"), req.ExpectedVersion)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
out := map[string]any{"proposal": proposal}
|
|
if plain != "" {
|
|
out["grant"] = plain
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (s *Server) rotateRecoveryGrant(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := s.requireFullPasswordSession(w, r); !ok {
|
|
return
|
|
}
|
|
var req struct {
|
|
ExpectedVersion int64 `json:"expectedVersion"`
|
|
ExpectedGeneration int64 `json:"expectedGeneration"`
|
|
}
|
|
if err := decodeStrictJSON(r, &req); err != nil {
|
|
http.Error(w, "invalid recovery grant rotation", http.StatusBadRequest)
|
|
return
|
|
}
|
|
plain, proposal, err := s.st.RotateRecoveryGrant(sessionToken(r), r.PathValue("id"), req.ExpectedVersion, req.ExpectedGeneration)
|
|
if err != nil {
|
|
writeStoreErr(w, err)
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
out := map[string]any{"proposal": proposal}
|
|
if plain != "" {
|
|
out["grant"] = plain
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|