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) }