feat: add im auth verification flows

This commit is contained in:
richjohnrambo
2026-08-12 00:20:27 +08:00
parent 1ffcb85bee
commit d239e37f56
16 changed files with 2030 additions and 22 deletions
+671
View File
@@ -0,0 +1,671 @@
package main
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"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"
)
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"
imReasonInternal = "INTERNAL_ERROR"
)
const (
imVerificationCodeLength = 6
imVerificationExpiresInSeconds = 300
imVerificationResendAfterSeconds = 60
imVerificationMaxAttempts = 5
)
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"`
}
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 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 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)
}