From bf123b764a27ec8066cf9ecc6eb013deb266b168 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Jaussoin?= Date: Wed, 2 Sep 2020 17:13:13 +0200 Subject: [PATCH] Add endpoint to create accounts from the API (authenticated, admin only) + tests + documentation --- .../Controllers/Api/AccountController.php | 43 +++++++++ flexiapi/database/factories/AdminFactory.php | 29 ++++++ .../resources/views/documentation.blade.php | 22 ++++- flexiapi/routes/api.php | 4 + flexiapi/tests/Feature/AccountApiTest.php | 68 ++++++++++++++ .../tests/Feature/AuthenticateDigestTest.php | 93 +++++-------------- flexiapi/tests/TestCase.php | 54 +++++++++++ 7 files changed, 240 insertions(+), 73 deletions(-) create mode 100644 flexiapi/app/Http/Controllers/Api/AccountController.php create mode 100644 flexiapi/database/factories/AdminFactory.php create mode 100644 flexiapi/tests/Feature/AccountApiTest.php diff --git a/flexiapi/app/Http/Controllers/Api/AccountController.php b/flexiapi/app/Http/Controllers/Api/AccountController.php new file mode 100644 index 0000000..a20c6aa --- /dev/null +++ b/flexiapi/app/Http/Controllers/Api/AccountController.php @@ -0,0 +1,43 @@ +validate([ + 'username' => 'required|unique:external.accounts,username|min:6', + 'algorithm' => 'required|in:SHA-256,MD5', + 'password' => 'required|min:6', + ]); + + $algorithm = $request->has('password_sha256') ? 'SHA-256' : 'MD5'; + + $account = new Account; + $account->username = $request->get('username'); + $account->email = $request->get('email'); + $account->activated = true; + $account->domain = config('app.sip_domain'); + $account->ip_address = $request->ip(); + $account->creation_time = Carbon::now(); + $account->user_agent = config('app.name'); + $account->save(); + + $password = new Password; + $password->account_id = $account->id; + $password->password = Utils::bchash($account->username, $account->domain, $request->get('password'), $request->get('algorithm')); + $password->algorithm = $request->get('algorithm'); + $password->save(); + + return response()->json($account); + } +} diff --git a/flexiapi/database/factories/AdminFactory.php b/flexiapi/database/factories/AdminFactory.php new file mode 100644 index 0000000..3d482c7 --- /dev/null +++ b/flexiapi/database/factories/AdminFactory.php @@ -0,0 +1,29 @@ +. +*/ + +use App\Admin; +use Faker\Generator as Faker; + +$factory->define(Admin::class, function (Faker $faker) use ($factory) { + $password = $factory->create(App\Password::class); + + return [ + 'account_id' => $password->account_id, + ]; +}); diff --git a/flexiapi/resources/views/documentation.blade.php b/flexiapi/resources/views/documentation.blade.php index 64fc3dd..e51a351 100644 --- a/flexiapi/resources/views/documentation.blade.php +++ b/flexiapi/resources/views/documentation.blade.php @@ -32,7 +32,27 @@ For the moment only DIGEST-MD5 and DIGEST-SHA-256 are supported through the auth

Endpoints

-

Current implemented endpoints

+

Accounts

+ +

POST /accounts

+ +

JSON parameters:

+ + + +

To create an account directly from the API.
This endpoint is authenticated and requires an admin account.

+ +

Ping

+ +

GET /ping

+ +

Returns pong

+ +

Devices

GET /devices

