138 lines
5.0 KiB
Go
138 lines
5.0 KiB
Go
// Package webtoken implements minimal JWT issuing/verification (HS256 only)
|
|
// for webmail/admin session tokens — hand-rolled on stdlib crypto/hmac
|
|
// rather than a third-party JWT library, matching the project's
|
|
// dependency-free principle. Supports exactly what session tokens need:
|
|
// a subject (user ID), an expiry, and tamper-evident signing. No JWK sets,
|
|
// no algorithm negotiation, no other algorithms — HS256 with a server-side
|
|
// secret is the right tool for "did we issue this token", nothing more.
|
|
package webtoken
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Claims struct {
|
|
Subject string `json:"sub"`
|
|
TenantID string `json:"tenant_id,omitempty"`
|
|
Role string `json:"role,omitempty"`
|
|
Purpose string `json:"purpose,omitempty"` // e.g. "mfa_pending", "password_reset" — empty means a normal full session
|
|
Ctx string `json:"ctx,omitempty"` // purpose-specific binding, e.g. a Fingerprint of the password hash for password_reset
|
|
IssuedAt int64 `json:"iat"`
|
|
ExpiresAt int64 `json:"exp"`
|
|
}
|
|
|
|
var header = base64URLEncode([]byte(`{"alg":"HS256","typ":"JWT"}`))
|
|
|
|
// Issue creates a signed token for the given subject, valid for ttl.
|
|
func Issue(secret, subject, tenantID, role string, ttl time.Duration) (string, error) {
|
|
return IssueWithPurpose(secret, subject, tenantID, role, "", ttl)
|
|
}
|
|
|
|
// IssueWithPurpose is Issue plus a purpose tag — used for tokens that are
|
|
// NOT a full session (MFA-pending, password-reset) so a caller checking
|
|
// claims.Purpose can refuse to treat them as one, even though they're
|
|
// structurally the same JWT and share the same verification path.
|
|
func IssueWithPurpose(secret, subject, tenantID, role, purpose string, ttl time.Duration) (string, error) {
|
|
now := time.Now().UTC()
|
|
claims := Claims{
|
|
Subject: subject, TenantID: tenantID, Role: role, Purpose: purpose,
|
|
IssuedAt: now.Unix(), ExpiresAt: now.Add(ttl).Unix(),
|
|
}
|
|
payloadJSON, err := json.Marshal(claims)
|
|
if err != nil {
|
|
return "", fmt.Errorf("marshal claims: %w", err)
|
|
}
|
|
payload := base64URLEncode(payloadJSON)
|
|
|
|
signingInput := header + "." + payload
|
|
sig := sign(secret, signingInput)
|
|
return signingInput + "." + sig, nil
|
|
}
|
|
|
|
// IssueResetToken issues a password_reset purpose token bound to
|
|
// passwordHashFingerprint (see Fingerprint) — the fingerprint of the
|
|
// user's password hash at issuance time. Because resetting the password
|
|
// changes that hash, FingerprintMatches will reject the same token on any
|
|
// second use, giving single-use semantics with no server-side token store.
|
|
func IssueResetToken(secret, subject, tenantID, role, passwordHashFingerprint string, ttl time.Duration) (string, error) {
|
|
now := time.Now().UTC()
|
|
claims := Claims{
|
|
Subject: subject, TenantID: tenantID, Role: role, Purpose: "password_reset",
|
|
Ctx: passwordHashFingerprint,
|
|
IssuedAt: now.Unix(), ExpiresAt: now.Add(ttl).Unix(),
|
|
}
|
|
payloadJSON, err := json.Marshal(claims)
|
|
if err != nil {
|
|
return "", fmt.Errorf("marshal claims: %w", err)
|
|
}
|
|
payload := base64URLEncode(payloadJSON)
|
|
|
|
signingInput := header + "." + payload
|
|
sig := sign(secret, signingInput)
|
|
return signingInput + "." + sig, nil
|
|
}
|
|
|
|
// Fingerprint returns a short, non-reversible fingerprint of s (e.g. a
|
|
// password hash), suitable for embedding in a token to detect whether the
|
|
// underlying value has changed since the token was issued.
|
|
func Fingerprint(s string) string {
|
|
sum := sha256.Sum256([]byte(s))
|
|
return base64.RawURLEncoding.EncodeToString(sum[:8])
|
|
}
|
|
|
|
// FingerprintMatches reports, in constant time, whether s's Fingerprint
|
|
// matches the one embedded in claims.Ctx.
|
|
func FingerprintMatches(claims *Claims, s string) bool {
|
|
return subtle.ConstantTimeCompare([]byte(Fingerprint(s)), []byte(claims.Ctx)) == 1
|
|
}
|
|
|
|
// Verify checks signature and expiry, returning the claims if valid.
|
|
func Verify(secret, token string) (*Claims, error) {
|
|
parts := strings.Split(token, ".")
|
|
if len(parts) != 3 {
|
|
return nil, fmt.Errorf("malformed token")
|
|
}
|
|
signingInput := parts[0] + "." + parts[1]
|
|
expectedSig := sign(secret, signingInput)
|
|
|
|
// Constant-time comparison — avoids leaking signature validity via timing.
|
|
if subtle.ConstantTimeCompare([]byte(expectedSig), []byte(parts[2])) != 1 {
|
|
return nil, fmt.Errorf("invalid signature")
|
|
}
|
|
|
|
payloadJSON, err := base64URLDecode(parts[1])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode payload: %w", err)
|
|
}
|
|
var claims Claims
|
|
if err := json.Unmarshal(payloadJSON, &claims); err != nil {
|
|
return nil, fmt.Errorf("unmarshal claims: %w", err)
|
|
}
|
|
|
|
if time.Now().UTC().Unix() > claims.ExpiresAt {
|
|
return nil, fmt.Errorf("token expired")
|
|
}
|
|
return &claims, nil
|
|
}
|
|
|
|
func sign(secret, signingInput string) string {
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
mac.Write([]byte(signingInput))
|
|
return base64URLEncode(mac.Sum(nil))
|
|
}
|
|
|
|
func base64URLEncode(b []byte) string {
|
|
return base64.RawURLEncoding.EncodeToString(b)
|
|
}
|
|
|
|
func base64URLDecode(s string) ([]byte, error) {
|
|
return base64.RawURLEncoding.DecodeString(s)
|
|
}
|