Skip to content

Commit

Permalink
Fix the cloudflare turnstile token
Browse files Browse the repository at this point in the history
  • Loading branch information
AchoArnold committed Jan 24, 2025
1 parent 92b6822 commit b31d260
Show file tree
Hide file tree
Showing 11 changed files with 203 additions and 38 deletions.
12 changes: 12 additions & 0 deletions api/pkg/di/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,18 @@ func (container *Container) MessageHandlerValidator() (validator *validators.Mes
container.Logger(),
container.Tracer(),
container.PhoneService(),
container.TurnstileTokenValidator(),
)
}

// TurnstileTokenValidator creates a new instance of validators.TurnstileTokenValidator
func (container *Container) TurnstileTokenValidator() (validator *validators.TurnstileTokenValidator) {
container.logger.Debug(fmt.Sprintf("creating %T", validator))
return validators.NewTurnstileTokenValidator(
container.Logger(),
container.Tracer(),
os.Getenv("CLOUDFLARE_TURNSTILE_SECRET_KEY"),
container.HTTPClient("turnstile"),
)
}

Expand Down
4 changes: 4 additions & 0 deletions api/pkg/handlers/message_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,7 @@ func (h *MessageHandler) PostCallMissed(c *fiber.Ctx) error {
// @Tags Messages
// @Accept json
// @Produce json
// @Param token header string true "Cloudflare turnstile token https://www.cloudflare.com/en-gb/application-services/products/turnstile/"
// @Param owners query string true "the owner's phone numbers" default(+18005550199,+18005550100)
// @Param skip query int false "number of messages to skip" minimum(0)
// @Param query query string false "filter messages containing query"
Expand All @@ -492,6 +493,9 @@ func (h *MessageHandler) Search(c *fiber.Ctx) error {
return h.responseBadRequest(c, err)
}

request.IPAddress = c.IP()
request.Token = c.Get("token")

if errors := h.validator.ValidateMessageSearch(ctx, request.Sanitize()); len(errors) != 0 {
msg := fmt.Sprintf("validation errors [%s], while searching messages [%+#v]", spew.Sdump(errors), request)
ctxLogger.Warn(stacktrace.NewError(msg))
Expand Down
3 changes: 3 additions & 0 deletions api/pkg/requests/message_search_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ type MessageSearch struct {
SortBy string `json:"sort_by" query:"sort_by"`
SortDescending bool `json:"sort_descending" query:"sort_descending"`
Limit string `json:"limit" query:"limit"`

IPAddress string `json:"ip_address" swaggerignore:"true"`
Token string `json:"token" swaggerignore:"true"`
}

// Sanitize sets defaults to MessageSearch
Expand Down
29 changes: 21 additions & 8 deletions api/pkg/validators/message_handler_validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,24 @@ import (
// MessageHandlerValidator validates models used in handlers.MessageHandler
type MessageHandlerValidator struct {
validator
logger telemetry.Logger
tracer telemetry.Tracer
phoneService *services.PhoneService
logger telemetry.Logger
tracer telemetry.Tracer
phoneService *services.PhoneService
tokenValidator *TurnstileTokenValidator
}

// NewMessageHandlerValidator creates a new handlers.MessageHandler validator
func NewMessageHandlerValidator(
logger telemetry.Logger,
tracer telemetry.Tracer,
phoneService *services.PhoneService,
tokenValidator *TurnstileTokenValidator,
) (v *MessageHandlerValidator) {
return &MessageHandlerValidator{
logger: logger.WithService(fmt.Sprintf("%T", v)),
tracer: tracer,
phoneService: phoneService,
logger: logger.WithService(fmt.Sprintf("%T", v)),
tracer: tracer,
phoneService: phoneService,
tokenValidator: tokenValidator,
}
}

Expand Down Expand Up @@ -208,7 +211,7 @@ func (validator MessageHandlerValidator) ValidateMessageIndex(_ context.Context,
}

// ValidateMessageSearch validates the requests.MessageSearch request
func (validator MessageHandlerValidator) ValidateMessageSearch(_ context.Context, request requests.MessageSearch) url.Values {
func (validator MessageHandlerValidator) ValidateMessageSearch(ctx context.Context, request requests.MessageSearch) url.Values {
v := govalidator.New(govalidator.Options{
Data: &request,
Rules: govalidator.MapData{
Expand Down Expand Up @@ -257,7 +260,17 @@ func (validator MessageHandlerValidator) ValidateMessageSearch(_ context.Context
},
},
})
return v.ValidateStruct()

errors := v.ValidateStruct()
if len(errors) > 0 {
return errors
}

if !validator.tokenValidator.ValidateToken(ctx, request.IPAddress, request.Token) {
errors.Add("token", "The captcha token from turnstile is invalid")
}

return errors
}

// ValidateMessageEvent validates the requests.MessageEvent request
Expand Down
91 changes: 91 additions & 0 deletions api/pkg/validators/turnstile_token_validator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package validators

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"

"github.com/NdoleStudio/httpsms/pkg/telemetry"
"github.com/palantir/stacktrace"
)

// TurnstileTokenValidator validates the token used to validate captchas from cloudflare
type TurnstileTokenValidator struct {
logger telemetry.Logger
tracer telemetry.Tracer
secretKey string
httpClient *http.Client
}

type turnstileVerifyResponse struct {
Success bool `json:"success"`
ChallengeTs time.Time `json:"challenge_ts"`
Hostname string `json:"hostname"`
ErrorCodes []any `json:"error-codes"`
Action string `json:"action"`
Cdata string `json:"cdata"`
Metadata struct {
EphemeralID string `json:"ephemeral_id"`
} `json:"metadata"`
}

// NewTurnstileTokenValidator creates a new TurnstileTokenValidator
func NewTurnstileTokenValidator(logger telemetry.Logger, tracer telemetry.Tracer, secretKey string, httpClient *http.Client) *TurnstileTokenValidator {
return &TurnstileTokenValidator{
logger.WithService(fmt.Sprintf("%T", &TurnstileTokenValidator{})),
tracer,
secretKey,
httpClient,
}
}

// ValidateToken validates the cloudflare turnstile token
// https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
func (v *TurnstileTokenValidator) ValidateToken(ctx context.Context, ipAddress, token string) bool {
ctx, span, ctxLogger := v.tracer.StartWithLogger(ctx, v.logger)
defer span.End()

payload, err := json.Marshal(map[string]string{
"secret": v.secretKey,
"response": token,
"remoteip": ipAddress,
})
if err != nil {
ctxLogger.Error(stacktrace.Propagate(err, "failed to marshal payload"))
return false
}

request, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://challenges.cloudflare.com/turnstile/v0/siteverify", bytes.NewBuffer(payload))
if err != nil {
ctxLogger.Error(stacktrace.Propagate(err, "failed to create http request request"))
return false
}

request.Header.Set("Content-Type", "application/json")
response, err := v.httpClient.Do(request)
if err != nil {
ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("failed to send http request to [%s]", request.URL.String())))
return false
}
defer response.Body.Close()

body, err := io.ReadAll(response.Body)
if err != nil {
ctxLogger.Error(stacktrace.Propagate(err, "failed to read response body from cloudflare turnstile"))
return false
}

ctxLogger.Info(fmt.Sprintf("successfully validated token with cloudflare with response [%s]", body))

data := new(turnstileVerifyResponse)
if err = json.Unmarshal(body, data); err != nil {
ctxLogger.Error(stacktrace.Propagate(err, "failed to unmarshal response from cloudflare turnstile"))
return false
}

return data.Success
}
2 changes: 2 additions & 0 deletions web/.env.production
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,5 @@ FIREBASE_STORAGE_BUCKET=httpsms-86c51.appspot.com
FIREBASE_MESSAGING_SENDER_ID=877524083399
FIREBASE_APP_ID=1:877524083399:web:430d6a29a0d808946514e2
FIREBASE_MEASUREMENT_ID=G-EZ5W9DVK8T

CLOUDFLARE_TURNSTILE_SITE_KEY=0x4AAAAAAA6Hpp8SDyMMPhWg
1 change: 0 additions & 1 deletion web/layouts/default.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
class="feedback-btn"
href="https://httpsms.featurebase.app"
color="#82a865"
flat
large
>
<v-icon left>{{ mdiBullhorn }}</v-icon>
Expand Down
1 change: 1 addition & 0 deletions web/models/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface SearchMessagesRequest {
statuses: string[]
query: string
sort_by: string
token?: string
sort_descending: boolean
skip: number
limit: number
Expand Down
5 changes: 5 additions & 0 deletions web/nuxt.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export default {
async: true,
defer: true,
},
{
hid: 'cloudflare',
src: 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit',
},
],
meta: [
{ charset: 'utf-8' },
Expand Down Expand Up @@ -150,6 +154,7 @@ export default {
publicRuntimeConfig: {
checkoutURL: process.env.CHECKOUT_URL,
enterpriseCheckoutURL: process.env.ENTERPRISE_CHECKOUT_URL,
cloudflareTurnstileSiteKey: process.env.CLOUDFLARE_TURNSTILE_SITE_KEY,
},

// Build Configuration: https://go.nuxtjs.dev/config-build
Expand Down
90 changes: 61 additions & 29 deletions web/pages/search-messages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
></v-text-field>
</v-col>
<v-col cols="4">
<div id="cloudflare-turnstile" class="d-none"></div>
<v-btn
:loading="loading"
:disabled="loading"
Expand Down Expand Up @@ -294,6 +295,18 @@ import {
import { formatPhoneNumber } from '~/plugins/filters'
import { SearchMessagesRequest } from '~/models/message'
interface Turnstile {
ready(callback: () => void): void
render(
container: string | HTMLElement,
params?: {
sitekey: string
callback?: (token: string) => void
'error-callback'?: ((error: string) => void) | undefined
},
): string | null | undefined
}
export default Vue.extend({
name: 'SearchMessagesIndex',
middleware: ['auth'],
Expand Down Expand Up @@ -386,6 +399,22 @@ export default Vue.extend({
},
methods: {
getCaptcha(): Promise<string> {
return new Promise<string>((resolve, reject) => {
const turnstile = (window as any).turnstile as Turnstile
turnstile.ready(() => {
turnstile.render('#cloudflare-turnstile', {
sitekey: this.$config.cloudflareTurnstileSiteKey,
callback: (token) => {
resolve(token)
},
'error-callback': (error: string) => {
reject(error)
},
})
})
})
},
exportMessages() {
let csvContent = 'data:text/csv;charset=utf-8,'
csvContent +=
Expand Down Expand Up @@ -456,35 +485,38 @@ export default Vue.extend({
this.options.page = 1
}
this.$store
.dispatch('searchMessages', {
owners: this.formOwners,
types: this.formTypes,
statuses: this.formStatuses,
query: this.formQuery,
sort_by: this.options.sortBy[0],
sort_descending: this.options.sortDesc[0],
skip: (this.options.page - 1) * this.options.itemsPerPage,
limit: this.options.itemsPerPage,
} as SearchMessagesRequest)
.then((messages: EntitiesMessage[]) => {
this.messages = messages
this.totalMessages =
(this.options.page - 1) * this.options.itemsPerPage +
messages.length
if (messages.length === this.options.itemsPerPage) {
this.totalMessages = this.totalMessages + 1
}
})
.catch((error: AxiosError<ResponsesUnprocessableEntity>) => {
this.errorTitle = capitalize(
error.response?.data?.message ?? 'Error while searching messages',
)
this.errorMessages = getErrorMessages(error)
})
.finally(() => {
this.loading = false
})
this.getCaptcha().then((token: string) => {
this.$store
.dispatch('searchMessages', {
token,
owners: this.formOwners,
types: this.formTypes,
statuses: this.formStatuses,
query: this.formQuery,
sort_by: this.options.sortBy[0],
sort_descending: this.options.sortDesc[0],
skip: (this.options.page - 1) * this.options.itemsPerPage,
limit: this.options.itemsPerPage,
} as SearchMessagesRequest)
.then((messages: EntitiesMessage[]) => {
this.messages = messages
this.totalMessages =
(this.options.page - 1) * this.options.itemsPerPage +
messages.length
if (messages.length === this.options.itemsPerPage) {
this.totalMessages = this.totalMessages + 1
}
})
.catch((error: AxiosError<ResponsesUnprocessableEntity>) => {
this.errorTitle = capitalize(
error.response?.data?.message ?? 'Error while searching messages',
)
this.errorMessages = getErrorMessages(error)
})
.finally(() => {
this.loading = false
})
})
},
},
})
Expand Down
3 changes: 3 additions & 0 deletions web/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,10 +518,13 @@ export const actions = {
_: ActionContext<State, State>,
payload: SearchMessagesRequest,
) {
const token = payload.token
delete payload.token
return new Promise<EntitiesMessage[]>((resolve, reject) => {
axios
.get<ResponsesMessagesResponse>(`/v1/messages/search`, {
params: payload,
headers: { token },
})
.then((response: AxiosResponse<ResponsesMessagesResponse>) => {
resolve(response.data.data)
Expand Down

0 comments on commit b31d260

Please sign in to comment.