Add endpoint to create accounts from the API (authenticated, admin only) + tests + documentation

This commit is contained in:
Timothée Jaussoin 2020-09-02 17:13:13 +02:00
parent a4fe44e59c
commit bf123b764a
7 changed files with 240 additions and 73 deletions

View file

@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Carbon\Carbon;
use App\Account;
use App\Password;
use App\Helpers\Utils;
class AccountController extends Controller
{
public function store(Request $request)
{
$request->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);
}
}

View file

@ -0,0 +1,29 @@
<?php
/*
Flexisip Account Manager is a set of tools to manage SIP accounts.
Copyright (C) 2019 Belledonne Communications SARL, All rights reserved.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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,
];
});

View file

@ -32,7 +32,27 @@ For the moment only DIGEST-MD5 and DIGEST-SHA-256 are supported through the auth
<h2>Endpoints</h2>
<p>Current implemented endpoints</p>
<h3>Accounts</h3>
<h4><code>POST /accounts</code></h4>
<p>JSON parameters:</p>
<ul>
<li><code>username</code> unique username, minimum 6 characters</li>
<li><code>password</code> required minimum 6 characters</li>
<li><code>algorithm</code> required, values can be <code>SHA-256</code> or <code>MD5</code></li>
</ul>
<p>To create an account directly from the API.<br />This endpoint is authenticated and requires an admin account.</p>
<h3>Ping</h3>
<h4><code>GET /ping</code></h4>
<p>Returns <code>pong</code></p>
<h3>Devices</h3>
<h4><code>GET /devices</code></h4>

View file

@ -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');
});
});

View file

@ -0,0 +1,68 @@
<?php
namespace Tests\Feature;
use App\Password;
use App\Admin;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;
class AccountApiTest extends TestCase
{
use RefreshDatabase;
protected $route = '/api/accounts';
protected $method = 'POST';
public function testMandatoryFrom()
{
$password = factory(Password::class)->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
]);
}
}

View file

@ -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]);
}
}

View file

@ -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]);
}
}