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-211 Add a JSON validation middleware + test
- Fix FLEXIAPI-212 Add CoTURN credentials support in the provisioning - 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-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 v1.5
--- ---

View file

@ -9,41 +9,8 @@ APP_LINPHONE_DAEMON_UNIX_PATH=
APP_FLEXISIP_PUSHER_PATH= APP_FLEXISIP_PUSHER_PATH=
APP_FLEXISIP_PUSHER_FIREBASE_KEYSMAP= # Each pair is separated using a space and defined as a key:value 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 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 specific parameters
INSTANCE_COPYRIGHT= # Simple text displayed in the page footer INSTANCE_COPYRIGHT= # Simple text displayed in the page footer
INSTANCE_INTRO_REGISTRATION= # Markdown text displayed in the home page 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 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 # FlexiSIP database
# Ensure that you have the proper SELinux configuration to allow database connections, see the README # Ensure that you have the proper SELinux configuration to allow database connections, see the README
DB_CONNECTION=mysql DB_CONNECTION=mysql
@ -126,3 +126,4 @@ HCAPTCHA_SITEKEY=site-key
# JWT # JWT
JWT_RSA_PUBLIC_KEY_PEM= JWT_RSA_PUBLIC_KEY_PEM=
JWT_SIP_IDENTIFIER= JWT_SIP_IDENTIFIER=

View file

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

View file

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

View file

@ -24,6 +24,7 @@ use Closure;
use DateTimeImmutable; use DateTimeImmutable;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Http\Response;
use Lcobucci\JWT\Encoding\JoseEncoder; use Lcobucci\JWT\Encoding\JoseEncoder;
use Lcobucci\JWT\Token\Parser; use Lcobucci\JWT\Token\Parser;
@ -94,6 +95,21 @@ class AuthenticateJWT
} }
Auth::login($account); 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); return $next($request);

View file

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

View file

@ -30,11 +30,18 @@ return [
'proxy_registrar_address' => env('ACCOUNT_PROXY_REGISTRAR_ADDRESS', 'sip.domain.com'), 'proxy_registrar_address' => env('ACCOUNT_PROXY_REGISTRAR_ADDRESS', 'sip.domain.com'),
'transport_protocol_text' => env('ACCOUNT_TRANSPORT_PROTOCOL_TEXT', 'TLS (recommended), TCP or UDP'), '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), '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_username_regex' => env('ACCOUNT_USERNAME_REGEX', '^[a-z0-9+_.-]*$'),
'account_default_password_algorithm' => env('ACCOUNT_DEFAULT_PASSWORD_ALGORITHM', 'SHA-256'), '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 * 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_overwrite_all' => env('ACCOUNT_PROVISIONING_OVERWRITE_ALL', false),
'provisioning_use_x_linphone_provisioning_header' => env('ACCOUNT_PROVISIONING_USE_X_LINPHONE_PROVISIONING_HEADER', true), '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 * /!\ Enable dangerous endpoints required for fallback
*/ */

View file

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

View file

@ -39,6 +39,8 @@ class AccountJWTAuthenticationTest extends TestCase
protected $serverPrivateKeyPem = null; protected $serverPrivateKeyPem = null;
protected $serverPublicKeyPem = null; protected $serverPublicKeyPem = null;
protected $routeAccountMe = '/api/accounts/me';
public function setUp(): void public function setUp(): void
{ {
parent::setUp(); parent::setUp();
@ -174,6 +176,46 @@ class AccountJWTAuthenticationTest extends TestCase
->assertStatus(403); ->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 private function checkToken(UnencryptedToken $token): void
{ {
$this->withHeaders([ $this->withHeaders([

View file

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

View file

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

View file

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

View file

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