diff --git a/flexiapi/routes/api.php b/flexiapi/routes/api.php index a976b3f..a818790 100644 --- a/flexiapi/routes/api.php +++ b/flexiapi/routes/api.php @@ -29,4 +29,8 @@ Route::group(['middleware' => ['auth.digest']], function () { Route::get('ping', 'Api\PingController@ping'); Route::get('devices', 'Api\DeviceController@index'); Route::delete('devices/{uuid}', 'Api\DeviceController@destroy'); + + Route::group(['middleware' => ['auth.admin']], function () { + Route::post('accounts', 'Api\AccountController@store'); + }); }); \ No newline at end of file diff --git a/flexiapi/tests/Feature/AccountApiTest.php b/flexiapi/tests/Feature/AccountApiTest.php new file mode 100644 index 0000000..0b07081 --- /dev/null +++ b/flexiapi/tests/Feature/AccountApiTest.php @@ -0,0 +1,68 @@ +create(); + $response = $this->json($this->method, $this->route); + $response->assertStatus(422); + } + + public function testNotAdminForbidden() + { + $password = factory(Password::class)->create(); + $response0 = $this->withHeaders([ + 'From' => 'sip:'.$password->account->identifier + ])->json($this->method, $this->route); + + $response1 = $this->withHeaders([ + 'From' => 'sip:'.$password->account->identifier, + 'Authorization' => $this->generateDigest($password, $response0), + ])->json($this->method, $this->route); + + $response1->assertStatus(403); + } + + public function testAdminOk() + { + $admin = factory(Admin::class)->create(); + $password = $admin->account->passwords()->first(); + + $response0 = $this->withHeaders([ + 'From' => 'sip:'.$password->account->identifier + ])->json($this->method, $this->route); + + $username = 'foobar'; + + $response1 = $this->withHeaders([ + 'From' => 'sip:'.$password->account->identifier, + 'Authorization' => $this->generateDigest($password, $response0), + ])->json($this->method, $this->route, [ + 'username' => $username, + 'algorithm' => 'SHA-256', + 'password' => '123456', + ]); + + $response1 + ->assertStatus(200) + ->assertJson([ + 'id' => 2, + 'username' => $username + ]); + } +} diff --git a/flexiapi/tests/Feature/AuthenticateDigestTest.php b/flexiapi/tests/Feature/AuthenticateDigestTest.php index 5404206..8481594 100644 --- a/flexiapi/tests/Feature/AuthenticateDigestTest.php +++ b/flexiapi/tests/Feature/AuthenticateDigestTest.php @@ -19,7 +19,6 @@ namespace Tests\Feature; -use App\Helpers\Utils; use App\Password; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -30,14 +29,13 @@ class AuthenticateDigestTest extends TestCase { use RefreshDatabase; - const ROUTE = '/api/ping'; - const METHOD = 'GET'; - const ALGORITHMS = ['md5' => 'MD5', 'sha256' => 'SHA-256']; + protected $route = '/api/ping'; + protected $method = 'GET'; public function testMandatoryFrom() { $password = factory(Password::class)->create(); - $response = $this->json(self::METHOD, self::ROUTE); + $response = $this->json($this->method, $this->route); $response->assertStatus(422); } @@ -46,7 +44,7 @@ class AuthenticateDigestTest extends TestCase $password = factory(Password::class)->create(); $response = $this->withHeaders([ 'From' => 'sip:missing@username', - ])->json(self::METHOD, self::ROUTE); + ])->json($this->method, $this->route); $response->assertStatus(404); } @@ -56,7 +54,7 @@ class AuthenticateDigestTest extends TestCase $password = factory(Password::class)->create(); $response = $this->withHeaders([ 'From' => 'sip:'.$password->account->identifier, - ])->json(self::METHOD, self::ROUTE); + ])->json($this->method, $this->route); $response->assertStatus(401); } @@ -70,7 +68,7 @@ class AuthenticateDigestTest extends TestCase $response = $this->withHeaders([ 'From' => 'sip:'.$passwordMD5->account->identifier, - ])->json(self::METHOD, self::ROUTE); + ])->json($this->method, $this->route); $response->assertStatus(401); @@ -83,12 +81,12 @@ class AuthenticateDigestTest extends TestCase $password = factory(Password::class)->create(); $response0 = $this->withHeaders([ 'From' => 'sip:'.$password->account->identifier - ])->json(self::METHOD, self::ROUTE); + ])->json($this->method, $this->route); $response1 = $this->withHeaders([ 'From' => 'sip:'.$password->account->identifier, 'Authorization' => $this->generateDigest($password, $response0), - ])->json(self::METHOD, self::ROUTE); + ])->json($this->method, $this->route); $response1->assertStatus(200); @@ -96,7 +94,7 @@ class AuthenticateDigestTest extends TestCase $response2 = $this->withHeaders([ 'From' => 'sip:'.$password->account->identifier, 'Authorization' => $this->generateDigest($password, $response1, 'md5', '00000002'), - ])->json(self::METHOD, self::ROUTE); + ])->json($this->method, $this->route); $response2->assertStatus(200); @@ -104,7 +102,7 @@ class AuthenticateDigestTest extends TestCase $response3 = $this->withHeaders([ 'From' => 'sip:'.$password->account->identifier, 'Authorization' => $this->generateDigest($password, $response2, 'md5', '00000002'), - ])->json(self::METHOD, self::ROUTE); + ])->json($this->method, $this->route); $response3->assertSee('Nonce replayed'); $response3->assertStatus(401); @@ -115,12 +113,12 @@ class AuthenticateDigestTest extends TestCase $password = factory(Password::class)->create(); $response1 = $this->withHeaders([ 'From' => 'sip:'.$password->account->identifier - ])->json(self::METHOD, self::ROUTE); + ])->json($this->method, $this->route); $response2 = $this->withHeaders([ 'From' => 'sip:'.$password->account->identifier, 'Authorization' => $this->generateDigest($password, $response1, 'md5', '00000001'), - ])->json(self::METHOD, self::ROUTE); + ])->json($this->method, $this->route); $response2->assertStatus(200); @@ -130,7 +128,7 @@ class AuthenticateDigestTest extends TestCase $response3 = $this->withHeaders([ 'From' => 'sip:'.$password->account->identifier, 'Authorization' => $this->generateDigest($password, $response2, 'md5', '00000002'), - ])->json(self::METHOD, self::ROUTE); + ])->json($this->method, $this->route); $response3->assertSee('Nonce invalid'); $response3->assertStatus(401); @@ -142,12 +140,12 @@ class AuthenticateDigestTest extends TestCase $password = factory(Password::class)->create(); $response = $this->withHeaders([ 'From' => 'sip:'.$password->account->identifier - ])->json(self::METHOD, self::ROUTE); + ])->json($this->method, $this->route); $response = $this->withHeaders([ 'From' => 'sip:'.$password->account->identifier, 'Authorization' => $this->generateDigest($password, $response), - ])->json(self::METHOD, self::ROUTE); + ])->json($this->method, $this->route); $this->assertStringContainsString('algorithm=MD5', $response->headers->all()['www-authenticate'][0]); @@ -159,12 +157,12 @@ class AuthenticateDigestTest extends TestCase $password = factory(Password::class)->states('sha256')->create(); $response = $this->withHeaders([ 'From' => 'sip:'.$password->account->identifier - ])->json(self::METHOD, self::ROUTE); + ])->json($this->method, $this->route); $response = $this->withHeaders([ 'From' => 'sip:'.$password->account->identifier, 'Authorization' => $this->generateDigest($password, $response, 'sha256'), - ])->json(self::METHOD, self::ROUTE); + ])->json($this->method, $this->route); $this->assertStringContainsString('algorithm=SHA-256', $response->headers->all()['www-authenticate'][0]); @@ -176,7 +174,7 @@ class AuthenticateDigestTest extends TestCase $password = factory(Password::class)->states('clrtxt')->create(); $response = $this->withHeaders([ 'From' => 'sip:'.$password->account->identifier - ])->json(self::METHOD, self::ROUTE); + ])->json($this->method, $this->route); // The server is generating all the available hash algorythms $this->assertStringContainsString('algorithm=MD5', $response->headers->all()['www-authenticate'][0]); @@ -192,7 +190,7 @@ class AuthenticateDigestTest extends TestCase $response = $this->withHeaders([ 'From' => 'sip:'.$password->account->identifier, 'Authorization' => $this->generateDigest($password, $response, $hash), - ])->json(self::METHOD, self::ROUTE); + ])->json($this->method, $this->route); $this->assertStringContainsString('algorithm=MD5', $response->headers->all()['www-authenticate'][0]); $this->assertStringContainsString('algorithm=SHA-256', $response->headers->all()['www-authenticate'][1]); @@ -205,63 +203,14 @@ class AuthenticateDigestTest extends TestCase $password = factory(Password::class)->create(); $response = $this->withHeaders([ 'From' => 'sip:'.$password->account->identifier - ])->json(self::METHOD, self::ROUTE); + ])->json($this->method, $this->route); $password->password = 'wrong'; $response = $this->withHeaders([ 'From' => 'sip:'.$password->account->identifier, 'Authorization' => $this->generateDigest($password, $response), - ])->json(self::METHOD, self::ROUTE); + ])->json($this->method, $this->route); $response->assertStatus(401); } - - private function generateDigest(Password $password, $response, $hash = 'md5', $nc = '00000001') - { - $challenge = \substr($response->headers->get('www-authenticate'), 7); - $extractedChallenge = $this->extractAuthenticateHeader($challenge); - - $cnonce = Utils::generateNonce(); - - $A1 = $password->password; - $A2 = hash($hash, self::METHOD . ':' . self::ROUTE); - $response = hash($hash, - sprintf( - '%s:%s:%s:%s:%s:%s', - $A1, - $extractedChallenge['nonce'], - $nc, - $cnonce, - $extractedChallenge['qop'], - $A2 - ) - ); - - $digest = \sprintf( - 'username="%s",realm="%s",nonce="%s",nc=%s,cnonce="%s",uri="%s",qop=%s,response="%s",opaque="%s",algorithm=%s', - $password->account->identifier, - $extractedChallenge['realm'], - $extractedChallenge['nonce'], - $nc, - $cnonce, - self::ROUTE, - $extractedChallenge['qop'], - $response, - $extractedChallenge['opaque'], - self::ALGORITHMS[$hash], - ); - - return 'Digest ' . $digest; - } - - private function extractAuthenticateHeader(string $string): array - { - preg_match_all( - '@(realm|nonce|qop|opaque|algorithm)=[\'"]?([^\'",]+)@', - $string, - $array - ); - - return array_combine($array[1], $array[2]); - } } diff --git a/flexiapi/tests/TestCase.php b/flexiapi/tests/TestCase.php index 2932d4a..a7eca71 100644 --- a/flexiapi/tests/TestCase.php +++ b/flexiapi/tests/TestCase.php @@ -2,9 +2,63 @@ namespace Tests; +use App\Password; +use App\Helpers\Utils; + use Illuminate\Foundation\Testing\TestCase as BaseTestCase; abstract class TestCase extends BaseTestCase { use CreatesApplication; + + const ALGORITHMS = ['md5' => 'MD5', 'sha256' => 'SHA-256']; + + protected function generateDigest(Password $password, $response, $hash = 'md5', $nc = '00000001') + { + $challenge = \substr($response->headers->get('www-authenticate'), 7); + $extractedChallenge = $this->extractAuthenticateHeader($challenge); + + $cnonce = Utils::generateNonce(); + + $A1 = $password->password; + $A2 = hash($hash, $this->method . ':' . $this->route); + $response = hash($hash, + sprintf( + '%s:%s:%s:%s:%s:%s', + $A1, + $extractedChallenge['nonce'], + $nc, + $cnonce, + $extractedChallenge['qop'], + $A2 + ) + ); + + $digest = \sprintf( + 'username="%s",realm="%s",nonce="%s",nc=%s,cnonce="%s",uri="%s",qop=%s,response="%s",opaque="%s",algorithm=%s', + $password->account->identifier, + $extractedChallenge['realm'], + $extractedChallenge['nonce'], + $nc, + $cnonce, + $this->route, + $extractedChallenge['qop'], + $response, + $extractedChallenge['opaque'], + self::ALGORITHMS[$hash], + ); + + return 'Digest ' . $digest; + } + + protected function extractAuthenticateHeader(string $string): array + { + preg_match_all( + '@(realm|nonce|qop|opaque|algorithm)=[\'"]?([^\'",]+)@', + $string, + $array + ); + + return array_combine($array[1], $array[2]); + } }