124 lines
3.5 KiB
Go
124 lines
3.5 KiB
Go
package customerauth
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// SessionCookieName is the name of the HttpOnly cookie that carries the signed
|
|
// session token for a logged-in customer.
|
|
const SessionCookieName = "sid"
|
|
|
|
// DefaultSessionTTL is how long an issued session stays valid.
|
|
const DefaultSessionTTL = 30 * 24 * time.Hour
|
|
|
|
var (
|
|
// ErrInvalidToken is returned when a token is malformed or its signature
|
|
// does not verify.
|
|
ErrInvalidToken = errors.New("customerauth: invalid session token")
|
|
// ErrExpiredToken is returned when a token's signature is valid but it has
|
|
// passed its expiry.
|
|
ErrExpiredToken = errors.New("customerauth: expired session token")
|
|
)
|
|
|
|
// Signer issues and verifies session tokens using an HMAC-SHA256 key.
|
|
type Signer struct {
|
|
secret []byte
|
|
}
|
|
|
|
// NewSigner returns a Signer over the given secret key bytes.
|
|
func NewSigner(secret []byte) *Signer { return &Signer{secret: secret} }
|
|
|
|
// token format: base64url(<profileID>.<expUnix>) "." base64url(hmac)
|
|
// The payload is signed, not encrypted — it carries no secrets, only the
|
|
// profile id and expiry, and the HMAC makes it tamper-evident.
|
|
|
|
// Issue returns a signed token for profileID that expires after ttl.
|
|
func (s *Signer) Issue(profileID uint64, ttl time.Duration) string {
|
|
exp := time.Now().Add(ttl).Unix()
|
|
payload := fmt.Sprintf("%d.%d", profileID, exp)
|
|
b := base64.RawURLEncoding.EncodeToString([]byte(payload))
|
|
return b + "." + s.sign(b)
|
|
}
|
|
|
|
// Parse verifies a token and returns the profile id it carries. It returns
|
|
// ErrInvalidToken for a bad signature/format and ErrExpiredToken when expired.
|
|
func (s *Signer) Parse(token string) (uint64, error) {
|
|
b, sig, ok := strings.Cut(token, ".")
|
|
if !ok || b == "" || sig == "" {
|
|
return 0, ErrInvalidToken
|
|
}
|
|
if !hmac.Equal([]byte(sig), []byte(s.sign(b))) {
|
|
return 0, ErrInvalidToken
|
|
}
|
|
raw, err := base64.RawURLEncoding.DecodeString(b)
|
|
if err != nil {
|
|
return 0, ErrInvalidToken
|
|
}
|
|
idStr, expStr, ok := strings.Cut(string(raw), ".")
|
|
if !ok {
|
|
return 0, ErrInvalidToken
|
|
}
|
|
id, err := strconv.ParseUint(idStr, 10, 64)
|
|
if err != nil {
|
|
return 0, ErrInvalidToken
|
|
}
|
|
exp, err := strconv.ParseInt(expStr, 10, 64)
|
|
if err != nil {
|
|
return 0, ErrInvalidToken
|
|
}
|
|
if time.Now().Unix() >= exp {
|
|
return 0, ErrExpiredToken
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
func (s *Signer) sign(b string) string {
|
|
mac := hmac.New(sha256.New, s.secret)
|
|
mac.Write([]byte(b))
|
|
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
// SetSession writes the session cookie for token onto w. The Secure flag is set
|
|
// when the request arrived over TLS (directly or via an https-terminating
|
|
// proxy) so the cookie still works on plain-http localhost in dev.
|
|
func SetSession(w http.ResponseWriter, r *http.Request, token string, ttl time.Duration) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: SessionCookieName,
|
|
Value: token,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: isSecure(r),
|
|
SameSite: http.SameSiteLaxMode,
|
|
Expires: time.Now().Add(ttl),
|
|
MaxAge: int(ttl.Seconds()),
|
|
})
|
|
}
|
|
|
|
// ClearSession expires the session cookie on w.
|
|
func ClearSession(w http.ResponseWriter, r *http.Request) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: SessionCookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: isSecure(r),
|
|
SameSite: http.SameSiteLaxMode,
|
|
MaxAge: -1,
|
|
})
|
|
}
|
|
|
|
func isSecure(r *http.Request) bool {
|
|
if r.TLS != nil {
|
|
return true
|
|
}
|
|
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
|
}
|