feat: add im auth verification flows
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user