Fix FLEXIAPI-216 Implement the RFC 8898 partially... for HTTP

This commit is contained in:
Timothée Jaussoin 2024-10-15 15:38:45 +02:00
parent 648936514f
commit 61bc04da02
13 changed files with 126 additions and 63 deletions

View file

@ -9,6 +9,7 @@ v1.6
- Fix FLEXIAPI-211 Add a JSON validation middleware + test
- Fix FLEXIAPI-212 Add CoTURN credentials support in the provisioning
- Fix FLEXIAPI-213 Add TURN credentials support in the API as defined in draft-uberti-behave-turn-rest-00
- Fix FLEXIAPI-216 Implement the RFC 8898 partially... for HTTP
v1.5
---

View file

@ -9,41 +9,8 @@ APP_LINPHONE_DAEMON_UNIX_PATH=
APP_FLEXISIP_PUSHER_PATH=
APP_FLEXISIP_PUSHER_FIREBASE_KEYSMAP= # Each pair is separated using a space and defined as a key:value
APP_API_ACCOUNT_CREATION_TOKEN_RETRY_MINUTES=60 # Number of minutes between two consecutive account_creation_token creation
APP_ALLOW_PHONE_NUMBER_USERNAME_ADMIN_API=false # Allow phone numbers to be set as username in admin account creation endpoints
# Risky toggles
APP_DANGEROUS_ENDPOINTS=false # Enable some dangerous endpoints used for XMLRPC like fallback usage
# SIP server parameters
ACCOUNT_PROXY_REGISTRAR_ADDRESS=sip.example.com # Proxy registrar address, can be different than the SIP domain
ACCOUNT_TRANSPORT_PROTOCOL_TEXT="TLS (recommended), TCP or UDP" # Simple text, to explain how the SIP server can be reached
ACCOUNT_REALM=null # Default realm for the accounts, fallback to the domain if not set, enforce null by default
# Expiration time for tokens and code, in minutes, 0 means no expiration
APP_ACCOUNT_CREATION_TOKEN_EXPIRATION_MINUTES=0
APP_EMAIL_CHANGE_CODE_EXPIRATION_MINUTES=10
APP_PHONE_CHANGE_CODE_EXPIRATION_MINUTES=10
APP_RECOVERY_CODE_EXPIRATION_MINUTES=10
APP_PROVISIONING_TOKEN_EXPIRATION_MINUTES=0
APP_API_KEY_EXPIRATION_MINUTES=60 # Number of minutes the unused API Keys are valid
# Account creation
ACCOUNT_EMAIL_UNIQUE=false # Emails are unique between all the accounts
ACCOUNT_BLACKLISTED_USERNAMES=
ACCOUNT_USERNAME_REGEX="^[a-z0-9+_.-]*$"
ACCOUNT_DEFAULT_PASSWORD_ALGORITHM=SHA-256 # Can ONLY be MD5 or SHA-256 in capital, default to SHA-256
# Account provisioning
ACCOUNT_PROVISIONING_RC_FILE=
ACCOUNT_PROVISIONING_OVERWRITE_ALL=
ACCOUNT_PROVISIONING_USE_X_LINPHONE_PROVISIONING_HEADER=true
# Blocking service
BLOCKING_TIME_PERIOD_CHECK=30 # Time span on which the blocking service will proceed, in minutes
BLOCKING_AMOUNT_EVENTS_AUTHORIZED_DURING_PERIOD=5 # Amount of account events authorized during this period
# Instance specific parameters
INSTANCE_COPYRIGHT= # Simple text displayed in the page footer
INSTANCE_INTRO_REGISTRATION= # Markdown text displayed in the home page
@ -63,6 +30,39 @@ APP_PROJECT_URL= # A URL pointing to the project information page
LOG_CHANNEL=stack
# Risky toggles
APP_DANGEROUS_ENDPOINTS=false # Enable some dangerous endpoints used for XMLRPC like fallback usage
# SIP server parameters
ACCOUNT_PROXY_REGISTRAR_ADDRESS=sip.example.com # Proxy registrar address, can be different than the SIP domain
ACCOUNT_TRANSPORT_PROTOCOL_TEXT="TLS (recommended), TCP or UDP" # Simple text, to explain how the SIP server can be reached
ACCOUNT_REALM=null # Default realm for the accounts, fallback to the domain if not set, enforce null by default
# Expiration time for tokens and code, in minutes, 0 means no expiration
APP_API_ACCOUNT_CREATION_TOKEN_RETRY_MINUTES=60 # Number of minutes between two consecutive account_creation_token creation
APP_ACCOUNT_CREATION_TOKEN_EXPIRATION_MINUTES=0
APP_EMAIL_CHANGE_CODE_EXPIRATION_MINUTES=10
APP_PHONE_CHANGE_CODE_EXPIRATION_MINUTES=10
APP_RECOVERY_CODE_EXPIRATION_MINUTES=10
APP_PROVISIONING_TOKEN_EXPIRATION_MINUTES=0
APP_API_KEY_EXPIRATION_MINUTES=60 # Number of minutes the unused API Keys are valid
# Account creation and authentication
ACCOUNT_EMAIL_UNIQUE=false # Emails are unique between all the accounts
ACCOUNT_BLACKLISTED_USERNAMES=
ACCOUNT_USERNAME_REGEX="^[a-z0-9+_.-]*$"
ACCOUNT_DEFAULT_PASSWORD_ALGORITHM=SHA-256 # Can ONLY be MD5 or SHA-256 in capital, default to SHA-256
ACCOUNT_AUTHENTICATION_BEARER_URL= # URL of the external service that can provide a trusted (eg. JWT token) for the authentication, takes priority and disable the DIGEST auth if set, see https://www.rfc-editor.org/rfc/rfc8898
# Account provisioning
ACCOUNT_PROVISIONING_RC_FILE=
ACCOUNT_PROVISIONING_OVERWRITE_ALL=
ACCOUNT_PROVISIONING_USE_X_LINPHONE_PROVISIONING_HEADER=true
# Blocking service
BLOCKING_TIME_PERIOD_CHECK=30 # Time span on which the blocking service will proceed, in minutes
BLOCKING_AMOUNT_EVENTS_AUTHORIZED_DURING_PERIOD=5 # Amount of account events authorized during this period
# FlexiSIP database
# Ensure that you have the proper SELinux configuration to allow database connections, see the README
DB_CONNECTION=mysql
@ -126,3 +126,4 @@ HCAPTCHA_SITEKEY=site-key
# JWT
JWT_RSA_PUBLIC_KEY_PEM=
JWT_SIP_IDENTIFIER=

