first commit
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user