299 lines
10 KiB
Go
299 lines
10 KiB
Go
package support
|
|
|
|
import (
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/hmac"
|
|
cryptorand "crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.118im.com/im_team/olons_server/internal/config"
|
|
"git.118im.com/im_team/olons_server/internal/imclient"
|
|
"git.118im.com/im_team/olons_server/internal/store"
|
|
)
|
|
|
|
type Repository interface {
|
|
CustomerServiceByID(context.Context, int64, int64) (*store.CustomerService, error)
|
|
MemberIMIdentity(context.Context, int64, int64) (*store.MemberIMIdentity, error)
|
|
MemberIMProfile(context.Context, int64, int64) (*store.MemberIMProfile, error)
|
|
SaveMemberIMIdentity(context.Context, *store.MemberIMIdentity) (*store.MemberIMIdentity, error)
|
|
}
|
|
|
|
type IM interface {
|
|
Ready() bool
|
|
Config() config.IMConfig
|
|
CreateUser(context.Context, string, string, string) (*imclient.CreatedUser, error)
|
|
FindUserByUsername(context.Context, string) (*imclient.CreatedUser, error)
|
|
LoginBasic(context.Context, string, string, string, string) (*imclient.Session, error)
|
|
ResetPassword(context.Context, string, string) error
|
|
UserProfile(context.Context, string, string) (*imclient.UserProfile, error)
|
|
CreateFriendRequest(context.Context, string, string, string) (*imclient.FriendRequest, error)
|
|
EnsureP2P(context.Context, string, string, string) (*imclient.Conversation, error)
|
|
}
|
|
|
|
type Service struct {
|
|
repository Repository
|
|
im IM
|
|
aead cipher.AEAD
|
|
}
|
|
|
|
type SessionRequest struct {
|
|
DeviceID string
|
|
Platform string
|
|
}
|
|
|
|
type Agent struct {
|
|
ID int64 `json:"id"`
|
|
IMUserID string `json:"im_user_id"`
|
|
Name string `json:"name"`
|
|
AvatarURL string `json:"avatar_url"`
|
|
OnlineStatus string `json:"online_status"`
|
|
}
|
|
|
|
type Session struct {
|
|
TenantCode string `json:"tenant_code"`
|
|
APIBaseURL string `json:"api_base_url"`
|
|
WebSocketURL string `json:"websocket_url"`
|
|
FileURL string `json:"file_url"`
|
|
APIKey string `json:"api_key"`
|
|
Token string `json:"token"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
CurrentUserID string `json:"current_user_id"`
|
|
Topic string `json:"topic"`
|
|
Relationship string `json:"relationship"`
|
|
Agent Agent `json:"agent"`
|
|
}
|
|
|
|
func New(repository Repository, im IM, authConfig config.AuthConfig) *Service {
|
|
return &Service{repository: repository, im: im, aead: credentialAEAD(authConfig)}
|
|
}
|
|
|
|
func (s *Service) OpenSession(ctx context.Context, tenantID, memberID, serviceID int64, in SessionRequest) (*Session, error) {
|
|
if s == nil || s.repository == nil || s.im == nil || !s.im.Ready() || s.aead == nil {
|
|
return nil, ErrIMUnavailable
|
|
}
|
|
agent, err := s.repository.CustomerServiceByID(ctx, tenantID, serviceID)
|
|
if errors.Is(err, store.ErrResourceNotFound) {
|
|
return nil, ErrServiceNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cfg := s.im.Config()
|
|
if strings.TrimSpace(agent.IMUserID) == "" || !strings.EqualFold(strings.TrimSpace(agent.IMTenantCode), strings.TrimSpace(cfg.TenantCode)) {
|
|
return nil, ErrServiceNotBound
|
|
}
|
|
identity, err := s.ensureIdentity(ctx, tenantID, memberID, cfg.TenantCode)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
platform := strings.TrimSpace(in.Platform)
|
|
if platform == "" {
|
|
platform = "web"
|
|
}
|
|
password, err := s.decryptCredential(identity.IMPasswordCipher)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decrypt IM credential: %w", err)
|
|
}
|
|
imSession, err := s.im.LoginBasic(ctx, identity.IMUsername, password, strings.TrimSpace(in.DeviceID), platform)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("login IM session: %w", err)
|
|
}
|
|
if !strings.EqualFold(strings.TrimSpace(imSession.UserID), strings.TrimSpace(identity.IMUserID)) {
|
|
return nil, ErrIMUnavailable
|
|
}
|
|
profile, err := s.im.UserProfile(ctx, imSession.Token, agent.IMUserID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query customer service relationship: %w", err)
|
|
}
|
|
relationship := strings.TrimSpace(profile.Relationship)
|
|
switch relationship {
|
|
case "friend":
|
|
case "none", "":
|
|
key := fmt.Sprintf("customer-service:%d:%d:%d", tenantID, memberID, serviceID)
|
|
request, requestErr := s.im.CreateFriendRequest(ctx, imSession.Token, agent.IMUserID, key)
|
|
if requestErr != nil && !imclient.IsConflict(requestErr) {
|
|
return nil, fmt.Errorf("create customer service friend request: %w", requestErr)
|
|
}
|
|
if requestErr != nil {
|
|
profile, err = s.im.UserProfile(ctx, imSession.Token, agent.IMUserID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("refresh customer service relationship: %w", err)
|
|
}
|
|
relationship = strings.TrimSpace(profile.Relationship)
|
|
} else {
|
|
relationship = strings.TrimSpace(request.Status)
|
|
}
|
|
if relationship != "accepted" && relationship != "friend" {
|
|
return nil, ErrRelationPending
|
|
}
|
|
case "pending_inbound", "pending_outbound", "pending":
|
|
return nil, ErrRelationPending
|
|
default:
|
|
return nil, ErrRelationDenied
|
|
}
|
|
conversation, err := s.im.EnsureP2P(ctx, imSession.Token, agent.IMUserID,
|
|
fmt.Sprintf("p2p:%s:%s", identity.IMUserID, agent.IMUserID))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ensure customer service conversation: %w", err)
|
|
}
|
|
if strings.TrimSpace(conversation.Topic) == "" || (conversation.Relationship != "" && conversation.Relationship != "friend") || !conversation.CanSend {
|
|
return nil, ErrRelationDenied
|
|
}
|
|
return &Session{
|
|
TenantCode: cfg.TenantCode, APIBaseURL: cfg.HTTPAPIURL, WebSocketURL: cfg.WebSocketURL,
|
|
FileURL: cfg.FileURL, APIKey: cfg.APIKey, Token: imSession.Token, ExpiresAt: imSession.ExpiresAt,
|
|
CurrentUserID: imSession.UserID, Topic: conversation.Topic, Relationship: "friend",
|
|
Agent: Agent{ID: agent.ID, IMUserID: agent.IMUserID, Name: agent.Name, AvatarURL: agent.AvatarURL,
|
|
OnlineStatus: customerServiceStatus(agent.OnlineStatus)},
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) ensureIdentity(ctx context.Context, tenantID, memberID int64, imTenantCode string) (*store.MemberIMIdentity, error) {
|
|
identity, err := s.repository.MemberIMIdentity(ctx, tenantID, memberID)
|
|
if err == nil {
|
|
if !strings.EqualFold(identity.IMTenantCode, imTenantCode) || identity.IMUserID == "" || identity.IMUsername == "" {
|
|
return nil, ErrIMUnavailable
|
|
}
|
|
if identity.IMPasswordCipher != "" {
|
|
return identity, nil
|
|
}
|
|
return s.resetAndSaveCredential(ctx, identity)
|
|
}
|
|
if !errors.Is(err, store.ErrResourceNotFound) {
|
|
return nil, err
|
|
}
|
|
member, err := s.repository.MemberIMProfile(ctx, tenantID, memberID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
username := memberIMUsername(tenantID, memberID, imTenantCode)
|
|
nickname := strings.TrimSpace(member.Username)
|
|
if nickname == "" {
|
|
nickname = strings.TrimSpace(member.MemberNo)
|
|
}
|
|
if nickname == "" {
|
|
nickname = "会员"
|
|
}
|
|
password, err := randomPassword()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
created, err := s.im.CreateUser(ctx, username, nickname, password)
|
|
if err != nil && imclient.IsConflict(err) {
|
|
created, err = s.im.FindUserByUsername(ctx, username)
|
|
if err == nil {
|
|
err = s.im.ResetPassword(ctx, string(created.UserID), password)
|
|
}
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create IM identity: %w", err)
|
|
}
|
|
issued, err := s.im.LoginBasic(ctx, created.Username, password, "provision", "server")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("login provisioned IM identity: %w", err)
|
|
}
|
|
ciphertext, err := s.encryptCredential(password)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
identity = &store.MemberIMIdentity{TenantID: tenantID, MemberID: memberID, IMTenantCode: imTenantCode,
|
|
IMUserID: issued.UserID, IMUsername: created.Username, IMPasswordCipher: ciphertext}
|
|
return s.repository.SaveMemberIMIdentity(ctx, identity)
|
|
}
|
|
|
|
func (s *Service) resetAndSaveCredential(ctx context.Context, identity *store.MemberIMIdentity) (*store.MemberIMIdentity, error) {
|
|
password, err := randomPassword()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err = s.im.ResetPassword(ctx, identity.IMUserID, password); err != nil {
|
|
return nil, fmt.Errorf("reset IM credential: %w", err)
|
|
}
|
|
identity.IMPasswordCipher, err = s.encryptCredential(password)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.repository.SaveMemberIMIdentity(ctx, identity)
|
|
}
|
|
|
|
func credentialAEAD(cfg config.AuthConfig) cipher.AEAD {
|
|
var key []byte
|
|
if masterKey := strings.TrimSpace(cfg.FinanceMasterKey); len([]byte(masterKey)) >= 32 {
|
|
mac := hmac.New(sha256.New, []byte(masterKey))
|
|
_, _ = mac.Write([]byte("olons-im:credential:v1"))
|
|
key = mac.Sum(nil)
|
|
} else {
|
|
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(cfg.DataEncryptionKey))
|
|
if err != nil {
|
|
decoded, err = base64.RawStdEncoding.DecodeString(strings.TrimSpace(cfg.DataEncryptionKey))
|
|
}
|
|
if err != nil || len(decoded) != 32 {
|
|
return nil
|
|
}
|
|
key = decoded
|
|
}
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
aead, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return aead
|
|
}
|
|
|
|
func (s *Service) encryptCredential(value string) (string, error) {
|
|
nonce := make([]byte, s.aead.NonceSize())
|
|
if _, err := cryptorand.Read(nonce); err != nil {
|
|
return "", err
|
|
}
|
|
sealed := s.aead.Seal(nil, nonce, []byte(value), nil)
|
|
return base64.RawStdEncoding.EncodeToString(append(nonce, sealed...)), nil
|
|
}
|
|
|
|
func (s *Service) decryptCredential(value string) (string, error) {
|
|
payload, err := base64.RawStdEncoding.DecodeString(strings.TrimSpace(value))
|
|
if err != nil || len(payload) <= s.aead.NonceSize() {
|
|
return "", errors.New("encrypted IM credential is invalid")
|
|
}
|
|
plain, err := s.aead.Open(nil, payload[:s.aead.NonceSize()], payload[s.aead.NonceSize():], nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(plain), nil
|
|
}
|
|
|
|
// The IM service limits usernames to ten lowercase letters or digits. A short
|
|
// stable hash keeps provisioning idempotent without leaking the business ID.
|
|
func memberIMUsername(tenantID, memberID int64, imTenantCode string) string {
|
|
sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%d:%d", strings.ToLower(strings.TrimSpace(imTenantCode)), tenantID, memberID)))
|
|
return "om" + fmt.Sprintf("%x", sum[:4])
|
|
}
|
|
|
|
func randomPassword() (string, error) {
|
|
buf := make([]byte, 32)
|
|
if _, err := cryptorand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(buf), nil
|
|
}
|
|
|
|
func customerServiceStatus(status int) string {
|
|
switch status {
|
|
case 1:
|
|
return "online"
|
|
case 3:
|
|
return "busy"
|
|
default:
|
|
return "offline"
|
|
}
|
|
}
|