From 415f32f857025998de4c18ce5862d2ae8768432c Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Fri, 23 Sep 2022 12:09:41 +0100 Subject: [PATCH] docs: bring readme up to date closes 48 --- README.md | 53 +++++++++++----------- src/HTMLDocumentProtector.php | 2 +- src/SessionTokenStore.php | 9 ++-- src/TokenStore.php | 2 +- test/phpunit/HTMLDocumentProtectorTest.php | 32 ++++++------- test/phpunit/TokenStoreTest.php | 24 +++++----- 6 files changed, 60 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 96019c3..a7bb6b5 100644 --- a/README.md +++ b/README.md @@ -31,38 +31,36 @@ The CSRF library does two things: Each is just a single method call, but you need to set up first. -### Step 1: Set Up +### Step 1: Set up -Start by creating the TokenStore. There is currently a single implementation — the `ArrayTokenStore`. Because the `ArrayTokenStore` is not persistent, you need to save it between page requests so that tokens generated for one page request can be checked on another. The easiest way to save it is to put it on the `Session`: +Start by creating the TokenStore. There are currently two implementations — the `ArrayTokenStore` and `SessionTokenStore`. The `ArrayTokenStore` is the most basic and does not persist in any way, but can be extended into custom integrations. The `SessionTokenStore` is an inbuilt implementation that persists tokens between requests, so that tokens generated for one page request can be checked on another. The easiest way to add CSRF protection is to use the Session: ```php -use Gt\Csrf\ArrayTokenStore; +use Gt\Csrf\SessionTokenStore; -// check to see if there's already a token store for this session, and -// create one if not -if(!$session->contains("gt.csrf")) { - $session->set("gt.csrf", new ArrayTokenStore()); -} - -$tokenStore = $session->get("gt.csrf"); +// $session is an object-oriented representation of $_SESSION +// that implements the Gt\Session\SessionContainer Interface. +$tokenStore = new SessionTokenStore($session); ``` ### Step 2: Verify -Before running any other code (especially things that could affect data), you should check to make sure that there's a valid CSRF token in place if it's needed. That step is also very straightforward: +Before running any other code (especially things that could affect data), you should check to make sure that there's a valid CSRF token in place if it's needed: ```php use Gt\Csrf\Exception\CSRFException; -try { - $tokenStore->processAndVerify(); -} -catch(CSRFException $e) { +if(this_is_a_post_request()) { + try { + $tokenStore->verify(); + } + catch(CSRFException $e) { // Stop processing this request and get out of there! + } } ``` -If the request contains a POST and there is no valid CSRF token, a `CSRFException` will be thrown — so you should plan to catch it. Remember, if that happens, the request was fraudulent so you shouldn't process it! +If the request contains a POST and there is no valid CSRF token, a `CSRFException` will be thrown — so you should plan to catch it. Remember, if that happens, the request was fraudulent, so you shouldn't process it! ### Step 3: Inject for Next Time @@ -76,28 +74,29 @@ use Gt\Csrf\HTMLDocumentProtector; $html = "..."; // Now do the processing. -$page = new HTMLDocumentProtector($html, $tokenStore); -$page->protectAndInject(); +$protector = new HTMLDocumentProtector($html, $tokenStore); +$protector->protect(); -// You can get it back out however you wish. -echo $page->getHTMLDocument()->saveHTML(); +// Output the HTML of the document - you will see the new fields have +// been automatically injected. +echo $protector->getHTMLDocument(); ``` -Using Tokens of a Different Length +Using tokens of a fifferent length ---------------------------------- By default, 32 character tokens are generated. They use characters from the set [a-zA-Z0-9], meaning a 64-bit token which would take a brute-force attacker making 100,000 requests per second around 2.93 million years to guess. If this seems either excessive or inadequate you can change the token length using `TokenStore::setTokenLength()`. -Special Note About AJAX Clients -------------------------------- +Special note about client-side requests +--------------------------------------- -Note that if there are several forms on your page, a unique token will be generated and injected into each form. When a form is submitted using AJAX, the response will contain a new token that must be refreshed in the page ready for the next submission. +Note that if there are several forms on your page, a unique token will be generated and injected into each form. When a form is submitted using a client-side request (XMLHTTPRequest or Fetch, a.k.a. AJAX), the response will contain a new token that must be refreshed in the page ready for the next submission. If you would prefer to have one token per page, shared across all forms, this can be configured by passing in the TOKEN_PER_PAGE parameter to the projectAndInject method: `$page->protectAndInject(HTMLDocumentProtector::TOKEN_PER_PAGE);`. -Storing one token per page will reduce the amount of server resources required, but concurrent AJAX requests will fail which is why one token per form is the default. +Storing one token per page will reduce the amount of server resources required, but concurrent client-side requests will fail, which is why one token per form is the default. -Alternatives to Storing Tokens on the Session +Alternatives to storing tokens on the session --------------------------------------------- -The package includes an `ArrayTokenStore`, which can be stored on the session. You can implement alternative token stores such as a RDBMS or Mongo by subclassing `TokenStore` and implementing the abstract methods. +The package includes an `ArrayTokenStore`, which can be stored on the session. You can implement alternative token stores such as a RDBMS or NoSQL by subclassing `TokenStore` and implementing the abstract methods. diff --git a/src/HTMLDocumentProtector.php b/src/HTMLDocumentProtector.php index 8e39827..4e8ca9d 100644 --- a/src/HTMLDocumentProtector.php +++ b/src/HTMLDocumentProtector.php @@ -48,7 +48,7 @@ public function __construct( * generating unique tokens. In most cases this default is suitable - wherever the normal * model of returning a new page in response to a form submit is used. */ - public function protectAndInject( + public function protect( string $tokenSharing = self::ONE_TOKEN_PER_PAGE ):string { $forms = $this->document->forms; diff --git a/src/SessionTokenStore.php b/src/SessionTokenStore.php index f764248..b41ff40 100644 --- a/src/SessionTokenStore.php +++ b/src/SessionTokenStore.php @@ -3,16 +3,15 @@ use Gt\Csrf\Exception\CsrfTokenInvalidException; use Gt\Csrf\Exception\CsrfTokenSpentException; -use Gt\Session\SessionStore; +use Gt\Session\SessionContainer; class SessionTokenStore extends TokenStore { const SESSION_KEY = "tokenList"; - /** @var SessionStore */ - protected $session; + protected SessionContainer $session; public function __construct( - SessionStore $session, + SessionContainer $session, int $maxTokens = null ) { $this->session = $session; @@ -51,4 +50,4 @@ public function consumeToken(string $token):void { $tokenList[$token] = time(); $this->session->set(self::SESSION_KEY, $tokenList); } -} \ No newline at end of file +} diff --git a/src/TokenStore.php b/src/TokenStore.php index 4d933f1..022ee96 100644 --- a/src/TokenStore.php +++ b/src/TokenStore.php @@ -62,7 +62,7 @@ public function generateNewToken():string { * $_POST but it has already been consumed by a previous request. * @see TokenStore::verifyToken(). */ - public function processAndVerify(array|object $postData):void { + public function verify(array|object $postData):void { // Expect the token to be present on ALL post requests. if(!is_array($postData) && is_callable([$postData, "asArray"])) { diff --git a/test/phpunit/HTMLDocumentProtectorTest.php b/test/phpunit/HTMLDocumentProtectorTest.php index baa2b95..22c51f3 100644 --- a/test/phpunit/HTMLDocumentProtectorTest.php +++ b/test/phpunit/HTMLDocumentProtectorTest.php @@ -114,12 +114,12 @@ public function testConstruct_fromDomDocument():void { ); } - public function testProtectAndInject_zeroForms():void { + public function testProtect_zeroForms():void { $sut = new HTMLDocumentProtector( new HTMLDocument(self::NO_FORMS), new ArrayTokenStore() ); - $sut->protectAndInject(); + $sut->protect(); $document = $sut->getHTMLDocument(); $nodeList = $document->querySelectorAll( @@ -133,12 +133,12 @@ public function testProtectAndInject_zeroForms():void { self::assertNull($document->querySelector("form")); } - public function testProtectAndInject_singleForm():void { + public function testProtect_singleForm():void { $sut = new HTMLDocumentProtector( new HTMLDocument(self::ONE_FORM), new ArrayTokenStore() ); - $sut->protectAndInject(); + $sut->protect(); // check that the token has been injected $document = $sut->getHTMLDocument(); @@ -162,12 +162,12 @@ public function testProtectAndInject_singleForm():void { ); } - public function testProtectAndInject_multipleForms():void { + public function testProtect_multipleForms():void { $sut = new HTMLDocumentProtector( new HTMLDocument(self::THREE_FORMS), new ArrayTokenStore() ); - $sut->protectAndInject(); + $sut->protect(); // check that the token has been injected in all POST forms (not GET) $document = $sut->getHTMLDocument(); @@ -184,12 +184,12 @@ public function testProtectAndInject_multipleForms():void { ); } - public function testProtectAndInject_singleCodeSharedAcrossForms():void { + public function testProtect_singleCodeSharedAcrossForms():void { $sut = new HTMLDocumentProtector( new HTMLDocument(self::THREE_FORMS), new ArrayTokenStore() ); - $sut->protectAndInject(); + $sut->protect(); $document = $sut->getHTMLDocument(); $token = null; @@ -206,12 +206,12 @@ public function testProtectAndInject_singleCodeSharedAcrossForms():void { self::assertEquals($token, $metaTag->content); } - public function testProtectAndInject_uniqueCodePerForm():void { + public function testProtect_uniqueCodePerForm():void { $sut = new HTMLDocumentProtector( new HTMLDocument(self::THREE_FORMS), new ArrayTokenStore() ); - $sut->protectAndInject(HTMLDocumentProtector::ONE_TOKEN_PER_FORM); + $sut->protect(HTMLDocumentProtector::ONE_TOKEN_PER_FORM); $document = $sut->getHTMLDocument(); $metaTag = $document->querySelector("head meta[name='" . HTMLDocumentProtector::TOKEN_NAME . "']"); @@ -226,12 +226,12 @@ public function testProtectAndInject_uniqueCodePerForm():void { } } - public function testProtectAndInject_metaTagNoHead():void { + public function testProtect_metaTagNoHead():void { $sut = new HTMLDocumentProtector( new HTMLDocument(self::NO_HEAD), new ArrayTokenStore() ); - $sut->protectAndInject(); + $sut->protect(); $document = $sut->getHTMLDocument(); $metaTag = $document->querySelector("head meta[name='" . HTMLDocumentProtector::TOKEN_NAME . "']"); @@ -239,12 +239,12 @@ public function testProtectAndInject_metaTagNoHead():void { self::assertNotEmpty($metaTag->content); } - public function testProtectAndInject_metaTagAlreadyExists():void { + public function testProtect_metaTagAlreadyExists():void { $document = new HTMLDocument(self::HAS_META_ALREADY); $metaTag = $document->querySelector("head meta[name='" . HTMLDocumentProtector::TOKEN_NAME . "']"); $originalValue = $metaTag->content; $sut = new HTMLDocumentProtector($document, new ArrayTokenStore()); - $sut->protectAndInject(); + $sut->protect(); $metaTag = $document->querySelector("head meta[name='" . HTMLDocumentProtector::TOKEN_NAME . "']"); self::assertNotNull($metaTag); @@ -253,10 +253,10 @@ public function testProtectAndInject_metaTagAlreadyExists():void { self::assertNotEquals($originalValue, $metaTag->content); } - public function testProtectAndInject_differentTokenName() { + public function testProtect_differentTokenName() { $sut = new HTMLDocumentProtector(new HTMLDocument(self::HAS_META_ALREADY), new ArrayTokenStore()); $tokenName = HTMLDocumentProtector::TOKEN_NAME; - $sut->protectAndInject(); + $sut->protect(); // check that the token has been injected in all forms $document = $sut->getHTMLDocument(); diff --git a/test/phpunit/TokenStoreTest.php b/test/phpunit/TokenStoreTest.php index 710a426..ec362aa 100644 --- a/test/phpunit/TokenStoreTest.php +++ b/test/phpunit/TokenStoreTest.php @@ -32,12 +32,12 @@ class TokenStoreTest extends TestCase { HTML; /** no post request received */ - public function testProcessAndVerify_noPost():void { + public function testVerify_noPost():void { $exception = null; try { $sut = new ArrayTokenStore(); - $sut->processAndVerify([]); + $sut->verify([]); } catch(CsrfException $exception) {} @@ -45,26 +45,26 @@ public function testProcessAndVerify_noPost():void { } /** POST request received but without a token */ - public function testProcessAndVerify_noToken():void { + public function testVerify_noToken():void { $post = []; $post["doink"] = "binky"; $sut = new ArrayTokenStore(); $this->expectException(CSRFTokenMissingException::class); - $sut->processAndVerify($post); + $sut->verify($post); } /** POST request received with token but invalid */ - public function testProcessAndVerify_invalidToken():void { + public function testVerify_invalidToken():void { $post = []; $post["doink"] = "binky"; $post[HTMLDocumentProtector::TOKEN_NAME] = "12321"; $sut = new ArrayTokenStore(); $this->expectException(CSRFTokenInvalidException::class); - $sut->processAndVerify($post); + $sut->verify($post); } /** POST request received with token but invalid */ - public function testProcessAndVerify_spentToken():void { + public function testVerify_spentToken():void { $tokenStore = new ArrayTokenStore(); $token = $tokenStore->generateNewToken(); $tokenStore->saveToken($token); @@ -76,11 +76,11 @@ public function testProcessAndVerify_spentToken():void { $post[HTMLDocumentProtector::TOKEN_NAME] = $token; $this->expectException(CSRFTokenSpentException::class); - $tokenStore->processAndVerify($post); + $tokenStore->verify($post); } /** POST request received with token and valid */ - public function testProcessAndVerify_validToken():void { + public function testVerify_validToken():void { $tokenStore = new ArrayTokenStore(); $token = $tokenStore->generateNewToken(); $tokenStore->saveToken($token); @@ -93,7 +93,7 @@ public function testProcessAndVerify_validToken():void { $exception = null; try { - $tokenStore->processAndVerify($post); + $tokenStore->verify($post); } catch(CsrfException $exception) {} @@ -104,7 +104,7 @@ public function testProcessAndVerify_validToken():void { * php.gt/webengine provides user input as a custom object * with an asArray function. */ - public function testProcessAndVerify_validTokenObj():void { + public function testVerify_validTokenObj():void { $tokenStore = new ArrayTokenStore(); $token = $tokenStore->generateNewToken(); $tokenStore->saveToken($token); @@ -124,7 +124,7 @@ public function testProcessAndVerify_validTokenObj():void { $exception = null; try { - $tokenStore->processAndVerify($post); + $tokenStore->verify($post); } catch(CsrfException $exception) {}