forked from TrueCloudLab/certificates
162 lines
3.4 KiB
Go
162 lines
3.4 KiB
Go
|
package webhook
|
||
|
|
||
|
import (
|
||
|
"bytes"
|
||
|
"context"
|
||
|
"crypto/hmac"
|
||
|
"crypto/sha256"
|
||
|
"encoding/base64"
|
||
|
"encoding/hex"
|
||
|
"encoding/json"
|
||
|
"errors"
|
||
|
"fmt"
|
||
|
"log"
|
||
|
"net/http"
|
||
|
"time"
|
||
|
|
||
|
"github.com/ryboe/q"
|
||
|
)
|
||
|
|
||
|
type Controller struct {
|
||
|
client *http.Client
|
||
|
webhook *Webhook
|
||
|
}
|
||
|
|
||
|
func New(options ...ControllerOption) (*Controller, error) {
|
||
|
c := &Controller{
|
||
|
client: http.DefaultClient,
|
||
|
webhook: &Webhook{},
|
||
|
}
|
||
|
for _, apply := range options {
|
||
|
if err := apply(c); err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
}
|
||
|
return c, nil
|
||
|
}
|
||
|
|
||
|
func (c *Controller) Validate(challenge string) (bool, error) {
|
||
|
req := &Request{
|
||
|
Challenge: challenge,
|
||
|
}
|
||
|
client := c.client
|
||
|
if client == nil {
|
||
|
client = http.DefaultClient
|
||
|
}
|
||
|
resp, err := c.webhook.Do(client, req)
|
||
|
if err != nil {
|
||
|
q.Q(err)
|
||
|
return false, fmt.Errorf("failed performing webhook request: %w", err)
|
||
|
}
|
||
|
|
||
|
if resp == nil {
|
||
|
return false, nil
|
||
|
}
|
||
|
|
||
|
return true, nil
|
||
|
}
|
||
|
|
||
|
type Webhook struct {
|
||
|
URL string
|
||
|
DisableTLSClientAuth bool
|
||
|
Secret string
|
||
|
BearerToken string
|
||
|
BasicAuth struct {
|
||
|
Username string
|
||
|
Password string
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func (w *Webhook) Do(client *http.Client, req *Request) (*Response, error) {
|
||
|
|
||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||
|
defer cancel()
|
||
|
|
||
|
reqBytes, err := json.Marshal(req)
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
|
||
|
retries := 1
|
||
|
retry:
|
||
|
|
||
|
r, err := http.NewRequestWithContext(ctx, "POST", w.URL, bytes.NewReader(reqBytes))
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
|
||
|
if w.Secret != "" {
|
||
|
secret, err := base64.StdEncoding.DecodeString(w.Secret)
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
sig := hmac.New(sha256.New, secret).Sum(reqBytes)
|
||
|
r.Header.Set("X-Smallstep-Signature", hex.EncodeToString(sig))
|
||
|
//req.Header.Set("X-Smallstep-Webhook-ID", w.ID)
|
||
|
}
|
||
|
|
||
|
if w.BearerToken != "" {
|
||
|
r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", w.BearerToken))
|
||
|
} else if w.BasicAuth.Username != "" || w.BasicAuth.Password != "" {
|
||
|
r.SetBasicAuth(w.BasicAuth.Username, w.BasicAuth.Password)
|
||
|
}
|
||
|
if w.DisableTLSClientAuth {
|
||
|
transport, ok := client.Transport.(*http.Transport)
|
||
|
if !ok {
|
||
|
return nil, errors.New("client transport is not a *http.Transport")
|
||
|
}
|
||
|
transport = transport.Clone()
|
||
|
tlsConfig := transport.TLSClientConfig.Clone()
|
||
|
tlsConfig.GetClientCertificate = nil
|
||
|
tlsConfig.Certificates = nil
|
||
|
transport.TLSClientConfig = tlsConfig
|
||
|
client = &http.Client{
|
||
|
Transport: transport,
|
||
|
}
|
||
|
}
|
||
|
|
||
|
resp, err := client.Do(r)
|
||
|
if err != nil {
|
||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||
|
return nil, err
|
||
|
} else if retries > 0 {
|
||
|
retries--
|
||
|
time.Sleep(time.Second)
|
||
|
goto retry
|
||
|
}
|
||
|
return nil, err
|
||
|
}
|
||
|
defer func() {
|
||
|
if err := resp.Body.Close(); err != nil {
|
||
|
// TODO: return this error instead of (just) logging?
|
||
|
log.Printf("failed to close body of response from %s", w.URL)
|
||
|
}
|
||
|
}()
|
||
|
|
||
|
if resp.StatusCode >= 500 && retries > 0 {
|
||
|
retries--
|
||
|
time.Sleep(time.Second)
|
||
|
goto retry
|
||
|
}
|
||
|
if resp.StatusCode >= 400 {
|
||
|
return nil, fmt.Errorf("webhook server responded with %d", resp.StatusCode)
|
||
|
}
|
||
|
|
||
|
respBody := &Response{}
|
||
|
// TODO: decide on the JSON structure for the response (if any); HTTP status code may be enough.
|
||
|
// if err := json.NewDecoder(resp.Body).Decode(respBody); err != nil {
|
||
|
// return nil, err
|
||
|
// }
|
||
|
|
||
|
return respBody, nil
|
||
|
}
|
||
|
|
||
|
type Request struct {
|
||
|
Challenge string `json:"challenge"`
|
||
|
}
|
||
|
|
||
|
type Response struct {
|
||
|
// TODO: define expected response format? Or do we consider 200 OK enough?
|
||
|
Allow bool `json:"allow"`
|
||
|
}
|