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.
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
// Modified in the independent hardening fork; see docs/hardening.
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -36,16 +40,19 @@ type customerSessionService struct {
|
||||
}
|
||||
|
||||
type customerSessionClaims struct {
|
||||
TokenType string `json:"typ"`
|
||||
ChannelID int64 `json:"channelId"`
|
||||
ChannelCode string `json:"channelCode"`
|
||||
CustomerID int64 `json:"customerId"`
|
||||
CustomerName string `json:"customerName"`
|
||||
IdentityKey string `json:"identityKey"`
|
||||
TokenType string `json:"typ"`
|
||||
ChannelID int64 `json:"channelId"`
|
||||
ChannelCode string `json:"channelCode"`
|
||||
CustomerID int64 `json:"customerId"`
|
||||
CustomerName string `json:"customerName"`
|
||||
IdentityKey string `json:"identityKey"`
|
||||
IdentityScope string `json:"identityScope"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type CustomerSessionVerifyResult struct {
|
||||
CustomerID int64
|
||||
ChannelID int64
|
||||
ExternalUser *openidentity.ExternalUser
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
@@ -56,6 +63,19 @@ func (s *customerSessionService) Exchange(channel *models.Channel, externalUser
|
||||
if channel == nil || channel.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0209")
|
||||
}
|
||||
if !openidentity.ValidIdentityScope(channel.ChannelID, externalUser) || (ChannelService.RequiresSignedIdentity(channel) && externalUser.ExternalSource != enums.ExternalSourceUser) {
|
||||
return nil, errorsx.UnauthorizedI18n("error.e0161")
|
||||
}
|
||||
externalUser.ChannelID = channel.ID
|
||||
if externalUser.ExternalSource == enums.ExternalSourceGuest {
|
||||
// A public caller's guest ID is not proof of possession. Only the signed
|
||||
// customer session can resume this server-issued anonymous identity.
|
||||
var id [32]byte
|
||||
if _, err := rand.Read(id[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
externalUser.ExternalID = hex.EncodeToString(id[:])
|
||||
}
|
||||
var customerID int64
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
id, err := CustomerService.EnsureExternalCustomer(ctx, externalUser)
|
||||
@@ -68,9 +88,13 @@ func (s *customerSessionService) Exchange(channel *models.Channel, externalUser
|
||||
return nil, err
|
||||
}
|
||||
customer := CustomerService.Get(customerID)
|
||||
if customer == nil || customer.Status == enums.StatusDeleted {
|
||||
if customer == nil || customer.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0155")
|
||||
}
|
||||
identity, identityErr := repositories.CustomerIdentityRepository.GetUniqueBy(sqls.DB(), externalUser.IdentityScope, externalUser.ExternalSource, externalUser.ExternalID)
|
||||
if identityErr != nil || identity == nil || identity.CustomerID != customer.ID {
|
||||
return nil, errorsx.UnauthorizedI18n("error.e0161")
|
||||
}
|
||||
token, expiresAt, err := s.Sign(channel, customer, externalUser)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -92,18 +116,19 @@ func (s *customerSessionService) Sign(channel *models.Channel, customer *models.
|
||||
if secret == "" {
|
||||
return "", time.Time{}, errorsx.BusinessErrorI18n(1, "error.customerSession.secretMissing")
|
||||
}
|
||||
if channel == nil || customer == nil {
|
||||
if channel == nil || customer == nil || !openidentity.ValidIdentityScope(channel.ChannelID, externalUser) {
|
||||
return "", time.Time{}, errorsx.InvalidParamI18n("error.e0158")
|
||||
}
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(time.Duration(cfg.TTL()) * time.Minute)
|
||||
claims := customerSessionClaims{
|
||||
TokenType: customerSessionTokenType,
|
||||
ChannelID: channel.ID,
|
||||
ChannelCode: strings.TrimSpace(channel.ChannelID),
|
||||
CustomerID: customer.ID,
|
||||
CustomerName: strings.TrimSpace(customer.Name),
|
||||
IdentityKey: s.identityKey(externalUser),
|
||||
TokenType: customerSessionTokenType,
|
||||
ChannelID: channel.ID,
|
||||
ChannelCode: strings.TrimSpace(channel.ChannelID),
|
||||
CustomerID: customer.ID,
|
||||
CustomerName: strings.TrimSpace(customer.Name),
|
||||
IdentityKey: s.identityKey(externalUser),
|
||||
IdentityScope: externalUser.IdentityScope,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||
@@ -125,27 +150,13 @@ func (s *customerSessionService) VerifyRequest(ctx *gin.Context, channel *models
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if channel == nil || channel.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0209")
|
||||
}
|
||||
if claims.ChannelID != channel.ID || strings.TrimSpace(claims.ChannelCode) != strings.TrimSpace(channel.ChannelID) {
|
||||
return nil, errorsx.UnauthorizedI18n("error.e0161")
|
||||
}
|
||||
customer := CustomerService.Get(claims.CustomerID)
|
||||
if customer == nil || customer.Status == enums.StatusDeleted {
|
||||
return nil, errorsx.UnauthorizedI18n("error.e0161")
|
||||
}
|
||||
external, err := s.externalUserFromClaims(claims, customer)
|
||||
result, err := s.verifyClaims(claims, channel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &CustomerSessionVerifyResult{
|
||||
ExternalUser: external,
|
||||
Token: token,
|
||||
ExpiresAt: claims.ExpiresAt.Time,
|
||||
}
|
||||
result.Token = token
|
||||
if s.shouldRefresh(claims.ExpiresAt.Time) {
|
||||
newToken, expiresAt, err := s.Sign(channel, customer, *external)
|
||||
newToken, expiresAt, err := s.Sign(channel, CustomerService.Get(result.CustomerID), *result.ExternalUser)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -190,7 +201,7 @@ func (s *customerSessionService) verifyToken(rawToken string) (*customerSessionC
|
||||
if token == nil || !token.Valid || claims.TokenType != customerSessionTokenType || claims.ExpiresAt == nil {
|
||||
return nil, errorsx.UnauthorizedI18n("error.e0161")
|
||||
}
|
||||
if claims.ChannelID <= 0 || strings.TrimSpace(claims.ChannelCode) == "" || claims.CustomerID <= 0 || strings.TrimSpace(claims.IdentityKey) == "" {
|
||||
if claims.ChannelID <= 0 || strings.TrimSpace(claims.ChannelCode) == "" || claims.CustomerID <= 0 || strings.TrimSpace(claims.IdentityKey) == "" || strings.TrimSpace(claims.IdentityScope) == "" {
|
||||
return nil, errorsx.UnauthorizedI18n("error.e0161")
|
||||
}
|
||||
return claims, nil
|
||||
@@ -211,8 +222,8 @@ func (s *customerSessionService) externalUserFromClaims(claims *customerSessionC
|
||||
default:
|
||||
return nil, errorsx.UnauthorizedI18n("error.e0161")
|
||||
}
|
||||
identity := repositories.CustomerIdentityRepository.GetBy(sqls.DB(), source, parts[1])
|
||||
if identity == nil || identity.CustomerID != claims.CustomerID {
|
||||
identity, err := repositories.CustomerIdentityRepository.GetUniqueBy(sqls.DB(), claims.IdentityScope, source, parts[1])
|
||||
if err != nil || identity == nil || identity.CustomerID != claims.CustomerID {
|
||||
return nil, errorsx.UnauthorizedI18n("error.e0161")
|
||||
}
|
||||
name := strings.TrimSpace(claims.CustomerName)
|
||||
@@ -223,6 +234,8 @@ func (s *customerSessionService) externalUserFromClaims(claims *customerSessionC
|
||||
ExternalSource: source,
|
||||
ExternalID: parts[1],
|
||||
ExternalName: name,
|
||||
IdentityScope: claims.IdentityScope,
|
||||
ChannelID: claims.ChannelID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -250,3 +263,38 @@ func (s *customerSessionService) getCustomerSessionToken(ctx *gin.Context) strin
|
||||
token, _ := params.Get(ctx, "customerSessionToken")
|
||||
return strings.TrimSpace(token)
|
||||
}
|
||||
|
||||
// VerifyConnection rechecks the signed credential, expiry, channel policy and
|
||||
// current identity mapping. It never refreshes an idle connection indefinitely.
|
||||
func (s *customerSessionService) VerifyConnection(result *CustomerSessionVerifyResult) bool {
|
||||
if result == nil || !time.Now().Before(result.ExpiresAt) {
|
||||
return false
|
||||
}
|
||||
claims, err := s.verifyToken(result.Token)
|
||||
if err != nil || claims.CustomerID != result.CustomerID || claims.ChannelID != result.ChannelID {
|
||||
return false
|
||||
}
|
||||
verified, err := s.verifyClaims(claims, ChannelService.Get(claims.ChannelID))
|
||||
return err == nil && verified.ExternalUser != nil && result.ExternalUser != nil &&
|
||||
verified.ExternalUser.IdentityScope == result.ExternalUser.IdentityScope &&
|
||||
verified.ExternalUser.ExternalSource == result.ExternalUser.ExternalSource &&
|
||||
verified.ExternalUser.ExternalID == result.ExternalUser.ExternalID
|
||||
}
|
||||
|
||||
func (s *customerSessionService) verifyClaims(claims *customerSessionClaims, channel *models.Channel) (*CustomerSessionVerifyResult, error) {
|
||||
if channel == nil || channel.Status != enums.StatusOk || claims.ChannelID != channel.ID || strings.TrimSpace(claims.ChannelCode) != strings.TrimSpace(channel.ChannelID) {
|
||||
return nil, errorsx.UnauthorizedI18n("error.e0161")
|
||||
}
|
||||
customer := CustomerService.Get(claims.CustomerID)
|
||||
if customer == nil || customer.Status != enums.StatusOk {
|
||||
return nil, errorsx.UnauthorizedI18n("error.e0161")
|
||||
}
|
||||
external, err := s.externalUserFromClaims(claims, customer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !openidentity.ValidIdentityScope(channel.ChannelID, *external) || (ChannelService.RequiresSignedIdentity(channel) && external.ExternalSource != enums.ExternalSourceUser) {
|
||||
return nil, errorsx.UnauthorizedI18n("error.e0161")
|
||||
}
|
||||
return &CustomerSessionVerifyResult{CustomerID: customer.ID, ChannelID: channel.ID, ExternalUser: external, ExpiresAt: claims.ExpiresAt.Time}, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user