mirror of
https://gitlab.linphone.org/BC/public/flexisip-account-manager.git
synced 2026-01-17 10:08:05 +00:00
Add endpoint to create accounts from the API (authenticated, admin only) + tests + documentation
This commit is contained in:
parent
a4fe44e59c
commit
bf123b764a
7 changed files with 240 additions and 73 deletions
43
flexiapi/app/Http/Controllers/Api/AccountController.php
Normal file
43
flexiapi/app/Http/Controllers/Api/AccountController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
29
flexiapi/database/factories/AdminFactory.php
Normal file
29
flexiapi/database/factories/AdminFactory.php
Normal 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,
|
||||
];
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
68
flexiapi/tests/Feature/AccountApiTest.php
Normal file
68
flexiapi/tests/Feature/AccountApiTest.php
Normal 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
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue