Files
ai-agent/internal/services/customer_session_service.go
T

253 lines
8.2 KiB
Go

package services
import (
"errors"
"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"`
jwt.RegisteredClaims
}
type CustomerSessionVerifyResult struct {
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")
}
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.StatusDeleted {
return nil, errorsx.InvalidParamI18n("error.e0155")
}
token, expiresAt, err := s.Sign(channel, customer, externalUser)
if err != nil {
return nil, err
}
return &response.CustomerSessionExchangeResponse{
CustomerSessionToken: token,
ExpiresAt: expiresAt.Format(time.DateTime),
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 {
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),
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
}
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)
if err != nil {
return nil, err
}
result := &CustomerSessionVerifyResult{
ExternalUser: external,
Token: token,
ExpiresAt: claims.ExpiresAt.Time,
}
if s.shouldRefresh(claims.ExpiresAt.Time) {
newToken, expiresAt, err := s.Sign(channel, customer, *external)
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.Format(time.DateTime))
}
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) == "" {
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 := repositories.CustomerIdentityRepository.GetBy(sqls.DB(), source, parts[1])
if 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,
}, 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)
}