// Modified in the independent hardening fork; see docs/hardening. package services import ( "errors" "crypto/rand" "encoding/hex" "strings" "time" "agent-desk/internal/models" "agent-desk/internal/pkg/config" "agent-desk/internal/pkg/dto/response" "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/errorsx" "agent-desk/internal/pkg/openidentity" "agent-desk/internal/repositories" "agent-desk/internal/pkg/httpx/params" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" "github.com/mlogclub/simple/sqls" ) const ( customerSessionTokenType = "customer_session" customerSessionHeader = "X-Customer-Session-Token" customerSessionExpHeader = "X-Customer-Session-Expires-At" ) var CustomerSessionService = newCustomerSessionService() func newCustomerSessionService() *customerSessionService { return &customerSessionService{} } 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"` IdentityScope string `json:"identityScope"` jwt.RegisteredClaims } type CustomerSessionVerifyResult struct { CustomerID int64 ChannelID int64 ExternalUser *openidentity.ExternalUser Token string ExpiresAt time.Time Refreshed bool } func (s *customerSessionService) Exchange(channel *models.Channel, externalUser openidentity.ExternalUser) (*response.CustomerSessionExchangeResponse, error) { 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) if err != nil { return err } customerID = id return nil }); err != nil { return nil, err } customer := CustomerService.Get(customerID) 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 } return &response.CustomerSessionExchangeResponse{ CustomerSessionToken: token, ExpiresAt: expiresAt.UTC().Format(time.RFC3339), IdentityKey: s.identityKey(externalUser), Customer: response.CustomerSessionCustomerResponse{ ID: customer.ID, Name: strings.TrimSpace(customer.Name), }, }, nil } func (s *customerSessionService) Sign(channel *models.Channel, customer *models.Customer, externalUser openidentity.ExternalUser) (string, time.Time, error) { cfg := config.Current().CustomerSession secret := strings.TrimSpace(cfg.Secret) if secret == "" { return "", time.Time{}, errorsx.BusinessErrorI18n(1, "error.customerSession.secretMissing") } 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), IdentityScope: externalUser.IdentityScope, RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(expiresAt), }, } token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret)) if err != nil { return "", time.Time{}, err } return token, expiresAt, nil } func (s *customerSessionService) VerifyRequest(ctx *gin.Context, channel *models.Channel) (*CustomerSessionVerifyResult, error) { token := s.getCustomerSessionToken(ctx) if token == "" { return nil, errorsx.UnauthorizedI18n("error.e0157") } claims, err := s.verifyToken(token) if err != nil { return nil, err } result, err := s.verifyClaims(claims, channel) if err != nil { return nil, err } result.Token = token if s.shouldRefresh(claims.ExpiresAt.Time) { newToken, expiresAt, err := s.Sign(channel, CustomerService.Get(result.CustomerID), *result.ExternalUser) if err != nil { return nil, err } result.Token = newToken result.ExpiresAt = expiresAt result.Refreshed = true } return result, nil } func (s *customerSessionService) SetRefreshHeaders(ctx *gin.Context, result *CustomerSessionVerifyResult) { if ctx == nil || result == nil || !result.Refreshed { return } ctx.Header(customerSessionHeader, result.Token) ctx.Header(customerSessionExpHeader, result.ExpiresAt.UTC().Format(time.RFC3339)) } func (s *customerSessionService) verifyToken(rawToken string) (*customerSessionClaims, error) { cfg := config.Current().CustomerSession secret := strings.TrimSpace(cfg.Secret) if secret == "" { return nil, errorsx.BusinessErrorI18n(1, "error.customerSession.secretMissing") } claims := &customerSessionClaims{} token, err := jwt.ParseWithClaims(rawToken, 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.e0160") } return nil, errorsx.UnauthorizedI18n("error.e0161") } 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) == "" || strings.TrimSpace(claims.IdentityScope) == "" { return nil, errorsx.UnauthorizedI18n("error.e0161") } return claims, nil } func (s *customerSessionService) externalUserFromClaims(claims *customerSessionClaims, customer *models.Customer) (*openidentity.ExternalUser, error) { identityKey := strings.TrimSpace(claims.IdentityKey) parts := strings.SplitN(identityKey, ":", 2) if len(parts) != 2 || strings.TrimSpace(parts[1]) == "" { return nil, errorsx.UnauthorizedI18n("error.e0161") } var source enums.ExternalSource switch parts[0] { case "user": source = enums.ExternalSourceUser case "guest": source = enums.ExternalSourceGuest default: return nil, errorsx.UnauthorizedI18n("error.e0161") } 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) if customer != nil && strings.TrimSpace(customer.Name) != "" { name = strings.TrimSpace(customer.Name) } return &openidentity.ExternalUser{ ExternalSource: source, ExternalID: parts[1], ExternalName: name, IdentityScope: claims.IdentityScope, ChannelID: claims.ChannelID, }, nil } func (s *customerSessionService) shouldRefresh(expiresAt time.Time) bool { threshold := config.Current().CustomerSession.RefreshThreshold() return time.Until(expiresAt) <= time.Duration(threshold)*time.Minute } func (s *customerSessionService) identityKey(externalUser openidentity.ExternalUser) string { switch externalUser.ExternalSource { case enums.ExternalSourceUser: return "user:" + strings.TrimSpace(externalUser.ExternalID) default: return "guest:" + strings.TrimSpace(externalUser.ExternalID) } } func (s *customerSessionService) getCustomerSessionToken(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 } } 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 }