Skip to content

Commit 7b7e4e0

Browse files
authored
Query formatter (leg100#3)
1 parent c4e3d2c commit 7b7e4e0

9 files changed

+454
-297
lines changed

formatter.go

+11-105
Original file line numberDiff line numberDiff line change
@@ -1,118 +1,24 @@
11
package surl
22

33
import (
4-
"bytes"
5-
"encoding/base64"
6-
"strconv"
4+
"net/url"
75
"time"
86
)
97

108
// Formatter adds/extracts the signature and expiry to/from a URL according to a
119
// specific format
1210
type Formatter interface {
13-
// AddExpiry adds the expiry to the data, creating a payload for signing
14-
AddExpiry(exp time.Time, data []byte) []byte
15-
// AddSignature adds the signature to the payload, creating a signed message
16-
AddSignature(sig, payload []byte) []byte
17-
// ExtractSignature extracts the signature from the signed message,
18-
// returning the signature as well as the signed payload.
19-
ExtractSignature(msg []byte) ([]byte, []byte, error)
20-
// ExtractExpiry extracts the expiry from the signed payload, returning the
21-
// expiry as well as the original data.
22-
ExtractExpiry(payload []byte) (time.Time, []byte, error)
23-
}
24-
25-
// URLPathFormatter includes the signature and expiry in a
26-
// message according to the format: <prefix><sig>.<exp>/<data>. Suitable for
27-
// URL paths as an alternative to using query parameters.
28-
type URLPathFormatter struct {
29-
// Prefix message with a string
30-
Prefix string
31-
}
32-
33-
// AddExpiry adds expiry as a path component e.g. /foo/bar ->
34-
// 390830893/foo/bar
35-
func (u *URLPathFormatter) AddExpiry(exp time.Time, data []byte) []byte {
36-
// convert expiry to bytes
37-
expBytes := strconv.FormatInt(exp.Unix(), 10)
38-
39-
payload := make([]byte, 0, len(expBytes)+len(data))
40-
// add expiry
41-
payload = append(payload, expBytes...)
42-
// add data
43-
return append(payload, data...)
44-
}
45-
46-
// AddSignature adds signature as a path component alongside the expiry e.g.
47-
// abZ3G/foo/bar -> KKLJjd3090fklaJKLJK.abZ3G/foo/bar
48-
func (u *URLPathFormatter) AddSignature(sig, payload []byte) []byte {
49-
encSize := base64.RawURLEncoding.EncodedLen(len(sig))
50-
// calculate msg capacity
51-
mcap := encSize + len(payload) + 1 // +1 for '.'
52-
if u.Prefix != "" {
53-
mcap += len(u.Prefix)
54-
}
55-
msg := make([]byte, 0, mcap)
56-
// add prefix
57-
msg = append(msg, u.Prefix...)
58-
// add encoded sig
59-
msg = msg[0 : len(u.Prefix)+encSize]
60-
base64.RawURLEncoding.Encode(msg[len(u.Prefix):], sig)
61-
// add '.'
62-
msg = append(msg, '.')
63-
// add payload
64-
return append(msg, payload...)
65-
}
66-
67-
// ExtractSignature decodes and splits the signature and payload from the signed message.
68-
func (u *URLPathFormatter) ExtractSignature(msg []byte) ([]byte, []byte, error) {
69-
if !bytes.HasPrefix(msg, []byte(u.Prefix)) {
70-
return nil, nil, ErrInvalidMessageFormat
71-
}
72-
// remove prefix
73-
msg = msg[len(u.Prefix):]
74-
75-
// prise apart sig and payload
76-
parts := bytes.SplitN(msg, []byte{'.'}, 2)
77-
if len(parts) != 2 {
78-
return nil, nil, ErrInvalidMessageFormat
79-
}
80-
sig := parts[0]
81-
payload := parts[1]
82-
83-
// decode base64-encoded sig into bytes
84-
decoded := make([]byte, base64.RawURLEncoding.DecodedLen(len(sig)))
85-
_, err := base64.RawURLEncoding.Decode(decoded, sig)
86-
if err != nil {
87-
return nil, nil, err
88-
}
89-
90-
return decoded, payload, nil
91-
}
11+
// AddExpiry adds an expiry to a URL
12+
AddExpiry(*url.URL, time.Time)
9213

93-
// ExtractExpiry decodes and splits the expiry and data from the payload.
94-
func (u *URLPathFormatter) ExtractExpiry(payload []byte) (time.Time, []byte, error) {
95-
// prise apart expiry and data
96-
slash := 0
97-
for i, b := range payload {
98-
if b == '/' {
99-
slash = i
100-
break
101-
}
102-
}
103-
if slash == 0 {
104-
return time.Time{}, nil, ErrInvalidMessageFormat
105-
}
106-
expBytes := payload[:slash]
107-
data := payload[slash:]
14+
// AddSignature adds a signature to a URL
15+
AddSignature(*url.URL, []byte)
10816

109-
// convert bytes into int
110-
expInt, err := strconv.ParseInt(string(expBytes), 10, 64)
111-
if err != nil {
112-
return time.Time{}, nil, err
113-
}
114-
// convert int into time.Time
115-
t := time.Unix(expInt, 0)
17+
// ExtractSignature extracts a signature from a URL, returning the modified
18+
// URL and the signature.
19+
ExtractSignature(*url.URL) (*url.URL, []byte, error)
11620

117-
return t, data, nil
21+
// ExtractExpiry extracts an expiry from a URL, returning the modified URL
22+
// and the signature.
23+
ExtractExpiry(*url.URL) (*url.URL, time.Time, error)
11824
}

formatter_test.go

-51
This file was deleted.

path_formatter.go

+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package surl
2+
3+
import (
4+
"encoding/base64"
5+
"fmt"
6+
"net/url"
7+
"strconv"
8+
"strings"
9+
"time"
10+
)
11+
12+
// PathFormatter includes the signature and expiry in a
13+
// message according to the format: <sig>.<exp>/<data>. Suitable for
14+
// URL paths as an alternative to using query parameters.
15+
type PathFormatter struct {
16+
signer *Signer
17+
}
18+
19+
// AddExpiry adds expiry as a path component e.g. /foo/bar ->
20+
// 390830893/foo/bar
21+
func (f *PathFormatter) AddExpiry(unsigned *url.URL, expiry time.Time) {
22+
// convert expiry to string
23+
expBytes := strconv.FormatInt(expiry.Unix(), 10)
24+
25+
unsigned.Path = expBytes + unsigned.Path
26+
}
27+
28+
// AddSignature adds signature as a path component alongside the expiry e.g.
29+
// abZ3G/foo/bar -> /KKLJjd3090fklaJKLJK.abZ3G/foo/bar
30+
func (f *PathFormatter) AddSignature(payload *url.URL, sig []byte) {
31+
encoded := base64.RawURLEncoding.EncodeToString(sig)
32+
33+
payload.Path = "/" + encoded + "." + payload.Path
34+
}
35+
36+
// ExtractSignature decodes and splits the signature and payload from the signed message.
37+
func (f *PathFormatter) ExtractSignature(u *url.URL) (*url.URL, []byte, error) {
38+
// prise apart encoded and payload
39+
encoded, payload, found := strings.Cut(u.Path, ".")
40+
if !found {
41+
return nil, nil, ErrInvalidSignedURL
42+
}
43+
// remove leading /
44+
encoded = encoded[1:]
45+
46+
sig, err := base64.RawURLEncoding.DecodeString(encoded)
47+
if err != nil {
48+
return nil, nil, fmt.Errorf("%w: invalid base64: %s", ErrInvalidSignature, encoded)
49+
}
50+
51+
u.Path = payload
52+
53+
if f.signer.skipQuery {
54+
// remove all query params because they don't form part of the input to
55+
// the signature computation
56+
u.RawQuery = ""
57+
}
58+
59+
return u, sig, nil
60+
}
61+
62+
// ExtractExpiry decodes and splits the expiry and data from the payload.
63+
func (*PathFormatter) ExtractExpiry(u *url.URL) (*url.URL, time.Time, error) {
64+
// prise apart expiry and data
65+
expiry, path, found := strings.Cut(u.Path, "/")
66+
if !found {
67+
return nil, time.Time{}, ErrInvalidSignedURL
68+
}
69+
// add leading slash back to path
70+
u.Path = "/" + path
71+
72+
// convert bytes into int
73+
expInt, err := strconv.ParseInt(string(expiry), 10, 64)
74+
if err != nil {
75+
return nil, time.Time{}, err
76+
}
77+
// convert int into time.Time
78+
t := time.Unix(expInt, 0)
79+
80+
return u, t, nil
81+
}

path_formatter_test.go

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package surl
2+
3+
import (
4+
"errors"
5+
"net/url"
6+
"testing"
7+
"time"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestPathFormatter(t *testing.T) {
14+
f := PathFormatter{&Signer{}}
15+
// unsigned url with existing path
16+
u := &url.URL{Path: "/foo/bar"}
17+
18+
expiry := time.Date(2081, time.February, 24, 4, 0, 0, 0, time.UTC)
19+
20+
f.AddExpiry(u, expiry)
21+
assert.Equal(t, "3507595200/foo/bar", u.Path)
22+
23+
f.AddSignature(u, []byte("abcdef"))
24+
assert.Equal(t, "/YWJjZGVm.3507595200/foo/bar", u.Path)
25+
26+
u, sig, err := f.ExtractSignature(u)
27+
require.NoError(t, err)
28+
assert.Equal(t, "abcdef", string(sig))
29+
assert.Equal(t, "3507595200/foo/bar", u.Path)
30+
31+
u, got, err := f.ExtractExpiry(u)
32+
require.NoError(t, err)
33+
assert.Equal(t, expiry, got.UTC())
34+
assert.Equal(t, "/foo/bar", u.Path)
35+
}
36+
37+
func TestPathFormatter_Errors(t *testing.T) {
38+
signer := New([]byte("abc123"), WithPathFormatter())
39+
40+
t.Run("invalid signature", func(t *testing.T) {
41+
err := signer.Verify("http://abc.com/MICKEYMOUSE.123/foo/bar")
42+
assert.Truef(t, errors.Is(err, ErrInvalidSignature), "got error: %w", err)
43+
})
44+
}

query_formatter.go

+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package surl
2+
3+
import (
4+
"encoding/base64"
5+
"fmt"
6+
"net/url"
7+
"strconv"
8+
"time"
9+
)
10+
11+
// QueryFormatter includes the signature and expiry as URL query parameters
12+
// according to the format: /path?expiry=<exp>&signature=<sig>.
13+
type QueryFormatter struct {
14+
signer *Signer
15+
}
16+
17+
// AddExpiry adds expiry as a query parameter e.g. /foo/bar ->
18+
// /foo/bar?expiry=<exp>
19+
func (f *QueryFormatter) AddExpiry(unsigned *url.URL, exp time.Time) {
20+
// convert expiry to string
21+
val := strconv.FormatInt(exp.Unix(), 10)
22+
23+
q := unsigned.Query()
24+
q.Add("expiry", val)
25+
unsigned.RawQuery = q.Encode()
26+
}
27+
28+
// AddSignature adds signature as a query parameter alongside the expiry e.g.
29+
// /foo/bar?expiry=<exp> -> /foo/bar?expiry=<exp>&signature=<sig>
30+
func (f *QueryFormatter) AddSignature(payload *url.URL, sig []byte) {
31+
encoded := base64.RawURLEncoding.EncodeToString(sig)
32+
33+
q := payload.Query()
34+
q.Add("signature", encoded)
35+
payload.RawQuery = q.Encode()
36+
}
37+
38+
// ExtractSignature decodes and splits the signature and payload from the signed message.
39+
func (f *QueryFormatter) ExtractSignature(u *url.URL) (*url.URL, []byte, error) {
40+
q := u.Query()
41+
encoded := q.Get("signature")
42+
if encoded == "" {
43+
return nil, nil, fmt.Errorf("%w: %s", ErrInvalidSignedURL, u.String())
44+
}
45+
46+
// decode base64-encoded sig into bytes
47+
sig, err := base64.RawURLEncoding.DecodeString(encoded)
48+
if err != nil {
49+
return nil, nil, err
50+
}
51+
52+
if f.signer.skipQuery {
53+
// remove all query params other than expiry because they don't form
54+
// part of the input to the signature computation.
55+
expiry := u.Query().Get("expiry")
56+
u.RawQuery = url.Values{"expiry": {expiry}}.Encode()
57+
} else {
58+
q.Del("signature")
59+
u.RawQuery = q.Encode()
60+
}
61+
62+
return u, sig, nil
63+
}
64+
65+
// ExtractExpiry decodes and splits the expiry and data from the payload.
66+
func (f *QueryFormatter) ExtractExpiry(u *url.URL) (*url.URL, time.Time, error) {
67+
q := u.Query()
68+
encoded := q.Get("expiry")
69+
if encoded == "" {
70+
return nil, time.Time{}, ErrInvalidSignedURL
71+
}
72+
q.Del("expiry")
73+
u.RawQuery = q.Encode()
74+
75+
// convert bytes into int
76+
expInt, err := strconv.ParseInt(string(encoded), 10, 64)
77+
if err != nil {
78+
return nil, time.Time{}, err
79+
}
80+
// convert int into time.Time
81+
t := time.Unix(expInt, 0)
82+
83+
return u, t, nil
84+
}

0 commit comments

Comments
 (0)