672 lines
24 KiB
Go
672 lines
24 KiB
Go
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)
|
|
}
|