View file

@ -302,12 +302,12 @@ class Account extends Authenticatable
public function getRealmAttribute()
{
return config('app.realm');
return config('app.account_realm');
}
public function getResolvedRealmAttribute()
{
return config('app.realm') ?? $this->domain;
return config('app.account_realm') ?? $this->domain;
}
public function getConfirmationKeyExpiresAttribute()

View file

@ -25,8 +25,8 @@ use Carbon\Carbon;
use Illuminate\Validation\Rule;
use Illuminate\Support\Facades\Auth;
use Illuminate\Http\Response;
use Closure;
use Illuminate\Http\Request;
use Closure;
use Validator;
class AuthenticateDigestOrKey
@ -58,9 +58,9 @@ class AuthenticateDigestOrKey
return $this->generateUnauthorizedResponse(null, 'Invalid API Key');
}
Validator::make(['from' => $request->header('From')], [
'from' => 'required',
])->validate();
if (empty($request->header('From'))) {
return $this->generateUnauthorizedResponse(null, 'From header is required or invalid token');
}
$from = $this->extractFromHeader($request->header('From'));
list($username, $domain) = parseSIP($from);
@ -70,7 +70,7 @@ class AuthenticateDigestOrKey
->where('domain', $domain)
->firstOrFail();
$resolvedRealm = config('app.realm') ?? $domain;
$resolvedRealm = config('app.account_realm') ?? $domain;
// DIGEST authentication
@ -132,7 +132,8 @@ class AuthenticateDigestOrKey
: $password->password; // username:realm/domain:password
$a2 = hash($hash, $request->method().':'.$auth['uri']);
$validResponse = hash($hash,
$validResponse = hash(
$hash,
$a1.
':'.$auth['nonce'].
':'.$auth['nc'].
@ -161,7 +162,7 @@ class AuthenticateDigestOrKey
private function generateUnauthorizedResponse(?Account $account = null, $message = 'Unauthenticated request')
{
$response = new Response;
$response = new Response();
if ($account) {
$nonce = generateValidNonce($account);
@ -198,7 +199,7 @@ class AuthenticateDigestOrKey
private function generateAuthHeaders(Account $account, string $nonce): array
{
$headers = [];
$resolvedRealm = config('app.realm') ?? $account->domain;
$resolvedRealm = config('app.account_realm') ?? $account->domain;
foreach ($account->passwords as $password) {
if ($password->algorithm == 'CLRTXT') {

View file

@ -24,6 +24,7 @@ use Closure;
use DateTimeImmutable;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Http\Response;
use Lcobucci\JWT\Encoding\JoseEncoder;
use Lcobucci\JWT\Token\Parser;
@ -94,6 +95,21 @@ class AuthenticateJWT
}
Auth::login($account);
return $next($request);
}
if (!empty(config('app.account_authentication_bearer_url'))) {
$response = new Response();
$response->header(
'WWW-Authenticate',
'Bearer authz_server="' . config('app.account_authentication_bearer_url') . '"'
);
$response->setStatusCode(401);
return $response;
}
return $next($request);

View file

@ -25,8 +25,8 @@ class BlacklistedUsername implements Rule
{
public function passes($attribute, $value)
{
if (!empty(config('app.blacklisted_usernames'))) {
foreach (explode(',', config('app.blacklisted_usernames')) as $username) {
if (!empty(config('app.account_blacklisted_usernames'))) {
foreach (explode(',', config('app.account_blacklisted_usernames')) as $username) {
if ($value == $username) return false;
// Regex rules

View file

@ -30,11 +30,18 @@ return [
'proxy_registrar_address' => env('ACCOUNT_PROXY_REGISTRAR_ADDRESS', 'sip.domain.com'),
'transport_protocol_text' => env('ACCOUNT_TRANSPORT_PROTOCOL_TEXT', 'TLS (recommended), TCP or UDP'),
'account_email_unique' => env('ACCOUNT_EMAIL_UNIQUE', false),
'allow_phone_number_username_admin_api' => env('APP_ALLOW_PHONE_NUMBER_USERNAME_ADMIN_API', false),
'blacklisted_usernames' => env('ACCOUNT_BLACKLISTED_USERNAMES', ''),
'account_blacklisted_usernames' => env('ACCOUNT_BLACKLISTED_USERNAMES', ''),
'account_email_unique' => env('ACCOUNT_EMAIL_UNIQUE', false),
'account_username_regex' => env('ACCOUNT_USERNAME_REGEX', '^[a-z0-9+_.-]*$'),
'account_default_password_algorithm' => env('ACCOUNT_DEFAULT_PASSWORD_ALGORITHM', 'SHA-256'),
'account_authentication_bearer_url' => env('ACCOUNT_AUTHENTICATION_BEARER_URL', null),
/**
* Set a global realm for all the accounts, if not set, the account domain
* will be used as a fallback
*/
'account_realm' => env('ACCOUNT_REALM', null),
/**
* Time limit before the API Key and related cookie are expired
@ -77,12 +84,6 @@ return [
'provisioning_overwrite_all' => env('ACCOUNT_PROVISIONING_OVERWRITE_ALL', false),
'provisioning_use_x_linphone_provisioning_header' => env('ACCOUNT_PROVISIONING_USE_X_LINPHONE_PROVISIONING_HEADER', true),
/**
* Set a global realm for all the accounts, if not set, the account domain
* will be used as a fallback
*/
'realm' => env('ACCOUNT_REALM', null),
/**
* /!\ Enable dangerous endpoints required for fallback
*/

View file

@ -30,7 +30,7 @@ class PasswordFactory extends Factory
public function definition()
{
$account = Account::factory()->create();
$realm = config('app.realm') ?? $account->domain;
$realm = config('app.account_realm') ?? $account->domain;
return [
'account_id' => $account->id,
@ -54,7 +54,7 @@ class PasswordFactory extends Factory
{
return $this->state(function (array $attributes) {
$account = Account::find($attributes['account_id']);
$realm = config('app.realm') ?? $account->domain;
$realm = config('app.account_realm') ?? $account->domain;
return [
'password' => hash('sha256', $account->username.':'.$realm.':testtest'),

View file

@ -39,6 +39,8 @@ class AccountJWTAuthenticationTest extends TestCase
protected $serverPrivateKeyPem = null;
protected $serverPublicKeyPem = null;
protected $routeAccountMe = '/api/accounts/me';
public function setUp(): void
{
parent::setUp();
@ -174,6 +176,46 @@ class AccountJWTAuthenticationTest extends TestCase
->assertStatus(403);
}
public function testAuthBearerUrl()
{
$server = 'https://auth_bearer.com/';
config()->set('app.account_authentication_bearer_url', $server);
$password = Password::factory()->create();
$response = $this->json($this->method, $this->routeAccountMe)
->assertStatus(401);
$this->assertStringContainsString(
'Bearer authz_server="' . $server . '"',
$response->headers->all()['www-authenticate'][0]
);
// Wrong From
$reponse = $this
->withHeaders(['From' => 'sip:missing@username'])
->json($this->method, $this->routeAccountMe)
->assertStatus(401);
$this->assertStringContainsString(
'Bearer authz_server="' . $server . '"',
$response->headers->all()['www-authenticate'][0]
);
// Wrong bearer message
$reponse = $this
->withHeaders([
'Authorization' => 'Bearer 1234'
])
->json($this->method, $this->routeAccountMe)
->assertStatus(401);
$this->assertStringContainsString(
'Bearer authz_server="' . $server . '"',
$response->headers->all()['www-authenticate'][0]
);
}
private function checkToken(UnencryptedToken $token): void
{
$this->withHeaders([

View file

@ -92,7 +92,7 @@ class AccountProvisioningTest extends TestCase
{
$this->withHeaders([
'x-linphone-provisioning' => true,
])->get($this->accountRoute)->assertStatus(302);
])->get($this->accountRoute)->assertStatus(401);
}
public function testAuthenticatedWithPasswordProvisioning()

View file

@ -181,7 +181,7 @@ class ApiAccountCreationTokenTest extends TestCase
{
$token = AccountCreationToken::factory()->create();
config()->set('app.blacklisted_usernames', 'foobar,blacklisted,username-.*');
config()->set('app.account_blacklisted_usernames', 'foobar,blacklisted,username-.*');
// Blacklisted username
$this->json($this->method, $this->accountRoute, [

View file

@ -37,7 +37,7 @@ class ApiAccountTest extends TestCase
{
Password::factory()->create();
$response = $this->json($this->method, $this->route);
$response->assertStatus(422);
$response->assertStatus(401);
}
public function testNotAdminForbidden()
@ -588,7 +588,7 @@ class ApiAccountTest extends TestCase
$password->account->save();
$realm = 'realm.com';
config()->set('app.realm', $realm);
config()->set('app.account_realm', $realm);
/**
* Public information

View file

@ -32,7 +32,7 @@ class ApiAuthenticationTest extends TestCase
{
Password::factory()->create();
$response = $this->json($this->method, $this->route);
$response->assertStatus(422);
$response->assertStatus(401);
}
public function testWrongFrom()
@ -204,7 +204,7 @@ class ApiAuthenticationTest extends TestCase
public function testAuthenticationSHA265FromCLRTXTWithRealm()
{
$realm = 'realm.com';
config()->set('app.realm', $realm);
config()->set('app.account_realm', $realm);
$password = Password::factory()->clrtxt()->create();
$response = $this->generateFirstResponse($password);
@ -230,7 +230,8 @@ class ApiAuthenticationTest extends TestCase
public function testAuthenticationBadPassword()
{
$password = Password::factory()->create();
$response = $this->generateFirstResponse($password);;
$response = $this->generateFirstResponse($password);
;
$password->password = 'wrong';
$response = $this->generateSecondResponse($password, $response)