1package hibp
2
3import (
4	"bytes"
5	"crypto/tls"
6	"fmt"
7	"io"
8	"log"
9	"net/http"
10	"net/url"
11	"time"
12)
13
14// Version represents the version of this package
15const Version = "1.0.0"
16
17// BaseUrl is the base URL for the majority of API calls
18const BaseUrl = "https://haveibeenpwned.com/api/v3"
19
20// DefaultUserAgent defines the default UA string for the HTTP client
21// Currently the URL in the UA string is comment out, as there is a bug in the HIBP API
22// not allowing multiple slashes
23const DefaultUserAgent = `go-hibp v` + Version // + ` - https://github.com/wneessen/go-hibp`
24
25// Client is the HIBP client object
26type Client struct {
27	hc *http.Client  // HTTP client to perform the API requests
28	to time.Duration // HTTP client timeout
29	ak string        // HIBP API key
30	ua string        // User agent string for the HTTP client
31
32	// If set to true, the HTTP client will sleep instead of failing in case the HTTP 429
33	// rate limit hits a request
34	rlSleep bool
35
36	PwnedPassApi     *PwnedPassApi         // Reference to the PwnedPassApi API
37	PwnedPassApiOpts *PwnedPasswordOptions // Additional options for the PwnedPassApi API
38
39	BreachApi *BreachApi // Reference to the BreachApi
40	PasteApi  *PasteApi  // Reference to the PasteApi
41}
42
43// Option is a function that is used for grouping of Client options.
44type Option func(*Client)
45
46// New creates and returns a new HIBP client object
47func New(options ...Option) Client {
48	c := Client{}
49
50	// Set defaults
51	c.to = time.Second * 5
52	c.PwnedPassApiOpts = &PwnedPasswordOptions{}
53	c.ua = DefaultUserAgent
54
55	// Set additional options
56	for _, opt := range options {
57		if opt == nil {
58			continue
59		}
60		opt(&c)
61	}
62
63	// Add a http client to the Client object
64	c.hc = httpClient(c.to)
65
66	// Associate the different HIBP service APIs with the Client
67	c.PwnedPassApi = &PwnedPassApi{hibp: &c}
68	c.BreachApi = &BreachApi{hibp: &c}
69	c.PasteApi = &PasteApi{hibp: &c}
70
71	return c
72}
73
74// WithHttpTimeout overrides the default http client timeout
75func WithHttpTimeout(t time.Duration) Option {
76	return func(c *Client) {
77		c.to = t
78	}
79}
80
81// WithApiKey set the optional API key to the Client object
82func WithApiKey(k string) Option {
83	return func(c *Client) {
84		c.ak = k
85	}
86}
87
88// WithPwnedPadding enables padding-mode for the PwnedPasswords API client
89func WithPwnedPadding() Option {
90	return func(c *Client) {
91		c.PwnedPassApiOpts.WithPadding = true
92	}
93}
94
95// WithUserAgent sets a custom user agent string for the HTTP client
96func WithUserAgent(a string) Option {
97	if a == "" {
98		return func(c *Client) {}
99	}
100	return func(c *Client) {
101		c.ua = a
102	}
103}
104
105// WithRateLimitSleep let's the HTTP client sleep in case the API rate limiting hits (Defaults to fail)
106func WithRateLimitSleep() Option {
107	return func(c *Client) {
108		c.rlSleep = true
109	}
110}
111
112// HttpReq performs an HTTP request to the corresponding API
113func (c *Client) HttpReq(m, p string, q map[string]string) (*http.Request, error) {
114	u, err := url.Parse(p)
115	if err != nil {
116		return nil, err
117	}
118
119	if m == http.MethodGet {
120		uq := u.Query()
121		for k, v := range q {
122			uq.Add(k, v)
123		}
124		u.RawQuery = uq.Encode()
125	}
126
127	hr, err := http.NewRequest(m, u.String(), nil)
128	if err != nil {
129		return nil, err
130	}
131
132	if m == http.MethodPost {
133		pd := url.Values{}
134		for k, v := range q {
135			pd.Add(k, v)
136		}
137
138		rb := io.NopCloser(bytes.NewBufferString(pd.Encode()))
139		hr.Body = rb
140	}
141
142	hr.Header.Set("Accept", "application/json")
143	hr.Header.Set("user-agent", c.ua)
144	if c.ak != "" {
145		hr.Header.Set("hibp-api-key", c.ak)
146	}
147	if c.PwnedPassApiOpts.WithPadding {
148		hr.Header.Set("Add-Padding", "true")
149	}
150
151	return hr, nil
152}
153
154// HttpResBody performs the API call to the given path and returns the response body as byte array
155func (c *Client) HttpResBody(m string, p string, q map[string]string) ([]byte, *http.Response, error) {
156	hreq, err := c.HttpReq(m, p, q)
157	if err != nil {
158		return nil, nil, err
159	}
160	hr, err := c.hc.Do(hreq)
161	if err != nil {
162		return nil, hr, err
163	}
164	defer func() {
165		_ = hr.Body.Close()
166	}()
167
168	hb, err := io.ReadAll(hr.Body)
169	if err != nil {
170		return nil, hr, err
171	}
172
173	if hr.StatusCode == 429 && c.rlSleep {
174		headerDelay := hr.Header.Get("Retry-After")
175		delayTime, err := time.ParseDuration(headerDelay + "s")
176		if err != nil {
177			return nil, hr, err
178		}
179		log.Printf("API rate limit hit. Retrying request in %s", delayTime.String())
180		time.Sleep(delayTime)
181		return c.HttpResBody(m, p, q)
182	}
183
184	if hr.StatusCode != 200 {
185		return nil, hr, fmt.Errorf("API responded with non HTTP-200: %s - %s", hr.Status, hb)
186	}
187
188	return hb, hr, nil
189}
190
191// httpClient returns a custom http client for the HIBP Client object
192func httpClient(to time.Duration) *http.Client {
193	tlsConfig := &tls.Config{
194		MaxVersion: tls.VersionTLS13,
195		MinVersion: tls.VersionTLS12,
196	}
197	httpTransport := &http.Transport{TLSClientConfig: tlsConfig}
198	httpClient := &http.Client{
199		Transport: httpTransport,
200		Timeout:   5 * time.Second,
201	}
202	if to.Nanoseconds() > 0 {
203		httpClient.Timeout = to
204	}
205
206	return httpClient
207}
208