From 61bc04da028eb9bebcef1a48531b929d622012a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Jaussoin?= Date: Tue, 15 Oct 2024 15:38:45 +0200 Subject: [PATCH] Fix FLEXIAPI-216 Implement the RFC 8898 partially... for HTTP --- CHANGELOG.md | 1 + flexiapi/.env.example | 67 ++++++++++--------- flexiapi/app/Account.php | 4 +- .../Middleware/AuthenticateDigestOrKey.php | 19 +++--- .../app/Http/Middleware/AuthenticateJWT.php | 16 +++++ flexiapi/app/Rules/BlacklistedUsername.php | 4 +- flexiapi/config/app.php | 17 ++--- .../database/factories/PasswordFactory.php | 4 +- .../Feature/AccountJWTAuthenticationTest.php | 42 ++++++++++++ .../tests/Feature/AccountProvisioningTest.php | 2 +- .../Feature/ApiAccountCreationTokenTest.php | 2 +- flexiapi/tests/Feature/ApiAccountTest.php | 4 +- .../tests/Feature/ApiAuthenticationTest.php | 7 +- 13 files changed, 126 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eccca6..b2f2c81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 --- diff --git a/flexiapi/.env.example b/flexiapi/.env.example index dbab144..2871038 100644 --- a/flexiapi/.env.example +++ b/flexiapi/.env.example @@ -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= + diff --git a/flexiapi/app/Account.php b/flexiapi/app/Account.php index bbbbe17..bdba0ba 100644 --- a/flexiapi/app/Account.php +++ b/flexiapi/app/Account.php @@ -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() diff --git a/flexiapi/app/Http/Middleware/AuthenticateDigestOrKey.php b/flexiapi/app/Http/Middleware/AuthenticateDigestOrKey.php index b11393a..8cae6ff 100644 --- a/flexiapi/app/Http/Middleware/AuthenticateDigestOrKey.php +++ b/flexiapi/app/Http/Middleware/AuthenticateDigestOrKey.php @@ -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') { @@ -209,7 +210,7 @@ class AuthenticateDigestOrKey ); } break; - } else if (\in_array($password->algorithm, array_keys(passwordAlgorithms()))) { + } elseif (\in_array($password->algorithm, array_keys(passwordAlgorithms()))) { array_push( $headers, $this->generateAuthHeader($resolvedRealm, $password->algorithm, $nonce) diff --git a/flexiapi/app/Http/Middleware/AuthenticateJWT.php b/flexiapi/app/Http/Middleware/AuthenticateJWT.php index df7186c..5c9e125 100644 --- a/flexiapi/app/Http/Middleware/AuthenticateJWT.php +++ b/flexiapi/app/Http/Middleware/AuthenticateJWT.php @@ -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); diff --git a/flexiapi/app/Rules/BlacklistedUsername.php b/flexiapi/app/Rules/BlacklistedUsername.php index 6092c57..d89a764 100644 --- a/flexiapi/app/Rules/BlacklistedUsername.php +++ b/flexiapi/app/Rules/BlacklistedUsername.php @@ -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 diff --git a/flexiapi/config/app.php b/flexiapi/config/app.php index 3521378..647e8ad 100644 --- a/flexiapi/config/app.php +++ b/flexiapi/config/app.php @@ -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 */ diff --git a/flexiapi/database/factories/PasswordFactory.php b/flexiapi/database/factories/PasswordFactory.php index 88f3853..f607f67 100644 --- a/flexiapi/database/factories/PasswordFactory.php +++ b/flexiapi/database/factories/PasswordFactory.php @@ -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'), diff --git a/flexiapi/tests/Feature/AccountJWTAuthenticationTest.php b/flexiapi/tests/Feature/AccountJWTAuthenticationTest.php index 310e7d4..db103fb 100644 --- a/flexiapi/tests/Feature/AccountJWTAuthenticationTest.php +++ b/flexiapi/tests/Feature/AccountJWTAuthenticationTest.php @@ -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([ diff --git a/flexiapi/tests/Feature/AccountProvisioningTest.php b/flexiapi/tests/Feature/AccountProvisioningTest.php index 4a56dd7..a69f828 100644 --- a/flexiapi/tests/Feature/AccountProvisioningTest.php +++ b/flexiapi/tests/Feature/AccountProvisioningTest.php @@ -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() diff --git a/flexiapi/tests/Feature/ApiAccountCreationTokenTest.php b/flexiapi/tests/Feature/ApiAccountCreationTokenTest.php index 9f40bcb..b22867a 100644 --- a/flexiapi/tests/Feature/ApiAccountCreationTokenTest.php +++ b/flexiapi/tests/Feature/ApiAccountCreationTokenTest.php @@ -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, [ diff --git a/flexiapi/tests/Feature/ApiAccountTest.php b/flexiapi/tests/Feature/ApiAccountTest.php index eb2b914..82d4baf 100644 --- a/flexiapi/tests/Feature/ApiAccountTest.php +++ b/flexiapi/tests/Feature/ApiAccountTest.php @@ -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 diff --git a/flexiapi/tests/Feature/ApiAuthenticationTest.php b/flexiapi/tests/Feature/ApiAuthenticationTest.php index 635a546..f18a63a 100644 --- a/flexiapi/tests/Feature/ApiAuthenticationTest.php +++ b/flexiapi/tests/Feature/ApiAuthenticationTest.php @@ -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)