Skip to content

Commit

Permalink
cookie.Manager and DefaultManager (#8)
Browse files Browse the repository at this point in the history
* `cookie.Manager` is now the primary API to the package
* `cookie.DefaultManager` is initialized and provides API stability for the global `Set/Get/...` methods.
* `Options` API Change:
  * `cookie.NewManager(cookie.WithSigningKey(signingKey))`
* Introduction of `CustomTypeHandler` so we no longer impose a specific dependency (gofrs/uuid)
* Significant upgrade to the `PopulateFromCookies` functionality
* File organization for better separation of functionality and tests
* Removal of `DefaultOptions` - The `mergeOptions` functionality wasn't working well anyways.
* Adds `omitempty` to silently ignore missing cookies.
  • Loading branch information
syntaqx authored Jul 2, 2024
1 parent b7e9622 commit c9e46f4
Show file tree
Hide file tree
Showing 18 changed files with 1,181 additions and 810 deletions.
42 changes: 42 additions & 0 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Contributing

Before making any changes to this project, please initiate discussions for the
proposed changes that do not yet have an issue associated with them. Your
collaboration is greatly appreciated!

## Labels

Please make use of the available labels when creating issues or pull requests:

- `enhancement`: New feature or request
- `bug`: Something isn't working
- `documentation`: Improvements or additions to documentation
- `help wanted`: Extra attention is needed
- `question`: Further information is requested

As we work through issues or pull requests, they may be additionally labeled
with:

- `duplicate`: This issue or pull request already exists
- `good first issue`: Great for newcomers
- `invalid`: This doesn't seem right
- `wontfix`: This will not be worked on

## Pull Requests

Pull requests should be made against the `main` branch. All pull requests that
contain a feature or fix are mandatory to have unit tests. Your PR is only to be
merged if you adhere to this flow.

## Security Vulnerabilities

If you discovery a security vulnerability within this project, please send an
email to `[email protected]`. All security vulnerabilities will be promptly
addressed.

## Contribute

If you want to say thank you and/or support the active development of the
project:

- Add a [GitHub Star](https://github.com/syntaqx/cookie/stargazers) to the project.
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
cover:
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
232 changes: 91 additions & 141 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,201 +9,151 @@

Cookies, but with structs, for happiness.

## Usage
## Overview

```go
import (
"github.com/syntaqx/cookie"
)
`cookie` is a Go package designed to make handling HTTP cookies simple and
robust, and simplifying the process of parsing them into your structs. It
supports standard data types, custom data types, and signed cookies to ensure
data integrity.

...

type MyCookies struct {
Debug bool `cookie:"DEBUG"`
}
## Features

...
- **Easy to use**: Simple API for managing cookies in your web applications.
- **Struct-based cookie values**: Easily get cookies into your structs.
- **Custom type support**: Extend cookie parsing with your own data types.
- **Signed cookies**: Ensure the integrity of your cookies with HMAC signatures.
- **No external dependencies**: Just pure standard library goodness.

var cookies Cookies
err := cookie.PopulateFromCookies(r, &cookies)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
## Installation

fmt.Println(cookies.Debug)
```bash
go get github.com/syntaqx/cookie
```

## Helper Methods

### Get
## Basic Usage

For when you just want the value of the cookie:
The `cookie` package provides a `DefaultManager` that can be used to plug and
play into your existing applications:

```go
debug, err := cookie.Get(r, "DEBUG")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
cookie.Get(r, "DEBUG")
cookie.GetSigned(r, "Access-Token")
cookie.Set(w, "DEBUG", "true", cookie.Options{})
cookie.Set(w, "Access-Token", "token_value", cookie.Options{Signed: true})
cookie.SetSigned(w, "Access-Token", "token_value")
```

### Set

While it's very easy to set Cookies in Go, often times you'll be setting
multiple cookies with the same options:
Or Populate a struct:

```go
options := &cookie.Options{
Domain: "example.com",
Expires: time.Now().Add(24 * time.Hour),
MaxAge: 86400,
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
type RequestCookies struct {
Theme string `cookie:"THEME"`
Debug bool `cookie:"DEBUG,unsigned"`
AccessToken string `cookie:"Access-Token,signed"`
}

cookie.Set(w, "debug", "true", options)
cookie.Set(w, "theme", "default", options)
var c RequestCookies
cookie.PopulateFromCookies(r, &c)
```

### Remove
In order to sign cookies however, you must provide a signing key:

```go
cookie.Remove(w, "debug")
signingKey := []byte("super-secret-key")
cookie.DefaultManager = cookie.NewManager(
cookie.WithSigningKey(signingKey),
)
```

## Signed Cookies
> [!TIP]
> Cookies are stored in plaintext by default (unsigned). A signed cookie is used
> to ensure the cookie value has not been tampered with. This is done by
> creating a [HMAC][] signature of the cookie value using a secret key. Then,
> when the cookie is read, the signature is verified to ensure the cookie value
> has not been modified.
>
> It is still recommended that sensitive data not be stored in cookies, and that
> HTTPS be used to prevent cookie [replay attacks][].
By default, cookies are stored in plaintext.
## Advanced Usage: Manager

Cookies can be signed to ensure their value has not been tampered with. This
works by creating a [HMAC](https://en.wikipedia.org/wiki/HMAC) of the value
(current cookie), and base64 encoding it. When the cookie gets read, it
recalculates the signature and makes sure that it matches the signature attached
to it.

It is still recommended that sensitive data not be stored in cookies, and that
HTTPS be used to prevent cookie
[replay attacks](https://en.wikipedia.org/wiki/Replay_attack).

If you want to sign your cookies, this can be accomplished by:

### `SetSigned`

If you want to set a signed cookie, you can use the `SetSigned` helper method:
For more advanced usage, you can create a `Manager` to handle your cookies,
rather than relying on the `DefaultManager`:

```go
cookie.SetSigned(w, "user_id", "123")
manager := cookie.NewManager()
```

Alternatively, you can pass `Signed` to the options when setting a cookie:
You can optionally provide a signing key for signed cookies:

```go
cookie.Set(w, "user_id", "123", &cookie.Options{
Signed: true,
})
```

These are functionally identical.

### `GetSigned`

If you want to get a signed cookie, you can use the `GetSigned` helper method:

```go
userID, err := cookie.GetSigned(r, "user_id")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
signingKey := []byte("super-secret-key")
manager := cookie.NewManager(
cookie.WithSigningKey(signingKey),
)
```

### Reading Signed Cookies

To read signed cookies into your struct, you can use the `signed` tag:

```go
type User struct {
ID uuid.UUID `cookie:"user_id,signed"`
}
```
[HMAC]: https://en.wikipedia.org/wiki/HMAC
[replay attacks]: https://en.wikipedia.org/wiki/Replay_attack

### Signing Key
### Setting Cookies

By default, the signing key is set to `[]byte(cookie.DefaultSigningKey)`. You
should change this signing key for your application by assigning the
`cookie.SigningKey` variable to a secret value of your own:
Use the `Set` method to set cookies. You can specify options such as path,
domain, expiration, and whether the cookie should be signed.

```go
cookie.SigningKey = []byte("my-secret-key")
err := manager.Set(w, "DEBUG", "true", cookie.Options{})
err := manager.Set(w, "Access-Token", "token_value", cookie.Options{Signed: true})
```

## Default Options
### Getting Cookies

You can set default options for all cookies by assigning the
`cookie.DefaultOptions` variable:
Use the Get method to retrieve unsigned cookies and GetSigned for signed cookies.

```go
cookie.DefaultOptions = &cookie.Options{
Domain: "example.com",
Expires: time.Now().Add(24 * time.Hour),
MaxAge: 86400,
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
}
value, err := manager.Get(r, "DEBUG")
value, err := manager.GetSigned(r, "Access-Token")
```

These options will be used as the defaults for cookies that do not strictly
override them, allowing you to only set the values you care about.

### Signed by Default
### Populating Structs from Cookies

If you want all cookies to be signed by default, you can set the `Signed` field
in the `cookie.DefaultOptions`:
Use `PopulateFromCookies` to populate a struct with cookie values. The struct
fields should be tagged with the cookie names.

```go
cookie.DefaultOptions = &cookie.Options{
Signed: true,
type RequestCookies struct {
Theme string `cookie:"THEME"`
Debug bool `cookie:"DEBUG,unsigned"`
AccessToken string `cookie:"Access-Token,signed"`
NotRequired string `cookie:"NOT_REQUIRED,omitempty"`
}
```

Which will now sign all cookies by default when using the `Set` method. You can
still override this by passing `Signed: false` to the options when setting a
cookie.

```go
cookie.Set(w, "debug", "true", &cookie.Options{
Signed: false,
})
var c RequestCookies
err := manager.PopulateFromCookies(r, &c)
```

This will require the use of the `GetSigned` method to retrieve cookie values.
> [!TIP]
> By default, the `PopulateFromCookies` method will return an error if a
> required cookie is missing. You can use the `omitempty` tag to make a field
> optional.
```go
debug, err := cookie.GetSigned(r, "debug")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
```
### Supporting Custom Types

When defaulting to signed cookies, unsigned cookies can still be populated by
using the `unsigned` tag in the struct field:
To support custom types, register a custom handler with the Manager.

```go
type MyCookies struct {
Debug bool `cookie:"debug,unsigned"`
}
```
import (
"reflect"
"github.com/gofrs/uuid/v5"
"github.com/syntaqx/manager"
)

Or retrieved using the `Get` method, which always retrieves the plaintext value:
...

```go
debug, err := cookie.Get(r, "debug")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
manager := cookie.NewManager(
cookie.WithSigningKey(signingKey),
cookie.WithCustomHandler(reflect.TypeOf(uuid.UUID{}), func(value string) (interface{}, error) {
return uuid.FromString(value)
}),
)
```
Loading

0 comments on commit c9e46f4

Please sign in to comment.