feat: 接入消息加密与实名认证能力
This commit is contained in:
+112
-8
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/tinode/chat/server/logs"
|
||||
"github.com/tinode/chat/server/media"
|
||||
"github.com/tinode/chat/server/moderation"
|
||||
realnamealiyun "github.com/tinode/chat/server/realname/aliyun"
|
||||
"github.com/tinode/chat/server/store"
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -1691,17 +1692,20 @@ func serveIMRealNameSubmissions(wrt http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
submissionID := hashIMSecret(principal.TenantID, principal.Uid.String(), idempotencyKey, "realname")[:32]
|
||||
status, reviewReason, verifiedAt := initialRealNameSubmissionState(req, principal, name, idType, idNumber)
|
||||
rv := &types.RealNameVerification{
|
||||
TenantID: principal.TenantID,
|
||||
UserID: principal.Uid,
|
||||
SubmissionID: submissionID,
|
||||
Status: "pending",
|
||||
Status: status,
|
||||
NameCipher: protectIMSensitive(principal.TenantID, name),
|
||||
IDType: idType,
|
||||
IDNumberCipher: protectIMSensitive(principal.TenantID, idNumber),
|
||||
IDNumberHash: hashIMSecret(principal.TenantID, idType, idNumber, "realname-id"),
|
||||
MaskedName: maskIMName(name),
|
||||
MaskedIDNumber: maskIMIDNumber(idNumber),
|
||||
ReviewReason: reviewReason,
|
||||
VerifiedAt: verifiedAt,
|
||||
}
|
||||
if err := store.Security.CreateRealNameSubmission(principal.TenantID, rv); err != nil {
|
||||
if errors.Is(err, types.ErrDuplicate) {
|
||||
@@ -1711,7 +1715,12 @@ func serveIMRealNameSubmissions(wrt http.ResponseWriter, req *http.Request) {
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
return
|
||||
}
|
||||
writeIMSuccess(wrt, req, map[string]any{"submission_id": submissionID, "status": "pending"})
|
||||
response := map[string]any{"submission_id": submissionID, "status": status}
|
||||
if status == "manual_review" {
|
||||
response["status"] = "pending"
|
||||
response["review_status"] = "manual_review"
|
||||
}
|
||||
writeIMSuccess(wrt, req, response)
|
||||
}
|
||||
|
||||
func serveIMRealNameAppeals(wrt http.ResponseWriter, req *http.Request) {
|
||||
@@ -2563,7 +2572,8 @@ func serveIMFavoriteForward(wrt http.ResponseWriter, req *http.Request) {
|
||||
results = append(results, map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "failed", "reason": relationErrorReason(err)})
|
||||
continue
|
||||
}
|
||||
head, content := favoriteForwardMessageHeadContent(item)
|
||||
clientMid := forwardClientMid(req.Header.Get("Idempotency-Key"), "favorite", target, favoriteID)
|
||||
head, content := favoriteForwardMessageHeadContent(item, clientMid)
|
||||
msg := &types.Message{
|
||||
TenantID: principal.TenantID,
|
||||
Topic: target,
|
||||
@@ -2581,6 +2591,7 @@ func serveIMFavoriteForward(wrt http.ResponseWriter, req *http.Request) {
|
||||
if saved != nil && saved.Pending {
|
||||
result := forwardResultUnknownPayload(target, saved.SeqId, relationErrorReason(err))
|
||||
result["favorite_id"] = favoriteID
|
||||
result["client_mid"] = clientMid
|
||||
mergeForwardCommentResult(result, commentResult)
|
||||
results = append(results, result)
|
||||
continue
|
||||
@@ -2588,6 +2599,7 @@ func serveIMFavoriteForward(wrt http.ResponseWriter, req *http.Request) {
|
||||
if msg.SeqId > 0 && relationErrorReason(err) == imReasonInternal {
|
||||
result := forwardResultUnknownPayload(target, msg.SeqId, relationErrorReason(err))
|
||||
result["favorite_id"] = favoriteID
|
||||
result["client_mid"] = clientMid
|
||||
mergeForwardCommentResult(result, commentResult)
|
||||
results = append(results, result)
|
||||
continue
|
||||
@@ -2597,11 +2609,12 @@ func serveIMFavoriteForward(wrt http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
if saved != nil && saved.Pending {
|
||||
result := map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "pending", "seq": saved.SeqId, "reason": "MESSAGE_PERSIST_PENDING"}
|
||||
result["client_mid"] = clientMid
|
||||
mergeForwardCommentResult(result, commentResult)
|
||||
results = append(results, result)
|
||||
continue
|
||||
}
|
||||
result := map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "sent", "seq": saved.SeqId}
|
||||
result := map[string]any{"target_topic": target, "favorite_id": favoriteID, "status": "sent", "seq": saved.SeqId, "client_mid": clientMid}
|
||||
mergeForwardCommentResult(result, commentResult)
|
||||
results = append(results, result)
|
||||
}
|
||||
@@ -6571,13 +6584,20 @@ func hashIMActionToken(tenantID types.TenantID, uid types.Uid, action, target, t
|
||||
|
||||
func realNamePayload(rv *types.RealNameVerification) map[string]any {
|
||||
canSubmit := rv.Status == "unverified" || rv.Status == "rejected"
|
||||
status := rv.Status
|
||||
if status == "manual_review" {
|
||||
status = "pending"
|
||||
}
|
||||
data := map[string]any{
|
||||
"status": rv.Status,
|
||||
"status": status,
|
||||
"submission_id": rv.SubmissionID,
|
||||
"masked_name": rv.MaskedName,
|
||||
"masked_id_number": rv.MaskedIDNumber,
|
||||
"can_submit": canSubmit,
|
||||
}
|
||||
if rv.Status == "manual_review" {
|
||||
data["review_status"] = rv.Status
|
||||
}
|
||||
if rv.VerifiedAt != nil {
|
||||
data["verified_at"] = rv.VerifiedAt.Format(time.RFC3339Nano)
|
||||
}
|
||||
@@ -6604,6 +6624,89 @@ func realNameAppealPayload(appeal *types.RealNameAppeal) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
func initialRealNameSubmissionState(req *http.Request, principal *imPrincipal, name, idType, idNumber string) (string, *string, *time.Time) {
|
||||
status := "pending"
|
||||
policy, err := store.RealNameConfig.Policy(principal.TenantID)
|
||||
if err != nil || policy == nil {
|
||||
logs.Warn.Printf("realname policy lookup failed: tenant=%d uid=%s err=%v", principal.TenantID, principal.Uid, err)
|
||||
return status, nil, nil
|
||||
}
|
||||
if policy.ReviewPolicy != types.RealNameReviewPolicyAutoManual {
|
||||
return status, nil, nil
|
||||
}
|
||||
result := reviewIMRealNameAutomatically(req, principal, policy, name, idType, idNumber)
|
||||
switch result.status {
|
||||
case "verified":
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
return "verified", nil, &now
|
||||
default:
|
||||
reason := strings.TrimSpace(result.reason)
|
||||
if reason == "" {
|
||||
reason = "自动审核未通过,已转人工审核"
|
||||
}
|
||||
return "manual_review", &reason, nil
|
||||
}
|
||||
}
|
||||
|
||||
type imRealNameAutoReviewResult struct {
|
||||
status string
|
||||
reason string
|
||||
}
|
||||
|
||||
func reviewIMRealNameAutomatically(req *http.Request, principal *imPrincipal, policy *types.RealNamePolicy, name, idType, idNumber string) imRealNameAutoReviewResult {
|
||||
values, err := store.ThirdPartyConfig.Get("im_realname",
|
||||
"realname_provider",
|
||||
"aliyun_region_id",
|
||||
"aliyun_endpoint",
|
||||
"aliyun_access_key_id",
|
||||
"aliyun_access_key_secret",
|
||||
"aliyun_timeout_ms",
|
||||
"mock_enabled",
|
||||
"mock_result")
|
||||
if err != nil {
|
||||
logs.Warn.Printf("realname auto review config lookup failed: tenant=%d uid=%s err=%v", principal.TenantID, principal.Uid, err)
|
||||
return imRealNameAutoReviewResult{status: "manual_review", reason: "自动审核配置读取失败,已转人工审核"}
|
||||
}
|
||||
if parseIMBool(values["mock_enabled"]) {
|
||||
switch strings.ToLower(strings.TrimSpace(values["mock_result"])) {
|
||||
case "verified", "pass", "passed", "success":
|
||||
return imRealNameAutoReviewResult{status: "verified"}
|
||||
default:
|
||||
return imRealNameAutoReviewResult{status: "manual_review", reason: "自动审核未通过,已转人工审核"}
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(values["realname_provider"]) == "" ||
|
||||
strings.TrimSpace(values["aliyun_access_key_id"]) == "" ||
|
||||
strings.TrimSpace(values["aliyun_access_key_secret"]) == "" {
|
||||
return imRealNameAutoReviewResult{status: "manual_review", reason: "自动审核配置未完成,已转人工审核"}
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(values["realname_provider"]), "aliyun_cloudauth") {
|
||||
return imRealNameAutoReviewResult{status: "manual_review", reason: "自动审核供应商暂不支持,已转人工审核"}
|
||||
}
|
||||
client, err := realnamealiyun.NewID2MetaClient(realnamealiyun.Config{
|
||||
AccessKeyID: values["aliyun_access_key_id"],
|
||||
AccessKeySecret: values["aliyun_access_key_secret"],
|
||||
RegionID: values["aliyun_region_id"],
|
||||
Endpoint: values["aliyun_endpoint"],
|
||||
TimeoutMS: realnamealiyun.ParseTimeoutMS(values["aliyun_timeout_ms"], 1500),
|
||||
})
|
||||
if err != nil {
|
||||
logs.Warn.Printf("realname auto review client init failed: tenant=%d uid=%s err=%v", principal.TenantID, principal.Uid, err)
|
||||
return imRealNameAutoReviewResult{status: "manual_review", reason: "自动审核客户端初始化失败,已转人工审核"}
|
||||
}
|
||||
result, err := client.Verify(name, idNumber)
|
||||
if err != nil {
|
||||
logs.Warn.Printf("realname auto review call failed: tenant=%d uid=%s trace=%s err=%v", principal.TenantID, principal.Uid, req.Header.Get("X-Request-ID"), err)
|
||||
return imRealNameAutoReviewResult{status: "manual_review", reason: "自动审核调用失败,已转人工审核"}
|
||||
}
|
||||
if result.Passed {
|
||||
logs.Info.Printf("realname auto review passed: tenant=%d uid=%s request_id=%s latency=%s", principal.TenantID, principal.Uid, result.RequestID, result.Latency)
|
||||
return imRealNameAutoReviewResult{status: "verified"}
|
||||
}
|
||||
logs.Info.Printf("realname auto review mismatch: tenant=%d uid=%s request_id=%s biz_code=%s latency=%s", principal.TenantID, principal.Uid, result.RequestID, result.BizCode, result.Latency)
|
||||
return imRealNameAutoReviewResult{status: "manual_review", reason: "自动审核未通过,已转人工审核"}
|
||||
}
|
||||
|
||||
func groupInvitationPayload(inv *types.GroupInvitation) map[string]any {
|
||||
if inv == nil {
|
||||
return map[string]any{}
|
||||
@@ -6770,7 +6873,7 @@ func parseIMNonNegativeCursor(raw string) (int, error) {
|
||||
|
||||
func parseIMBool(raw string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "1", "true", "yes", "y":
|
||||
case "1", "true", "yes", "y", "on", "enabled":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -9038,7 +9141,7 @@ func favoriteForwardContent(item *types.FavoriteItem, comment string) map[string
|
||||
return payload
|
||||
}
|
||||
|
||||
func favoriteForwardMessageHeadContent(item *types.FavoriteItem) (types.KVMap, any) {
|
||||
func favoriteForwardMessageHeadContent(item *types.FavoriteItem, clientMid string) (types.KVMap, any) {
|
||||
itemType := "text"
|
||||
if item != nil && strings.TrimSpace(item.ItemType) != "" {
|
||||
itemType = strings.TrimSpace(item.ItemType)
|
||||
@@ -9059,12 +9162,13 @@ func favoriteForwardMessageHeadContent(item *types.FavoriteItem) (types.KVMap, a
|
||||
if _, ok := head["type"]; !ok {
|
||||
head["type"] = itemType
|
||||
}
|
||||
head["x-im-client-mid"] = clientMid
|
||||
if content, ok := snapshot["content"]; ok {
|
||||
return head, content
|
||||
}
|
||||
}
|
||||
}
|
||||
return forwardBundleMessageHead(""), favoriteForwardContent(item, "")
|
||||
return forwardBundleMessageHead(clientMid), favoriteForwardContent(item, "")
|
||||
}
|
||||
|
||||
func forwardFailurePayload(topic string, seq int64, target, reason string) map[string]any {
|
||||
|
||||
Reference in New Issue
Block a user