Skip to content
Snippets Groups Projects
auth.go 4.75 KiB
Newer Older
package auth

import (
	"encoding/hex"
	"errors"
	"fmt"
	"net/http"
	"time"

	"gopkg.in/dgrijalva/jwt-go.v3"
)

type ContextIdentityType int

const ContextIdentity ContextIdentityType = iota

var (
	// ErrInvalidCredentials is returned when auth fails because the credentials are
	// invalid.
	ErrInvalidCredentials = errors.New("invalid credentials")

	// ErrAccountidPointofsaleidIncompatible is returned when auth fails because the
	// credentials contains both a accountid and pointofsaleid. This error will
	// disappear in a later version.
	ErrAccountidPointofsaleidIncompatible = errors.New(
		"credentials cannot contain both accountid and pointofsaleid")

	// ErrAccountidCobrandidIncompatible is returned when auth fails because the
	// credentials contains both a accountid and cobrandid. This error will
	// disappear in a later version.
	ErrAccountidCobrandidIncompatible = errors.New(
		"credentials cannot contain both accountid and cobrandid")
)

// TokenOptions holds options related to the JWT
//
//nolint:lll
type TokenOptions struct {
	SecretOpt          string             `long:"token-secret" env:"AUTH_TOKEN_SECRET" ini-name:"secret" description:"hex HMAC256/AES256 secret for signing/verifying JWT & crypting (32 bytes)"`
	Secret             []byte             `no-flag:"t"`
	Expiration         time.Duration      `no-flag:"t"`
	ExpirationOpt      func(string) error `long:"token-expiration" ini-name:"expiration" required:"false" description:"Expiration time of the generated tokens" default:"5m"`
	CookieDomain       string             `long:"cookie-domain" ini-name:"cookie-domain" description:"The domain used in cookies, must match the public-facing server host name"`
	CookieSecure       bool               `long:"cookie-secure" ini-name:"cookie-secure" description:"Set to true if the cookie must be over https only (recommended)"`
	CacheMaxSize       int                `long:"token-cache-max-size" ini-name:"cache-max-size" required:"false" description:"Maximum number of entries in the token cache" default:"10000"`
	CachePurgeDelay    time.Duration      `no-flag:"t"`
	CachePurgeDelayOpt func(string) error `long:"token-cache-purge-delay" ini-name:"cache-purge-delay" required:"false" description:"Delay between token cache purges" default:"10m"`
}

func durationOption(tgt *time.Duration) func(string) error {
	return func(duration string) error {
		d, err := time.ParseDuration(duration)
		if err != nil {
			return err
		}
		*tgt = d

		return nil
	}
}

// NewTokenOptions creates a TokenOptions.
func NewTokenOptions(cookieBasename string, getLog func() *zerolog.Logger) *TokenOptions {
	options := TokenOptions{
		cookieBasename: cookieBasename,
	options.ExpirationOpt = durationOption(&options.Expiration)
	options.CachePurgeDelayOpt = durationOption(&options.CachePurgeDelay)

	return &options
}

type Claims interface {
	jwt.Claims
	SetExpiresAt(time.Time)
}

// EnsureSecret makes sure a valid secret is configured.
func (t *TokenOptions) EnsureSecret() error {
	if len(t.Secret) == 32 {
		return nil
	}
	if t.SecretOpt == "" {
		t.log().Warn().Msg("no secret configured. A random secret will be used, meaning authenticated session will not survice a server restart. To avoid this, please set the token-secret option")

		t.Secret = make([]byte, 32)
		_, _ = rand.Read(t.Secret)
	} else {
		b, err := hex.DecodeString(t.SecretOpt)
		if err != nil {
			return fmt.Errorf("'secret' is not a valid hex string: %w", err)
		}
		if len(b) != 32 {
			return fmt.Errorf("'secret' has in invalid size. Expected %d bytes, got: %d", 32, len(b))
		}
		t.Secret = b
	}

	return nil
}

func (t *TokenOptions) MakeToken(
	claims Claims, withCookie bool,
) (string, *http.Cookie, error) {
	if err := t.EnsureSecret(); err != nil {
		return "", nil, err
	}
	expiration := t.Expiration
	if withCookie {
		expiration *= 2
	}
	expiresAt := time.Now().Add(expiration)
	claims.SetExpiresAt(expiresAt)
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	stoken, err := token.SignedString(t.Secret)
	if err != nil {
		return "", nil, err
	}

	var cookie *http.Cookie
	if withCookie {
		cookie = &http.Cookie{
			Name:    t.cookieBasename + "-auth",
			Value:   stoken,
			Expires: expiresAt,
			Path:    "/",
			Domain:  t.CookieDomain,
			Secure:  t.CookieSecure,
		}
	}

	return stoken, cookie, nil
}
func (t *TokenOptions) ParseToken(tokenString string, claims Claims) error {
	if err := t.EnsureSecret(); err != nil {
		return err
	}
	token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (any, error) {
		return t.Secret, nil
	})
	if err != nil {
		return fmt.Errorf("invalid token: %w", err)
	}
	if !token.Valid {
		return fmt.Errorf("invalid token")
	}

	return nil
}