boulder/sfe/sfe.go

294 lines
9.8 KiB
Go

package sfe
import (
"embed"
"errors"
"fmt"
"html/template"
"io/fs"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/jmhodges/clock"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"github.com/letsencrypt/boulder/core"
blog "github.com/letsencrypt/boulder/log"
"github.com/letsencrypt/boulder/metrics/measured_http"
rapb "github.com/letsencrypt/boulder/ra/proto"
sapb "github.com/letsencrypt/boulder/sa/proto"
"github.com/letsencrypt/boulder/unpause"
)
const (
unpausePostForm = unpause.APIPrefix + "/do-unpause"
unpauseStatus = unpause.APIPrefix + "/unpause-status"
)
var (
//go:embed all:static
staticFS embed.FS
//go:embed all:templates all:pages all:static
dynamicFS embed.FS
)
// SelfServiceFrontEndImpl provides all the logic for Boulder's selfservice
// frontend web-facing interface, i.e., a portal where a subscriber can unpause
// their account. Its methods are primarily handlers for HTTPS requests for the
// various non-ACME functions.
type SelfServiceFrontEndImpl struct {
ra rapb.RegistrationAuthorityClient
sa sapb.StorageAuthorityReadOnlyClient
log blog.Logger
clk clock.Clock
// requestTimeout is the per-request overall timeout.
requestTimeout time.Duration
unpauseHMACKey []byte
templatePages *template.Template
}
// NewSelfServiceFrontEndImpl constructs a web service for Boulder
func NewSelfServiceFrontEndImpl(
stats prometheus.Registerer,
clk clock.Clock,
logger blog.Logger,
requestTimeout time.Duration,
rac rapb.RegistrationAuthorityClient,
sac sapb.StorageAuthorityReadOnlyClient,
unpauseHMACKey []byte,
) (SelfServiceFrontEndImpl, error) {
// Parse the files once at startup to avoid each request causing the server
// to JIT parse. The pages are stored in an in-memory embed.FS to prevent
// unnecessary filesystem I/O on a physical HDD.
tmplPages := template.Must(template.New("pages").ParseFS(dynamicFS, "templates/layout.html", "pages/*"))
sfe := SelfServiceFrontEndImpl{
log: logger,
clk: clk,
requestTimeout: requestTimeout,
ra: rac,
sa: sac,
unpauseHMACKey: unpauseHMACKey,
templatePages: tmplPages,
}
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 {
mux := http.NewServeMux()
sfs, _ := fs.Sub(staticFS, "static")
staticAssetsHandler := http.StripPrefix("/static/", http.FileServerFS(sfs))
mux.Handle("GET /static/", staticAssetsHandler)
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(mux, sfe.clk, stats, oTelHTTPOptions...)
}
// renderTemplate takes the name of an HTML template and optional dynamicData
// which are rendered and served back to the client via the response writer.
func (sfe *SelfServiceFrontEndImpl) renderTemplate(w http.ResponseWriter, filename string, dynamicData any) {
if len(filename) == 0 {
http.Error(w, "Template page does not exist", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
err := sfe.templatePages.ExecuteTemplate(w, filename, dynamicData)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// Index is the homepage of the SFE
func (sfe *SelfServiceFrontEndImpl) Index(response http.ResponseWriter, request *http.Request) {
sfe.renderTemplate(response, "index.html", nil)
}
// BuildID tells the requester what boulder build version is running.
func (sfe *SelfServiceFrontEndImpl) BuildID(response http.ResponseWriter, request *http.Request) {
response.Header().Set("Content-Type", "text/plain")
response.WriteHeader(http.StatusOK)
detailsString := fmt.Sprintf("Boulder=(%s %s)", core.GetBuildID(), core.GetBuildTime())
if _, err := fmt.Fprintln(response, detailsString); err != nil {
sfe.log.Warningf("Could not write response: %s", err)
}
}
// UnpauseForm allows a requester to unpause their account via a form present on
// the page. The Subscriber's client will receive a log line emitted by the WFE
// which contains a URL pre-filled with a JWT that will populate a hidden field
// in this form.
func (sfe *SelfServiceFrontEndImpl) UnpauseForm(response http.ResponseWriter, request *http.Request) {
incomingJWT := request.URL.Query().Get("jwt")
accountID, idents, err := sfe.parseUnpauseJWT(incomingJWT)
if err != nil {
if errors.Is(err, jwt.ErrExpired) {
// JWT expired before the Subscriber visited the unpause page.
sfe.unpauseTokenExpired(response)
return
}
if errors.Is(err, unpause.ErrMalformedJWT) {
// JWT is malformed. This could happen if the Subscriber failed to
// copy the entire URL from their logs.
sfe.unpauseRequestMalformed(response)
return
}
sfe.unpauseFailed(response)
return
}
// If any of these values change, ensure any relevant pages in //sfe/pages/
// are also updated.
type tmplData struct {
PostPath string
JWT string
AccountID int64
Idents []string
}
// Present the unpause form to the Subscriber.
sfe.renderTemplate(response, "unpause-form.html", tmplData{unpausePostForm, incomingJWT, accountID, idents})
}
// UnpauseSubmit serves a page showing the result of the unpause form submission.
// CSRF is not addressed because a third party causing submission of an unpause
// form is not harmful.
func (sfe *SelfServiceFrontEndImpl) UnpauseSubmit(response http.ResponseWriter, request *http.Request) {
incomingJWT := request.URL.Query().Get("jwt")
accountID, _, err := sfe.parseUnpauseJWT(incomingJWT)
if err != nil {
if errors.Is(err, jwt.ErrExpired) {
// JWT expired before the Subscriber could click the unpause button.
sfe.unpauseTokenExpired(response)
return
}
if errors.Is(err, unpause.ErrMalformedJWT) {
// JWT is malformed. This should never happen if the request came
// from our form.
sfe.unpauseRequestMalformed(response)
return
}
sfe.unpauseFailed(response)
return
}
unpaused, err := sfe.ra.UnpauseAccount(request.Context(), &rapb.UnpauseAccountRequest{
RegistrationID: accountID,
})
if err != nil {
sfe.unpauseFailed(response)
return
}
// 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) {
sfe.renderTemplate(response, "unpause-invalid-request.html", nil)
}
func (sfe *SelfServiceFrontEndImpl) unpauseTokenExpired(response http.ResponseWriter) {
sfe.renderTemplate(response, "unpause-expired.html", nil)
}
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, 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
// their account has been unpaused.
func (sfe *SelfServiceFrontEndImpl) UnpauseStatus(response http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodHead && request.Method != http.MethodGet {
response.Header().Set("Access-Control-Allow-Methods", "GET, HEAD")
response.WriteHeader(http.StatusMethodNotAllowed)
return
}
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
// slice of paused identifiers from the claims. If the JWT cannot be parsed or
// is otherwise invalid, an error is returned. If the JWT is missing or
// malformed, unpause.ErrMalformedJWT is returned.
func (sfe *SelfServiceFrontEndImpl) parseUnpauseJWT(incomingJWT string) (int64, []string, error) {
if incomingJWT == "" || len(strings.Split(incomingJWT, ".")) != 3 {
// JWT is missing or malformed. This could happen if the Subscriber
// failed to copy the entire URL from their logs. This should never
// happen if the request came from our form.
return 0, nil, unpause.ErrMalformedJWT
}
claims, err := unpause.RedeemJWT(incomingJWT, sfe.unpauseHMACKey, unpause.APIVersion, sfe.clk)
if err != nil {
return 0, nil, err
}
account, convErr := strconv.ParseInt(claims.Subject, 10, 64)
if convErr != nil {
// This should never happen as this was just validated by the call to
// unpause.RedeemJWT().
return 0, nil, errors.New("failed to parse account ID from JWT")
}
return account, strings.Split(claims.I, ","), nil
}