refactor: separate cookie operations to own package

This commit is contained in:
Trong Huu Nguyen
2022-01-07 11:03:22 +01:00
parent 2d4ced719f
commit 05e2509fac
13 changed files with 389 additions and 129 deletions
+91
View File
@@ -0,0 +1,91 @@
package cookie
import (
"encoding/base64"
"fmt"
"net/http"
"time"
"github.com/nais/wonderwall/pkg/crypto"
)
type Cookie struct {
*http.Cookie
}
func (in Cookie) Encrypt(crypter crypto.Crypter) (*Cookie, error) {
plaintext := []byte(in.Cookie.Value)
ciphertext, err := crypter.Encrypt(plaintext)
if err != nil {
return nil, fmt.Errorf("unable to encrypt cookie '%s': %w", in.Cookie.Name, err)
}
value := base64.StdEncoding.EncodeToString(ciphertext)
encryptedCookie := in.Cookie
encryptedCookie.Value = value
return &Cookie{encryptedCookie}, nil
}
func (in Cookie) Decrypt(crypter crypto.Crypter) (string, error) {
ciphertext, err := base64.StdEncoding.DecodeString(in.Value)
if err != nil {
return "", fmt.Errorf("value for cookie '%s' is not base64 encoded: %w", in.Name, err)
}
plaintext, err := crypter.Decrypt(ciphertext)
if err != nil {
return "", fmt.Errorf("unable to decrypt cookie '%s': %w", in.Name, err)
}
return string(plaintext), err
}
func Clear(w http.ResponseWriter, name string, opts Options) {
expires := time.Now().Add(-7 * 24 * time.Hour)
maxAge := -1
cookie := &http.Cookie{
Expires: expires,
HttpOnly: true,
MaxAge: maxAge,
Name: name,
Path: "/",
SameSite: opts.SameSite,
Secure: opts.Secure,
}
http.SetCookie(w, cookie)
}
func Get(r *http.Request, key string) (*Cookie, error) {
cookie, err := r.Cookie(key)
if err != nil {
return nil, fmt.Errorf("no cookie named '%s': %w", key, err)
}
return &Cookie{cookie}, nil
}
func Make(name, value string, opts Options) *Cookie {
expires := time.Now().Add(opts.ExpiresIn)
maxAge := int(opts.ExpiresIn.Seconds())
cookie := &http.Cookie{
Expires: expires,
HttpOnly: true,
MaxAge: maxAge,
Name: name,
Path: "/",
SameSite: opts.SameSite,
Secure: opts.Secure,
Value: value,
}
return &Cookie{cookie}
}
func Set(w http.ResponseWriter, cookie *Cookie) {
http.SetCookie(w, cookie.Cookie)
}
+97
View File
@@ -0,0 +1,97 @@
package cookie_test
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/nais/wonderwall/pkg/cookie"
"github.com/nais/wonderwall/pkg/crypto"
)
var (
encryptionKey = `G8Roe6AcoBpdr5GhO3cs9iORl4XIC8eq` // 256 bits AES
)
func TestMake(t *testing.T) {
expiresIn := 5 * time.Minute
opts := cookie.DefaultOptions().WithExpiresIn(expiresIn)
name := "some-cookie"
value := "some-value"
result := cookie.Make(name, value, opts)
shouldExpireBefore := time.Now().Add(expiresIn)
assert.True(t, result.Expires.Before(shouldExpireBefore))
assert.Equal(t, int(opts.ExpiresIn.Seconds()), result.MaxAge)
assert.True(t, result.HttpOnly)
assert.Equal(t, name, result.Name)
assert.Equal(t, value, result.Value)
assert.Equal(t, opts.SameSite, result.SameSite)
assert.Equal(t, opts.Secure, result.Secure)
assert.Equal(t, "/", result.Path)
}
func TestClear(t *testing.T) {
opts := cookie.DefaultOptions()
name := "some-name"
writer := httptest.NewRecorder()
cookie.Clear(writer, name, opts)
cookies := writer.Result().Cookies()
var result *http.Cookie
for _, c := range cookies {
if c.Name == name {
result = c
}
}
shouldExpireBefore := time.Now().Add(-7 * 24 * time.Hour)
assert.NotNil(t, result)
assert.True(t, result.Expires.Before(time.Now()))
assert.True(t, result.Expires.Before(shouldExpireBefore))
assert.Equal(t, -1, result.MaxAge)
assert.True(t, result.HttpOnly)
assert.Equal(t, name, result.Name)
assert.Equal(t, "", result.Value)
assert.Equal(t, opts.SameSite, result.SameSite)
assert.Equal(t, opts.Secure, result.Secure)
assert.Equal(t, "/", result.Path)
}
func TestCookie_Encrypt(t *testing.T) {
crypter := crypto.NewCrypter([]byte(encryptionKey))
opts := cookie.DefaultOptions().WithExpiresIn(1 * time.Minute)
name := "some-name"
value := "some-value"
plaintextCookie := cookie.Make(name, value, opts)
encryptedCookie, err := plaintextCookie.Encrypt(crypter)
assert.NoError(t, err)
assert.NotEqual(t, value, encryptedCookie.Value)
}
func TestCookie_Decrypt(t *testing.T) {
crypter := crypto.NewCrypter([]byte(encryptionKey))
opts := cookie.DefaultOptions().WithExpiresIn(1 * time.Minute)
name := "some-name"
value := "some-value"
plaintextCookie := cookie.Make(name, value, opts)
encryptedCookie, err := plaintextCookie.Encrypt(crypter)
assert.NoError(t, err)
assert.NotEqual(t, value, encryptedCookie.Value)
plaintext, err := encryptedCookie.Decrypt(crypter)
assert.NoError(t, err)
assert.Equal(t, value, plaintext)
}
+34
View File
@@ -0,0 +1,34 @@
package cookie
import (
"net/http"
"time"
)
type Options struct {
ExpiresIn time.Duration
SameSite http.SameSite
Secure bool
}
func DefaultOptions() Options {
return Options{
SameSite: http.SameSiteLaxMode,
Secure: true,
}
}
func (o Options) WithSameSite(sameSite http.SameSite) Options {
o.SameSite = sameSite
return o
}
func (o Options) WithExpiresIn(expiresIn time.Duration) Options {
o.ExpiresIn = expiresIn
return o
}
func (o Options) WithSecure(secure bool) Options {
o.Secure = secure
return o
}
+63
View File
@@ -0,0 +1,63 @@
package cookie_test
import (
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/nais/wonderwall/pkg/cookie"
)
func TestDefaultOptions(t *testing.T) {
opts := cookie.DefaultOptions()
assert.Equal(t, http.SameSiteLaxMode, opts.SameSite)
assert.True(t, opts.Secure)
assert.Empty(t, opts.ExpiresIn)
}
func TestOptions_WithExpiresIn(t *testing.T) {
expiresIn := 1 * time.Minute
opts := cookie.Options{}.WithExpiresIn(expiresIn)
assert.Equal(t, 1*time.Minute, opts.ExpiresIn)
opts = cookie.Options{
ExpiresIn: 2 * time.Minute,
}
newOpts := opts.WithExpiresIn(expiresIn)
assert.Equal(t, 2*time.Minute, opts.ExpiresIn, "original options should be unchanged")
assert.Equal(t, 1*time.Minute, newOpts.ExpiresIn, "copy of options should have new value")
}
func TestOptions_WithSameSite(t *testing.T) {
sameSite := http.SameSiteDefaultMode
opts := cookie.Options{}.WithSameSite(sameSite)
assert.Equal(t, http.SameSiteDefaultMode, opts.SameSite)
opts = cookie.Options{
SameSite: http.SameSiteLaxMode,
}
newOpts := opts.WithSameSite(sameSite)
assert.Equal(t, http.SameSiteLaxMode, opts.SameSite, "original options should be unchanged")
assert.Equal(t, http.SameSiteDefaultMode, newOpts.SameSite, "copy of options should have new value")
}
func TestOptions_WithSecure(t *testing.T) {
opts := cookie.Options{}.WithSecure(true)
assert.True(t, opts.Secure)
opts = cookie.Options{
Secure: false,
}
newOpts := opts.WithSecure(true)
assert.False(t, opts.Secure, "original options should be unchanged")
assert.True(t, newOpts.Secure, "copy of options should have new value")
}
+11 -82
View File
@@ -1,107 +1,36 @@
package router
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/nais/wonderwall/pkg/openid"
"github.com/nais/wonderwall/pkg/cookie"
)
const (
LoginCookieLifetime = 60 * time.Minute
SessionCookieNameTemplate = "io.nais.wonderwall.session"
LoginCookieNameTemplate = "io.nais.wonderwall.callback"
SessionCookieName = "io.nais.wonderwall.session"
LoginCookieName = "io.nais.wonderwall.callback"
)
func (h *Handler) GetLoginCookieName() string {
return LoginCookieNameTemplate
}
func (h *Handler) GetSessionCookieName() string {
return SessionCookieNameTemplate
}
func (h *Handler) getLoginCookie(r *http.Request) (*openid.LoginCookie, error) {
loginCookieJson, err := h.getEncryptedCookie(r, h.GetLoginCookieName())
if err != nil {
return nil, err
}
var loginCookie openid.LoginCookie
err = json.Unmarshal([]byte(loginCookieJson), &loginCookie)
if err != nil {
return nil, err
}
return &loginCookie, nil
}
func (h *Handler) setLoginCookie(w http.ResponseWriter, loginCookie *openid.LoginCookie) error {
loginCookieJson, err := json.Marshal(loginCookie)
if err != nil {
return fmt.Errorf("marshalling login cookie: %w", err)
}
err = h.setEncryptedCookie(w, h.GetLoginCookieName(), string(loginCookieJson), LoginCookieLifetime)
func (h *Handler) setEncryptedCookie(w http.ResponseWriter, key string, plaintext string, opts cookie.Options) error {
encryptedCookie, err := cookie.Make(key, plaintext, opts).Encrypt(h.Crypter)
if err != nil {
return err
}
cookie.Set(w, encryptedCookie)
return nil
}
func (h *Handler) setEncryptedCookie(w http.ResponseWriter, key string, plaintext string, expiresIn time.Duration) error {
ciphertext, err := h.Crypter.Encrypt([]byte(plaintext))
if err != nil {
return fmt.Errorf("unable to encrypt cookie '%s': %w", key, err)
}
http.SetCookie(w, &http.Cookie{
Expires: time.Now().Add(expiresIn),
HttpOnly: true,
MaxAge: int(expiresIn.Seconds()),
Name: key,
Path: "/",
SameSite: http.SameSiteLaxMode,
Secure: h.SecureCookies,
Value: base64.StdEncoding.EncodeToString(ciphertext),
})
return nil
}
func (h *Handler) getEncryptedCookie(r *http.Request, key string) (string, error) {
encoded, err := r.Cookie(key)
func (h *Handler) getDecryptedCookie(r *http.Request, key string) (string, error) {
encryptedCookie, err := cookie.Get(r, key)
if err != nil {
return "", fmt.Errorf("no cookie named '%s': %w", key, err)
}
ciphertext, err := base64.StdEncoding.DecodeString(encoded.Value)
if err != nil {
return "", fmt.Errorf("cookie named '%s' is not base64 encoded: %w", key, err)
}
plaintext, err := h.Crypter.Decrypt(ciphertext)
if err != nil {
return "", fmt.Errorf("unable to decrypt cookie '%s': %w", key, err)
}
return string(plaintext), nil
return encryptedCookie.Decrypt(h.Crypter)
}
func (h *Handler) deleteCookie(w http.ResponseWriter, key string) {
expires := time.Now().Add(-7 * 24 * time.Hour)
http.SetCookie(w, &http.Cookie{
Expires: expires,
HttpOnly: true,
MaxAge: -1,
Name: key,
Path: "/",
SameSite: http.SameSiteLaxMode,
Secure: h.SecureCookies,
})
func (h *Handler) deleteCookie(w http.ResponseWriter, name string, opts cookie.Options) {
cookie.Clear(w, name, opts)
}
+17 -21
View File
@@ -7,20 +7,21 @@ import (
"golang.org/x/oauth2"
"github.com/nais/wonderwall/pkg/config"
"github.com/nais/wonderwall/pkg/cookie"
"github.com/nais/wonderwall/pkg/crypto"
"github.com/nais/wonderwall/pkg/openid"
"github.com/nais/wonderwall/pkg/session"
)
type Handler struct {
Config config.Config
Crypter crypto.Crypter
OauthConfig oauth2.Config
Provider openid.Provider
SecureCookies bool
Sessions session.Store
lock sync.Mutex
Httplogger zerolog.Logger
Config config.Config
Cookies cookie.Options
Crypter crypto.Crypter
OauthConfig oauth2.Config
Provider openid.Provider
Sessions session.Store
lock sync.Mutex
Httplogger zerolog.Logger
}
func NewHandler(
@@ -41,18 +42,13 @@ func NewHandler(
}
return &Handler{
Config: cfg,
Crypter: crypter,
Httplogger: httplogger,
lock: sync.Mutex{},
OauthConfig: oauthConfig,
Provider: provider,
Sessions: sessionStore,
SecureCookies: true,
Config: cfg,
Cookies: cookie.DefaultOptions(),
Crypter: crypter,
Httplogger: httplogger,
lock: sync.Mutex{},
OauthConfig: oauthConfig,
Provider: provider,
Sessions: sessionStore,
}, nil
}
func (h *Handler) WithSecureCookie(enabled bool) *Handler {
h.SecureCookies = enabled
return h
}
+1 -2
View File
@@ -57,8 +57,7 @@ func (h *Handler) Callback(w http.ResponseWriter, r *http.Request) {
return
}
// delete login cookie as we no longer need it
h.deleteCookie(w, h.GetLoginCookieName())
h.clearLoginCookie(w)
http.Redirect(w, r, loginCookie.Referer, http.StatusTemporaryRedirect)
}
+1 -1
View File
@@ -15,7 +15,7 @@ func (h *Handler) FrontChannelLogout(w http.ResponseWriter, r *http.Request) {
sid := params.Get("sid")
// Unconditionally destroy all local references to the session.
h.deleteCookie(w, h.GetSessionCookieName())
h.deleteCookie(w, SessionCookieName, h.Cookies)
if len(sid) == 0 {
log.Info("sid parameter not set in request; ignoring")
+45 -1
View File
@@ -1,14 +1,21 @@
package router
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/nais/wonderwall/pkg/cookie"
"github.com/nais/wonderwall/pkg/openid"
"github.com/nais/wonderwall/pkg/router/request"
)
const (
LoginCookieLifetime = 1 * time.Hour
)
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
params, err := openid.GenerateLoginParameters()
if err != nil {
@@ -29,7 +36,7 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
return
}
err = h.setLoginCookie(w, &openid.LoginCookie{
err = h.setLoginCookies(w, &openid.LoginCookie{
State: params.State,
Nonce: params.Nonce,
CodeVerifier: params.CodeVerifier,
@@ -42,3 +49,40 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect)
}
func (h *Handler) getLoginCookie(r *http.Request) (*openid.LoginCookie, error) {
loginCookieJson, err := h.getDecryptedCookie(r, LoginCookieName)
if err != nil {
return nil, err
}
var loginCookie openid.LoginCookie
err = json.Unmarshal([]byte(loginCookieJson), &loginCookie)
if err != nil {
return nil, err
}
return &loginCookie, nil
}
func (h *Handler) setLoginCookies(w http.ResponseWriter, loginCookie *openid.LoginCookie) error {
loginCookieJson, err := json.Marshal(loginCookie)
if err != nil {
return fmt.Errorf("marshalling login cookie: %w", err)
}
opts := h.Cookies.WithExpiresIn(LoginCookieLifetime)
value := string(loginCookieJson)
err = h.setEncryptedCookie(w, LoginCookieName, value, opts)
if err != nil {
return err
}
return nil
}
func (h *Handler) clearLoginCookie(w http.ResponseWriter) {
opts := h.Cookies
cookie.Clear(w, LoginCookieName, opts)
}
+3 -2
View File
@@ -28,7 +28,7 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
}
}
h.deleteCookie(w, h.GetSessionCookieName())
h.deleteCookie(w, SessionCookieName, h.Cookies)
v := u.Query()
@@ -37,9 +37,10 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
v.Add("post_logout_redirect_uri", postLogoutURI)
}
if len(idToken) != 0 {
if len(idToken) > 0 {
v.Add("id_token_hint", idToken)
}
u.RawQuery = v.Encode()
http.Redirect(w, r, u.String(), http.StatusTemporaryRedirect)
+10 -8
View File
@@ -38,7 +38,9 @@ func newHandler(provider openid.Provider) *router.Handler {
if err != nil {
panic(err)
}
return h.WithSecureCookie(false)
h.Cookies = h.Cookies.WithSecure(false)
return h
}
func TestHandler_Login(t *testing.T) {
@@ -65,7 +67,7 @@ func TestHandler_Login(t *testing.T) {
defer resp.Body.Close()
cookies := client.Jar.Cookies(loginURL)
loginCookie := getCookieFromJar(h.GetLoginCookieName(), cookies)
loginCookie := getCookieFromJar(router.LoginCookieName, cookies)
assert.NotNil(t, loginCookie)
location := resp.Header.Get("location")
@@ -122,8 +124,8 @@ func TestHandler_Callback_and_Logout(t *testing.T) {
defer resp.Body.Close()
cookies := client.Jar.Cookies(loginURL)
sessionCookie := getCookieFromJar(h.GetSessionCookieName(), cookies)
loginCookie := getCookieFromJar(h.GetLoginCookieName(), cookies)
sessionCookie := getCookieFromJar(router.SessionCookieName, cookies)
loginCookie := getCookieFromJar(router.LoginCookieName, cookies)
assert.Nil(t, sessionCookie)
assert.NotNil(t, loginCookie)
@@ -150,8 +152,8 @@ func TestHandler_Callback_and_Logout(t *testing.T) {
assert.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
cookies = client.Jar.Cookies(callbackURL)
sessionCookie = getCookieFromJar(h.GetSessionCookieName(), cookies)
loginCookie = getCookieFromJar(h.GetLoginCookieName(), cookies)
sessionCookie = getCookieFromJar(router.SessionCookieName, cookies)
loginCookie = getCookieFromJar(router.LoginCookieName, cookies)
assert.NotNil(t, sessionCookie)
assert.Nil(t, loginCookie)
@@ -166,7 +168,7 @@ func TestHandler_Callback_and_Logout(t *testing.T) {
defer resp.Body.Close()
cookies = client.Jar.Cookies(logoutURL)
sessionCookie = getCookieFromJar(h.GetSessionCookieName(), cookies)
sessionCookie = getCookieFromJar(router.SessionCookieName, cookies)
assert.Nil(t, sessionCookie)
@@ -232,7 +234,7 @@ func TestHandler_FrontChannelLogout(t *testing.T) {
assert.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
cookies := client.Jar.Cookies(callbackURL)
sessionCookie := getCookieFromJar(h.GetSessionCookieName(), cookies)
sessionCookie := getCookieFromJar(router.SessionCookieName, cookies)
assert.NotNil(t, sessionCookie)
+4 -2
View File
@@ -25,7 +25,7 @@ func (h *Handler) localSessionID(sid string) string {
}
func (h *Handler) getSessionFromCookie(w http.ResponseWriter, r *http.Request) (*session.Data, error) {
sessionID, err := h.getEncryptedCookie(r, h.GetSessionCookieName())
sessionID, err := h.getDecryptedCookie(r, SessionCookieName)
if err != nil {
return nil, fmt.Errorf("no session cookie: %w", err)
}
@@ -80,7 +80,9 @@ func (h *Handler) createSession(w http.ResponseWriter, r *http.Request, external
return fmt.Errorf("getting access token lifetime: %w", err)
}
err = h.setEncryptedCookie(w, h.GetSessionCookieName(), sessionID, sessionLifetime)
opts := h.Cookies.WithExpiresIn(sessionLifetime)
err = h.setEncryptedCookie(w, SessionCookieName, sessionID, opts)
if err != nil {
return fmt.Errorf("setting session cookie: %w", err)
}
+12 -10
View File
@@ -10,29 +10,31 @@ import (
)
func (h *Handler) SessionFallbackExternalIDCookieName() string {
return h.GetSessionCookieName() + ".1"
return SessionCookieName + ".1"
}
func (h *Handler) SessionFallbackIDTokenCookieName() string {
return h.GetSessionCookieName() + ".2"
return SessionCookieName + ".2"
}
func (h *Handler) SessionFallbackAccessTokenCookieName() string {
return h.GetSessionCookieName() + ".3"
return SessionCookieName + ".3"
}
func (h *Handler) SetSessionFallback(w http.ResponseWriter, data *session.Data, expiresIn time.Duration) error {
err := h.setEncryptedCookie(w, h.SessionFallbackExternalIDCookieName(), data.ExternalSessionID, expiresIn)
opts := h.Cookies.WithExpiresIn(expiresIn)
err := h.setEncryptedCookie(w, h.SessionFallbackExternalIDCookieName(), data.ExternalSessionID, opts)
if err != nil {
return fmt.Errorf("setting session id fallback cookie: %w", err)
}
err = h.setEncryptedCookie(w, h.SessionFallbackAccessTokenCookieName(), data.AccessToken, expiresIn)
err = h.setEncryptedCookie(w, h.SessionFallbackAccessTokenCookieName(), data.AccessToken, opts)
if err != nil {
return fmt.Errorf("setting session id_token fallback cookie: %w", err)
}
err = h.setEncryptedCookie(w, h.SessionFallbackIDTokenCookieName(), data.IDToken, expiresIn)
err = h.setEncryptedCookie(w, h.SessionFallbackIDTokenCookieName(), data.IDToken, opts)
if err != nil {
return fmt.Errorf("setting session access_token fallback cookie: %w", err)
}
@@ -41,17 +43,17 @@ func (h *Handler) SetSessionFallback(w http.ResponseWriter, data *session.Data,
}
func (h *Handler) GetSessionFallback(r *http.Request) (*session.Data, error) {
externalSessionID, err := h.getEncryptedCookie(r, h.SessionFallbackExternalIDCookieName())
externalSessionID, err := h.getDecryptedCookie(r, h.SessionFallbackExternalIDCookieName())
if err != nil {
return nil, fmt.Errorf("reading session ID from fallback cookie: %w", err)
}
idToken, err := h.getEncryptedCookie(r, h.SessionFallbackIDTokenCookieName())
idToken, err := h.getDecryptedCookie(r, h.SessionFallbackIDTokenCookieName())
if err != nil {
return nil, fmt.Errorf("reading id_token from fallback cookie: %w", err)
}
accessToken, err := h.getEncryptedCookie(r, h.SessionFallbackAccessTokenCookieName())
accessToken, err := h.getDecryptedCookie(r, h.SessionFallbackAccessTokenCookieName())
if err != nil {
return nil, fmt.Errorf("reading access_token from fallback cookie: %w", err)
}
@@ -66,7 +68,7 @@ func (h *Handler) DeleteSessionFallback(w http.ResponseWriter, r *http.Request)
return
}
h.deleteCookie(w, cookieName)
h.deleteCookie(w, cookieName, h.Cookies)
}
deleteIfNotFound(h, w, h.SessionFallbackAccessTokenCookieName())