Skip to content

Commit

Permalink
Consolidate timeouts into config (#1231)
Browse files Browse the repository at this point in the history
* Increase the allowed time for form filling to be 1 minute in the pow captcha flow, plus, allow for a custom verification time to be set

* Don't export if we don't need to

* Consolidate the various timeouts into the types package

* Add default for image captcha timeout

* Align terminology

* Add timeout options to config

* Add timeout for successful image challenge expired on-page

* Create sensible config for timeouts

* Update docs

* Question

* Set default timeouts

* Use correct type for parsed config

* Use correct type for parsed config

* Make timeouts inherit from each other. Use default when user did not supply

* Increase timeout for server checking PoW captchas

* remove comment

* Add readme and call timeouts object

* lint:fix

* Add more defaulting
  • Loading branch information
forgetso authored May 20, 2024
1 parent 751740c commit 79da166
Show file tree
Hide file tree
Showing 13 changed files with 291 additions and 97 deletions.
49 changes: 48 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,10 @@ const payload = JSON.parse(event.body)
// parse the procaptcha response, which is a JSON string
const procaptchaResponse = JSON.parse(payload[ApiParams.procaptchaResponse])

// send the
// initialise the `ProsopoServer` class
const prosopoServer = new ProsopoServer(config, pair)

// check if the captcha response is verified
if (await prosopoServer.isVerified(procaptchaResponse)) {
// perform CAPTCHA protected action
}
Expand All @@ -269,6 +272,50 @@ if (await prosopoServer.isVerified(procaptchaResponse)) {
There is an example TypeScript server side implementation
in [demos/client-example-server](https://github.com/prosopo/captcha/tree/main/demos/client-example-server).

#### Specifying timeouts

Custom timeouts can be specified for the length of time in which a user has to solve the CAPTCHA challenge. The defaults are as follows:

```typescript
const defaultCaptchaTimeouts = {
image: {
// The timeframe in which a user must complete an image captcha (1 minute)
challengeTimeout: 60000,
// The timeframe in which an image captcha solution remains valid on the page before timing out (2 minutes)
solutionTimeout: 60000 * 2,
// The timeframe in which an image captcha solution must be verified server side (3 minutes)
verifiedTimeout: 60000 * 3,
// The time in milliseconds that a cached, verified, image captcha solution is valid for (15 minutes)
cachedTimeout: 60000 * 15,
},
pow: {
// The timeframe in which a pow captcha solution remains valid on the page before timing out (1 minute)
challengeTimeout: 60000,
// The timeframe in which a pow captcha must be completed and verified (2 minutes)
solutionTimeout: 60000 * 2,
// The time in milliseconds that a Provider cached, verified, pow captcha solution is valid for (3 minutes)
cachedTimeout: 60000 * 3,
},
}
```

To specify timeouts using API verification, pass the above object in a field called `timeouts`, implementing one or more of the timeouts.

```typescript
// send a POST application/json request to the API endpoint
response = POST('https://api.prosopo.io/siteverify', {
...
timeouts: defaultCaptchaTimeouts, // add timeouts object here
})
```

To specify timeouts using the verification package, pass the above object in the `timeouts` field of the `ProsopoServer` config, implementing one or more of the timeouts.

```typescript
config = { timeouts: defaultCaptchaTimeouts, ...config }
const prosopoServer = new ProsopoServer(config, pair)
```

## Rendering different CAPTCHA types with Procaptcha

### Frictionless CAPTCHA
Expand Down
24 changes: 18 additions & 6 deletions packages/api/src/api/ProviderApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ import {
NetworkConfig,
PowCaptchaSolutionResponse,
ProviderRegistered,
ServerPowCaptchaVerifyRequestBodyType,
StoredEvents,
SubmitPowCaptchaSolutionBodyType,
SubmitPowCaptchaSolutionBody,
VerificationResponse,
VerifySolutionBodyType,
} from '@prosopo/types'
Expand Down Expand Up @@ -110,10 +111,11 @@ export default class ProviderApi extends HttpClientBase implements ProviderApi {
userAccount: AccountId,
dappAccount: AccountId,
randomProvider: RandomProvider,
nonce: number
nonce: number,
timeout?: number
): Promise<PowCaptchaSolutionResponse> {
const { blockNumber } = randomProvider
const body: SubmitPowCaptchaSolutionBodyType = {
const body = SubmitPowCaptchaSolutionBody.parse({
[ApiParams.blockNumber]: blockNumber,
[ApiParams.challenge]: challenge.challenge,
[ApiParams.difficulty]: challenge.difficulty,
Expand All @@ -122,7 +124,8 @@ export default class ProviderApi extends HttpClientBase implements ProviderApi {
[ApiParams.user]: userAccount.toString(),
[ApiParams.dapp]: dappAccount.toString(),
[ApiParams.nonce]: nonce,
}
[ApiParams.verifiedTimeout]: timeout,
})
return this.post(ApiPaths.SubmitPowCaptchaSolution, body)
}

Expand All @@ -138,7 +141,16 @@ export default class ProviderApi extends HttpClientBase implements ProviderApi {
return this.fetch(ApiPaths.GetProviderDetails)
}

public submitPowCaptchaVerify(challenge: string, dapp: string): Promise<VerificationResponse> {
return this.post(ApiPaths.ServerPowCaptchaVerify, { [ApiParams.challenge]: challenge, [ApiParams.dapp]: dapp })
public submitPowCaptchaVerify(
challenge: string,
dapp: string,
recencyLimit: number
): Promise<VerificationResponse> {
const body: ServerPowCaptchaVerifyRequestBodyType = {
[ApiParams.challenge]: challenge,
[ApiParams.dapp]: dapp,
[ApiParams.verifiedTimeout]: recencyLimit,
}
return this.post(ApiPaths.ServerPowCaptchaVerify, body)
}
}
2 changes: 2 additions & 0 deletions packages/common/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@
"PAYMENT_INFO_NOT_FOUND": "Payment info not found for given block and transaction hashes",
"USER_VERIFIED": "User verified",
"USER_NOT_VERIFIED": "User not verified",
"USER_NOT_VERIFIED_TIME_EXPIRED": "User not verified. Captcha solution has expired.",
"USER_NOT_VERIFIED_NO_SOLUTION": "User not verified. No captcha solution found.",
"UNKNOWN": "Unknown API error"
},
"CLI": {
Expand Down
17 changes: 17 additions & 0 deletions packages/contract/src/contract/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,20 @@ export const getBlockTimeMs = (api: ApiPromise): number => {
export const getCurrentBlockNumber = async (api: ApiPromise): Promise<number> => {
return (await api.rpc.chain.getBlock()).block.header.number.toNumber()
}

/**
* Verify the time since the blockNumber is equal to or less than the maxVerifiedTime.
* @param api
* @param maxVerifiedTime
* @param blockNumber
*/
export const verifyRecency = async (api: ApiPromise, blockNumber: number, maxVerifiedTime: number) => {
// Get the current block number
const currentBlock = await getCurrentBlockNumber(api)
// Calculate how many blocks have passed since the blockNumber
const blocksPassed = currentBlock - blockNumber
// Get the expected block time
const blockTime = getBlockTimeMs(api)
// Check if the time since the last correct captcha is within the limit
return blockTime * blocksPassed <= maxVerifiedTime
}
17 changes: 13 additions & 4 deletions packages/procaptcha-bundle/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
FeaturesEnum,
NetworkNamesSchema,
ProcaptchaClientConfigInput,
ProcaptchaClientConfigOutput,
ProcaptchaConfigSchema,
ProcaptchaOutput,
} from '@prosopo/types'
Expand Down Expand Up @@ -56,7 +57,7 @@ const extractParams = (name: string) => {
return { onloadUrlCallback: undefined, renderExplicit: undefined }
}

const getConfig = (siteKey?: string) => {
const getConfig = (siteKey?: string): ProcaptchaClientConfigOutput => {
if (!siteKey) {
siteKey = process.env.PROSOPO_SITE_KEY || ''
}
Expand Down Expand Up @@ -106,15 +107,23 @@ const customThemeSet = new Set(['light', 'dark'])
const validateTheme = (themeAttribute: string): 'light' | 'dark' =>
customThemeSet.has(themeAttribute) ? (themeAttribute as 'light' | 'dark') : 'light'

/**
* Set the timeout for a solved captcha, after which point the captcha will be considered invalid and the captcha widget
* will re-render. The same value is used for PoW and image captcha.
* @param renderOptions
* @param element
* @param config
*/
const setValidChallengeLength = (
renderOptions: ProcaptchaRenderOptions | undefined,
element: Element,
config: ProcaptchaClientConfigInput
config: ProcaptchaClientConfigOutput
) => {
const challengeValidLengthAttribute =
renderOptions?.['challenge-valid-length'] || element.getAttribute('data-challenge-valid-length')
if (challengeValidLengthAttribute) {
config.challengeValidLength = parseInt(challengeValidLengthAttribute)
config.captchas.image.solutionTimeout = parseInt(challengeValidLengthAttribute)
config.captchas.pow.solutionTimeout = parseInt(challengeValidLengthAttribute)
}
}

Expand Down Expand Up @@ -223,7 +232,7 @@ function setUserCallbacks(

const renderLogic = (
elements: Element[],
config: ProcaptchaClientConfigInput,
config: ProcaptchaClientConfigOutput,
renderOptions?: ProcaptchaRenderOptions
) => {
elements.forEach((element) => {
Expand Down
28 changes: 18 additions & 10 deletions packages/procaptcha-pow/src/Services/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { ApiPromise } from '@polkadot/api/promise/Api'
import { ExtensionWeb2 } from '@prosopo/account'
import { Keyring } from '@polkadot/keyring'
import { ProsopoCaptchaContract, wrapQuery } from '@prosopo/contract'
import { ProsopoContractError, ProsopoEnvError, trimProviderUrl } from '@prosopo/common'
import { ProsopoEnvError, trimProviderUrl } from '@prosopo/common'
import { ProviderApi } from '@prosopo/api'
import { RandomProvider } from '@prosopo/captcha-contract/types-returns'
import { WsProvider } from '@polkadot/rpc-provider/ws'
Expand All @@ -40,6 +40,8 @@ export const Manager = (
onStateUpdate: ProcaptchaStateUpdateFn,
callbacks: ProcaptchaCallbacks
) => {
const events = getDefaultEvents(onStateUpdate, state, callbacks)

const defaultState = (): Partial<ProcaptchaState> => {
return {
// note order matters! see buildUpdateState. These fields are set in order, so disable modal first, then set loading to false, etc.
Expand Down Expand Up @@ -120,14 +122,6 @@ export const Manager = (
return dappAccount
}

const getBlockNumber = () => {
if (!state.blockNumber) {
throw new ProsopoContractError('CAPTCHA.INVALID_BLOCK_NO', { context: { error: 'Block number not found' } })
}
const blockNumber: number = state.blockNumber
return blockNumber
}

// get the state update mechanism
const updateState = buildUpdateState(state, onStateUpdate)

Expand All @@ -137,6 +131,18 @@ export const Manager = (
updateState(defaultState())
}

const setValidChallengeTimeout = () => {
const timeMillis: number = getConfig().captchas.pow.solutionTimeout
const successfullChallengeTimeout = setTimeout(() => {
// Human state expired, disallow user's claim to be human
updateState({ isHuman: false })

events.onExpired()
}, timeMillis)

updateState({ successfullChallengeTimeout })
}

const start = async () => {
if (state.loading) {
return
Expand Down Expand Up @@ -200,7 +206,8 @@ export const Manager = (
getAccount().account.address,
getDappAccount(),
getRandomProviderResponse,
solution
solution,
config.captchas.pow.verifiedTimeout
)
if (verifiedSolution[ApiParams.verified]) {
updateState({
Expand All @@ -214,6 +221,7 @@ export const Manager = (
[ApiParams.challenge]: challenge.challenge,
[ApiParams.blockNumber]: getRandomProviderResponse.blockNumber,
})
setValidChallengeTimeout()
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/procaptcha-react/src/components/ProcaptchaWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,15 @@ import {
} from '@prosopo/web-components'
import { Logo } from '@prosopo/web-components'
import { Manager } from '@prosopo/procaptcha'
import { ProcaptchaProps } from '@prosopo/types'
import { ProcaptchaConfigSchema, ProcaptchaProps } from '@prosopo/types'
import { useProcaptcha } from '@prosopo/procaptcha-common'
import { useRef, useState } from 'react'
import CaptchaComponent from './CaptchaComponent.js'
import Collector from './collector.js'
import Modal from './Modal.js'

const ProcaptchaWidget = (props: ProcaptchaProps) => {
const config = props.config
const config = ProcaptchaConfigSchema.parse(props.config)
const callbacks = props.callbacks || {}
const [state, updateState] = useProcaptcha(useState, useRef)
const manager = Manager(config, state, updateState, callbacks)
Expand Down
12 changes: 7 additions & 5 deletions packages/procaptcha/src/modules/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ const getNetwork = (config: ProcaptchaClientConfigOutput) => {
* The state operator. This is used to mutate the state of Procaptcha during the captcha process. State updates are published via the onStateUpdate callback. This should be used by frontends, e.g. react, to maintain the state of Procaptcha across renders.
*/
export function Manager(
configOptional: ProcaptchaClientConfigInput,
configOptional: ProcaptchaClientConfigOutput,
state: ProcaptchaState,
onStateUpdate: ProcaptchaStateUpdateFn,
callbacks: ProcaptchaCallbacks
Expand Down Expand Up @@ -192,7 +192,7 @@ export function Manager(
account.account.address,
procaptchaStorage.blockNumber,
undefined,
configOptional.challengeValidLength
configOptional.captchas.image.cachedTimeout
)
if (verifyDappUserResponse.verified) {
updateState({ isHuman: true, loading: false })
Expand Down Expand Up @@ -234,9 +234,11 @@ export function Manager(
throw new ProsopoApiError('DEVELOPER.PROVIDER_NO_CAPTCHA')
}

// setup timeout
// setup timeout, taking the timeout from the individual captcha or the global default
const timeMillis: number = challenge.captchas
.map((captcha: CaptchaWithProof) => captcha.captcha.timeLimitMs || 30 * 1000)
.map(
(captcha: CaptchaWithProof) => captcha.captcha.timeLimitMs || config.captchas.image.challengeTimeout
)
.reduce((a: number, b: number) => a + b)
const timeout = setTimeout(() => {
events.onChallengeExpired()
Expand Down Expand Up @@ -431,7 +433,7 @@ export function Manager(
}

const setValidChallengeTimeout = () => {
const timeMillis: number = configOptional.challengeValidLength || 120 * 1000 // default to 2 minutes
const timeMillis: number = configOptional.captchas.image.solutionTimeout
const successfullChallengeTimeout = setTimeout(() => {
// Human state expired, disallow user's claim to be human
updateState({ isHuman: false })
Expand Down
Loading

0 comments on commit 79da166

Please sign in to comment.