262 lines
7.9 KiB
Go
262 lines
7.9 KiB
Go
// Package identity implements the legacy zhinian_session cookie wire contract.
|
|
package identity
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
SessionCookieName = "zhinian_session"
|
|
CookieChunkSize = 3000
|
|
CookieMaxChunks = 20
|
|
CookieMaxValueLength = CookieChunkSize * CookieMaxChunks
|
|
)
|
|
|
|
type AuthMode string
|
|
|
|
const (
|
|
AuthModeUser AuthMode = "user"
|
|
AuthModeAdmin AuthMode = "admin"
|
|
)
|
|
|
|
// Session is the validated version-one session contract.
|
|
type Session struct {
|
|
Version int `json:"version"`
|
|
AuthMode AuthMode `json:"authMode"`
|
|
IssuedAt int64 `json:"issuedAt"`
|
|
ExpiresAt int64 `json:"expiresAt"`
|
|
SessionVersion *int `json:"sessionVersion,omitempty"`
|
|
AccessToken string `json:"accessToken,omitempty"`
|
|
TokenType string `json:"tokenType,omitempty"`
|
|
User User `json:"user"`
|
|
}
|
|
|
|
type User struct {
|
|
ID string `json:"id"`
|
|
Subject string `json:"subject"`
|
|
Username string `json:"username,omitempty"`
|
|
Phone string `json:"phone,omitempty"`
|
|
DisplayName string `json:"displayName"`
|
|
ClientID string `json:"clientId"`
|
|
TenantID string `json:"tenantId,omitempty"`
|
|
OrganizationID string `json:"organizationId,omitempty"`
|
|
OrganizationName string `json:"organizationName,omitempty"`
|
|
Role string `json:"role,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
Authorities []string `json:"authorities"`
|
|
Scope []string `json:"scope"`
|
|
}
|
|
|
|
type CookieChunk struct {
|
|
Name string
|
|
Value string
|
|
}
|
|
|
|
// CookieAttributes is a transport-neutral description of the legacy
|
|
// Set-Cookie attributes. Adapters can translate it to their HTTP framework.
|
|
type CookieAttributes struct {
|
|
HTTPOnly bool
|
|
SameSite string
|
|
Secure bool
|
|
Path string
|
|
Expires *time.Time
|
|
MaxAgeSeconds *int
|
|
}
|
|
|
|
// CookieWrite describes one cookie mutation. A session write always returns
|
|
// all 20 names so stale chunks are cleared atomically with the new chunks.
|
|
type CookieWrite struct {
|
|
Name string
|
|
Value string
|
|
Attributes CookieAttributes
|
|
}
|
|
|
|
var (
|
|
ErrMalformedSession = errors.New("malformed session cookie")
|
|
ErrInvalidSignature = errors.New("invalid session cookie signature")
|
|
ErrInvalidSession = errors.New("invalid session")
|
|
ErrSessionTooLarge = errors.New("session cookie exceeds maximum supported size")
|
|
)
|
|
|
|
// Sign preserves raw JSON bytes, base64url-encodes them without padding, and
|
|
// signs that encoded payload with HMAC-SHA256.
|
|
func Sign(rawJSON []byte, secret string) (string, error) {
|
|
if !json.Valid(rawJSON) {
|
|
return "", fmt.Errorf("%w: invalid JSON", ErrInvalidSession)
|
|
}
|
|
payload := base64.RawURLEncoding.EncodeToString(rawJSON)
|
|
signature := signPayload(payload, secret)
|
|
return payload + "." + signature, nil
|
|
}
|
|
|
|
// Parse authenticates and validates a version-one session cookie.
|
|
func Parse(value, secret string, now time.Time) (Session, error) {
|
|
payload, signature, ok := strings.Cut(value, ".")
|
|
if !ok || payload == "" || signature == "" || strings.Contains(signature, ".") {
|
|
return Session{}, ErrMalformedSession
|
|
}
|
|
expected := signPayload(payload, secret)
|
|
if !hmac.Equal([]byte(signature), []byte(expected)) {
|
|
return Session{}, ErrInvalidSignature
|
|
}
|
|
rawJSON, err := base64.RawURLEncoding.DecodeString(payload)
|
|
if err != nil {
|
|
return Session{}, fmt.Errorf("%w: invalid payload encoding", ErrMalformedSession)
|
|
}
|
|
var session Session
|
|
if err := json.Unmarshal(rawJSON, &session); err != nil {
|
|
return Session{}, fmt.Errorf("%w: %v", ErrInvalidSession, err)
|
|
}
|
|
if session.Version != 1 || session.User.ID == "" || session.User.ClientID == "" || session.ExpiresAt <= now.Unix() {
|
|
return Session{}, ErrInvalidSession
|
|
}
|
|
if session.AuthMode != AuthModeAdmin {
|
|
session.AuthMode = AuthModeUser
|
|
}
|
|
if session.User.Authorities == nil {
|
|
session.User.Authorities = []string{}
|
|
}
|
|
if session.User.Scope == nil {
|
|
session.User.Scope = []string{}
|
|
}
|
|
return session, nil
|
|
}
|
|
|
|
func signPayload(payload, secret string) string {
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
_, _ = mac.Write([]byte(payload))
|
|
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
// Chunk splits a value into legacy cookie chunks named base, base.1, base.2,
|
|
// ... and rejects values the legacy reader cannot fully reassemble.
|
|
func Chunk(baseName, value string) ([]CookieChunk, error) {
|
|
if len(value) > CookieMaxValueLength {
|
|
return nil, ErrSessionTooLarge
|
|
}
|
|
if value == "" {
|
|
return []CookieChunk{{Name: baseName}}, nil
|
|
}
|
|
chunks := make([]CookieChunk, 0, (len(value)+CookieChunkSize-1)/CookieChunkSize)
|
|
for start, index := 0, 0; start < len(value); start, index = start+CookieChunkSize, index+1 {
|
|
end := start + CookieChunkSize
|
|
if end > len(value) {
|
|
end = len(value)
|
|
}
|
|
chunks = append(chunks, CookieChunk{Name: chunkName(baseName, index), Value: value[start:end]})
|
|
}
|
|
return chunks, nil
|
|
}
|
|
|
|
// CookieNames returns every cookie name read or cleared by the legacy session
|
|
// contract, from zhinian_session through zhinian_session.19.
|
|
func CookieNames() []string {
|
|
names := make([]string, CookieMaxChunks)
|
|
for index := range names {
|
|
names[index] = chunkName(SessionCookieName, index)
|
|
}
|
|
return names
|
|
}
|
|
|
|
// SetSessionCookies creates writes for the current chunks and deletion writes
|
|
// for every remaining legacy chunk name.
|
|
func SetSessionCookies(value string, expires time.Time, secure bool) ([]CookieWrite, error) {
|
|
chunks, err := Chunk(SessionCookieName, value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
writes := make([]CookieWrite, 0, CookieMaxChunks)
|
|
for _, chunk := range chunks {
|
|
expiresCopy := expires
|
|
writes = append(writes, CookieWrite{
|
|
Name: chunk.Name,
|
|
Value: chunk.Value,
|
|
Attributes: CookieAttributes{
|
|
HTTPOnly: true,
|
|
SameSite: "lax",
|
|
Secure: secure,
|
|
Path: "/",
|
|
Expires: &expiresCopy,
|
|
},
|
|
})
|
|
}
|
|
for index := len(chunks); index < CookieMaxChunks; index++ {
|
|
writes = append(writes, clearCookieWrite(chunkName(SessionCookieName, index), secure))
|
|
}
|
|
return writes, nil
|
|
}
|
|
|
|
// ClearSessionCookies clears the base cookie and every possible legacy chunk.
|
|
func ClearSessionCookies(secure bool) []CookieWrite {
|
|
writes := make([]CookieWrite, 0, CookieMaxChunks)
|
|
for _, name := range CookieNames() {
|
|
writes = append(writes, clearCookieWrite(name, secure))
|
|
}
|
|
return writes
|
|
}
|
|
|
|
func clearCookieWrite(name string, secure bool) CookieWrite {
|
|
maxAge := 0
|
|
return CookieWrite{
|
|
Name: name,
|
|
Attributes: CookieAttributes{
|
|
HTTPOnly: true,
|
|
SameSite: "lax",
|
|
Secure: secure,
|
|
Path: "/",
|
|
MaxAgeSeconds: &maxAge,
|
|
},
|
|
}
|
|
}
|
|
|
|
// ResolveSecureCookie mirrors the legacy environment precedence: a recognized
|
|
// explicit setting wins, then the configured public base URL wins over the
|
|
// request URL, and only HTTPS enables Secure.
|
|
func ResolveSecureCookie(explicit, publicBaseURL, requestURL string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(explicit)) {
|
|
case "1", "true", "yes", "on":
|
|
return true
|
|
case "0", "false", "no", "off":
|
|
return false
|
|
}
|
|
candidate := strings.TrimSpace(publicBaseURL)
|
|
if candidate == "" {
|
|
candidate = strings.TrimSpace(requestURL)
|
|
}
|
|
parsed, err := url.Parse(candidate)
|
|
return err == nil && strings.EqualFold(parsed.Scheme, "https")
|
|
}
|
|
|
|
// Reassemble reads at most 20 contiguous legacy cookie chunks.
|
|
func Reassemble(baseName string, getValue func(name string) (string, bool)) (string, bool) {
|
|
first, ok := getValue(baseName)
|
|
if !ok || first == "" {
|
|
return "", false
|
|
}
|
|
var value strings.Builder
|
|
value.WriteString(first)
|
|
for index := 1; index < CookieMaxChunks; index++ {
|
|
chunk, found := getValue(chunkName(baseName, index))
|
|
if !found || chunk == "" {
|
|
break
|
|
}
|
|
value.WriteString(chunk)
|
|
}
|
|
return value.String(), true
|
|
}
|
|
|
|
func chunkName(baseName string, index int) string {
|
|
if index == 0 {
|
|
return baseName
|
|
}
|
|
return fmt.Sprintf("%s.%d", baseName, index)
|
|
}
|