SFE: Call RA.UnpauseAccount and handle result (#7638)
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 https://github.com/letsencrypt/boulder/issues/7406
This commit is contained in:
parent
c6c7617851
commit
c13591ab82
8
sa/sa.go
8
sa/sa.go
|
@ -25,6 +25,7 @@ import (
|
||||||
blog "github.com/letsencrypt/boulder/log"
|
blog "github.com/letsencrypt/boulder/log"
|
||||||
"github.com/letsencrypt/boulder/revocation"
|
"github.com/letsencrypt/boulder/revocation"
|
||||||
sapb "github.com/letsencrypt/boulder/sa/proto"
|
sapb "github.com/letsencrypt/boulder/sa/proto"
|
||||||
|
"github.com/letsencrypt/boulder/unpause"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
@ -1411,10 +1412,9 @@ func (ssa *SQLStorageAuthority) UnpauseAccount(ctx context.Context, req *sapb.Re
|
||||||
return nil, errIncompleteRequest
|
return nil, errIncompleteRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
const batchSize = 10000
|
|
||||||
total := &sapb.Count{}
|
total := &sapb.Count{}
|
||||||
|
|
||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < unpause.MaxBatches; i++ {
|
||||||
result, err := ssa.dbMap.ExecContext(ctx, `
|
result, err := ssa.dbMap.ExecContext(ctx, `
|
||||||
UPDATE paused
|
UPDATE paused
|
||||||
SET unpausedAt = ?
|
SET unpausedAt = ?
|
||||||
|
@ -1424,7 +1424,7 @@ func (ssa *SQLStorageAuthority) UnpauseAccount(ctx context.Context, req *sapb.Re
|
||||||
LIMIT ?`,
|
LIMIT ?`,
|
||||||
ssa.clk.Now(),
|
ssa.clk.Now(),
|
||||||
req.Id,
|
req.Id,
|
||||||
batchSize,
|
unpause.BatchSize,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -1436,7 +1436,7 @@ func (ssa *SQLStorageAuthority) UnpauseAccount(ctx context.Context, req *sapb.Re
|
||||||
}
|
}
|
||||||
|
|
||||||
total.Count += rowsAffected
|
total.Count += rowsAffected
|
||||||
if rowsAffected < batchSize {
|
if rowsAffected < unpause.BatchSize {
|
||||||
// Fewer than batchSize rows were updated, so we're done.
|
// Fewer than batchSize rows were updated, so we're done.
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
|
@ -56,7 +56,7 @@
|
||||||
resume requesting certificates for all affected identifiers associated
|
resume requesting certificates for all affected identifiers associated
|
||||||
with your account, not just those listed above.
|
with your account, not just those listed above.
|
||||||
</p>
|
</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>
|
<button class="primary" id="submit">Please Unpause My Account</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,21 +2,41 @@
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
|
|
||||||
{{ if eq .Successful true }}
|
{{ if and .Successful (gt .Count 0) (lt .Count .Limit) }}
|
||||||
|
<h1>Successfully unpaused all {{ .Count }} identifier(s)</h1>
|
||||||
<h1>Account successfully unpaused</h1>
|
|
||||||
<p>
|
<p>
|
||||||
To obtain a new certificate, re-attempt issuance with your ACME client.
|
To obtain a new certificate, re-attempt issuance with your ACME client.
|
||||||
Future repeated validation failures with no successes will result in
|
Future repeated validation failures with no successes will result in
|
||||||
identifiers being paused again.
|
identifiers being paused again.
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
|
{{ else if and .Successful (eq .Count 0) }}
|
||||||
|
<h1>Account already unpaused</h1>
|
||||||
|
<p>
|
||||||
|
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>
|
<h1>An error occurred while unpausing your account</h1>
|
||||||
<p>
|
<p>
|
||||||
Please try again later. If you face continued difficulties, please visit our <a
|
Please try again later. If you face continued difficulties, please visit
|
||||||
href="https://community.letsencrypt.org">community support forum</a>
|
our <a href="https://community.letsencrypt.org">community support
|
||||||
|
forum</a>
|
||||||
for troubleshooting and advice.
|
for troubleshooting and advice.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
@ -24,4 +44,4 @@
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{template "footer"}}
|
{{ template "footer" }}
|
||||||
|
|
85
sfe/sfe.go
85
sfe/sfe.go
|
@ -6,6 +6,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
@ -84,23 +85,35 @@ func NewSelfServiceFrontEndImpl(
|
||||||
return sfe, nil
|
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
|
// Handler returns an http.Handler that uses various functions for various
|
||||||
// non-ACME-specified paths. Each endpoint should have a corresponding HTML
|
// non-ACME-specified paths. Each endpoint should have a corresponding HTML
|
||||||
// page that shares the same name as the endpoint.
|
// page that shares the same name as the endpoint.
|
||||||
func (sfe *SelfServiceFrontEndImpl) Handler(stats prometheus.Registerer, oTelHTTPOptions ...otelhttp.Option) http.Handler {
|
func (sfe *SelfServiceFrontEndImpl) Handler(stats prometheus.Registerer, oTelHTTPOptions ...otelhttp.Option) http.Handler {
|
||||||
m := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
sfs, _ := fs.Sub(staticFS, "static")
|
sfs, _ := fs.Sub(staticFS, "static")
|
||||||
staticAssetsHandler := http.StripPrefix("/static/", http.FileServerFS(sfs))
|
staticAssetsHandler := http.StripPrefix("/static/", http.FileServerFS(sfs))
|
||||||
|
mux.Handle("GET /static/", staticAssetsHandler)
|
||||||
|
|
||||||
m.Handle("GET /static/", staticAssetsHandler)
|
sfe.handleWithTimeout(mux, "/", sfe.Index)
|
||||||
m.HandleFunc("/", sfe.Index)
|
sfe.handleWithTimeout(mux, "GET /build", sfe.BuildID)
|
||||||
m.HandleFunc("GET /build", sfe.BuildID)
|
sfe.handleWithTimeout(mux, "GET "+unpause.GetForm, sfe.UnpauseForm)
|
||||||
m.HandleFunc("GET "+unpause.GetForm, sfe.UnpauseForm)
|
sfe.handleWithTimeout(mux, "POST "+unpausePostForm, sfe.UnpauseSubmit)
|
||||||
m.HandleFunc("POST "+unpausePostForm, sfe.UnpauseSubmit)
|
sfe.handleWithTimeout(mux, "GET "+unpauseStatus, sfe.UnpauseStatus)
|
||||||
m.HandleFunc("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
|
// renderTemplate takes the name of an HTML template and optional dynamicData
|
||||||
|
@ -139,7 +152,7 @@ func (sfe *SelfServiceFrontEndImpl) BuildID(response http.ResponseWriter, reques
|
||||||
func (sfe *SelfServiceFrontEndImpl) UnpauseForm(response http.ResponseWriter, request *http.Request) {
|
func (sfe *SelfServiceFrontEndImpl) UnpauseForm(response http.ResponseWriter, request *http.Request) {
|
||||||
incomingJWT := request.URL.Query().Get("jwt")
|
incomingJWT := request.URL.Query().Get("jwt")
|
||||||
|
|
||||||
regID, identifiers, err := sfe.parseUnpauseJWT(incomingJWT)
|
accountID, identifiers, err := sfe.parseUnpauseJWT(incomingJWT)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, jwt.ErrExpired) {
|
if errors.Is(err, jwt.ErrExpired) {
|
||||||
// JWT expired before the Subscriber visited the unpause page.
|
// JWT expired before the Subscriber visited the unpause page.
|
||||||
|
@ -157,15 +170,14 @@ func (sfe *SelfServiceFrontEndImpl) UnpauseForm(response http.ResponseWriter, re
|
||||||
}
|
}
|
||||||
|
|
||||||
type tmplData struct {
|
type tmplData struct {
|
||||||
UnpauseFormRedirectionPath string
|
PostPath string
|
||||||
JWT string
|
JWT string
|
||||||
AccountID int64
|
AccountID int64
|
||||||
Identifiers []string
|
Identifiers []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serve the actual unpause page given to a Subscriber. Populates the
|
// Present the unpause form to the Subscriber.
|
||||||
// unpause form with the JWT from the URL.
|
sfe.renderTemplate(response, "unpause-form.html", tmplData{unpausePostForm, incomingJWT, accountID, identifiers})
|
||||||
sfe.renderTemplate(response, "unpause-form.html", tmplData{unpausePostForm, incomingJWT, regID, identifiers})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnpauseSubmit serves a page showing the result of the unpause form submission.
|
// UnpauseSubmit serves a page showing the result of the unpause form submission.
|
||||||
|
@ -174,7 +186,7 @@ func (sfe *SelfServiceFrontEndImpl) UnpauseForm(response http.ResponseWriter, re
|
||||||
func (sfe *SelfServiceFrontEndImpl) UnpauseSubmit(response http.ResponseWriter, request *http.Request) {
|
func (sfe *SelfServiceFrontEndImpl) UnpauseSubmit(response http.ResponseWriter, request *http.Request) {
|
||||||
incomingJWT := request.URL.Query().Get("jwt")
|
incomingJWT := request.URL.Query().Get("jwt")
|
||||||
|
|
||||||
_, _, err := sfe.parseUnpauseJWT(incomingJWT)
|
accountID, _, err := sfe.parseUnpauseJWT(incomingJWT)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, jwt.ErrExpired) {
|
if errors.Is(err, jwt.ErrExpired) {
|
||||||
// JWT expired before the Subscriber could click the unpause button.
|
// JWT expired before the Subscriber could click the unpause button.
|
||||||
|
@ -191,13 +203,19 @@ func (sfe *SelfServiceFrontEndImpl) UnpauseSubmit(response http.ResponseWriter,
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(#7536) Send gRPC request to the RA informing it to unpause
|
unpaused, err := sfe.ra.UnpauseAccount(request.Context(), &rapb.UnpauseAccountRequest{
|
||||||
// the account specified in the claim. At this point we should wait
|
RegistrationID: accountID,
|
||||||
// for the RA to process the request before returning to the client,
|
})
|
||||||
// just in case the request fails.
|
if err != nil {
|
||||||
|
sfe.unpauseFailed(response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Success, the account has been unpaused.
|
// Redirect to the unpause status page with the count of unpaused
|
||||||
http.Redirect(response, request, unpauseStatus, http.StatusFound)
|
// 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) {
|
func (sfe *SelfServiceFrontEndImpl) unpauseRequestMalformed(response http.ResponseWriter) {
|
||||||
|
@ -210,14 +228,20 @@ func (sfe *SelfServiceFrontEndImpl) unpauseTokenExpired(response http.ResponseWr
|
||||||
|
|
||||||
type unpauseStatusTemplate struct {
|
type unpauseStatusTemplate struct {
|
||||||
Successful bool
|
Successful bool
|
||||||
|
Limit int64
|
||||||
|
Count int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sfe *SelfServiceFrontEndImpl) unpauseFailed(response http.ResponseWriter) {
|
func (sfe *SelfServiceFrontEndImpl) unpauseFailed(response http.ResponseWriter) {
|
||||||
sfe.renderTemplate(response, "unpause-status.html", unpauseStatusTemplate{Successful: false})
|
sfe.renderTemplate(response, "unpause-status.html", unpauseStatusTemplate{Successful: false})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sfe *SelfServiceFrontEndImpl) unpauseSuccessful(response http.ResponseWriter) {
|
func (sfe *SelfServiceFrontEndImpl) unpauseSuccessful(response http.ResponseWriter, count int64) {
|
||||||
sfe.renderTemplate(response, "unpause-status.html", unpauseStatusTemplate{Successful: true})
|
sfe.renderTemplate(response, "unpause-status.html", unpauseStatusTemplate{
|
||||||
|
Successful: true,
|
||||||
|
Limit: unpause.RequestLimit,
|
||||||
|
Count: count},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnpauseStatus displays a success message to the Subscriber indicating that
|
// UnpauseStatus displays a success message to the Subscriber indicating that
|
||||||
|
@ -229,10 +253,13 @@ func (sfe *SelfServiceFrontEndImpl) UnpauseStatus(response http.ResponseWriter,
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(#7580) This should only be reachable after a client has clicked the
|
count, err := strconv.ParseInt(request.URL.Query().Get("count"), 10, 64)
|
||||||
// "Please unblock my account" button and that request succeeding. No one
|
if err != nil || count < 0 {
|
||||||
// should be able to access this page otherwise.
|
sfe.unpauseFailed(response)
|
||||||
sfe.unpauseSuccessful(response)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sfe.unpauseSuccessful(response, count)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseUnpauseJWT extracts and returns the subscriber's registration ID and a
|
// parseUnpauseJWT extracts and returns the subscriber's registration ID and a
|
||||||
|
|
|
@ -182,14 +182,34 @@ func TestUnpausePaths(t *testing.T) {
|
||||||
URL: mustParseURL(fmt.Sprintf(unpausePostForm + "?jwt=" + validJWT)),
|
URL: mustParseURL(fmt.Sprintf(unpausePostForm + "?jwt=" + validJWT)),
|
||||||
})
|
})
|
||||||
test.AssertEquals(t, responseWriter.Code, http.StatusFound)
|
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.
|
// Redirecting after a successful unpause POST displays the success page.
|
||||||
responseWriter = httptest.NewRecorder()
|
responseWriter = httptest.NewRecorder()
|
||||||
sfe.UnpauseStatus(responseWriter, &http.Request{
|
sfe.UnpauseStatus(responseWriter, &http.Request{
|
||||||
Method: "GET",
|
Method: "GET",
|
||||||
URL: mustParseURL(unpauseStatus),
|
URL: mustParseURL(unpauseStatus + "?count=1"),
|
||||||
})
|
})
|
||||||
test.AssertEquals(t, responseWriter.Code, http.StatusOK)
|
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")
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,7 +20,7 @@ import (
|
||||||
"github.com/letsencrypt/boulder/test"
|
"github.com/letsencrypt/boulder/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPausedOrderFails(t *testing.T) {
|
func TestIdentifiersPausedForAccount(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
if !strings.Contains(os.Getenv("BOULDER_CONFIG_DIR"), "test/config-next") {
|
if !strings.Contains(os.Getenv("BOULDER_CONFIG_DIR"), "test/config-next") {
|
||||||
|
@ -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.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(), "Your account is temporarily prevented from requesting certificates for")
|
||||||
test.AssertContains(t, err.Error(), "https://boulder.service.consul:4003/sfe/v1/unpause?jwt=")
|
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")
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,6 +21,19 @@ const (
|
||||||
APIPrefix = "/sfe/" + APIVersion
|
APIPrefix = "/sfe/" + APIVersion
|
||||||
GetForm = APIPrefix + "/unpause"
|
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
|
// JWT
|
||||||
defaultIssuer = "WFE"
|
defaultIssuer = "WFE"
|
||||||
defaultAudience = "SFE Unpause"
|
defaultAudience = "SFE Unpause"
|
||||||
|
|
Loading…
Reference in New Issue