Skip to content

Commit

Permalink
SFE: Call RA.UnpauseAccount and handle result (letsencrypt#7638)
Browse files Browse the repository at this point in the history
Call `RA.UnpauseAccount` for valid unpause form submissions.

Determine and display the appropriate outcome to the Subscriber based on
the count returned by `RA.UnpauseAccount`:
- If the count is zero, display the "Account already unpaused" message.
- If the count equals the max number of identifiers allowed in a single
request, display a page explaining the need to visit the unpause URL
again.
- Otherwise, display the "Successfully unpaused all N identifiers"
message.

Apply per-request timeout from the SFE configuration.

Part of letsencrypt#7406
  • Loading branch information
beautifulentropy authored Jul 31, 2024
1 parent c6c7617 commit c13591a
Show file tree
Hide file tree
Showing 7 changed files with 133 additions and 45 deletions.
8 changes: 4 additions & 4 deletions sa/sa.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
blog "github.com/letsencrypt/boulder/log"
"github.com/letsencrypt/boulder/revocation"
sapb "github.com/letsencrypt/boulder/sa/proto"
"github.com/letsencrypt/boulder/unpause"
)

var (
Expand Down Expand Up @@ -1411,10 +1412,9 @@ func (ssa *SQLStorageAuthority) UnpauseAccount(ctx context.Context, req *sapb.Re
return nil, errIncompleteRequest
}

const batchSize = 10000
total := &sapb.Count{}

for i := 0; i < 5; i++ {
for i := 0; i < unpause.MaxBatches; i++ {
result, err := ssa.dbMap.ExecContext(ctx, `
UPDATE paused
SET unpausedAt = ?
Expand All @@ -1424,7 +1424,7 @@ func (ssa *SQLStorageAuthority) UnpauseAccount(ctx context.Context, req *sapb.Re
LIMIT ?`,
ssa.clk.Now(),
req.Id,
batchSize,
unpause.BatchSize,
)
if err != nil {
return nil, err
Expand All @@ -1436,7 +1436,7 @@ func (ssa *SQLStorageAuthority) UnpauseAccount(ctx context.Context, req *sapb.Re
}

total.Count += rowsAffected
if rowsAffected < batchSize {
if rowsAffected < unpause.BatchSize {
// Fewer than batchSize rows were updated, so we're done.
break
}
Expand Down
2 changes: 1 addition & 1 deletion sfe/pages/unpause-form.html
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ <h1>Ready to unpause?</h1>
resume requesting certificates for all affected identifiers associated
with your account, not just those listed above.
</p>
<form action="{{ .UnpauseFormRedirectionPath }}?jwt={{ .JWT }}" method="POST">
<form action="{{ .PostPath }}?jwt={{ .JWT }}" method="POST">
<button class="primary" id="submit">Please Unpause My Account</button>
</form>
</div>
Expand Down
34 changes: 27 additions & 7 deletions sfe/pages/unpause-status.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,46 @@

<div class="section">

{{ if eq .Successful true }}

<h1>Account successfully unpaused</h1>
{{ if and .Successful (gt .Count 0) (lt .Count .Limit) }}
<h1>Successfully unpaused all {{ .Count }} identifier(s)</h1>
<p>
To obtain a new certificate, re-attempt issuance with your ACME client.
Future repeated validation failures with no successes will result in
identifiers being paused again.
</p>

{{ else }}
{{ else if and .Successful (eq .Count .Limit)}}
<h1>Some identifiers were unpaused</h1>
<p>
We can only unpause a limited number of identifiers for each request ({{
.Limit }}). There are potentially more identifiers paused for your
account.
</p>
<p>
To attempt to unpause more identifiers, visit the unpause URL from
your logs again and click the "Please Unpause My Account" button.
</p>

<h1>An error occurred while unpausing your account</h1>
{{ else if and .Successful (eq .Count 0) }}
<h1>Account already unpaused</h1>
<p>
Please try again later. If you face continued difficulties, please visit our <a
There were no identifiers to unpause for your account. If you face
continued difficulties, please visit our <a
href="https://community.letsencrypt.org">community support forum</a>
for troubleshooting and advice.
</p>

{{ else }}
<h1>An error occurred while unpausing your account</h1>
<p>
Please try again later. If you face continued difficulties, please visit
our <a href="https://community.letsencrypt.org">community support
forum</a>
for troubleshooting and advice.
</p>

{{ end }}

</div>

{{template "footer"}}
{{ template "footer" }}
85 changes: 56 additions & 29 deletions sfe/sfe.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io/fs"
"net/http"
"net/url"
"strconv"
"strings"
"text/template"
Expand Down Expand Up @@ -84,23 +85,35 @@ func NewSelfServiceFrontEndImpl(
return sfe, nil
}

// handleWithTimeout registers a handler with a timeout using an
// http.TimeoutHandler.
func (sfe *SelfServiceFrontEndImpl) handleWithTimeout(mux *http.ServeMux, path string, handler http.HandlerFunc) {
timeout := sfe.requestTimeout
if timeout <= 0 {
// Default to 5 minutes if no timeout is set.
timeout = 5 * time.Minute
}
timeoutHandler := http.TimeoutHandler(handler, timeout, "Request timed out")
mux.Handle(path, timeoutHandler)
}

// Handler returns an http.Handler that uses various functions for various
// non-ACME-specified paths. Each endpoint should have a corresponding HTML
// page that shares the same name as the endpoint.
func (sfe *SelfServiceFrontEndImpl) Handler(stats prometheus.Registerer, oTelHTTPOptions ...otelhttp.Option) http.Handler {
m := http.NewServeMux()
mux := http.NewServeMux()

sfs, _ := fs.Sub(staticFS, "static")
staticAssetsHandler := http.StripPrefix("/static/", http.FileServerFS(sfs))
mux.Handle("GET /static/", staticAssetsHandler)

m.Handle("GET /static/", staticAssetsHandler)
m.HandleFunc("/", sfe.Index)
m.HandleFunc("GET /build", sfe.BuildID)
m.HandleFunc("GET "+unpause.GetForm, sfe.UnpauseForm)
m.HandleFunc("POST "+unpausePostForm, sfe.UnpauseSubmit)
m.HandleFunc("GET "+unpauseStatus, sfe.UnpauseStatus)
sfe.handleWithTimeout(mux, "/", sfe.Index)
sfe.handleWithTimeout(mux, "GET /build", sfe.BuildID)
sfe.handleWithTimeout(mux, "GET "+unpause.GetForm, sfe.UnpauseForm)
sfe.handleWithTimeout(mux, "POST "+unpausePostForm, sfe.UnpauseSubmit)
sfe.handleWithTimeout(mux, "GET "+unpauseStatus, sfe.UnpauseStatus)

return measured_http.New(m, sfe.clk, stats, oTelHTTPOptions...)
return measured_http.New(mux, sfe.clk, stats, oTelHTTPOptions...)
}

// renderTemplate takes the name of an HTML template and optional dynamicData
Expand Down Expand Up @@ -139,7 +152,7 @@ func (sfe *SelfServiceFrontEndImpl) BuildID(response http.ResponseWriter, reques
func (sfe *SelfServiceFrontEndImpl) UnpauseForm(response http.ResponseWriter, request *http.Request) {
incomingJWT := request.URL.Query().Get("jwt")

regID, identifiers, err := sfe.parseUnpauseJWT(incomingJWT)
accountID, identifiers, err := sfe.parseUnpauseJWT(incomingJWT)
if err != nil {
if errors.Is(err, jwt.ErrExpired) {
// JWT expired before the Subscriber visited the unpause page.
Expand All @@ -157,15 +170,14 @@ func (sfe *SelfServiceFrontEndImpl) UnpauseForm(response http.ResponseWriter, re
}

type tmplData struct {
UnpauseFormRedirectionPath string
JWT string
AccountID int64
Identifiers []string
PostPath string
JWT string
AccountID int64
Identifiers []string
}

// Serve the actual unpause page given to a Subscriber. Populates the
// unpause form with the JWT from the URL.
sfe.renderTemplate(response, "unpause-form.html", tmplData{unpausePostForm, incomingJWT, regID, identifiers})
// Present the unpause form to the Subscriber.
sfe.renderTemplate(response, "unpause-form.html", tmplData{unpausePostForm, incomingJWT, accountID, identifiers})
}

// UnpauseSubmit serves a page showing the result of the unpause form submission.
Expand All @@ -174,7 +186,7 @@ func (sfe *SelfServiceFrontEndImpl) UnpauseForm(response http.ResponseWriter, re
func (sfe *SelfServiceFrontEndImpl) UnpauseSubmit(response http.ResponseWriter, request *http.Request) {
incomingJWT := request.URL.Query().Get("jwt")

_, _, err := sfe.parseUnpauseJWT(incomingJWT)
accountID, _, err := sfe.parseUnpauseJWT(incomingJWT)
if err != nil {
if errors.Is(err, jwt.ErrExpired) {
// JWT expired before the Subscriber could click the unpause button.
Expand All @@ -191,13 +203,19 @@ func (sfe *SelfServiceFrontEndImpl) UnpauseSubmit(response http.ResponseWriter,
return
}

// TODO(#7536) Send gRPC request to the RA informing it to unpause
// the account specified in the claim. At this point we should wait
// for the RA to process the request before returning to the client,
// just in case the request fails.
unpaused, err := sfe.ra.UnpauseAccount(request.Context(), &rapb.UnpauseAccountRequest{
RegistrationID: accountID,
})
if err != nil {
sfe.unpauseFailed(response)
return
}

// Success, the account has been unpaused.
http.Redirect(response, request, unpauseStatus, http.StatusFound)
// Redirect to the unpause status page with the count of unpaused
// identifiers.
params := url.Values{}
params.Add("count", fmt.Sprintf("%d", unpaused.Count))
http.Redirect(response, request, unpauseStatus+"?"+params.Encode(), http.StatusFound)
}

func (sfe *SelfServiceFrontEndImpl) unpauseRequestMalformed(response http.ResponseWriter) {
Expand All @@ -210,14 +228,20 @@ func (sfe *SelfServiceFrontEndImpl) unpauseTokenExpired(response http.ResponseWr

type unpauseStatusTemplate struct {
Successful bool
Limit int64
Count int64
}

func (sfe *SelfServiceFrontEndImpl) unpauseFailed(response http.ResponseWriter) {
sfe.renderTemplate(response, "unpause-status.html", unpauseStatusTemplate{Successful: false})
}

func (sfe *SelfServiceFrontEndImpl) unpauseSuccessful(response http.ResponseWriter) {
sfe.renderTemplate(response, "unpause-status.html", unpauseStatusTemplate{Successful: true})
func (sfe *SelfServiceFrontEndImpl) unpauseSuccessful(response http.ResponseWriter, count int64) {
sfe.renderTemplate(response, "unpause-status.html", unpauseStatusTemplate{
Successful: true,
Limit: unpause.RequestLimit,
Count: count},
)
}

// UnpauseStatus displays a success message to the Subscriber indicating that
Expand All @@ -229,10 +253,13 @@ func (sfe *SelfServiceFrontEndImpl) UnpauseStatus(response http.ResponseWriter,
return
}

// TODO(#7580) This should only be reachable after a client has clicked the
// "Please unblock my account" button and that request succeeding. No one
// should be able to access this page otherwise.
sfe.unpauseSuccessful(response)
count, err := strconv.ParseInt(request.URL.Query().Get("count"), 10, 64)
if err != nil || count < 0 {
sfe.unpauseFailed(response)
return
}

sfe.unpauseSuccessful(response, count)
}

// parseUnpauseJWT extracts and returns the subscriber's registration ID and a
Expand Down
26 changes: 23 additions & 3 deletions sfe/sfe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,14 +182,34 @@ func TestUnpausePaths(t *testing.T) {
URL: mustParseURL(fmt.Sprintf(unpausePostForm + "?jwt=" + validJWT)),
})
test.AssertEquals(t, responseWriter.Code, http.StatusFound)
test.AssertEquals(t, unpauseStatus, responseWriter.Result().Header.Get("Location"))
test.AssertEquals(t, unpauseStatus+"?count=0", responseWriter.Result().Header.Get("Location"))

// Redirecting after a successful unpause POST displays the success page.
responseWriter = httptest.NewRecorder()
sfe.UnpauseStatus(responseWriter, &http.Request{
Method: "GET",
URL: mustParseURL(unpauseStatus),
URL: mustParseURL(unpauseStatus + "?count=1"),
})
test.AssertEquals(t, responseWriter.Code, http.StatusOK)
test.AssertContains(t, responseWriter.Body.String(), "Account successfully unpaused")
test.AssertContains(t, responseWriter.Body.String(), "Successfully unpaused all 1 identifier(s)")

// Redirecting after a successful unpause POST with a count of 0 displays
// the already unpaused page.
responseWriter = httptest.NewRecorder()
sfe.UnpauseStatus(responseWriter, &http.Request{
Method: "GET",
URL: mustParseURL(unpauseStatus + "?count=0"),
})
test.AssertEquals(t, responseWriter.Code, http.StatusOK)
test.AssertContains(t, responseWriter.Body.String(), "Account already unpaused")

// Redirecting after a successful unpause POST with a count equal to the
// maximum number of identifiers displays the success with caveat page.
responseWriter = httptest.NewRecorder()
sfe.UnpauseStatus(responseWriter, &http.Request{
Method: "GET",
URL: mustParseURL(unpauseStatus + "?count=" + fmt.Sprintf("%d", unpause.RequestLimit)),
})
test.AssertEquals(t, responseWriter.Code, http.StatusOK)
test.AssertContains(t, responseWriter.Body.String(), "Some identifiers were unpaused")
}
10 changes: 9 additions & 1 deletion test/integration/pausing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import (
"github.com/letsencrypt/boulder/test"
)

func TestPausedOrderFails(t *testing.T) {
func TestIdentifiersPausedForAccount(t *testing.T) {
t.Parallel()

if !strings.Contains(os.Getenv("BOULDER_CONFIG_DIR"), "test/config-next") {
Expand Down Expand Up @@ -72,4 +72,12 @@ func TestPausedOrderFails(t *testing.T) {
test.AssertError(t, err, "Should not be able to issue a certificate for a paused domain")
test.AssertContains(t, err.Error(), "Your account is temporarily prevented from requesting certificates for")
test.AssertContains(t, err.Error(), "https://boulder.service.consul:4003/sfe/v1/unpause?jwt=")

_, err = saClient.UnpauseAccount(context.Background(), &sapb.RegistrationID{
Id: regID,
})
test.AssertNotError(t, err, "Failed to unpause domain")

_, err = authAndIssue(c, nil, []string{domain}, true)
test.AssertNotError(t, err, "Should be able to issue a certificate for an unpaused domain")
}
13 changes: 13 additions & 0 deletions unpause/unpause.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@ const (
APIPrefix = "/sfe/" + APIVersion
GetForm = APIPrefix + "/unpause"

// BatchSize is the maximum number of identifiers that the SA will unpause
// in a single batch.
BatchSize = 10000

// MaxBatches is the maximum number of batches that the SA will unpause in a
// single request.
MaxBatches = 5

// RequestLimit is the maximum number of identifiers that the SA will
// unpause in a single request. This is used by the SFE to infer whether
// there are more identifiers to unpause.
RequestLimit = BatchSize * MaxBatches

// JWT
defaultIssuer = "WFE"
defaultAudience = "SFE Unpause"
Expand Down

0 comments on commit c13591a

Please sign in to comment.