sfe: Implement self-service frontend for account pausing/unpausing (#7500)
Adds a new boulder component named `sfe` aka the Self-service FrontEnd which is dedicated to non-ACME related Subscriber functions. This change implements one such function which is a web interface and handlers for account unpausing. When paused, an ACME client receives a log line URL with a JWT parameter from the WFE. For the observant Subscriber, manually clicking the link opens their web browser and displays a page with a pre-filled HTML form. Upon clicking the form button, the SFE sends an HTTP POST back to itself and either validates the JWT and issues an RA gRPC request to unpause the account, or returns an HTML error page. The SFE and WFE should share a 32 byte seed value e.g. the output of `openssl rand -hex 16` which will be used as a go-jose symmetric signer using the HS256 algorithm. The SFE will check various [RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519) claims on the JWT such as the `iss`, `aud`, `nbf`, `exp`, `iat`, and a custom `apiVersion` claim. The SFE should not yet be relied upon or deployed to staging/production environments. It is very much a work in progress, but this change is big enough as-is. Related to https://github.com/letsencrypt/boulder/issues/7406 Part of https://github.com/letsencrypt/boulder/issues/7499
This commit is contained in:
parent
63452d5afe
commit
30c6e592f7
|
@ -6,7 +6,6 @@ import (
|
|||
"encoding/pem"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
@ -19,12 +18,12 @@ import (
|
|||
bgrpc "github.com/letsencrypt/boulder/grpc"
|
||||
"github.com/letsencrypt/boulder/grpc/noncebalancer"
|
||||
"github.com/letsencrypt/boulder/issuance"
|
||||
blog "github.com/letsencrypt/boulder/log"
|
||||
"github.com/letsencrypt/boulder/nonce"
|
||||
rapb "github.com/letsencrypt/boulder/ra/proto"
|
||||
"github.com/letsencrypt/boulder/ratelimits"
|
||||
bredis "github.com/letsencrypt/boulder/redis"
|
||||
sapb "github.com/letsencrypt/boulder/sa/proto"
|
||||
"github.com/letsencrypt/boulder/web"
|
||||
"github.com/letsencrypt/boulder/wfe2"
|
||||
)
|
||||
|
||||
|
@ -42,7 +41,7 @@ type Config struct {
|
|||
TLSListenAddress string `validate:"omitempty,hostname_port"`
|
||||
|
||||
// Timeout is the per-request overall timeout. This should be slightly
|
||||
// lower than the upstream's timeout when making request to the WFE.
|
||||
// lower than the upstream's timeout when making requests to the WFE.
|
||||
Timeout config.Duration `validate:"-"`
|
||||
|
||||
ServerCertificatePath string `validate:"required_with=TLSListenAddress"`
|
||||
|
@ -196,22 +195,6 @@ func loadChain(certFiles []string) (*issuance.Certificate, []byte, error) {
|
|||
return certs[0], buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type errorWriter struct {
|
||||
blog.Logger
|
||||
}
|
||||
|
||||
func (ew errorWriter) Write(p []byte) (n int, err error) {
|
||||
// log.Logger will append a newline to all messages before calling
|
||||
// Write. Our log checksum checker doesn't like newlines, because
|
||||
// syslog will strip them out so the calculated checksums will
|
||||
// differ. So that we don't hit this corner case for every line
|
||||
// logged from inside net/http.Server we strip the newline before
|
||||
// we get to the checksum generator.
|
||||
p = bytes.TrimRight(p, "\n")
|
||||
ew.Logger.Err(fmt.Sprintf("net/http.Server: %s", string(p)))
|
||||
return
|
||||
}
|
||||
|
||||
func main() {
|
||||
listenAddr := flag.String("addr", "", "HTTP listen address override")
|
||||
tlsAddr := flag.String("tls-addr", "", "HTTPS listen address override")
|
||||
|
@ -391,15 +374,7 @@ func main() {
|
|||
logger.Infof("Server running, listening on %s....", c.WFE.ListenAddress)
|
||||
handler := wfe.Handler(stats, c.OpenTelemetryHTTPConfig.Options()...)
|
||||
|
||||
srv := http.Server{
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 120 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
Addr: c.WFE.ListenAddress,
|
||||
ErrorLog: log.New(errorWriter{logger}, "", 0),
|
||||
Handler: handler,
|
||||
}
|
||||
|
||||
srv := web.NewServer(c.WFE.ListenAddress, handler, logger)
|
||||
go func() {
|
||||
err := srv.ListenAndServe()
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
|
@ -407,14 +382,7 @@ func main() {
|
|||
}
|
||||
}()
|
||||
|
||||
tlsSrv := http.Server{
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 120 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
Addr: c.WFE.TLSListenAddress,
|
||||
ErrorLog: log.New(errorWriter{logger}, "", 0),
|
||||
Handler: handler,
|
||||
}
|
||||
tlsSrv := web.NewServer(c.WFE.TLSListenAddress, handler, logger)
|
||||
if tlsSrv.Addr != "" {
|
||||
go func() {
|
||||
logger.Infof("TLS server listening on %s", tlsSrv.Addr)
|
||||
|
|
|
@ -29,6 +29,7 @@ import (
|
|||
_ "github.com/letsencrypt/boulder/cmd/remoteva"
|
||||
_ "github.com/letsencrypt/boulder/cmd/reversed-hostname-checker"
|
||||
_ "github.com/letsencrypt/boulder/cmd/rocsp-tool"
|
||||
_ "github.com/letsencrypt/boulder/cmd/sfe"
|
||||
"github.com/letsencrypt/boulder/core"
|
||||
|
||||
"github.com/letsencrypt/boulder/cmd"
|
||||
|
|
|
@ -52,6 +52,8 @@ func TestConfigValidation(t *testing.T) {
|
|||
}
|
||||
case "boulder-wfe2":
|
||||
fileNames = []string{"wfe2.json"}
|
||||
case "sfe":
|
||||
fileNames = []string{"sfe.json"}
|
||||
case "nonce-service":
|
||||
fileNames = []string{
|
||||
"nonce-a.json",
|
||||
|
|
|
@ -553,3 +553,12 @@ type DNSProvider struct {
|
|||
// 1 1 8153 0a4d4d4d.addr.dc1.consul.
|
||||
SRVLookup ServiceDomain `validate:"required"`
|
||||
}
|
||||
|
||||
type UnpauseConfig struct {
|
||||
// HMACKey is a shared symmetric secret used to sign/validate unpause JWTs.
|
||||
// It should be 32 alphanumeric characters, e.g. the output of `openssl rand
|
||||
// -hex 16` to satisfy the go-jose HS256 algorithm implementation. In a
|
||||
// multi-DC deployment this value should be the same across all boulder-wfe
|
||||
// and sfe instances.
|
||||
HMACKey PasswordConfig `validate:"-"`
|
||||
}
|
||||
|
|
|
@ -0,0 +1,143 @@
|
|||
package notmain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/letsencrypt/boulder/cmd"
|
||||
"github.com/letsencrypt/boulder/config"
|
||||
"github.com/letsencrypt/boulder/features"
|
||||
bgrpc "github.com/letsencrypt/boulder/grpc"
|
||||
rapb "github.com/letsencrypt/boulder/ra/proto"
|
||||
sapb "github.com/letsencrypt/boulder/sa/proto"
|
||||
"github.com/letsencrypt/boulder/sfe"
|
||||
"github.com/letsencrypt/boulder/web"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
SFE struct {
|
||||
DebugAddr string `validate:"omitempty,hostname_port"`
|
||||
|
||||
// ListenAddress is the address:port on which to listen for incoming
|
||||
// HTTP requests. Defaults to ":80".
|
||||
ListenAddress string `validate:"omitempty,hostname_port"`
|
||||
|
||||
// Timeout is the per-request overall timeout. This should be slightly
|
||||
// lower than the upstream's timeout when making requests to the SFE.
|
||||
Timeout config.Duration `validate:"-"`
|
||||
|
||||
// ShutdownStopTimeout is the duration that the SFE will wait before
|
||||
// shutting down any listening servers.
|
||||
ShutdownStopTimeout config.Duration
|
||||
|
||||
TLS cmd.TLSConfig
|
||||
|
||||
RAService *cmd.GRPCClientConfig
|
||||
SAService *cmd.GRPCClientConfig
|
||||
|
||||
Unpause cmd.UnpauseConfig
|
||||
|
||||
Features features.Config
|
||||
}
|
||||
|
||||
Syslog cmd.SyslogConfig
|
||||
OpenTelemetry cmd.OpenTelemetryConfig
|
||||
|
||||
// OpenTelemetryHTTPConfig configures tracing on incoming HTTP requests
|
||||
OpenTelemetryHTTPConfig cmd.OpenTelemetryHTTPConfig
|
||||
}
|
||||
|
||||
func main() {
|
||||
listenAddr := flag.String("addr", "", "HTTP listen address override")
|
||||
debugAddr := flag.String("debug-addr", "", "Debug server address override")
|
||||
configFile := flag.String("config", "", "File path to the configuration file for this service")
|
||||
flag.Parse()
|
||||
if *configFile == "" {
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var c Config
|
||||
err := cmd.ReadConfigFile(*configFile, &c)
|
||||
cmd.FailOnError(err, "Reading JSON config file into config structure")
|
||||
|
||||
features.Set(c.SFE.Features)
|
||||
|
||||
if *listenAddr != "" {
|
||||
c.SFE.ListenAddress = *listenAddr
|
||||
}
|
||||
if c.SFE.ListenAddress == "" {
|
||||
cmd.Fail("HTTP listen address is not configured")
|
||||
}
|
||||
if *debugAddr != "" {
|
||||
c.SFE.DebugAddr = *debugAddr
|
||||
}
|
||||
|
||||
stats, logger, oTelShutdown := cmd.StatsAndLogging(c.Syslog, c.OpenTelemetry, c.SFE.DebugAddr)
|
||||
logger.Info(cmd.VersionString())
|
||||
|
||||
clk := cmd.Clock()
|
||||
|
||||
unpauseHMACKey, err := c.SFE.Unpause.HMACKey.Pass()
|
||||
cmd.FailOnError(err, "Failed to load unpauseHMACKey")
|
||||
|
||||
if len(unpauseHMACKey) != 32 {
|
||||
cmd.Fail("Invalid unpauseHMACKey length, should be 32 alphanumeric characters")
|
||||
}
|
||||
|
||||
// The jose.SigningKey key interface where this is used can be satisfied by
|
||||
// a byte slice, not a string.
|
||||
unpauseHMACKeyBytes := []byte(unpauseHMACKey)
|
||||
|
||||
tlsConfig, err := c.SFE.TLS.Load(stats)
|
||||
cmd.FailOnError(err, "TLS config")
|
||||
|
||||
raConn, err := bgrpc.ClientSetup(c.SFE.RAService, tlsConfig, stats, clk)
|
||||
cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to RA")
|
||||
rac := rapb.NewRegistrationAuthorityClient(raConn)
|
||||
|
||||
saConn, err := bgrpc.ClientSetup(c.SFE.SAService, tlsConfig, stats, clk)
|
||||
cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to SA")
|
||||
sac := sapb.NewStorageAuthorityReadOnlyClient(saConn)
|
||||
|
||||
sfei, err := sfe.NewSelfServiceFrontEndImpl(
|
||||
stats,
|
||||
clk,
|
||||
logger,
|
||||
c.SFE.Timeout.Duration,
|
||||
rac,
|
||||
sac,
|
||||
unpauseHMACKeyBytes,
|
||||
)
|
||||
cmd.FailOnError(err, "Unable to create SFE")
|
||||
|
||||
logger.Infof("Server running, listening on %s....", c.SFE.ListenAddress)
|
||||
handler := sfei.Handler(stats, c.OpenTelemetryHTTPConfig.Options()...)
|
||||
|
||||
srv := web.NewServer(c.SFE.ListenAddress, handler, logger)
|
||||
go func() {
|
||||
err := srv.ListenAndServe()
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
cmd.FailOnError(err, "Running HTTP server")
|
||||
}
|
||||
}()
|
||||
|
||||
// When main is ready to exit (because it has received a shutdown signal),
|
||||
// gracefully shutdown the servers. Calling these shutdown functions causes
|
||||
// ListenAndServe() and ListenAndServeTLS() to immediately return, then waits
|
||||
// for any lingering connection-handling goroutines to finish their work.
|
||||
defer func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), c.SFE.ShutdownStopTimeout.Duration)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(ctx)
|
||||
oTelShutdown(ctx)
|
||||
}()
|
||||
|
||||
cmd.WaitForSignal()
|
||||
}
|
||||
|
||||
func init() {
|
||||
cmd.RegisterCommand("sfe", main, &cmd.ConfigValidator{Config: &Config{}})
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
<!doctype html>
|
||||
<html dir="ltr" lang="en-US">
|
||||
<header>
|
||||
<title>Self-Service Frontend</title>
|
||||
{{ template "meta" }}
|
||||
</header>
|
||||
<body>
|
||||
<h1>No Action Required</h1>
|
||||
<div>
|
||||
<p>
|
||||
There is no action for you to take. This page is intended for
|
||||
Subscribers whose accounts have been temporarily restricted from
|
||||
requesting new certificates for certain hostnames, following a
|
||||
significant number of failed validation attempts without any recent
|
||||
successes. If your account was paused, your <a
|
||||
href="https://letsencrypt.org/docs/client-options/">ACME client</a>
|
||||
would provide you with a URL to visit to unpause your account.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
{{template "footer"}}
|
|
@ -0,0 +1,64 @@
|
|||
<!doctype html>
|
||||
<html dir="ltr" lang="en-US">
|
||||
<header>
|
||||
<title>Unpause - Self-Service Frontend</title>
|
||||
{{ template "meta" }}
|
||||
</header>
|
||||
<body>
|
||||
<div>
|
||||
<h1>Action Required to Unpause Your ACME Account</h1>
|
||||
|
||||
<p>
|
||||
You have been directed to this page because your Account ID {{ .AccountID }}
|
||||
is temporarily restricted from requesting new certificates for certain
|
||||
hostnames including, but potentially not limited to, the following:
|
||||
<ul>
|
||||
{{ range $domain := .PausedDomains }}<li>{{ $domain }}</li>{{ end }}
|
||||
</ul>
|
||||
</p>
|
||||
|
||||
<h2>Why Did This Happen?</h2>
|
||||
<p>
|
||||
This often happens when domain names expire, point to new hosts, or if
|
||||
there are issues with the DNS configuration or web server settings.
|
||||
These problems prevent your ACME client from successfully <a
|
||||
href="https://letsencrypt.org/how-it-works/">validating control over the
|
||||
domain</a>, which is necessary for issuing TLS certificates.
|
||||
</p>
|
||||
|
||||
<h2>What Can You Do?</h2>
|
||||
<p>
|
||||
Please check the DNS configuration and web server settings for the
|
||||
affected hostnames. Ensure they are properly set up to respond to ACME
|
||||
challenges. This might involve updating DNS records, renewing domain
|
||||
registrations, or adjusting web server configurations. If you use a
|
||||
hosting provider or third-party service for domain management, you may
|
||||
need to coordinate with them. If you believe you've fixed the underlying
|
||||
issue, consider attempting issuance against our <a
|
||||
href="https://letsencrypt.org/docs/staging-environment/">staging
|
||||
environment</a> to verify your fix.
|
||||
</p>
|
||||
|
||||
<h2>Ready to Unpause?</h2>
|
||||
<p>
|
||||
Once you have addressed these issues, click the button below to remove
|
||||
the pause on your account. This action will allow you to resume
|
||||
requesting certificates for all affected hostnames associated with your
|
||||
account.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Note:</strong> If you face difficulties unpausing your account or
|
||||
need more guidance, our <a
|
||||
href="https://community.letsencrypt.org">community support forum</a> is
|
||||
a great resource for troubleshooting and advice.
|
||||
</p>
|
||||
<div>
|
||||
<form action="{{ .UnpauseFormRedirectionPath }}?jwt={{ .JWT }}" method="POST">
|
||||
<button class="primary" id="submit">Please Unpause My Account</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
|
||||
{{template "footer"}}
|
|
@ -0,0 +1,20 @@
|
|||
<!doctype html>
|
||||
<html dir="ltr" lang="en-US">
|
||||
<header>
|
||||
<title>Unpause - Self-Service Frontend</title>
|
||||
{{ template "meta" }}
|
||||
</header>
|
||||
<body>
|
||||
<div>
|
||||
<h1>Invalid Request To Unpause Account</h1>
|
||||
<p>
|
||||
Your unpause request was invalid meaning that we could not find all of
|
||||
the data required in the URL. Please verify you copied the log line from
|
||||
your client correctly. You may visit our <a
|
||||
href="https://community.letsencrypt.org">community forum</a> and request
|
||||
assistance if the problem persists.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
{{template "footer"}}
|
|
@ -0,0 +1,32 @@
|
|||
<!doctype html>
|
||||
<html dir="ltr" lang="en-US">
|
||||
<header>
|
||||
<title>Unpause - Self-Service Frontend</title>
|
||||
{{ template "meta" }}
|
||||
</header>
|
||||
<body>
|
||||
<div>
|
||||
{{ if eq .UnpauseSuccessful true }}
|
||||
<h1>Your ACME Account Has Been Unpaused</h1>
|
||||
|
||||
<p>
|
||||
Your ACME account has been unpaused. To obtain a new certificate,
|
||||
re-attempt issuance with your ACME client. Future repeated validation
|
||||
failures with no successes will result in your account being paused
|
||||
again.
|
||||
</p>
|
||||
{{ else }}
|
||||
<h1>Error Occurred While Unpausing Account</h1>
|
||||
|
||||
<p>
|
||||
An error was encountered when attempting to unpause your account. Please
|
||||
try again later. You may visit our <a
|
||||
href="https://community.letsencrypt.org">community forum</a> and request
|
||||
assistance if the problem persists.
|
||||
</p>
|
||||
{{ end }}
|
||||
|
||||
</div>
|
||||
</body>
|
||||
|
||||
{{template "footer"}}
|
|
@ -0,0 +1,320 @@
|
|||
package sfe
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
"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"
|
||||
)
|
||||
|
||||
const (
|
||||
// The API version should be checked when parsing parameters to quickly deny
|
||||
// a client request. Can be used to mass-invalidate URLs. Must be
|
||||
// concatenated with other path slugs.
|
||||
unpauseAPIPrefix = "/sfe/v1"
|
||||
unpauseGetForm = unpauseAPIPrefix + "/unpause"
|
||||
unpausePostForm = unpauseAPIPrefix + "/do-unpause"
|
||||
unpauseStatus = unpauseAPIPrefix + "/unpause-status"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed all:static
|
||||
staticFS embed.FS
|
||||
|
||||
//go:embed all:templates all:pages
|
||||
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 is used to validate incoming JWT signatures on the unpause
|
||||
// endpoint and should be shared by the SFE and WFE.
|
||||
unpauseHMACKey []byte
|
||||
|
||||
// HTML pages served by the SFE
|
||||
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
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
sfs, _ := fs.Sub(staticFS, "static")
|
||||
staticAssetsHandler := http.StripPrefix("/static/", http.FileServerFS(sfs))
|
||||
|
||||
m.Handle("GET /static/", staticAssetsHandler)
|
||||
m.HandleFunc("/", sfe.Index)
|
||||
m.HandleFunc("GET /build", sfe.BuildID)
|
||||
m.HandleFunc(unpauseGetForm, sfe.UnpauseForm)
|
||||
m.HandleFunc(unpausePostForm, sfe.UnpauseSubmit)
|
||||
m.HandleFunc(unpauseStatus, sfe.UnpauseStatus)
|
||||
|
||||
return measured_http.New(m, 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
|
||||
}
|
||||
|
||||
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) {
|
||||
if request.Method != http.MethodGet && request.Method != http.MethodHead {
|
||||
response.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// unpauseJWT is generated by a WFE and is used to round-trip back through the
|
||||
// SFE to unpause the requester's account.
|
||||
type unpauseJWT string
|
||||
|
||||
// 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) {
|
||||
if request.Method != http.MethodHead && request.Method != http.MethodGet {
|
||||
response.Header().Set("Access-Control-Allow-Methods", "GET, HEAD")
|
||||
response.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
incomingJWT := request.URL.Query().Get("jwt")
|
||||
if incomingJWT == "" {
|
||||
sfe.unpauseInvalidRequest(response)
|
||||
return
|
||||
}
|
||||
|
||||
regID, domains, err := sfe.validateUnpauseJWTforAccount(unpauseJWT(incomingJWT))
|
||||
if err != nil {
|
||||
sfe.unpauseStatusHelper(response, false)
|
||||
return
|
||||
}
|
||||
|
||||
type tmplData struct {
|
||||
UnpauseFormRedirectionPath string
|
||||
JWT string
|
||||
AccountID string
|
||||
PausedDomains []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, domains})
|
||||
}
|
||||
|
||||
// UnpauseSubmit serves a page indicating if the unpause form submission
|
||||
// succeeded or failed upon clicking the unpause button. We are explicitly
|
||||
// choosing to not address CSRF at this time because we control creation and
|
||||
// redemption of the JWT.
|
||||
func (sfe *SelfServiceFrontEndImpl) UnpauseSubmit(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodPost {
|
||||
response.Header().Set("Access-Control-Allow-Methods", "POST")
|
||||
response.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
incomingJWT := request.URL.Query().Get("jwt")
|
||||
if incomingJWT == "" {
|
||||
sfe.unpauseInvalidRequest(response)
|
||||
return
|
||||
}
|
||||
|
||||
regID, _, err := sfe.validateUnpauseJWTforAccount(unpauseJWT(incomingJWT))
|
||||
if err != nil {
|
||||
sfe.unpauseStatusHelper(response, false)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO(#7356) Declare a registration ID variable to populate an
|
||||
// rapb unpause account request message.
|
||||
_, innerErr := strconv.ParseInt(regID, 10, 64)
|
||||
if innerErr != nil {
|
||||
sfe.unpauseStatusHelper(response, false)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO(#7536) Send gRPC nrequest 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.
|
||||
|
||||
// Success, the account has been unpaused.
|
||||
http.Redirect(response, request, unpauseStatus, http.StatusFound)
|
||||
}
|
||||
|
||||
// unpauseInvalidRequest is a helper that displays a page indicating the
|
||||
// Subscriber perform basic troubleshooting due to lack of JWT in the data
|
||||
// object.
|
||||
func (sfe *SelfServiceFrontEndImpl) unpauseInvalidRequest(response http.ResponseWriter) {
|
||||
sfe.renderTemplate(response, "unpause-invalid-request.html", nil)
|
||||
}
|
||||
|
||||
type unpauseStatusTemplateData struct {
|
||||
UnpauseSuccessful bool
|
||||
}
|
||||
|
||||
// unpauseStatus is a helper that, by default, displays a failure message to the
|
||||
// Subscriber indicating that their account has failed to unpause. For failure
|
||||
// scenarios, only when the JWT validation should call this. Other types of
|
||||
// failures should use unpauseInvalidRequest. For successes, call UnpauseStatus
|
||||
// instead.
|
||||
func (sfe *SelfServiceFrontEndImpl) unpauseStatusHelper(response http.ResponseWriter, status bool) {
|
||||
sfe.renderTemplate(response, "unpause-status.html", unpauseStatusTemplateData{status})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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.unpauseStatusHelper(response, true)
|
||||
}
|
||||
|
||||
// validateUnpauseJWTforAccount validates the signature and contents of an
|
||||
// unpauseJWT and verify that the its claims match a set of expected claims.
|
||||
// After JWT validation, return the registration ID from claim's subject and
|
||||
// paused domains if the validation was successful or an error.
|
||||
func (sfe *SelfServiceFrontEndImpl) validateUnpauseJWTforAccount(incomingJWT unpauseJWT) (string, []string, error) {
|
||||
slug := strings.Split(unpauseAPIPrefix, "/")
|
||||
if len(slug) != 3 {
|
||||
return "", nil, errors.New("Could not parse API version")
|
||||
}
|
||||
|
||||
token, err := jwt.ParseSigned(string(incomingJWT), []jose.SignatureAlgorithm{jose.HS256})
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("parsing JWT: %s", err)
|
||||
}
|
||||
|
||||
type sfeJWTClaims struct {
|
||||
jwt.Claims
|
||||
|
||||
// Version is a custom claim used to mass invalidate existing JWTs by
|
||||
// changing the API version via unpausePath.
|
||||
Version string `json:"apiVersion,omitempty"`
|
||||
|
||||
// Domains is set of comma separated paused domains.
|
||||
Domains string `json:"pausedDomains,omitempty"`
|
||||
}
|
||||
|
||||
incomingClaims := sfeJWTClaims{}
|
||||
err = token.Claims(sfe.unpauseHMACKey[:], &incomingClaims)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
expectedClaims := jwt.Expected{
|
||||
Issuer: "WFE",
|
||||
AnyAudience: jwt.Audience{"SFE Unpause"},
|
||||
// Time is passed into the jwt package for tests to manipulate time.
|
||||
Time: sfe.clk.Now(),
|
||||
}
|
||||
|
||||
err = incomingClaims.Validate(expectedClaims)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
if len(incomingClaims.Subject) == 0 {
|
||||
return "", nil, errors.New("Account ID required for account unpausing")
|
||||
}
|
||||
|
||||
if incomingClaims.Version == "" {
|
||||
return "", nil, errors.New("Incoming JWT was created with no API version")
|
||||
}
|
||||
|
||||
if incomingClaims.Version != slug[2] {
|
||||
return "", nil, fmt.Errorf("JWT created for unpause API version %s was provided to the incompatible API version %s", incomingClaims.Version, slug[2])
|
||||
}
|
||||
|
||||
return incomingClaims.Subject, strings.Split(incomingClaims.Domains, ","), nil
|
||||
}
|
|
@ -0,0 +1,431 @@
|
|||
package sfe
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
"github.com/jmhodges/clock"
|
||||
"golang.org/x/crypto/ocsp"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/letsencrypt/boulder/core"
|
||||
"github.com/letsencrypt/boulder/features"
|
||||
blog "github.com/letsencrypt/boulder/log"
|
||||
"github.com/letsencrypt/boulder/metrics"
|
||||
"github.com/letsencrypt/boulder/mocks"
|
||||
"github.com/letsencrypt/boulder/must"
|
||||
"github.com/letsencrypt/boulder/revocation"
|
||||
"github.com/letsencrypt/boulder/test"
|
||||
|
||||
capb "github.com/letsencrypt/boulder/ca/proto"
|
||||
corepb "github.com/letsencrypt/boulder/core/proto"
|
||||
rapb "github.com/letsencrypt/boulder/ra/proto"
|
||||
)
|
||||
|
||||
type MockRegistrationAuthority struct {
|
||||
lastRevocationReason revocation.Reason
|
||||
}
|
||||
|
||||
func (ra *MockRegistrationAuthority) NewRegistration(ctx context.Context, in *corepb.Registration, _ ...grpc.CallOption) (*corepb.Registration, error) {
|
||||
in.Id = 1
|
||||
created := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
in.CreatedAt = timestamppb.New(created)
|
||||
return in, nil
|
||||
}
|
||||
|
||||
func (ra *MockRegistrationAuthority) UpdateRegistration(ctx context.Context, in *rapb.UpdateRegistrationRequest, _ ...grpc.CallOption) (*corepb.Registration, error) {
|
||||
if !bytes.Equal(in.Base.Key, in.Update.Key) {
|
||||
in.Base.Key = in.Update.Key
|
||||
}
|
||||
return in.Base, nil
|
||||
}
|
||||
|
||||
func (ra *MockRegistrationAuthority) PerformValidation(context.Context, *rapb.PerformValidationRequest, ...grpc.CallOption) (*corepb.Authorization, error) {
|
||||
return &corepb.Authorization{}, nil
|
||||
}
|
||||
|
||||
func (ra *MockRegistrationAuthority) RevokeCertByApplicant(ctx context.Context, in *rapb.RevokeCertByApplicantRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) {
|
||||
ra.lastRevocationReason = revocation.Reason(in.Code)
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
func (ra *MockRegistrationAuthority) RevokeCertByKey(ctx context.Context, in *rapb.RevokeCertByKeyRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) {
|
||||
ra.lastRevocationReason = revocation.Reason(ocsp.KeyCompromise)
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
func (ra *MockRegistrationAuthority) GenerateOCSP(ctx context.Context, req *rapb.GenerateOCSPRequest, _ ...grpc.CallOption) (*capb.OCSPResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (ra *MockRegistrationAuthority) AdministrativelyRevokeCertificate(context.Context, *rapb.AdministrativelyRevokeCertificateRequest, ...grpc.CallOption) (*emptypb.Empty, error) {
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
func (ra *MockRegistrationAuthority) OnValidationUpdate(context.Context, core.Authorization, ...grpc.CallOption) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ra *MockRegistrationAuthority) DeactivateAuthorization(context.Context, *corepb.Authorization, ...grpc.CallOption) (*emptypb.Empty, error) {
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
func (ra *MockRegistrationAuthority) DeactivateRegistration(context.Context, *corepb.Registration, ...grpc.CallOption) (*emptypb.Empty, error) {
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
func (ra *MockRegistrationAuthority) UnpauseAccount(context.Context, *rapb.UnpauseAccountRequest, ...grpc.CallOption) (*emptypb.Empty, error) {
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
func (ra *MockRegistrationAuthority) NewOrder(ctx context.Context, in *rapb.NewOrderRequest, _ ...grpc.CallOption) (*corepb.Order, error) {
|
||||
created := time.Date(2021, 1, 1, 1, 1, 1, 0, time.UTC)
|
||||
expires := time.Date(2021, 2, 1, 1, 1, 1, 0, time.UTC)
|
||||
|
||||
return &corepb.Order{
|
||||
Id: 1,
|
||||
RegistrationID: in.RegistrationID,
|
||||
Created: timestamppb.New(created),
|
||||
Expires: timestamppb.New(expires),
|
||||
Names: in.Names,
|
||||
Status: string(core.StatusPending),
|
||||
V2Authorizations: []int64{1},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (ra *MockRegistrationAuthority) FinalizeOrder(ctx context.Context, in *rapb.FinalizeOrderRequest, _ ...grpc.CallOption) (*corepb.Order, error) {
|
||||
in.Order.Status = string(core.StatusProcessing)
|
||||
return in.Order, nil
|
||||
}
|
||||
|
||||
func mustParseURL(s string) *url.URL {
|
||||
return must.Do(url.Parse(s))
|
||||
}
|
||||
|
||||
const hmacKey = "pcl04dl3tt3rb1gb4dd4db0d34ts000p"
|
||||
|
||||
func setupSFE(t *testing.T) (SelfServiceFrontEndImpl, clock.FakeClock) {
|
||||
features.Reset()
|
||||
|
||||
fc := clock.NewFake()
|
||||
// Set to some non-zero time.
|
||||
fc.Set(time.Date(2020, 10, 10, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
stats := metrics.NoopRegisterer
|
||||
|
||||
mockSA := mocks.NewStorageAuthorityReadOnly(fc)
|
||||
|
||||
sfe, err := NewSelfServiceFrontEndImpl(
|
||||
stats,
|
||||
fc,
|
||||
blog.NewMock(),
|
||||
10*time.Second,
|
||||
&MockRegistrationAuthority{},
|
||||
mockSA,
|
||||
[]byte(hmacKey),
|
||||
)
|
||||
test.AssertNotError(t, err, "Unable to create SFE")
|
||||
|
||||
return sfe, fc
|
||||
}
|
||||
|
||||
func TestIndexPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
sfe, _ := setupSFE(t)
|
||||
responseWriter := httptest.NewRecorder()
|
||||
sfe.Index(responseWriter, &http.Request{
|
||||
Method: "GET",
|
||||
URL: mustParseURL("/"),
|
||||
})
|
||||
|
||||
test.AssertEquals(t, responseWriter.Code, http.StatusOK)
|
||||
test.AssertContains(t, responseWriter.Body.String(), "<title>Self-Service Frontend</title>")
|
||||
}
|
||||
|
||||
func TestBuildIDPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
sfe, _ := setupSFE(t)
|
||||
responseWriter := httptest.NewRecorder()
|
||||
sfe.BuildID(responseWriter, &http.Request{
|
||||
Method: "GET",
|
||||
URL: mustParseURL("/build"),
|
||||
})
|
||||
|
||||
test.AssertEquals(t, responseWriter.Code, http.StatusOK)
|
||||
test.AssertContains(t, responseWriter.Body.String(), "Boulder=(")
|
||||
}
|
||||
|
||||
func TestUnpausePaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
sfe, fc := setupSFE(t)
|
||||
now := fc.Now()
|
||||
|
||||
// GET with no JWT
|
||||
responseWriter := httptest.NewRecorder()
|
||||
sfe.UnpauseForm(responseWriter, &http.Request{
|
||||
Method: "GET",
|
||||
URL: mustParseURL(unpauseGetForm),
|
||||
})
|
||||
test.AssertEquals(t, responseWriter.Code, http.StatusOK)
|
||||
test.AssertContains(t, responseWriter.Body.String(), "request was invalid meaning that we could not")
|
||||
|
||||
// GET with an invalid JWT
|
||||
responseWriter = httptest.NewRecorder()
|
||||
sfe.UnpauseForm(responseWriter, &http.Request{
|
||||
Method: "GET",
|
||||
URL: mustParseURL(fmt.Sprintf(unpauseGetForm + "?jwt=x")),
|
||||
})
|
||||
test.AssertEquals(t, responseWriter.Code, http.StatusOK)
|
||||
test.AssertContains(t, responseWriter.Body.String(), "error was encountered when attempting to unpause your account")
|
||||
|
||||
// GET with a valid JWT
|
||||
validJWT, err := makeJWTForAccount(now, now, now.Add(24*time.Hour), []byte(hmacKey), 1, "v1", "example.com")
|
||||
test.AssertNotError(t, err, "Should have been able to create JWT, but could not")
|
||||
responseWriter = httptest.NewRecorder()
|
||||
sfe.UnpauseForm(responseWriter, &http.Request{
|
||||
Method: "GET",
|
||||
URL: mustParseURL(fmt.Sprintf(unpauseGetForm + "?jwt=" + string(validJWT))),
|
||||
})
|
||||
test.AssertEquals(t, responseWriter.Code, http.StatusOK)
|
||||
test.AssertContains(t, responseWriter.Body.String(), "This action will allow you to resume")
|
||||
|
||||
// POST with no JWT
|
||||
responseWriter = httptest.NewRecorder()
|
||||
sfe.UnpauseSubmit(responseWriter, &http.Request{
|
||||
Method: "POST",
|
||||
URL: mustParseURL(unpausePostForm),
|
||||
})
|
||||
test.AssertEquals(t, responseWriter.Code, http.StatusOK)
|
||||
test.AssertContains(t, responseWriter.Body.String(), "request was invalid meaning that we could not")
|
||||
|
||||
// POST with an invalid JWT
|
||||
responseWriter = httptest.NewRecorder()
|
||||
sfe.UnpauseSubmit(responseWriter, &http.Request{
|
||||
Method: "POST",
|
||||
URL: mustParseURL(fmt.Sprintf(unpausePostForm + "?jwt=x")),
|
||||
})
|
||||
test.AssertEquals(t, responseWriter.Code, http.StatusOK)
|
||||
test.AssertContains(t, responseWriter.Body.String(), "An error was encountered when attempting to unpause")
|
||||
|
||||
// POST with a valid JWT redirects to a success page
|
||||
responseWriter = httptest.NewRecorder()
|
||||
sfe.UnpauseSubmit(responseWriter, &http.Request{
|
||||
Method: "POST",
|
||||
URL: mustParseURL(fmt.Sprintf(unpausePostForm + "?jwt=" + string(validJWT))),
|
||||
})
|
||||
test.AssertEquals(t, responseWriter.Code, http.StatusFound)
|
||||
test.AssertEquals(t, unpauseStatus, 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),
|
||||
})
|
||||
test.AssertEquals(t, responseWriter.Code, http.StatusOK)
|
||||
test.AssertContains(t, responseWriter.Body.String(), "Your ACME account has been unpaused.")
|
||||
}
|
||||
|
||||
// makeJWTForAccount is a standin for a WFE method that returns an unpauseJWT or
|
||||
// an error. The JWT contains a set of claims which should be validated by the
|
||||
// caller.
|
||||
func makeJWTForAccount(notBefore time.Time, issuedAt time.Time, expiresAt time.Time, hmacKey []byte, regID int64, apiVersion string, pausedDomains string) (unpauseJWT, error) {
|
||||
if len(hmacKey) != 32 {
|
||||
return "", fmt.Errorf("invalid seed length")
|
||||
}
|
||||
|
||||
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: hmacKey}, (&jose.SignerOptions{}).WithType("JWT"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("making signer: %s", err)
|
||||
}
|
||||
|
||||
// Ensure that we test an empty subject
|
||||
var subject string
|
||||
if regID == 0 {
|
||||
subject = ""
|
||||
} else {
|
||||
subject = fmt.Sprint(regID)
|
||||
}
|
||||
|
||||
// Ensure that we test receiving an empty API version string while
|
||||
// defaulting the rest to match SFE unpausePath.
|
||||
if apiVersion == "magicEmptyString" {
|
||||
apiVersion = ""
|
||||
} else if apiVersion == "" {
|
||||
apiVersion = "v1"
|
||||
}
|
||||
|
||||
// Ensure that we always send at least one domain in the JWT.
|
||||
if pausedDomains == "" {
|
||||
pausedDomains = "example.com"
|
||||
}
|
||||
|
||||
// The SA returns a maximum of 15 domains and the SFE displays some text
|
||||
// about "potentially more domains" being paused.
|
||||
domains := strings.Split(pausedDomains, ",")
|
||||
if len(domains) > 15 {
|
||||
domains = domains[:15]
|
||||
}
|
||||
|
||||
// Join slice back into a comma separated string with the maximum of 15
|
||||
// domains.
|
||||
pausedDomains = strings.Join(domains, ",")
|
||||
|
||||
customClaims := struct {
|
||||
Version string `json:"apiVersion,omitempty"`
|
||||
Domains string `json:"pausedDomains,omitempty"`
|
||||
}{
|
||||
apiVersion,
|
||||
pausedDomains,
|
||||
}
|
||||
|
||||
wfeClaims := jwt.Claims{
|
||||
Issuer: "WFE",
|
||||
Subject: subject,
|
||||
Audience: jwt.Audience{"SFE Unpause"},
|
||||
NotBefore: jwt.NewNumericDate(notBefore),
|
||||
IssuedAt: jwt.NewNumericDate(issuedAt),
|
||||
Expiry: jwt.NewNumericDate(expiresAt),
|
||||
}
|
||||
|
||||
signedJWT, err := jwt.Signed(signer).Claims(&wfeClaims).Claims(&customClaims).Serialize()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("signing JWT: %s", err)
|
||||
}
|
||||
|
||||
return unpauseJWT(signedJWT), nil
|
||||
}
|
||||
|
||||
func TestValidateJWT(t *testing.T) {
|
||||
t.Parallel()
|
||||
sfe, fc := setupSFE(t)
|
||||
|
||||
now := fc.Now()
|
||||
originalClock := fc
|
||||
testCases := []struct {
|
||||
Name string
|
||||
IssuedAt time.Time
|
||||
NotBefore time.Time
|
||||
ExpiresAt time.Time
|
||||
HMACKey string
|
||||
RegID int64 // Default value set in makeJWTForAccount
|
||||
Version string // Default value set in makeJWTForAccount
|
||||
PausedDomains string // Default value set in makeJWTForAccount
|
||||
ExpectedPausedDomains []string
|
||||
ExpectedMakeJWTSubstr string
|
||||
ExpectedValidationErrSubstr string
|
||||
}{
|
||||
{
|
||||
Name: "valid",
|
||||
IssuedAt: now,
|
||||
NotBefore: now,
|
||||
ExpiresAt: now.Add(1 * time.Hour),
|
||||
HMACKey: hmacKey,
|
||||
RegID: 1,
|
||||
ExpectedPausedDomains: []string{"example.com"},
|
||||
},
|
||||
{
|
||||
Name: "valid, but more than 15 domains sent",
|
||||
IssuedAt: now,
|
||||
NotBefore: now,
|
||||
ExpiresAt: now.Add(1 * time.Hour),
|
||||
HMACKey: hmacKey,
|
||||
RegID: 1,
|
||||
PausedDomains: "1.example.com,2.example.com,3.example.com,4.example.com,5.example.com,6.example.com,7.example.com,8.example.com,9.example.com,10.example.com,11.example.com,12.example.com,13.example.com,14.example.com,15.example.com,16.example.com",
|
||||
ExpectedPausedDomains: []string{"1.example.com", "2.example.com", "3.example.com", "4.example.com", "5.example.com", "6.example.com", "7.example.com", "8.example.com", "9.example.com", "10.example.com", "11.example.com", "12.example.com", "13.example.com", "14.example.com", "15.example.com"},
|
||||
},
|
||||
{
|
||||
Name: "apiVersion mismatch",
|
||||
IssuedAt: now,
|
||||
NotBefore: now.Add(5 * time.Minute),
|
||||
ExpiresAt: now.Add(1 * time.Hour),
|
||||
HMACKey: hmacKey,
|
||||
RegID: 1,
|
||||
Version: "v2",
|
||||
ExpectedValidationErrSubstr: "incompatible API version",
|
||||
},
|
||||
{
|
||||
Name: "no API specified in claim",
|
||||
IssuedAt: now,
|
||||
NotBefore: now.Add(5 * time.Minute),
|
||||
ExpiresAt: now.Add(1 * time.Hour),
|
||||
HMACKey: hmacKey,
|
||||
RegID: 1,
|
||||
Version: "magicEmptyString",
|
||||
ExpectedValidationErrSubstr: "no API version",
|
||||
},
|
||||
{
|
||||
Name: "creating JWT with empty seed fails",
|
||||
IssuedAt: now,
|
||||
NotBefore: now.Add(5 * time.Minute),
|
||||
ExpiresAt: now.Add(1 * time.Hour),
|
||||
HMACKey: "",
|
||||
RegID: 1,
|
||||
ExpectedMakeJWTSubstr: "invalid seed length",
|
||||
ExpectedValidationErrSubstr: "JWS format must have",
|
||||
},
|
||||
{
|
||||
Name: "registration ID is required to pass validation",
|
||||
IssuedAt: now,
|
||||
NotBefore: now.Add(5 * time.Minute),
|
||||
ExpiresAt: now.Add(24 * time.Hour),
|
||||
HMACKey: hmacKey,
|
||||
RegID: 0, // This is a magic case where 0 is turned into an empty string in the Subject field of a jwt.Claims
|
||||
ExpectedValidationErrSubstr: "required for account unpausing",
|
||||
},
|
||||
{
|
||||
Name: "validating expired JWT fails",
|
||||
IssuedAt: now,
|
||||
NotBefore: now.Add(5 * time.Minute),
|
||||
ExpiresAt: now.Add(-24 * time.Hour),
|
||||
HMACKey: hmacKey,
|
||||
RegID: 1,
|
||||
ExpectedValidationErrSubstr: "token is expired (exp)",
|
||||
},
|
||||
{
|
||||
Name: "validating JWT with hash derived from different seed fails",
|
||||
IssuedAt: now,
|
||||
NotBefore: now.Add(5 * time.Minute),
|
||||
ExpiresAt: now.Add(1 * time.Hour),
|
||||
HMACKey: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
RegID: 1,
|
||||
ExpectedValidationErrSubstr: "cryptographic primitive",
|
||||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
fc = originalClock
|
||||
newJWT, err := makeJWTForAccount(tc.NotBefore, tc.IssuedAt, tc.ExpiresAt, []byte(tc.HMACKey), tc.RegID, tc.Version, tc.PausedDomains)
|
||||
if tc.ExpectedMakeJWTSubstr != "" || string(newJWT) == "" {
|
||||
test.AssertError(t, err, "JWT was created but should not have been")
|
||||
test.AssertContains(t, err.Error(), tc.ExpectedMakeJWTSubstr)
|
||||
} else {
|
||||
test.AssertNotError(t, err, "Should have been able to create a JWT")
|
||||
}
|
||||
|
||||
// Advance the clock an arbitrary amount. The WFE sets a notBefore
|
||||
// claim in the JWT as a first pass annoyance for clients attempting
|
||||
// to automate unpausing.
|
||||
fc.Add(10 * time.Minute)
|
||||
_, domains, err := sfe.validateUnpauseJWTforAccount(newJWT)
|
||||
if tc.ExpectedValidationErrSubstr != "" || err != nil {
|
||||
test.AssertError(t, err, "Error expected, but received none")
|
||||
test.AssertContains(t, err.Error(), tc.ExpectedValidationErrSubstr)
|
||||
} else {
|
||||
test.AssertNotError(t, err, "Unable to validate JWT")
|
||||
test.AssertDeepEquals(t, domains, tc.ExpectedPausedDomains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
/* Universal styles */
|
||||
* {
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
/* Basic layout */
|
||||
body {
|
||||
max-width: 40em;
|
||||
margin: 2em auto 0;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
/* Link styling */
|
||||
a {
|
||||
color: #3771c8;
|
||||
}
|
||||
a:hover {
|
||||
color: #274f8c;
|
||||
}
|
||||
/* Content styling */
|
||||
.content {
|
||||
display: none;
|
||||
border: 1px solid #c2e9e9;
|
||||
background-color: #c2e9e9;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0 0.25rem 0.25rem 0.25rem;
|
||||
}
|
||||
.content.active {
|
||||
display: block;
|
||||
}
|
||||
/* Button styling */
|
||||
button {
|
||||
font-size: 1rem;
|
||||
}
|
||||
button.primary {
|
||||
color: #fff;
|
||||
background-color: #3771c8;
|
||||
padding: 0.625rem 1.6rem;
|
||||
font-weight: 600;
|
||||
border-radius: 0.25rem;
|
||||
border: none;
|
||||
}
|
||||
button.primary:hover {
|
||||
background-color: #285999;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* jep 2024 */
|
|
@ -0,0 +1,16 @@
|
|||
{{define "meta"}}
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
|
||||
<link rel="stylesheet" href="/static/main.css">
|
||||
{{ end }}
|
||||
|
||||
{{ define "footer" }}
|
||||
<footer>
|
||||
<div>
|
||||
<p><a href="https://letsencrypt.org">Let's Encrypt</a> is a free, automated, and open certificate authority brought to you by the nonprofit <a href="https://www.isrg.org/">Internet Security Research Group (ISRG)</a>.</p>
|
||||
</div>
|
||||
</footer>
|
||||
{{ end }}
|
||||
|
|
@ -42,7 +42,7 @@ ipki() (
|
|||
# Used by Boulder gRPC services as both server and client mTLS certificates.
|
||||
for SERVICE in admin-revoker expiration-mailer ocsp-responder consul \
|
||||
wfe akamai-purger bad-key-revoker crl-updater crl-storer \
|
||||
health-checker rocsp-tool; do
|
||||
health-checker rocsp-tool sfe; do
|
||||
minica -domains "${SERVICE}.boulder" &
|
||||
done
|
||||
|
||||
|
|
|
@ -94,7 +94,8 @@
|
|||
"admin-revoker.boulder",
|
||||
"bad-key-revoker.boulder",
|
||||
"ocsp-responder.boulder",
|
||||
"wfe.boulder"
|
||||
"wfe.boulder",
|
||||
"sfe.boulder"
|
||||
]
|
||||
},
|
||||
"grpc.health.v1.Health": {
|
||||
|
|
|
@ -35,7 +35,8 @@
|
|||
"clientNames": [
|
||||
"admin-revoker.boulder",
|
||||
"ocsp-responder.boulder",
|
||||
"wfe.boulder"
|
||||
"wfe.boulder",
|
||||
"sfe.boulder"
|
||||
]
|
||||
},
|
||||
"grpc.health.v1.Health": {
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
{
|
||||
"sfe": {
|
||||
"listenAddress": "0.0.0.0:4003",
|
||||
"debugAddr": ":8015",
|
||||
"timeout": "30s",
|
||||
"shutdownStopTimeout": "10s",
|
||||
"tls": {
|
||||
"caCertFile": "test/certs/ipki/minica.pem",
|
||||
"certFile": "test/certs/ipki/sfe.boulder/cert.pem",
|
||||
"keyFile": "test/certs/ipki/sfe.boulder/key.pem"
|
||||
},
|
||||
"raService": {
|
||||
"dnsAuthority": "consul.service.consul",
|
||||
"srvLookup": {
|
||||
"service": "ra",
|
||||
"domain": "service.consul"
|
||||
},
|
||||
"timeout": "15s",
|
||||
"noWaitForReady": true,
|
||||
"hostOverride": "ra.boulder"
|
||||
},
|
||||
"saService": {
|
||||
"dnsAuthority": "consul.service.consul",
|
||||
"srvLookup": {
|
||||
"service": "sa",
|
||||
"domain": "service.consul"
|
||||
},
|
||||
"timeout": "15s",
|
||||
"noWaitForReady": true,
|
||||
"hostOverride": "sa.boulder"
|
||||
},
|
||||
"unpause": {
|
||||
"hmacKey": {
|
||||
"passwordFile": "test/secrets/sfe_unpause_key"
|
||||
}
|
||||
},
|
||||
"features": {}
|
||||
},
|
||||
"syslog": {
|
||||
"stdoutlevel": 4,
|
||||
"sysloglevel": -1
|
||||
},
|
||||
"openTelemetry": {
|
||||
"endpoint": "bjaeger:4317",
|
||||
"sampleratio": 1
|
||||
},
|
||||
"openTelemetryHttpConfig": {
|
||||
"trustIncomingSpans": true
|
||||
}
|
||||
}
|
|
@ -14,6 +14,7 @@
|
|||
"/var/log/boulder-sa.log",
|
||||
"/var/log/boulder-va.log",
|
||||
"/var/log/boulder-wfe2.log",
|
||||
"/var/log/sfe.log",
|
||||
"/var/log/crl-storer.log",
|
||||
"/var/log/crl-updater.log",
|
||||
"/var/log/nonce-service.log",
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
{
|
||||
"sfe": {
|
||||
"listenAddress": "0.0.0.0:4003",
|
||||
"debugAddr": ":8015",
|
||||
"timeout": "30s",
|
||||
"shutdownStopTimeout": "10s",
|
||||
"tls": {
|
||||
"caCertFile": "test/certs/ipki/minica.pem",
|
||||
"certFile": "test/certs/ipki/sfe.boulder/cert.pem",
|
||||
"keyFile": "test/certs/ipki/sfe.boulder/key.pem"
|
||||
},
|
||||
"raService": {
|
||||
"dnsAuthority": "consul.service.consul",
|
||||
"srvLookup": {
|
||||
"service": "ra",
|
||||
"domain": "service.consul"
|
||||
},
|
||||
"timeout": "15s",
|
||||
"noWaitForReady": true,
|
||||
"hostOverride": "ra.boulder"
|
||||
},
|
||||
"saService": {
|
||||
"dnsAuthority": "consul.service.consul",
|
||||
"srvLookup": {
|
||||
"service": "sa",
|
||||
"domain": "service.consul"
|
||||
},
|
||||
"timeout": "15s",
|
||||
"noWaitForReady": true,
|
||||
"hostOverride": "sa.boulder"
|
||||
},
|
||||
"unpause": {
|
||||
"hmacKey": {
|
||||
"passwordFile": "test/secrets/sfe_unpause_key"
|
||||
}
|
||||
},
|
||||
"features": {}
|
||||
},
|
||||
"syslog": {
|
||||
"stdoutlevel": 6,
|
||||
"sysloglevel": -1
|
||||
},
|
||||
"openTelemetry": {
|
||||
"endpoint": "bjaeger:4317",
|
||||
"sampleratio": 1
|
||||
},
|
||||
"openTelemetryHttpConfig": {
|
||||
"trustIncomingSpans": true
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
0074cfe534d69a17c916df92e8982972
|
|
@ -133,6 +133,10 @@ SERVICES = (
|
|||
4001, None, None,
|
||||
('./bin/boulder', 'boulder-wfe2', '--config', os.path.join(config_dir, 'wfe2.json'), '--addr', ':4001', '--tls-addr', ':4431', '--debug-addr', ':8013'),
|
||||
('boulder-ra-1', 'boulder-ra-2', 'boulder-sa-1', 'boulder-sa-2', 'nonce-service-taro-1', 'nonce-service-taro-2', 'nonce-service-zinc-1')),
|
||||
Service('sfe',
|
||||
4003, None, None,
|
||||
('./bin/boulder', 'sfe', '--config', os.path.join(config_dir, 'sfe.json'), '--addr', ':4003', '--debug-addr', ':8015'),
|
||||
('boulder-ra-1', 'boulder-ra-2', 'boulder-sa-1', 'boulder-sa-2',)),
|
||||
Service('log-validator',
|
||||
8016, None, None,
|
||||
('./bin/boulder', 'log-validator', '--config', os.path.join(config_dir, 'log-validator.json'), '--debug-addr', ':8016'),
|
||||
|
|
|
@ -0,0 +1,315 @@
|
|||
/*-
|
||||
* Copyright 2016 Zbigniew Mandziejewicz
|
||||
* Copyright 2016 Square, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"reflect"
|
||||
|
||||
"github.com/go-jose/go-jose/v4/json"
|
||||
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
)
|
||||
|
||||
// Builder is a utility for making JSON Web Tokens. Calls can be chained, and
|
||||
// errors are accumulated until the final call to Serialize.
|
||||
type Builder interface {
|
||||
// Claims encodes claims into JWE/JWS form. Multiple calls will merge claims
|
||||
// into single JSON object. If you are passing private claims, make sure to set
|
||||
// struct field tags to specify the name for the JSON key to be used when
|
||||
// serializing.
|
||||
Claims(i interface{}) Builder
|
||||
// Token builds a JSONWebToken from provided data.
|
||||
Token() (*JSONWebToken, error)
|
||||
// Serialize serializes a token.
|
||||
Serialize() (string, error)
|
||||
}
|
||||
|
||||
// NestedBuilder is a utility for making Signed-Then-Encrypted JSON Web Tokens.
|
||||
// Calls can be chained, and errors are accumulated until final call to
|
||||
// Serialize.
|
||||
type NestedBuilder interface {
|
||||
// Claims encodes claims into JWE/JWS form. Multiple calls will merge claims
|
||||
// into single JSON object. If you are passing private claims, make sure to set
|
||||
// struct field tags to specify the name for the JSON key to be used when
|
||||
// serializing.
|
||||
Claims(i interface{}) NestedBuilder
|
||||
// Token builds a NestedJSONWebToken from provided data.
|
||||
Token() (*NestedJSONWebToken, error)
|
||||
// Serialize serializes a token.
|
||||
Serialize() (string, error)
|
||||
}
|
||||
|
||||
type builder struct {
|
||||
payload map[string]interface{}
|
||||
err error
|
||||
}
|
||||
|
||||
type signedBuilder struct {
|
||||
builder
|
||||
sig jose.Signer
|
||||
}
|
||||
|
||||
type encryptedBuilder struct {
|
||||
builder
|
||||
enc jose.Encrypter
|
||||
}
|
||||
|
||||
type nestedBuilder struct {
|
||||
builder
|
||||
sig jose.Signer
|
||||
enc jose.Encrypter
|
||||
}
|
||||
|
||||
// Signed creates builder for signed tokens.
|
||||
func Signed(sig jose.Signer) Builder {
|
||||
return &signedBuilder{
|
||||
sig: sig,
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypted creates builder for encrypted tokens.
|
||||
func Encrypted(enc jose.Encrypter) Builder {
|
||||
return &encryptedBuilder{
|
||||
enc: enc,
|
||||
}
|
||||
}
|
||||
|
||||
// SignedAndEncrypted creates builder for signed-then-encrypted tokens.
|
||||
// ErrInvalidContentType will be returned if encrypter doesn't have JWT content type.
|
||||
func SignedAndEncrypted(sig jose.Signer, enc jose.Encrypter) NestedBuilder {
|
||||
if contentType, _ := enc.Options().ExtraHeaders[jose.HeaderContentType].(jose.ContentType); contentType != "JWT" {
|
||||
return &nestedBuilder{
|
||||
builder: builder{
|
||||
err: ErrInvalidContentType,
|
||||
},
|
||||
}
|
||||
}
|
||||
return &nestedBuilder{
|
||||
sig: sig,
|
||||
enc: enc,
|
||||
}
|
||||
}
|
||||
|
||||
func (b builder) claims(i interface{}) builder {
|
||||
if b.err != nil {
|
||||
return b
|
||||
}
|
||||
|
||||
m, ok := i.(map[string]interface{})
|
||||
switch {
|
||||
case ok:
|
||||
return b.merge(m)
|
||||
case reflect.Indirect(reflect.ValueOf(i)).Kind() == reflect.Struct:
|
||||
m, err := normalize(i)
|
||||
if err != nil {
|
||||
return builder{
|
||||
err: err,
|
||||
}
|
||||
}
|
||||
return b.merge(m)
|
||||
default:
|
||||
return builder{
|
||||
err: ErrInvalidClaims,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalize(i interface{}) (map[string]interface{}, error) {
|
||||
m := make(map[string]interface{})
|
||||
|
||||
raw, err := json.Marshal(i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d := json.NewDecoder(bytes.NewReader(raw))
|
||||
d.SetNumberType(json.UnmarshalJSONNumber)
|
||||
|
||||
if err := d.Decode(&m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (b *builder) merge(m map[string]interface{}) builder {
|
||||
p := make(map[string]interface{})
|
||||
for k, v := range b.payload {
|
||||
p[k] = v
|
||||
}
|
||||
for k, v := range m {
|
||||
p[k] = v
|
||||
}
|
||||
|
||||
return builder{
|
||||
payload: p,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *builder) token(p func(interface{}) ([]byte, error), h []jose.Header) (*JSONWebToken, error) {
|
||||
return &JSONWebToken{
|
||||
payload: p,
|
||||
Headers: h,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *signedBuilder) Claims(i interface{}) Builder {
|
||||
return &signedBuilder{
|
||||
builder: b.builder.claims(i),
|
||||
sig: b.sig,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *signedBuilder) Token() (*JSONWebToken, error) {
|
||||
sig, err := b.sign()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h := make([]jose.Header, len(sig.Signatures))
|
||||
for i, v := range sig.Signatures {
|
||||
h[i] = v.Header
|
||||
}
|
||||
|
||||
return b.builder.token(sig.Verify, h)
|
||||
}
|
||||
|
||||
func (b *signedBuilder) Serialize() (string, error) {
|
||||
sig, err := b.sign()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return sig.CompactSerialize()
|
||||
}
|
||||
|
||||
func (b *signedBuilder) sign() (*jose.JSONWebSignature, error) {
|
||||
if b.err != nil {
|
||||
return nil, b.err
|
||||
}
|
||||
|
||||
p, err := json.Marshal(b.payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b.sig.Sign(p)
|
||||
}
|
||||
|
||||
func (b *encryptedBuilder) Claims(i interface{}) Builder {
|
||||
return &encryptedBuilder{
|
||||
builder: b.builder.claims(i),
|
||||
enc: b.enc,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *encryptedBuilder) Serialize() (string, error) {
|
||||
enc, err := b.encrypt()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return enc.CompactSerialize()
|
||||
}
|
||||
|
||||
func (b *encryptedBuilder) Token() (*JSONWebToken, error) {
|
||||
enc, err := b.encrypt()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b.builder.token(enc.Decrypt, []jose.Header{enc.Header})
|
||||
}
|
||||
|
||||
func (b *encryptedBuilder) encrypt() (*jose.JSONWebEncryption, error) {
|
||||
if b.err != nil {
|
||||
return nil, b.err
|
||||
}
|
||||
|
||||
p, err := json.Marshal(b.payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b.enc.Encrypt(p)
|
||||
}
|
||||
|
||||
func (b *nestedBuilder) Claims(i interface{}) NestedBuilder {
|
||||
return &nestedBuilder{
|
||||
builder: b.builder.claims(i),
|
||||
sig: b.sig,
|
||||
enc: b.enc,
|
||||
}
|
||||
}
|
||||
|
||||
// Token produced a token suitable for serialization. It cannot be decrypted
|
||||
// without serializing and then deserializing.
|
||||
func (b *nestedBuilder) Token() (*NestedJSONWebToken, error) {
|
||||
enc, err := b.signAndEncrypt()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &NestedJSONWebToken{
|
||||
allowedSignatureAlgorithms: nil,
|
||||
enc: enc,
|
||||
Headers: []jose.Header{enc.Header},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *nestedBuilder) Serialize() (string, error) {
|
||||
enc, err := b.signAndEncrypt()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return enc.CompactSerialize()
|
||||
}
|
||||
|
||||
func (b *nestedBuilder) FullSerialize() (string, error) {
|
||||
enc, err := b.signAndEncrypt()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return enc.FullSerialize(), nil
|
||||
}
|
||||
|
||||
func (b *nestedBuilder) signAndEncrypt() (*jose.JSONWebEncryption, error) {
|
||||
if b.err != nil {
|
||||
return nil, b.err
|
||||
}
|
||||
|
||||
p, err := json.Marshal(b.payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sig, err := b.sig.Sign(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p2, err := sig.CompactSerialize()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b.enc.Encrypt([]byte(p2))
|
||||
}
|
|
@ -0,0 +1,130 @@
|
|||
/*-
|
||||
* Copyright 2016 Zbigniew Mandziejewicz
|
||||
* Copyright 2016 Square, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v4/json"
|
||||
)
|
||||
|
||||
// Claims represents public claim values (as specified in RFC 7519).
|
||||
type Claims struct {
|
||||
Issuer string `json:"iss,omitempty"`
|
||||
Subject string `json:"sub,omitempty"`
|
||||
Audience Audience `json:"aud,omitempty"`
|
||||
Expiry *NumericDate `json:"exp,omitempty"`
|
||||
NotBefore *NumericDate `json:"nbf,omitempty"`
|
||||
IssuedAt *NumericDate `json:"iat,omitempty"`
|
||||
ID string `json:"jti,omitempty"`
|
||||
}
|
||||
|
||||
// NumericDate represents date and time as the number of seconds since the
|
||||
// epoch, ignoring leap seconds. Non-integer values can be represented
|
||||
// in the serialized format, but we round to the nearest second.
|
||||
// See RFC7519 Section 2: https://tools.ietf.org/html/rfc7519#section-2
|
||||
type NumericDate int64
|
||||
|
||||
// NewNumericDate constructs NumericDate from time.Time value.
|
||||
func NewNumericDate(t time.Time) *NumericDate {
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// While RFC 7519 technically states that NumericDate values may be
|
||||
// non-integer values, we don't bother serializing timestamps in
|
||||
// claims with sub-second accurancy and just round to the nearest
|
||||
// second instead. Not convined sub-second accuracy is useful here.
|
||||
out := NumericDate(t.Unix())
|
||||
return &out
|
||||
}
|
||||
|
||||
// MarshalJSON serializes the given NumericDate into its JSON representation.
|
||||
func (n NumericDate) MarshalJSON() ([]byte, error) {
|
||||
return []byte(strconv.FormatInt(int64(n), 10)), nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON reads a date from its JSON representation.
|
||||
func (n *NumericDate) UnmarshalJSON(b []byte) error {
|
||||
s := string(b)
|
||||
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return ErrUnmarshalNumericDate
|
||||
}
|
||||
|
||||
*n = NumericDate(f)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Time returns time.Time representation of NumericDate.
|
||||
func (n *NumericDate) Time() time.Time {
|
||||
if n == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return time.Unix(int64(*n), 0)
|
||||
}
|
||||
|
||||
// Audience represents the recipients that the token is intended for.
|
||||
type Audience []string
|
||||
|
||||
// UnmarshalJSON reads an audience from its JSON representation.
|
||||
func (s *Audience) UnmarshalJSON(b []byte) error {
|
||||
var v interface{}
|
||||
if err := json.Unmarshal(b, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
*s = []string{v}
|
||||
case []interface{}:
|
||||
a := make([]string, len(v))
|
||||
for i, e := range v {
|
||||
s, ok := e.(string)
|
||||
if !ok {
|
||||
return ErrUnmarshalAudience
|
||||
}
|
||||
a[i] = s
|
||||
}
|
||||
*s = a
|
||||
default:
|
||||
return ErrUnmarshalAudience
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON converts audience to json representation.
|
||||
func (s Audience) MarshalJSON() ([]byte, error) {
|
||||
if len(s) == 1 {
|
||||
return json.Marshal(s[0])
|
||||
}
|
||||
return json.Marshal([]string(s))
|
||||
}
|
||||
|
||||
// Contains checks whether a given string is included in the Audience
|
||||
func (s Audience) Contains(v string) bool {
|
||||
for _, a := range s {
|
||||
if a == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
/*-
|
||||
* Copyright 2017 Square Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/*
|
||||
Package jwt provides an implementation of the JSON Web Token standard.
|
||||
*/
|
||||
package jwt
|
|
@ -0,0 +1,53 @@
|
|||
/*-
|
||||
* Copyright 2016 Zbigniew Mandziejewicz
|
||||
* Copyright 2016 Square, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package jwt
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrUnmarshalAudience indicates that aud claim could not be unmarshalled.
|
||||
var ErrUnmarshalAudience = errors.New("go-jose/go-jose/jwt: expected string or array value to unmarshal to Audience")
|
||||
|
||||
// ErrUnmarshalNumericDate indicates that JWT NumericDate could not be unmarshalled.
|
||||
var ErrUnmarshalNumericDate = errors.New("go-jose/go-jose/jwt: expected number value to unmarshal NumericDate")
|
||||
|
||||
// ErrInvalidClaims indicates that given claims have invalid type.
|
||||
var ErrInvalidClaims = errors.New("go-jose/go-jose/jwt: expected claims to be value convertible into JSON object")
|
||||
|
||||
// ErrInvalidIssuer indicates invalid iss claim.
|
||||
var ErrInvalidIssuer = errors.New("go-jose/go-jose/jwt: validation failed, invalid issuer claim (iss)")
|
||||
|
||||
// ErrInvalidSubject indicates invalid sub claim.
|
||||
var ErrInvalidSubject = errors.New("go-jose/go-jose/jwt: validation failed, invalid subject claim (sub)")
|
||||
|
||||
// ErrInvalidAudience indicated invalid aud claim.
|
||||
var ErrInvalidAudience = errors.New("go-jose/go-jose/jwt: validation failed, invalid audience claim (aud)")
|
||||
|
||||
// ErrInvalidID indicates invalid jti claim.
|
||||
var ErrInvalidID = errors.New("go-jose/go-jose/jwt: validation failed, invalid ID claim (jti)")
|
||||
|
||||
// ErrNotValidYet indicates that token is used before time indicated in nbf claim.
|
||||
var ErrNotValidYet = errors.New("go-jose/go-jose/jwt: validation failed, token not valid yet (nbf)")
|
||||
|
||||
// ErrExpired indicates that token is used after expiry time indicated in exp claim.
|
||||
var ErrExpired = errors.New("go-jose/go-jose/jwt: validation failed, token is expired (exp)")
|
||||
|
||||
// ErrIssuedInTheFuture indicates that the iat field is in the future.
|
||||
var ErrIssuedInTheFuture = errors.New("go-jose/go-jose/jwt: validation field, token issued in the future (iat)")
|
||||
|
||||
// ErrInvalidContentType indicates that token requires JWT cty header.
|
||||
var ErrInvalidContentType = errors.New("go-jose/go-jose/jwt: expected content type to be JWT (cty header)")
|
|
@ -0,0 +1,198 @@
|
|||
/*-
|
||||
* Copyright 2016 Zbigniew Mandziejewicz
|
||||
* Copyright 2016 Square, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
jose "github.com/go-jose/go-jose/v4"
|
||||
"github.com/go-jose/go-jose/v4/json"
|
||||
)
|
||||
|
||||
// JSONWebToken represents a JSON Web Token (as specified in RFC7519).
|
||||
type JSONWebToken struct {
|
||||
payload func(k interface{}) ([]byte, error)
|
||||
unverifiedPayload func() []byte
|
||||
Headers []jose.Header
|
||||
}
|
||||
|
||||
type NestedJSONWebToken struct {
|
||||
enc *jose.JSONWebEncryption
|
||||
Headers []jose.Header
|
||||
// Used when parsing and decrypting an input
|
||||
allowedSignatureAlgorithms []jose.SignatureAlgorithm
|
||||
}
|
||||
|
||||
// Claims deserializes a JSONWebToken into dest using the provided key.
|
||||
func (t *JSONWebToken) Claims(key interface{}, dest ...interface{}) error {
|
||||
b, err := t.payload(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, d := range dest {
|
||||
if err := json.Unmarshal(b, d); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnsafeClaimsWithoutVerification deserializes the claims of a
|
||||
// JSONWebToken into the dests. For signed JWTs, the claims are not
|
||||
// verified. This function won't work for encrypted JWTs.
|
||||
func (t *JSONWebToken) UnsafeClaimsWithoutVerification(dest ...interface{}) error {
|
||||
if t.unverifiedPayload == nil {
|
||||
return fmt.Errorf("go-jose/go-jose: Cannot get unverified claims")
|
||||
}
|
||||
claims := t.unverifiedPayload()
|
||||
for _, d := range dest {
|
||||
if err := json.Unmarshal(claims, d); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *NestedJSONWebToken) Decrypt(decryptionKey interface{}) (*JSONWebToken, error) {
|
||||
b, err := t.enc.Decrypt(decryptionKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sig, err := ParseSigned(string(b), t.allowedSignatureAlgorithms)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return sig, nil
|
||||
}
|
||||
|
||||
// ParseSigned parses token from JWS form.
|
||||
func ParseSigned(s string, signatureAlgorithms []jose.SignatureAlgorithm) (*JSONWebToken, error) {
|
||||
sig, err := jose.ParseSignedCompact(s, signatureAlgorithms)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
headers := make([]jose.Header, len(sig.Signatures))
|
||||
for i, signature := range sig.Signatures {
|
||||
headers[i] = signature.Header
|
||||
}
|
||||
|
||||
return &JSONWebToken{
|
||||
payload: sig.Verify,
|
||||
unverifiedPayload: sig.UnsafePayloadWithoutVerification,
|
||||
Headers: headers,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateKeyEncryptionAlgorithm(algs []jose.KeyAlgorithm) error {
|
||||
for _, alg := range algs {
|
||||
switch alg {
|
||||
case jose.ED25519,
|
||||
jose.RSA1_5,
|
||||
jose.RSA_OAEP,
|
||||
jose.RSA_OAEP_256,
|
||||
jose.ECDH_ES,
|
||||
jose.ECDH_ES_A128KW,
|
||||
jose.ECDH_ES_A192KW,
|
||||
jose.ECDH_ES_A256KW:
|
||||
return fmt.Errorf("asymmetric encryption algorithms not supported for JWT: "+
|
||||
"invalid key encryption algorithm: %s", alg)
|
||||
case jose.PBES2_HS256_A128KW,
|
||||
jose.PBES2_HS384_A192KW,
|
||||
jose.PBES2_HS512_A256KW:
|
||||
return fmt.Errorf("password-based encryption not supported for JWT: "+
|
||||
"invalid key encryption algorithm: %s", alg)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseEncryptedCompact(
|
||||
s string,
|
||||
keyAlgorithms []jose.KeyAlgorithm,
|
||||
contentEncryption []jose.ContentEncryption,
|
||||
) (*jose.JSONWebEncryption, error) {
|
||||
err := validateKeyEncryptionAlgorithm(keyAlgorithms)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enc, err := jose.ParseEncryptedCompact(s, keyAlgorithms, contentEncryption)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return enc, nil
|
||||
}
|
||||
|
||||
// ParseEncrypted parses token from JWE form.
|
||||
//
|
||||
// The keyAlgorithms and contentEncryption parameters are used to validate the "alg" and "enc"
|
||||
// header parameters respectively. They must be nonempty, and each "alg" or "enc" header in
|
||||
// parsed data must contain a value that is present in the corresponding parameter. That
|
||||
// includes the protected and unprotected headers as well as all recipients. To accept
|
||||
// multiple algorithms, pass a slice of all the algorithms you want to accept.
|
||||
func ParseEncrypted(s string,
|
||||
keyAlgorithms []jose.KeyAlgorithm,
|
||||
contentEncryption []jose.ContentEncryption,
|
||||
) (*JSONWebToken, error) {
|
||||
enc, err := parseEncryptedCompact(s, keyAlgorithms, contentEncryption)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &JSONWebToken{
|
||||
payload: enc.Decrypt,
|
||||
Headers: []jose.Header{enc.Header},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ParseSignedAndEncrypted parses signed-then-encrypted token from JWE form.
|
||||
//
|
||||
// The encryptionKeyAlgorithms and contentEncryption parameters are used to validate the "alg" and "enc"
|
||||
// header parameters, respectively, of the outer JWE. They must be nonempty, and each "alg" or "enc"
|
||||
// header in parsed data must contain a value that is present in the corresponding parameter. That
|
||||
// includes the protected and unprotected headers as well as all recipients. To accept
|
||||
// multiple algorithms, pass a slice of all the algorithms you want to accept.
|
||||
//
|
||||
// The signatureAlgorithms parameter is used to validate the "alg" header parameter of the
|
||||
// inner JWS. It must be nonempty, and the "alg" header in the inner JWS must contain a value
|
||||
// that is present in the parameter.
|
||||
func ParseSignedAndEncrypted(s string,
|
||||
encryptionKeyAlgorithms []jose.KeyAlgorithm,
|
||||
contentEncryption []jose.ContentEncryption,
|
||||
signatureAlgorithms []jose.SignatureAlgorithm,
|
||||
) (*NestedJSONWebToken, error) {
|
||||
enc, err := parseEncryptedCompact(s, encryptionKeyAlgorithms, contentEncryption)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
contentType, _ := enc.Header.ExtraHeaders[jose.HeaderContentType].(string)
|
||||
if strings.ToUpper(contentType) != "JWT" {
|
||||
return nil, ErrInvalidContentType
|
||||
}
|
||||
|
||||
return &NestedJSONWebToken{
|
||||
allowedSignatureAlgorithms: signatureAlgorithms,
|
||||
enc: enc,
|
||||
Headers: []jose.Header{enc.Header},
|
||||
}, nil
|
||||
}
|
|
@ -0,0 +1,127 @@
|
|||
/*-
|
||||
* Copyright 2016 Zbigniew Mandziejewicz
|
||||
* Copyright 2016 Square, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package jwt
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
// DefaultLeeway defines the default leeway for matching NotBefore/Expiry claims.
|
||||
DefaultLeeway = 1.0 * time.Minute
|
||||
)
|
||||
|
||||
// Expected defines values used for protected claims validation.
|
||||
// If field has zero value then validation is skipped, with the exception of
|
||||
// Time, where the zero value means "now." To skip validating them, set the
|
||||
// corresponding field in the Claims struct to nil.
|
||||
type Expected struct {
|
||||
// Issuer matches the "iss" claim exactly.
|
||||
Issuer string
|
||||
// Subject matches the "sub" claim exactly.
|
||||
Subject string
|
||||
// AnyAudience matches if there is a non-empty intersection between
|
||||
// its values and the values in the "aud" claim.
|
||||
AnyAudience Audience
|
||||
// ID matches the "jti" claim exactly.
|
||||
ID string
|
||||
// Time matches the "exp", "nbf" and "iat" claims with leeway.
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
// WithTime copies expectations with new time.
|
||||
func (e Expected) WithTime(t time.Time) Expected {
|
||||
e.Time = t
|
||||
return e
|
||||
}
|
||||
|
||||
// Validate checks claims in a token against expected values.
|
||||
// A default leeway value of one minute is used to compare time values.
|
||||
//
|
||||
// The default leeway will cause the token to be deemed valid until one
|
||||
// minute after the expiration time. If you're a server application that
|
||||
// wants to give an extra minute to client tokens, use this
|
||||
// function. If you're a client application wondering if the server
|
||||
// will accept your token, use ValidateWithLeeway with a leeway <=0,
|
||||
// otherwise this function might make you think a token is valid when
|
||||
// it is not.
|
||||
func (c Claims) Validate(e Expected) error {
|
||||
return c.ValidateWithLeeway(e, DefaultLeeway)
|
||||
}
|
||||
|
||||
// ValidateWithLeeway checks claims in a token against expected values. A
|
||||
// custom leeway may be specified for comparing time values. You may pass a
|
||||
// zero value to check time values with no leeway, but you should note that
|
||||
// numeric date values are rounded to the nearest second and sub-second
|
||||
// precision is not supported.
|
||||
//
|
||||
// The leeway gives some extra time to the token from the server's
|
||||
// point of view. That is, if the token is expired, ValidateWithLeeway
|
||||
// will still accept the token for 'leeway' amount of time. This fails
|
||||
// if you're using this function to check if a server will accept your
|
||||
// token, because it will think the token is valid even after it
|
||||
// expires. So if you're a client validating if the token is valid to
|
||||
// be submitted to a server, use leeway <=0, if you're a server
|
||||
// validation a token, use leeway >=0.
|
||||
func (c Claims) ValidateWithLeeway(e Expected, leeway time.Duration) error {
|
||||
if e.Issuer != "" && e.Issuer != c.Issuer {
|
||||
return ErrInvalidIssuer
|
||||
}
|
||||
|
||||
if e.Subject != "" && e.Subject != c.Subject {
|
||||
return ErrInvalidSubject
|
||||
}
|
||||
|
||||
if e.ID != "" && e.ID != c.ID {
|
||||
return ErrInvalidID
|
||||
}
|
||||
|
||||
if len(e.AnyAudience) != 0 {
|
||||
var intersection bool
|
||||
for _, v := range e.AnyAudience {
|
||||
if c.Audience.Contains(v) {
|
||||
intersection = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !intersection {
|
||||
return ErrInvalidAudience
|
||||
}
|
||||
}
|
||||
|
||||
// validate using the e.Time, or time.Now if not provided
|
||||
validationTime := e.Time
|
||||
if validationTime.IsZero() {
|
||||
validationTime = time.Now()
|
||||
}
|
||||
|
||||
if c.NotBefore != nil && validationTime.Add(leeway).Before(c.NotBefore.Time()) {
|
||||
return ErrNotValidYet
|
||||
}
|
||||
|
||||
if c.Expiry != nil && validationTime.Add(-leeway).After(c.Expiry.Time()) {
|
||||
return ErrExpired
|
||||
}
|
||||
|
||||
// IssuedAt is optional but cannot be in the future. This is not required by the RFC, but
|
||||
// something is misconfigured if this happens and we should not trust it.
|
||||
if c.IssuedAt != nil && validationTime.Add(leeway).Before(c.IssuedAt.Time()) {
|
||||
return ErrIssuedInTheFuture
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -149,6 +149,7 @@ github.com/fsnotify/fsnotify
|
|||
github.com/go-jose/go-jose/v4
|
||||
github.com/go-jose/go-jose/v4/cipher
|
||||
github.com/go-jose/go-jose/v4/json
|
||||
github.com/go-jose/go-jose/v4/jwt
|
||||
# github.com/go-logr/logr v1.4.1
|
||||
## explicit; go 1.18
|
||||
github.com/go-logr/logr
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
blog "github.com/letsencrypt/boulder/log"
|
||||
)
|
||||
|
||||
type errorWriter struct {
|
||||
blog.Logger
|
||||
}
|
||||
|
||||
func (ew errorWriter) Write(p []byte) (n int, err error) {
|
||||
// log.Logger will append a newline to all messages before calling
|
||||
// Write. Our log checksum checker doesn't like newlines, because
|
||||
// syslog will strip them out so the calculated checksums will
|
||||
// differ. So that we don't hit this corner case for every line
|
||||
// logged from inside net/http.Server we strip the newline before
|
||||
// we get to the checksum generator.
|
||||
p = bytes.TrimRight(p, "\n")
|
||||
ew.Logger.Err(fmt.Sprintf("net/http.Server: %s", string(p)))
|
||||
return
|
||||
}
|
||||
|
||||
// NewServer returns an http.Server which will listen on the given address, when
|
||||
// started, for each path in the handler. Errors are sent to the given logger.
|
||||
func NewServer(listenAddr string, handler http.Handler, logger blog.Logger) http.Server {
|
||||
return http.Server{
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 120 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
Addr: listenAddr,
|
||||
ErrorLog: log.New(errorWriter{logger}, "", 0),
|
||||
Handler: handler,
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
blog "github.com/letsencrypt/boulder/log"
|
||||
"github.com/letsencrypt/boulder/test"
|
||||
)
|
||||
|
||||
func TestNewServer(t *testing.T) {
|
||||
srv := NewServer(":0", nil, blog.NewMock())
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
err := srv.ListenAndServe()
|
||||
test.Assert(t, errors.Is(err, http.ErrServerClosed), "Could not start server")
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
err := srv.Shutdown(context.TODO())
|
||||
test.AssertNotError(t, err, "Could not shut down server")
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestUnorderedShutdownIsFine(t *testing.T) {
|
||||
srv := NewServer(":0", nil, blog.NewMock())
|
||||
err := srv.Shutdown(context.TODO())
|
||||
test.AssertNotError(t, err, "Could not shut down server")
|
||||
err = srv.ListenAndServe()
|
||||
test.Assert(t, errors.Is(err, http.ErrServerClosed), "Could not start server")
|
||||
}
|
Loading…
Reference in New Issue