Files
NianAIGC/backend/internal/identity/session.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)
}