Files
im_server/server/im_http.go
T

1693 lines
58 KiB
Go

package main
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/nyaruka/phonenumbers"
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
"golang.org/x/crypto/bcrypt"
)
const (
imReasonAPIKeyRequired = "API_KEY_REQUIRED"
imReasonMethodNotAllowed = "METHOD_NOT_ALLOWED"
imReasonMalformedRequest = "MALFORMED_REQUEST"
imReasonTenantUnavailable = "TENANT_UNAVAILABLE"
imReasonVerificationRate = "VERIFICATION_RATE_LIMITED"
imReasonChallengeExpired = "CHALLENGE_EXPIRED"
imReasonCodeInvalid = "CODE_INVALID"
imReasonTooManyAttempts = "TOO_MANY_ATTEMPTS"
imReasonPasswordPolicy = "PASSWORD_POLICY_VIOLATION"
imReasonAuthRequired = "AUTH_REQUIRED"
imReasonRealNameRequired = "REALNAME_REQUIRED"
imReasonActionToken = "ACTION_TOKEN_INVALID"
imReasonPermissionDenied = "PERMISSION_DENIED"
imReasonInternal = "INTERNAL_ERROR"
)
const (
imVerificationCodeLength = 6
imVerificationExpiresInSeconds = 300
imVerificationResendAfterSeconds = 60
imVerificationMaxAttempts = 5
imActionTokenExpiresInSeconds = 300
imIndependentPasswordMaxAttempts = 5
imIndependentPasswordLockSeconds = 300
)
type imHTTPResponse struct {
Code int `json:"code"`
Data any `json:"data,omitempty"`
Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"`
Details any `json:"details,omitempty"`
RequestID string `json:"request_id,omitempty"`
ServerTime string `json:"server_time"`
}
type imTenantResolveRequest struct {
TenantCode string `json:"tenant_code"`
Platform string `json:"platform,omitempty"`
AppVersion string `json:"app_version,omitempty"`
}
type imVerificationCreateRequest struct {
TenantCode string `json:"tenant_code,omitempty"`
Scene string `json:"scene"`
Phone string `json:"phone"`
Country string `json:"country,omitempty"`
}
type imVerificationVerifyRequest struct {
Code string `json:"code"`
}
type imPasswordResetConfirmRequest struct {
ResetToken string `json:"reset_token"`
NewPassword string `json:"new_password"`
}
type imPrincipal struct {
TenantID types.TenantID
Uid types.Uid
}
type imSecurityChallengeRequest struct {
Scene string `json:"scene"`
Channel string `json:"channel"`
}
type imRealNameSubmissionRequest struct {
Name string `json:"name"`
IDType string `json:"id_type"`
IDNumber string `json:"id_number"`
}
type imIndependentPasswordRequest struct {
Password string `json:"password"`
Purpose string `json:"purpose"`
ActionToken string `json:"action_token,omitempty"`
}
type imIndependentPasswordVerifyRequest struct {
Password string `json:"password"`
Purpose string `json:"purpose"`
Action string `json:"action,omitempty"`
}
type imSystemNotificationReadRequest struct {
ReadAt string `json:"read_at,omitempty"`
}
type imMessageRecallRequest struct {
Version int `json:"version"`
Reason string `json:"reason,omitempty"`
}
type imMessageRevisionRequest struct {
Version int `json:"version"`
Content any `json:"content"`
ClientMid string `json:"client_mid,omitempty"`
Head types.KVMap `json:"head,omitempty"`
Reason string `json:"reason,omitempty"`
}
func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
return
}
var body imTenantResolveRequest
if req.Body != nil {
dec := json.NewDecoder(req.Body)
dec.DisallowUnknownFields()
err := dec.Decode(&body)
if err != nil {
if errors.Is(err, io.EOF) {
err = nil
}
}
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
var extra any
if err := dec.Decode(&extra); err != nil && !errors.Is(err, io.EOF) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
} else if err == nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
}
tenantCode := strings.TrimSpace(req.Header.Get("X-Tenant-Code"))
if tenantCode == "" {
tenantCode = strings.TrimSpace(body.TenantCode)
}
if tenantCode == "" || len(tenantCode) > 64 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{
"field": "tenant_code",
})
return
}
if store.Tenants == nil {
logs.Err.Println("im tenant resolve: tenant store is not initialized")
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
tenant, err := store.Tenants.GetByCode(tenantCode)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusNotFound, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
return
}
logs.Warn.Println("im tenant resolve: failed to resolve tenant", tenantCode, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if !tenant.IsActive() {
writeIMError(wrt, req, http.StatusForbidden, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{
"tenant": map[string]any{
"code": tenant.Code,
"name": tenant.Name,
"tenant_desc": tenant.TenantDesc,
"branding": map[string]any{},
},
"endpoints": map[string]any{
"websocket": strings.TrimRight(globals.servingAt, "/") + "/v0/channels",
"api": strings.TrimRight(globals.servingAt, "/") + "/v1/im",
"file": strings.TrimRight(globals.servingAt, "/") + "/v0/file",
},
"auth_methods": []string{"password", "sms"},
"password_policy": map[string]any{
"min_length": 8,
"max_length": 64,
},
"app_update": map[string]any{"type": "none"},
})
}
func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
return
}
var body imVerificationCreateRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
scene := strings.TrimSpace(body.Scene)
if !isAllowedVerificationScene(scene) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "scene"})
return
}
phone, err := normalizeIMPhone(body.Phone, body.Country)
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "phone"})
return
}
tenant, ok := resolveIMHTTPTenant(wrt, req, body.TenantCode)
if !ok {
return
}
now := time.Now().UTC().Round(time.Millisecond)
phoneHash := hashIMSecret(tenant.ID, scene, phone, "phone")
active, err := store.Verifications.GetActive(tenant.ID, scene, phoneHash, now)
if err != nil && !errors.Is(err, types.ErrNotFound) {
logs.Warn.Println("im verification create: active lookup failed", tenant.Code, scene, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if active != nil && now.Before(active.ResendAfter) {
retryAfter := int(time.Until(active.ResendAfter).Seconds())
if retryAfter < 1 {
retryAfter = 1
}
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
"challenge_id": active.ChallengeID,
"retry_after_seconds": retryAfter,
})
return
}
challengeID, err := randomIMToken(18)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
code, err := randomIMNumericCode(imVerificationCodeLength)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
vc := &types.VerificationCode{
TenantID: tenant.ID,
ChallengeID: challengeID,
Scene: scene,
PhoneHash: phoneHash,
PhoneMask: maskIMPhone(phone),
CodeHash: hashIMSecret(tenant.ID, scene, challengeID, phoneHash, code),
State: types.VerificationStatePending,
MaxAttempts: imVerificationMaxAttempts,
ExpiresAt: now.Add(imVerificationExpiresInSeconds * time.Second),
ResendAfter: now.Add(imVerificationResendAfterSeconds * time.Second),
}
if scene == "reset_password" {
if uid, err := store.Users.GetByCred(tenant.ID, "tel", phone); err != nil {
logs.Warn.Println("im verification create: credential lookup failed", tenant.Code, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
} else {
vc.TargetUser = uid
}
}
if err := store.Verifications.Create(tenant.ID, vc); err != nil {
logs.Warn.Println("im verification create: store failed", tenant.Code, scene, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
logs.Info.Println("im verification code generated:", tenant.Code, scene, vc.PhoneMask)
writeIMSuccess(wrt, req, map[string]any{
"challenge_id": vc.ChallengeID,
"phone_mask": vc.PhoneMask,
"expires_in_seconds": imVerificationExpiresInSeconds,
"resend_after_seconds": imVerificationResendAfterSeconds,
})
}
func serveIMVerificationCodeVerify(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
return
}
challengeID := verificationChallengeIDFromPath(req.URL.Path)
if challengeID == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "challenge_id"})
return
}
var body imVerificationVerifyRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
code := strings.TrimSpace(body.Code)
if len(code) != imVerificationCodeLength {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "code"})
return
}
tenant, ok := resolveIMHTTPTenant(wrt, req, "")
if !ok {
return
}
vc, err := store.Verifications.Get(tenant.ID, challengeID)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusNotFound, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
logs.Warn.Println("im verification verify: lookup failed", tenant.Code, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
now := time.Now().UTC().Round(time.Millisecond)
if vc.State == types.VerificationStateLocked || vc.Attempts >= vc.MaxAttempts {
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil)
return
}
if vc.State != types.VerificationStatePending || !now.Before(vc.ExpiresAt) {
_ = store.Verifications.Fail(tenant.ID, challengeID, types.VerificationStateExpired)
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
if vc.CodeHash != hashIMSecret(tenant.ID, vc.Scene, vc.ChallengeID, vc.PhoneHash, code) {
nextState := types.VerificationStatePending
if vc.Attempts+1 >= vc.MaxAttempts {
nextState = types.VerificationStateLocked
}
_ = store.Verifications.Fail(tenant.ID, challengeID, nextState)
if nextState == types.VerificationStateLocked {
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil)
return
}
writeIMError(wrt, req, http.StatusUnauthorized, 30031, imReasonCodeInvalid, "code invalid", nil)
return
}
token, err := randomIMToken(32)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if err := store.Verifications.MarkVerified(tenant.ID, challengeID, hashIMSecret(tenant.ID, vc.Scene, vc.ChallengeID, vc.PhoneHash, token), now); err != nil {
logs.Warn.Println("im verification verify: mark verified failed", tenant.Code, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{
"verification_token": token,
"expires_in_seconds": imVerificationExpiresInSeconds,
})
}
func serveIMPasswordResetVerify(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
return
}
challengeID := passwordResetChallengeIDFromPath(req.URL.Path)
if challengeID == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "challenge_id"})
return
}
var body imVerificationVerifyRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
tenant, ok := resolveIMHTTPTenant(wrt, req, "")
if !ok {
return
}
vc, token, ok := verifyIMCodeChallenge(wrt, req, tenant.ID, challengeID, strings.TrimSpace(body.Code), "reset_password")
if !ok {
return
}
if vc.TargetUser.IsZero() {
writeIMSuccess(wrt, req, map[string]any{
"reset_token": vc.ChallengeID + "." + token,
"expires_in_seconds": imVerificationExpiresInSeconds,
})
return
}
writeIMSuccess(wrt, req, map[string]any{
"reset_token": vc.ChallengeID + "." + token,
"expires_in_seconds": imVerificationExpiresInSeconds,
})
}
func serveIMPasswordResetConfirm(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
return
}
var body imPasswordResetConfirmRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
tenant, ok := resolveIMHTTPTenant(wrt, req, "")
if !ok {
return
}
challengeID, token, ok := strings.Cut(strings.TrimSpace(body.ResetToken), ".")
if !ok || challengeID == "" || token == "" || strings.TrimSpace(body.NewPassword) == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
vc, err := store.Verifications.Get(tenant.ID, challengeID)
if err != nil {
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
if vc.Scene != "reset_password" || vc.State != types.VerificationStateVerified || vc.VerificationTokenHash == nil {
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
tokenHash := hashIMSecret(tenant.ID, vc.Scene, vc.ChallengeID, vc.PhoneHash, token)
if *vc.VerificationTokenHash != tokenHash || !time.Now().UTC().Before(vc.ExpiresAt) {
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
if vc.TargetUser.IsZero() {
_ = store.Verifications.Consume(tenant.ID, vc.ChallengeID, tokenHash, time.Now().UTC().Round(time.Millisecond))
writeIMSuccess(wrt, req, map[string]any{"status": "ok"})
return
}
login, authLevel, _, _, err := store.Users.GetAuthRecord(tenant.ID, vc.TargetUser, "basic")
if err != nil || login == "" {
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
basicAuth := store.Store.GetLogicalAuthHandler("basic")
if basicAuth == nil || !basicAuth.IsInitialized() {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if _, err = basicAuth.UpdateRecord(auth.AuthContext{TenantID: tenant.ID}, &auth.Rec{
TenantID: tenant.ID,
Uid: vc.TargetUser,
AuthLevel: authLevel,
}, []byte(":"+body.NewPassword)); err != nil {
if err == types.ErrPolicy {
writeIMError(wrt, req, http.StatusBadRequest, 30040, imReasonPasswordPolicy, "password policy violation", nil)
return
}
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
now := time.Now().UTC().Round(time.Millisecond)
if store.Security == nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if _, err = store.Security.BumpAuthVersion(tenant.ID, vc.TargetUser, now); err != nil {
logs.Warn.Println("im password reset confirm: bump auth version failed", err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if err = store.Verifications.Consume(tenant.ID, vc.ChallengeID, tokenHash, now); err != nil {
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
if globals.sessionStore != nil {
globals.sessionStore.EvictUser(vc.TargetUser, "")
}
writeIMSuccess(wrt, req, map[string]any{"status": "ok"})
}
func serveIMSecurityChallenges(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body imSecurityChallengeRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
action := strings.TrimSpace(body.Scene)
if action == "" || len(action) > 64 || strings.TrimSpace(body.Channel) != "sms" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
phone, err := currentUserPhone(principal.TenantID, principal.Uid)
if err != nil {
writeIMError(wrt, req, http.StatusForbidden, 30041, imReasonRealNameRequired, "verified phone required", nil)
return
}
createSensitiveChallenge(wrt, req, principal, action, phone)
}
func serveIMSecurityChallengeVerify(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
challengeID := securityChallengeIDFromPath(req.URL.Path)
if challengeID == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
var body imVerificationVerifyRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
vc, _, ok := verifyIMCodeChallenge(wrt, req, principal.TenantID, challengeID, strings.TrimSpace(body.Code), "sensitive_action")
if !ok {
return
}
if vc.TargetUser != principal.Uid {
writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
return
}
token, expiresAt, err := issueIMActionToken(principal.TenantID, principal.Uid, vc.Action, defaultIMActionTarget(vc.Action, vc.ChallengeID))
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{
"action_token": token,
"action": vc.Action,
"expires_at": expiresAt.Format(time.RFC3339Nano),
})
}
func serveIMRealName(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
rv, err := store.Security.GetRealName(principal.TenantID, principal.Uid)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMSuccess(wrt, req, map[string]any{"status": "unverified", "can_submit": true})
return
}
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, realNamePayload(rv))
}
func serveIMRealNameSubmissions(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
idempotencyKey := strings.TrimSpace(req.Header.Get("Idempotency-Key"))
if idempotencyKey == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "Idempotency-Key"})
return
}
var body imRealNameSubmissionRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
name := strings.TrimSpace(body.Name)
idType := strings.TrimSpace(body.IDType)
idNumber := strings.ToUpper(strings.TrimSpace(body.IDNumber))
if !validIMRealName(name, idType, idNumber) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
current, err := store.Security.GetRealName(principal.TenantID, principal.Uid)
if err == nil && (current.Status == "pending" || current.Status == "verified") {
writeIMSuccess(wrt, req, realNamePayload(current))
return
}
if err != nil && !errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
submissionID := hashIMSecret(principal.TenantID, principal.Uid.String(), idempotencyKey, "realname")[:32]
rv := &types.RealNameVerification{
TenantID: principal.TenantID,
UserID: principal.Uid,
SubmissionID: submissionID,
Status: "pending",
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),
}
if err := store.Security.CreateRealNameSubmission(principal.TenantID, rv); err != nil {
if errors.Is(err, types.ErrDuplicate) {
writeIMSuccess(wrt, req, map[string]any{"submission_id": submissionID, "status": "pending", "can_submit": false})
return
}
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{"submission_id": submissionID, "status": "pending"})
}
func serveIMBootstrap(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
status := "unverified"
if rv, err := store.Security.GetRealName(principal.TenantID, principal.Uid); err == nil {
status = rv.Status
} else if err != nil && !errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{
"features": map[string]any{
"create_group": true,
"user_search": true,
"scan_qr": true,
"system_notifications": true,
"message_recall": false,
"message_edit": false,
},
"permissions": map[string]any{
"create_group": true,
"user_search": true,
"scan_qr": true,
},
"system_topic": map[string]any{
"topic": "sys",
"type": "system",
},
"message_policy": map[string]any{
"recall_window_seconds": 120,
"edit_window_seconds": 300,
"supported_actions": []string{"copy", "quote", "delete"},
},
"security": map[string]any{
"realname": map[string]any{
"status": status,
"required": false,
"allow_skip": true,
"remind_after": "24h",
},
},
})
}
func serveIMConversationSearch(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
query := strings.ToLower(strings.TrimSpace(req.URL.Query().Get("q")))
cursor := strings.TrimSpace(req.URL.Query().Get("cursor"))
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100)
subs, err := store.Users.GetTopics(principal.TenantID, principal.Uid, &types.QueryOpt{Limit: 100})
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
start := 0
if cursor != "" {
parsed, err := strconv.Atoi(cursor)
if err != nil || parsed < 0 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "cursor"})
return
}
start = parsed
}
items := make([]map[string]any, 0, limit)
nextIndex := start
for i := start; i < len(subs); i++ {
if query != "" && !conversationMatches(subs[i], query) {
continue
}
items = append(items, conversationPayload(subs[i]))
nextIndex = i + 1
if len(items) == limit {
break
}
}
nextCursor := ""
if nextIndex < len(subs) {
nextCursor = strconv.Itoa(nextIndex)
}
writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor})
}
func serveIMSystemNotifications(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 20, 100)
items, nextCursor, err := store.Security.ListSystemNotifications(principal.TenantID, principal.Uid, strings.TrimSpace(req.URL.Query().Get("cursor")), limit)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor})
}
func serveIMSystemNotificationRead(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
eventID := systemNotificationIDFromPath(req.URL.Path)
if eventID == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "id"})
return
}
if strings.TrimSpace(req.Header.Get("Idempotency-Key")) == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "Idempotency-Key"})
return
}
var body imSystemNotificationReadRequest
if req.Body != nil && req.ContentLength != 0 {
if !decodeIMJSONBody(wrt, req, &body) {
return
}
}
readAt := time.Now().UTC().Round(time.Millisecond)
if body.ReadAt != "" {
parsed, err := time.Parse(time.RFC3339Nano, body.ReadAt)
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "read_at"})
return
}
readAt = parsed.UTC().Round(time.Millisecond)
}
if err := store.Security.MarkSystemNotificationRead(principal.TenantID, principal.Uid, eventID, readAt); err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusNotFound, 30044, imReasonChallengeExpired, "notification not found", nil)
return
}
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{"status": "ok", "id": eventID, "read_at": readAt.Format(time.RFC3339Nano)})
}
func serveIMTopicMessageAction(wrt http.ResponseWriter, req *http.Request) {
switch {
case strings.HasSuffix(req.URL.Path, "/receipts"):
serveIMMessageReceipts(wrt, req)
case strings.HasSuffix(req.URL.Path, "/recall"):
serveIMMessageRecall(wrt, req)
case strings.HasSuffix(req.URL.Path, "/revisions"):
serveIMMessageRevision(wrt, req)
default:
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message action route not found", nil)
}
}
func serveIMMessageReceipts(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
topic, seq, ok := messageReceiptsPath(req.URL.Path)
if !ok {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message receipt route not found", nil)
return
}
if seq <= 0 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "seq"})
return
}
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
return
}
limit := parseIMLimit(req.URL.Query().Get("limit"), 100, 200)
cursor, _ := strconv.Atoi(req.URL.Query().Get("cursor"))
members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: limit + 1})
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
readItems := make([]map[string]any, 0, limit)
deliveredItems := make([]map[string]any, 0, limit)
nextCursor := 0
for i, member := range members {
if i < cursor {
continue
}
if len(readItems) >= limit && len(deliveredItems) >= limit {
nextCursor = i
break
}
item := receiptMemberPayload(member)
if member.ReadSeqId >= seq && len(readItems) < limit {
readItems = append(readItems, item)
}
if member.RecvSeqId >= seq && len(deliveredItems) < limit {
deliveredItems = append(deliveredItems, item)
}
}
writeIMSuccess(wrt, req, map[string]any{
"topic": topic,
"seq": seq,
"read": readItems,
"delivered": deliveredItems,
"next_cursor": nextCursor,
})
}
func serveIMMessageRecall(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
topic, seq, ok := messageActionPath(req.URL.Path, "recall")
if !ok || seq <= 0 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
var body imMessageRecallRequest
if !decodeIMJSONBody(wrt, req, &body) || body.Version <= 0 {
if body.Version <= 0 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "version"})
}
return
}
msg, authorized, err := authorizeIMMessageMutation(principal, topic, seq, true)
if err != nil {
writeIMMessageMutationError(wrt, req, err)
return
}
if !authorized {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message recall permission required", nil)
return
}
if time.Since(msg.CreatedAt) > 120*time.Second {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message recall window expired", nil)
return
}
recalledAt := time.Now().UTC().Round(time.Millisecond)
updated, err := store.Messages.Recall(principal.TenantID, topic, seq, principal.Uid, body.Version, recalledAt)
if err != nil {
writeIMMessageMutationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, messageMutationPayload(updated, "recalled"))
}
func serveIMMessageRevision(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
topic, seq, ok := messageActionPath(req.URL.Path, "revisions")
if !ok || seq <= 0 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
var body imMessageRevisionRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
if body.Version <= 0 || body.Content == nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "version/content"})
return
}
msg, authorized, err := authorizeIMMessageMutation(principal, topic, seq, false)
if err != nil {
writeIMMessageMutationError(wrt, req, err)
return
}
if !authorized {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message edit permission required", nil)
return
}
if time.Since(msg.CreatedAt) > 300*time.Second {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "message edit window expired", nil)
return
}
if !isIMEditableTextMessage(msg) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "only text messages can be edited", nil)
return
}
head := body.Head
if head == nil {
head = types.KVMap{}
}
head["type"] = "text"
if body.ClientMid != "" {
head["x-im-client-mid"] = body.ClientMid
}
editedAt := time.Now().UTC().Round(time.Millisecond)
updated, err := store.Messages.Edit(principal.TenantID, topic, seq, principal.Uid, body.Version, head, body.Content, body.Reason, editedAt)
if err != nil {
writeIMMessageMutationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, messageMutationPayload(updated, "edited"))
}
func serveIMIndependentPassword(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body imIndependentPasswordRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
purpose := strings.TrimSpace(body.Purpose)
if purpose == "" {
purpose = "youth_mode"
}
if !validIMIndependentPassword(body.Password) || purpose != "youth_mode" {
writeIMError(wrt, req, http.StatusBadRequest, 30040, imReasonPasswordPolicy, "password policy violation", nil)
return
}
if _, err := store.Security.ConsumeActionToken(principal.TenantID, principal.Uid, strings.TrimSpace(body.ActionToken), "set_independent_password", purpose, time.Now().UTC().Round(time.Millisecond)); err != nil {
writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if err := store.Security.UpsertIndependentPassword(principal.TenantID, &types.IndependentPassword{
TenantID: principal.TenantID,
UserID: principal.Uid,
Purpose: purpose,
PasswordHash: hash,
}); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{"status": "ok", "purpose": purpose})
}
func serveIMIndependentPasswordVerify(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
principal, ok := authenticateIMHTTPBearer(wrt, req)
if !ok {
return
}
var body imIndependentPasswordVerifyRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
purpose := strings.TrimSpace(body.Purpose)
if purpose == "" {
purpose = "youth_mode"
}
action := strings.TrimSpace(body.Action)
if action == "" {
action = "close_youth_mode"
}
pass, err := store.Security.GetIndependentPassword(principal.TenantID, principal.Uid, purpose)
if err != nil {
writeIMError(wrt, req, http.StatusForbidden, 30042, imReasonActionToken, "action token invalid", nil)
return
}
now := time.Now().UTC().Round(time.Millisecond)
if pass.LockedUntil != nil && now.Before(*pass.LockedUntil) {
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", map[string]any{
"retry_after_seconds": int(time.Until(*pass.LockedUntil).Seconds()) + 1,
})
return
}
if bcrypt.CompareHashAndPassword(pass.PasswordHash, []byte(body.Password)) != nil {
attempts := pass.FailedAttempts + 1
var lockedUntil *time.Time
if attempts >= imIndependentPasswordMaxAttempts {
until := now.Add(imIndependentPasswordLockSeconds * time.Second)
lockedUntil = &until
}
_ = store.Security.UpdateIndependentPasswordAttempts(principal.TenantID, principal.Uid, purpose, attempts, lockedUntil, now)
if lockedUntil != nil {
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", map[string]any{
"retry_after_seconds": imIndependentPasswordLockSeconds,
})
return
}
writeIMError(wrt, req, http.StatusUnauthorized, 30031, imReasonCodeInvalid, "code invalid", nil)
return
}
_ = store.Security.UpdateIndependentPasswordAttempts(principal.TenantID, principal.Uid, purpose, 0, nil, now)
token, expiresAt, err := issueIMActionToken(principal.TenantID, principal.Uid, action, purpose)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{
"action_token": token,
"action": action,
"expires_at": expiresAt.Format(time.RFC3339Nano),
})
}
func verifyIMCodeChallenge(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, challengeID, code, scene string) (*types.VerificationCode, string, bool) {
if len(code) != imVerificationCodeLength {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "code"})
return nil, "", false
}
vc, err := store.Verifications.Get(tenantID, challengeID)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusNotFound, 30030, imReasonChallengeExpired, "challenge expired", nil)
return nil, "", false
}
logs.Warn.Println("im verification verify: lookup failed", err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return nil, "", false
}
now := time.Now().UTC().Round(time.Millisecond)
if vc.Scene != scene || vc.State == types.VerificationStateLocked || vc.Attempts >= vc.MaxAttempts {
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil)
return nil, "", false
}
if vc.State != types.VerificationStatePending || !now.Before(vc.ExpiresAt) {
_ = store.Verifications.Fail(tenantID, challengeID, types.VerificationStateExpired)
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return nil, "", false
}
if vc.CodeHash != hashIMSecret(tenantID, vc.Scene, vc.ChallengeID, vc.PhoneHash, code) {
nextState := types.VerificationStatePending
if vc.Attempts+1 >= vc.MaxAttempts {
nextState = types.VerificationStateLocked
}
_ = store.Verifications.Fail(tenantID, challengeID, nextState)
if nextState == types.VerificationStateLocked {
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil)
return nil, "", false
}
writeIMError(wrt, req, http.StatusUnauthorized, 30031, imReasonCodeInvalid, "code invalid", nil)
return nil, "", false
}
token, err := randomIMToken(32)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return nil, "", false
}
if err := store.Verifications.MarkVerified(tenantID, challengeID, hashIMSecret(tenantID, vc.Scene, vc.ChallengeID, vc.PhoneHash, token), now); err != nil {
logs.Warn.Println("im verification verify: mark verified failed", err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return nil, "", false
}
return vc, token, true
}
func authenticateIMHTTPBearer(wrt http.ResponseWriter, req *http.Request) (*imPrincipal, bool) {
authz := strings.TrimSpace(req.Header.Get("Authorization"))
token, ok := strings.CutPrefix(authz, "Bearer ")
if !ok || strings.TrimSpace(token) == "" || store.Store == nil {
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "authentication required", nil)
return nil, false
}
handler := store.Store.GetLogicalAuthHandler("token")
if handler == nil || !handler.IsInitialized() {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return nil, false
}
rec, _, err := handler.Authenticate(auth.AuthContext{}, []byte(strings.TrimSpace(token)))
if err != nil || rec == nil || rec.TenantID.IsZero() || rec.Uid.IsZero() {
writeIMError(wrt, req, http.StatusUnauthorized, 30043, imReasonAuthRequired, "authentication required", nil)
return nil, false
}
return &imPrincipal{TenantID: rec.TenantID, Uid: rec.Uid}, true
}
func currentUserPhone(tenantID types.TenantID, uid types.Uid) (string, error) {
creds, err := store.Users.GetAllCreds(tenantID, uid, "tel", true)
if err != nil {
return "", err
}
if len(creds) == 0 || strings.TrimSpace(creds[0].Value) == "" {
return "", types.ErrNotFound
}
return strings.TrimSpace(creds[0].Value), nil
}
func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, action, phone string) {
now := time.Now().UTC().Round(time.Millisecond)
phoneHash := hashIMSecret(principal.TenantID, "sensitive_action", phone, "phone")
active, err := store.Verifications.GetActive(principal.TenantID, "sensitive_action", phoneHash, now)
if err != nil && !errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if active != nil && now.Before(active.ResendAfter) {
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
"challenge_id": active.ChallengeID,
"retry_after_seconds": int(time.Until(active.ResendAfter).Seconds()) + 1,
})
return
}
challengeID, err := randomIMToken(18)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
code, err := randomIMNumericCode(imVerificationCodeLength)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
vc := &types.VerificationCode{
TenantID: principal.TenantID,
ChallengeID: challengeID,
Scene: "sensitive_action",
Action: action,
PhoneHash: phoneHash,
PhoneMask: maskIMPhone(phone),
TargetUser: principal.Uid,
CodeHash: hashIMSecret(principal.TenantID, "sensitive_action", challengeID, phoneHash, code),
State: types.VerificationStatePending,
MaxAttempts: imVerificationMaxAttempts,
ExpiresAt: now.Add(imVerificationExpiresInSeconds * time.Second),
ResendAfter: now.Add(imVerificationResendAfterSeconds * time.Second),
}
if err := store.Verifications.Create(principal.TenantID, vc); err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
logs.Info.Println("im security verification code generated:", action, vc.PhoneMask)
writeIMSuccess(wrt, req, map[string]any{
"challenge_id": vc.ChallengeID,
"phone_mask": vc.PhoneMask,
"expires_in_seconds": imVerificationExpiresInSeconds,
"resend_after_seconds": imVerificationResendAfterSeconds,
})
}
func issueIMActionToken(tenantID types.TenantID, uid types.Uid, action, target string) (string, time.Time, error) {
if action == "" {
return "", time.Time{}, types.ErrMalformed
}
tokenID, err := randomIMToken(12)
if err != nil {
return "", time.Time{}, err
}
secret, err := randomIMToken(32)
if err != nil {
return "", time.Time{}, err
}
expiresAt := time.Now().UTC().Round(time.Millisecond).Add(imActionTokenExpiresInSeconds * time.Second)
if err := store.Security.CreateActionToken(tenantID, &types.ActionToken{
TenantID: tenantID,
UserID: uid,
TokenID: tokenID,
Action: action,
Target: target,
TokenHash: hashIMActionToken(tenantID, uid, action, target, tokenID, secret),
State: 0,
ExpiresAt: expiresAt,
}); err != nil {
return "", time.Time{}, err
}
return tokenID + "." + secret, expiresAt, nil
}
func defaultIMActionTarget(action, fallback string) string {
switch action {
case "set_independent_password", "close_youth_mode", "reset_independent_password":
return "youth_mode"
default:
return fallback
}
}
func hashIMActionToken(tenantID types.TenantID, uid types.Uid, action, target, tokenID, secret string) string {
h := sha256.New()
h.Write([]byte(fmt.Sprintf("%d", tenantID)))
h.Write([]byte{0})
h.Write([]byte(fmt.Sprintf("%d", store.DecodeUid(uid))))
h.Write([]byte{0})
h.Write([]byte(action))
h.Write([]byte{0})
h.Write([]byte(target))
h.Write([]byte{0})
h.Write([]byte(tokenID))
h.Write([]byte{0})
h.Write([]byte(secret))
return fmt.Sprintf("%x", h.Sum(nil))
}
func realNamePayload(rv *types.RealNameVerification) map[string]any {
canSubmit := rv.Status == "unverified" || rv.Status == "rejected"
data := map[string]any{
"status": rv.Status,
"submission_id": rv.SubmissionID,
"masked_name": rv.MaskedName,
"masked_id_number": rv.MaskedIDNumber,
"can_submit": canSubmit,
}
if rv.VerifiedAt != nil {
data["verified_at"] = rv.VerifiedAt.Format(time.RFC3339Nano)
}
if rv.ReviewReason != nil {
data["review_reason"] = *rv.ReviewReason
}
return data
}
func parseIMLimit(raw string, fallback, max int) int {
limit := fallback
if raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 {
limit = parsed
}
}
if limit > max {
limit = max
}
return limit
}
func conversationMatches(sub types.Subscription, query string) bool {
if strings.Contains(strings.ToLower(sub.Topic), query) {
return true
}
encoded, _ := json.Marshal(sub.GetPublic())
return strings.Contains(strings.ToLower(string(encoded)), query)
}
func conversationPayload(sub types.Subscription) map[string]any {
unread := sub.GetSeqId() - sub.ReadSeqId
if unread < 0 {
unread = 0
}
return map[string]any{
"topic": sub.Topic,
"public": sub.GetPublic(),
"private": sub.Private,
"updated_at": sub.LastModified().UTC().Format(time.RFC3339Nano),
"seq": sub.GetSeqId(),
"read": sub.ReadSeqId,
"recv": sub.RecvSeqId,
"unread": unread,
"with": sub.GetWith(),
}
}
func receiptMemberPayload(sub types.Subscription) map[string]any {
return map[string]any{
"user_id": sub.User,
"public": sub.GetPublic(),
"read": sub.ReadSeqId,
"recv": sub.RecvSeqId,
}
}
func authorizeIMMessageMutation(principal *imPrincipal, topic string, seq int, allowAdmin bool) (*types.Message, bool, error) {
if principal == nil {
return nil, false, types.ErrPermissionDenied
}
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil {
return nil, false, err
}
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() {
return nil, false, types.ErrPermissionDenied
}
msg, err := store.Messages.GetOne(principal.TenantID, topic, seq)
if err != nil {
return nil, false, err
}
if msg == nil {
return nil, false, types.ErrNotFound
}
if msg.RecalledAt != nil || msg.DeletedAt != nil {
return msg, false, types.ErrDuplicate
}
from := types.ParseUid(msg.From)
access := sub.ModeGiven & sub.ModeWant
authorized := from == principal.Uid || (allowAdmin && access.IsAdmin())
return msg, authorized, nil
}
func isIMEditableTextMessage(msg *types.Message) bool {
if msg == nil {
return false
}
if msg.Head == nil {
return true
}
msgType, _ := msg.Head["type"].(string)
return msgType == "" || msgType == "text"
}
func writeIMMessageMutationError(wrt http.ResponseWriter, req *http.Request, err error) {
switch {
case errors.Is(err, types.ErrNotFound):
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "message not found", nil)
case errors.Is(err, types.ErrDuplicate):
writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "message version conflict", nil)
case errors.Is(err, types.ErrPermissionDenied):
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "topic read permission required", nil)
case errors.Is(err, types.ErrMalformed):
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
default:
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
}
}
func messageMutationPayload(msg *types.Message, status string) map[string]any {
data := map[string]any{
"status": status,
"topic": msg.Topic,
"seq": msg.SeqId,
"version": msg.Version,
"updated_at": msg.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
if msg.RecalledAt != nil {
data["recalled_at"] = msg.RecalledAt.UTC().Format(time.RFC3339Nano)
data["recalled_by"] = msg.RecalledBy
}
if status == "edited" {
data["head"] = msg.Head
data["content"] = msg.Content
}
return data
}
func validIMRealName(name, idType, idNumber string) bool {
if len([]rune(name)) < 2 || len([]rune(name)) > 32 || idType != "cn_id" {
return false
}
return regexp.MustCompile(`^[0-9]{17}[0-9X]$`).MatchString(idNumber)
}
func protectIMSensitive(tenantID types.TenantID, value string) string {
return base64.RawStdEncoding.EncodeToString([]byte(hashIMSecret(tenantID, value, "sensitive")))
}
func maskIMName(name string) string {
runes := []rune(name)
if len(runes) <= 1 {
return name
}
return string(runes[0]) + "*"
}
func maskIMIDNumber(id string) string {
if len(id) <= 8 {
return id
}
return id[:3] + strings.Repeat("*", len(id)-7) + id[len(id)-4:]
}
func validIMIndependentPassword(password string) bool {
if !regexp.MustCompile(`^[0-9]{4}$`).MatchString(password) {
return false
}
if password[0] == password[1] && password[1] == password[2] && password[2] == password[3] {
return false
}
ascending, descending := true, true
for i := 1; i < len(password); i++ {
ascending = ascending && password[i] == password[i-1]+1
descending = descending && password[i]+1 == password[i-1]
}
return !ascending && !descending
}
func decodeIMJSONBody(wrt http.ResponseWriter, req *http.Request, dst any) bool {
if req.Body == nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return false
}
dec := json.NewDecoder(req.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return false
}
var extra any
if err := dec.Decode(&extra); err != nil && !errors.Is(err, io.EOF) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return false
} else if err == nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return false
}
return true
}
func resolveIMHTTPTenant(wrt http.ResponseWriter, req *http.Request, fallbackCode string) (*types.Tenant, bool) {
tenantCode := strings.TrimSpace(req.Header.Get("X-Tenant-Code"))
if tenantCode == "" {
tenantCode = strings.TrimSpace(fallbackCode)
}
if tenantCode == "" || len(tenantCode) > 64 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"})
return nil, false
}
if store.Tenants == nil {
logs.Err.Println("im http: tenant store is not initialized")
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return nil, false
}
tenant, err := store.Tenants.GetByCode(tenantCode)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusNotFound, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
return nil, false
}
logs.Warn.Println("im http: failed to resolve tenant", tenantCode, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return nil, false
}
if !tenant.IsActive() {
writeIMError(wrt, req, http.StatusForbidden, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
return nil, false
}
return tenant, true
}
func writeIMSuccess(wrt http.ResponseWriter, req *http.Request, data any) {
writeIMJSON(wrt, http.StatusOK, imHTTPResponse{
Code: 0,
Data: data,
RequestID: req.Header.Get("X-Request-ID"),
ServerTime: time.Now().UTC().Round(time.Millisecond).Format(time.RFC3339Nano),
})
}
func writeIMError(wrt http.ResponseWriter, req *http.Request, status, code int, reason, message string, details any) {
writeIMJSON(wrt, status, imHTTPResponse{
Code: code,
Reason: reason,
Message: message,
Details: details,
RequestID: req.Header.Get("X-Request-ID"),
ServerTime: time.Now().UTC().Round(time.Millisecond).Format(time.RFC3339Nano),
})
}
func writeIMJSON(wrt http.ResponseWriter, status int, resp imHTTPResponse) {
wrt.Header().Set("Content-Type", "application/json; charset=utf-8")
wrt.WriteHeader(status)
if err := json.NewEncoder(wrt).Encode(resp); err != nil {
logs.Warn.Println("im http: failed to write response", err)
}
}
func isAllowedVerificationScene(scene string) bool {
switch scene {
case "register", "login", "reset_password", "sensitive_action":
return true
default:
return false
}
}
func normalizeIMPhone(phone, country string) (string, error) {
if strings.TrimSpace(country) == "" {
country = "CN"
}
number, err := phonenumbers.Parse(strings.TrimSpace(phone), country)
if err != nil || !phonenumbers.IsValidNumber(number) {
return "", types.ErrMalformed
}
return phonenumbers.Format(number, phonenumbers.E164), nil
}
func maskIMPhone(phone string) string {
if len(phone) <= 7 {
return phone
}
return phone[:4] + "****" + phone[len(phone)-3:]
}
func randomIMNumericCode(length int) (string, error) {
max := big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(length)), nil)
n, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
code := fmt.Sprintf("%0*d", length, n.Int64())
return code, nil
}
func randomIMToken(size int) (string, error) {
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
func hashIMSecret(tenantID types.TenantID, parts ...string) string {
h := sha256.New()
h.Write(globals.apiKeySalt)
h.Write([]byte(fmt.Sprintf(":%d:", tenantID)))
for _, part := range parts {
h.Write([]byte{0})
h.Write([]byte(part))
}
return fmt.Sprintf("%x", h.Sum(nil))
}
func verificationChallengeIDFromPath(path string) string {
const marker = "/v1/im/auth/verification-codes/"
idx := strings.Index(path, marker)
if idx < 0 {
return ""
}
rest := strings.TrimPrefix(path[idx:], marker)
challengeID, suffix, ok := strings.Cut(rest, "/")
if !ok || suffix != "verify" {
return ""
}
return strings.TrimSpace(challengeID)
}
func passwordResetChallengeIDFromPath(path string) string {
const marker = "/v1/im/auth/password-reset/challenges/"
idx := strings.Index(path, marker)
if idx < 0 {
return ""
}
rest := strings.TrimPrefix(path[idx:], marker)
challengeID, suffix, ok := strings.Cut(rest, "/")
if !ok || suffix != "verify" {
return ""
}
return strings.TrimSpace(challengeID)
}
func securityChallengeIDFromPath(path string) string {
const marker = "/v1/im/security/challenges/"
idx := strings.Index(path, marker)
if idx < 0 {
return ""
}
rest := strings.TrimPrefix(path[idx:], marker)
challengeID, suffix, ok := strings.Cut(rest, "/")
if !ok || suffix != "verify" {
return ""
}
return strings.TrimSpace(challengeID)
}
func systemNotificationIDFromPath(path string) string {
const marker = "/v1/im/system-notifications/"
idx := strings.Index(path, marker)
if idx < 0 {
return ""
}
rest := strings.TrimPrefix(path[idx:], marker)
eventID, suffix, ok := strings.Cut(rest, "/")
if !ok || suffix != "read" {
return ""
}
return strings.TrimSpace(eventID)
}
func messageReceiptsPath(path string) (string, int, bool) {
const marker = "/v1/im/topics/"
idx := strings.Index(path, marker)
if idx < 0 {
return "", 0, false
}
rest := strings.TrimPrefix(path[idx:], marker)
topic, rest, ok := strings.Cut(rest, "/messages/")
if !ok || topic == "" {
return "", 0, false
}
seqValue, suffix, ok := strings.Cut(rest, "/")
if !ok || suffix != "receipts" {
return "", 0, false
}
seq, err := strconv.Atoi(seqValue)
if err != nil {
return topic, 0, true
}
return topic, seq, true
}
func messageActionPath(path, action string) (string, int, bool) {
const marker = "/v1/im/topics/"
idx := strings.Index(path, marker)
if idx < 0 {
return "", 0, false
}
rest := strings.TrimPrefix(path[idx:], marker)
topic, rest, ok := strings.Cut(rest, "/messages/")
if !ok || topic == "" {
return "", 0, false
}
seqValue, suffix, ok := strings.Cut(rest, "/")
if !ok || suffix != action {
return "", 0, false
}
seq, err := strconv.Atoi(seqValue)
if err != nil {
return topic, 0, true
}
return topic, seq, true
}