Files
agent-desk-ng/internal/pkg/openidentity/openidentity.go
mono 2f7a491666 feat: harden support, localize knowledge and add operator availability [skip ci]
Source-only publication of reviewed commit 64e96a7f423673dc29ad56485be9fc79bce8af1c.

Passed in isolation: focused frontend/backend tests, race checks, TypeScript typecheck, lint, Go compilation, Flowgram build, and deterministic SDK build/tests.

Not accepted as a production release: Next.js export/native assembly remains blocked by environment memory limits; browser interaction and MySQL staging acceptance remain unverified. No production server, database/schema, runtime configuration, deployment, release tag, or workflow execution was performed.

Expected source tree: 0a4a7528e9.
2026-10-16 15:21:42 +00:00

308 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Modified in the independent hardening fork; see docs/hardening.
package openidentity
import (
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"net/url"
"strconv"
"strings"
"time"
"agent-desk/internal/pkg/httpx/params"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/mlogclub/simple/common/strs"
)
// ExternalUser 外部访客身份(IM 客户),与站内 AuthPrincipal 区分。
type ExternalUser struct {
ExternalSource enums.ExternalSource `json:"externalSource"`
ExternalID string `json:"externalId"`
ExternalName string `json:"externalName"`
// IdentityScope and ChannelID are server-established authorization context, never request fields.
IdentityScope string `json:"-"`
ChannelID int64 `json:"-"`
}
type UserTokenClaims struct {
UserID string `json:"userId"`
Name string `json:"name"`
jwt.RegisteredClaims
}
// Secrets holds everything needed to decide how much a visitor's claimed
// identity can be trusted.
type Secrets struct {
// UserToken is the per-channel secret used to verify a host-issued JWT.
UserToken string
// IdentityHMAC is the deployment-wide shared secret a host uses to sign an
// external visitor ID. When empty, no signature is ever accepted and every
// visitor stays anonymous.
IdentityHMAC string
// IdentityMaxAge bounds how old a signed identity may be. Zero means
// DefaultIdentityMaxAge.
IdentityMaxAge time.Duration
// ChannelID is bound into the signed payload so a signature obtained for one
// channel cannot be replayed on another.
ChannelID string
RequireSignedIdentity bool
}
// DefaultIdentityMaxAge is how long a signed visitor identity stays acceptable
// when identity.maxAgeMinutes is not configured.
const DefaultIdentityMaxAge = 24 * time.Hour
// identityFutureSkewTolerance absorbs modest clock differences between the host
// platform and this server. It is deliberately far smaller than the max age, so
// it widens the replay window by minutes rather than by hours.
const identityFutureSkewTolerance = 5 * time.Minute
// SignedIdentityPayload is the exact byte string a host must sign to have its
// external visitor ID treated as identified.
//
// HMAC-SHA256(identitySecret, channelId + "\n" + externalId + "\n" + issuedAt)
//
// issuedAt is unix seconds. Lower-case hex output. Three details are deliberate:
//
// - The channel id is included so signatures cannot be replayed on a
// different channel, even when a deployment shares an HMAC signing key.
// - The timestamp is inside the signed bytes even though it also travels as its
// own field, so it cannot be re-dated without breaking the signature. This
// bounds how long a leaked pair stays usable; it is not a defence against key
// recovery, which HMAC does not permit in the first place.
// - Nothing is encrypted. The signature passes through the visitor's browser,
// so a cipher would hide nothing from the party we are defending against.
func SignedIdentityPayload(channelID, externalID string, issuedAt time.Time) string {
return channelID + "\n" + externalID + "\n" + strconv.FormatInt(issuedAt.UTC().Unix(), 10)
}
// SignExternalID produces the signature a host sends alongside its external
// visitor ID and the issuedAt value it was signed over. Exported so integrators
// and tests share one definition.
func SignExternalID(secret, channelID, externalID string, issuedAt time.Time) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(SignedIdentityPayload(channelID, externalID, issuedAt)))
return hex.EncodeToString(mac.Sum(nil))
}
// SignExternalIDNow is the form a host calls: sign the id as of now.
func SignExternalIDNow(secret, channelID, externalID string) (signature string, issuedAt int64) {
now := time.Now()
return SignExternalID(secret, channelID, externalID, now), now.UTC().Unix()
}
// VerifyExternalIDSignature reports whether signature is a currently valid
// signature for externalID on this channel. A blank secret disables the feature
// rather than failing, so an unconfigured deployment keeps today's behaviour.
func VerifyExternalIDSignature(secret, channelID, externalID, signature string, issuedAtUnix int64, maxAge time.Duration, now time.Time) bool {
if strs.IsBlank(secret) || strs.IsBlank(externalID) || strs.IsBlank(signature) || issuedAtUnix <= 0 {
return false
}
if maxAge <= 0 {
maxAge = DefaultIdentityMaxAge
}
issuedAt := time.Unix(issuedAtUnix, 0)
age := now.Sub(issuedAt)
if age > maxAge || age < -identityFutureSkewTolerance {
return false
}
expected := SignExternalID(secret, channelID, externalID, issuedAt)
// hmac.Equal is constant time, so a caller cannot recover the expected
// signature byte by byte by timing the rejection.
return hmac.Equal([]byte(expected), []byte(strings.TrimSpace(signature)))
}
func GetExternalUser(ctx *gin.Context, secrets Secrets) (*ExternalUser, error) {
if userToken := getUserToken(ctx); strs.IsNotBlank(userToken) {
claims, err := verifyUserToken(userToken, secrets.UserToken)
if err != nil {
return nil, err
}
return &ExternalUser{
ExternalSource: enums.ExternalSourceUser,
ExternalID: claims.UserID,
ExternalName: claims.Name,
IdentityScope: IdentityScope(secrets.ChannelID, "jwt"),
}, nil
}
return getExternalIDUser(ctx, secrets)
}
func verifyUserToken(userToken, secret string) (*UserTokenClaims, error) {
if strs.IsBlank(userToken) {
return nil, errorsx.UnauthorizedI18n("error.e0263")
}
if strs.IsBlank(secret) {
return nil, errorsx.UnauthorizedI18n("error.e0266")
}
claims := &UserTokenClaims{}
token, err := jwt.ParseWithClaims(userToken, claims, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unsupported signing method")
}
return []byte(secret), nil
}, jwt.WithExpirationRequired(), jwt.WithValidMethods([]string{
jwt.SigningMethodHS256.Alg(),
jwt.SigningMethodHS384.Alg(),
jwt.SigningMethodHS512.Alg(),
}))
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, errorsx.UnauthorizedI18n("error.e0264")
}
return nil, errorsx.UnauthorizedI18n("error.e0265")
}
if token == nil || !token.Valid {
return nil, errorsx.UnauthorizedI18n("error.e0265")
}
if strs.IsBlank(claims.UserID) {
return nil, errorsx.UnauthorizedI18n("error.e0262")
}
if strs.IsBlank(claims.Name) {
return nil, errorsx.UnauthorizedI18n("error.e0261")
}
if claims.ExpiresAt == nil {
return nil, errorsx.UnauthorizedI18n("error.e0264")
}
return claims, nil
}
func getUserToken(ctx *gin.Context) string {
auth := strings.TrimSpace(ctx.GetHeader("Authorization"))
if len(auth) > 7 && strings.EqualFold(auth[:7], "Bearer ") {
if token := strings.TrimSpace(auth[7:]); token != "" {
return token
}
}
userToken, _ := params.Get(ctx, "userToken")
return strings.TrimSpace(userToken)
}
// getExternalIDUser resolves the visitor from an external ID. A host that signs
// its ID with the shared identity secret is trusted and treated as identified;
// anything else, including a missing or wrong signature, is downgraded to an
// anonymous guest rather than rejected, so the plain embed keeps working.
func getExternalIDUser(ctx *gin.Context, secrets Secrets) (*ExternalUser, error) {
externalID := getExternalID(ctx)
if strs.IsBlank(externalID) {
return nil, errorsx.UnauthorizedI18n("error.e0262")
}
externalID = strings.TrimSpace(externalID)
externalName := getExternalName(ctx)
if VerifyExternalIDSignature(secrets.IdentityHMAC, secrets.ChannelID, externalID, getExternalIDSignature(ctx), getExternalIDIssuedAt(ctx), secrets.IdentityMaxAge, time.Now()) {
return &ExternalUser{
ExternalSource: enums.ExternalSourceUser,
ExternalID: externalID,
ExternalName: externalName,
IdentityScope: IdentityScope(secrets.ChannelID, "hmac"),
}, nil
}
if secrets.RequireSignedIdentity {
return nil, errorsx.UnauthorizedI18n("error.e0161")
}
return &ExternalUser{
ExternalSource: enums.ExternalSourceGuest,
ExternalID: externalID,
ExternalName: externalName,
IdentityScope: IdentityScope(secrets.ChannelID, "guest"),
}, nil
}
func getExternalID(ctx *gin.Context) string {
externalID := ctx.GetHeader("X-External-Id")
if strs.IsBlank(externalID) {
externalID, _ = params.Get(ctx, "externalId")
}
return externalID
}
func getExternalIDSignature(ctx *gin.Context) string {
signature := ctx.GetHeader("X-External-Id-Signature")
if strs.IsBlank(signature) {
signature, _ = params.Get(ctx, "externalIdSignature")
}
return signature
}
// getExternalIDIssuedAt reads the unix-seconds timestamp the host signed. It is
// covered by the signature, so a tampered value simply fails to match.
func getExternalIDIssuedAt(ctx *gin.Context) int64 {
raw := ctx.GetHeader("X-External-Id-Signed-At")
if strs.IsBlank(raw) {
raw, _ = params.Get(ctx, "externalIdSignedAt")
}
raw = strings.TrimSpace(raw)
if raw == "" {
return 0
}
issuedAt, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return 0
}
return issuedAt
}
func getExternalName(ctx *gin.Context) string {
externalName := ctx.GetHeader("X-External-Name")
if strs.IsBlank(externalName) {
externalName, _ = params.Get(ctx, "externalName")
}
if strs.IsNotBlank(externalName) {
externalName, _ = url.QueryUnescape(externalName)
}
return externalName
}
func decodeExternalDisplayName(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
dec, err := url.QueryUnescape(s)
if err != nil {
return s
}
return strings.TrimSpace(dec)
}
// IdentityScope separates independent identity issuers, trust levels and channels.
// It deliberately does not depend on a secret so ordinary key rotation preserves
// the issuer's history. Reassigning a channel to a different issuer requires a new
// channel, not silently reusing that issuer's namespace.
func IdentityScope(channelCode, issuer string) string {
if strings.TrimSpace(channelCode) == "" {
return ""
}
switch issuer {
case "jwt", "hmac", "guest":
default:
return ""
}
digest := sha256.Sum256([]byte(strings.TrimSpace(channelCode)))
return "v1:" + issuer + ":" + hex.EncodeToString(digest[:])
}
func ValidIdentityScope(channelCode string, external ExternalUser) bool {
if external.ExternalSource == enums.ExternalSourceGuest {
return external.IdentityScope != "" && external.IdentityScope == IdentityScope(channelCode, "guest")
}
if external.ExternalSource == enums.ExternalSourceUser {
return external.IdentityScope != "" && (external.IdentityScope == IdentityScope(channelCode, "jwt") || external.IdentityScope == IdentityScope(channelCode, "hmac"))
}
return false
}