Compare commits

...

30 commits

Author SHA1 Message Date
Timothée Jaussoin
03741bda83 Apply the same workaround than for the email registration for the phone ones 2023-11-28 09:48:51 +00:00
Timothée Jaussoin
eb2e93f77f Fix #124 Return 404 when the account is already provisioned or the provisioning_token not valid 2023-09-27 17:07:50 +02:00
Peio Rigaux
2aef80c792 Added Debian 12 to CI 2023-09-25 14:53:14 +02:00
Timothée Jaussoin
c67aabdf24 Fix #121 Only apply throttling to redeemed tokens 2023-09-11 09:27:51 +00:00
Timothée Jaussoin
50ec273cdd Fix #120 Add a workaround because Laravel seems to clear the errors when the Captcha is used 2023-09-07 14:10:01 +00:00
Timothée Jaussoin
96690590ae Fix #118 Add a throttling system for the AccountCreationToken push notification endpoint 2023-09-06 15:42:46 +02:00
Timothée Jaussoin
17300d3ae5 Fix #117 Redeem properly the tokens to prevent reuse 2023-08-29 09:55:04 +00:00
Timothée Jaussoin
1f84042e59 Fix #116 Fix Change SMS endpoint 2023-08-24 14:35:29 +02:00
Timothée Jaussoin
3f3ddda282 Fix #115 Check if $account exists 2023-08-24 14:18:12 +02:00
Timothée Jaussoin
7562218480 Release the 1.3.1 2023-07-28 10:48:52 +02:00
Timothée Jaussoin
2ddad9d851 Fix #111 Disable phone authentication routes when the related toggle is set to false 2023-07-28 10:22:19 +02:00
Timothée Jaussoin
c102bcec77 Release the 1.3 2023-06-12 13:00:41 +00:00
Timothée Jaussoin
25bec44486 Fix #108 Add + in the sed selection when renaming packages 2023-06-12 12:31:12 +00:00
Timothée Jaussoin
6555112715 Fix #97 Validate usernames with a configurable regex 2023-05-31 15:45:45 +02:00
Timothée Jaussoin
30b8e492d8 Fix #105 Return 404 and not 403 on POST... 2023-05-29 12:47:50 +00:00
Timothée Jaussoin
ec1bdba376 Fix #95 PUT /accounts admin endpoint implementation 2023-05-25 15:15:50 +00:00
Timothée Jaussoin
ca4320e734 Fix #104 Return validation URL when creation an account creation request token 2023-05-23 17:07:57 +02:00
Timothée Jaussoin
1e5052da1d Fix #103 Limit the size of account_creation_request_tokens token column to 16 characters 2023-05-23 16:35:57 +02:00
Timothée Jaussoin
cf6b007f26 Fix #102 Implement AccountCreationRequestToken 2023-05-23 14:29:01 +00:00
Timothée Jaussoin
6ca20e6a9c Fix #92 Add two new endpoints regarding email account reset and account search per email for admins 2023-05-10 10:01:46 +00:00
Timothée Jaussoin
d2b2a9dd6d Fix #100 Fix and move the SMS log to OvhSMS library 2023-05-04 17:49:10 +02:00
Timothée Jaussoin
82b5e967dc Fix #93 Remove the .htaccess file, redundant with the existing Apache configuration 2023-05-04 16:53:22 +02:00
Timothée Jaussoin
0d21f3fda9 Fix #99 Remove username restriction in Public unsecure endpoint 2023-05-04 14:36:41 +00:00
Timothée Jaussoin
9569c79008 Fix #98 Validate the existence of a similar key on POST /account_types for 1.3 2023-05-04 13:41:08 +00:00
Timothée Jaussoin
e11d55e3f9 Fix #94 Implement the deprecated endpoint changes + tests + documentation 2023-05-02 17:38:34 +02:00
Timothée Jaussoin
3f35954071 Fix #96 Add the missing sipmessage paramater value for dtmf_protocol in the documentation Release 1.3 2023-04-27 15:27:02 +00:00
Timothée Jaussoin
0a8eda05c4 Fix #60 Rename code to confirmation_key to be more consistent with the API,... 2023-04-26 15:45:32 +00:00
Timothée Jaussoin
c22e713e1a Complete the changelog 2023-04-06 14:08:05 +00:00
Timothée Jaussoin
9cb5953209 Fix #90 Also deploy packages on release branches 2023-04-06 15:33:48 +02:00
Timothée Jaussoin
233feef9d8 Fix #89 Rename packages before deployment to prevent some deployment issues 2023-04-06 14:52:56 +02:00
74 changed files with 1863 additions and 1149 deletions

View file

@ -14,6 +14,14 @@ debian11-deploy:
- debian11-package
- debian11-test
debian12-deploy:
extends: .deploy
script:
- ./deploy_packages.sh debian bookworm
needs:
- debian12-package
- debian12-test
remi-deploy:
extends: .deploy
script:
@ -28,11 +36,12 @@ remi-deploy:
stage: deploy
tags: ["docker"]
only:
refs:
- master
- master
- /^release/.*$/
before_script:
- rm -f $CI_PROJECT_DIR/build/*devel*.rpm
- rm -f $CI_PROJECT_DIR/build/*devel*.rpm # Remove devel packages
- cd $CI_PROJECT_DIR/build/ && for file in *; do mv "$file" $(echo "$file" | sed -e 's/[^A-Za-z0-9._+-]//g'); done || true && cd .. # Rename non standard packages
- eval $(ssh-agent -s)
- echo "$DEPLOY_USER_KEY" | tr -d '\r' | ssh-add - > /dev/null
- mkdir -p ~/.ssh && chmod 700 ~/.ssh

View file

@ -4,12 +4,19 @@ rocky8-package:
script:
- make rpm
debian11-package:
.debian_package:
extends: .package
image: gitlab.linphone.org:4567/bc/public/docker/debian11-php:$DEBIAN_11_IMAGE_VERSION
script:
- make deb
debian11-package:
extends: .debian_package
image: gitlab.linphone.org:4567/bc/public/docker/debian11-php:$DEBIAN_11_IMAGE_VERSION
debian12-package:
extends: .debian_package
image: gitlab.linphone.org:4567/bc/public/docker/debian12-php:$DEBIAN_12_IMAGE_VERSION
remi-phpredis-package:
extends: .remi-package
before_script:
@ -62,4 +69,4 @@ remi-xmlrpc-package:
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- flexiapi/vendor/
- flexiapi/vendor/

View file

@ -13,11 +13,8 @@ rocky8-test:
- php artisan key:generate
- vendor/bin/phpunit --log-junit $CI_PROJECT_DIR/flexiapi_phpunit.log
debian11-test:
.debian-test:
extends: .test
image: gitlab.linphone.org:4567/bc/public/docker/debian11-php:$DEBIAN_11_IMAGE_VERSION
needs:
- debian11-package
script:
- pwd
- apt update
@ -29,6 +26,18 @@ debian11-test:
- php artisan key:generate
- vendor/bin/phpunit --log-junit $CI_PROJECT_DIR/flexiapi_phpunit.log
debian11-test:
extends: .debian-test
image: gitlab.linphone.org:4567/bc/public/docker/debian11-php:$DEBIAN_11_IMAGE_VERSION
needs:
- debian11-package
debian12-test:
extends: .debian-test
image: gitlab.linphone.org:4567/bc/public/docker/debian12-php:$DEBIAN_12_IMAGE_VERSION
needs:
- debian12-package
remi-phpredis-test:
extends: .test
image: gitlab.linphone.org:4567/bc/public/docker/rocky8-php:$ROCKY_8_IMAGE_VERSION

View file

@ -1,9 +1,10 @@
variables:
ROCKY_8_IMAGE_VERSION: 20230330_163028_remove_remi
DEBIAN_11_IMAGE_VERSION: 20230322_172926_missing_tools
DEBIAN_11_IMAGE_VERSION: 20231127_160206_refresh_php_packages
DEBIAN_12_IMAGE_VERSION: 20230925_143235_enable_debian12_packaging
PHP_REDIS_REMI_VERSION: php-pecl-redis5-5.3.6-1
PHP_IGBINARY_REMI_VERSION: php-pecl-igbinary-3.2.14-1
PHP_MSGPACK_REMI_VERSION: php-pecl-msgpack-2.1.2-1
PHP_MSGPACK_REMI_VERSION: php-pecl-msgpack-2.2.0-1
PHP_XMLRPC_REMI_VERSION: php-pecl-xmlrpc-1.0.0~rc3-2
include:
@ -14,4 +15,4 @@ include:
stages:
- package
- test
- deploy
- deploy

View file

@ -1,7 +1,12 @@
# Flexisip Account Manager Changelog
v1.3.1
------
- Fix #111 Disable phone authentication routes when the related toggle is set to false
v1.3
----
- Fix #90 Deploy packages from release branches as well
- Fix #58 Fix the packaging process to use git describe as a reference
- Fix #58 Move the generated packages in the build directory, and fix the release and version format in the .spec
- Fix #58 Refactor and cleanup the .gitlab-ci file
@ -14,6 +19,11 @@ v1.3
- Fix #79 Add a refresh_password parameter to the provisioning URLs
- Fix #78 Add a APP_ACCOUNTS_EMAIL_UNIQUE environnement setting
- Fix #30 Remove APP_EVERYONE_IS_ADMIN
- Fix #97 Validate usernames with a configurable regex
- Fix #95 PUT /accounts admin endpoint implementation
- Fix #102 Implement AccountCreationRequestToken
- Fix #92 Add two new endpoints regarding email account reset and account search per email for admins
- Fix #94 Implement the deprecated endpoint changes + tests + documentation
v1.2
----

View file

@ -78,7 +78,7 @@ deb-only:
fakeroot alien -g -k --scripts $(OUTPUT_DIR)/rpmbuild/tmp.rpm
rm -r $(OUTPUT_DIR)/rpmbuild
rm -rf $(OUTPUT_DIR)/*.orig
sed -i 's/Depends:.*/Depends: $${shlibs:Depends}, php (>= 8.0), php-xml, php-pdo, php-gd, php-redis, php-mysql, php-mbstring, php-sqlite3/g' $(OUTPUT_DIR)/bc-flexisip-account-manager*/debian/control
sed -i 's/Depends:.*/Depends: $${shlibs:Depends}, php8.2-xml, php8.2-pdo, php8.2-gd, php8.2-redis, php8.2-mysql, php8.2-mbstring, php8.2-sqlite3/g' $(OUTPUT_DIR)/bc-flexisip-account-manager*/debian/control
cd `ls -rt $(OUTPUT_DIR) | tail -1` && dpkg-buildpackage --no-sign
@echo "📦✅ DEB Package Created"

View file

@ -10,6 +10,7 @@ APP_FLEXISIP_PUSHER_PATH=
APP_FLEXISIP_PUSHER_FIREBASE_KEY=
APP_API_KEY_EXPIRATION_MINUTES=60 # Number of minutes the generated API Keys are valid
APP_API_ACCOUNT_CREATION_TOKEN_RETRY_MINUTES=60 # Number of minutes between two consecutive account_creation_token creation
# Risky toggles
APP_ADMINS_MANAGE_MULTI_DOMAINS=false # Allow admins to handle all the accounts in the database
@ -24,6 +25,7 @@ ACCOUNT_REALM=null # Default realm for the accounts, fallback to the domain if n
ACCOUNT_EMAIL_UNIQUE=false # Emails are unique between all the accounts
ACCOUNT_CONSUME_EXTERNAL_ACCOUNT_ON_CREATE=false
ACCOUNT_BLACKLISTED_USERNAMES=
ACCOUNT_USERNAME_REGEX="^[a-z0-9+_.-]*$"
# Account provisioning
ACCOUNT_PROVISIONING_RC_FILE=

View file

@ -32,6 +32,7 @@ use App\Password;
use App\EmailChanged;
use App\Mail\ChangingEmail;
use Carbon\Carbon;
use Illuminate\Http\Request;
class Account extends Authenticatable
{
@ -108,62 +109,62 @@ class Account extends Authenticatable
public function activationExpiration()
{
return $this->hasOne('App\ActivationExpiration');
return $this->hasOne(ActivationExpiration::class);
}
public function admin()
{
return $this->hasOne('App\Admin');
return $this->hasOne(Admin::class);
}
public function alias()
{
return $this->hasOne('App\Alias');
return $this->hasOne(Alias::class);
}
public function apiKey()
{
return $this->hasOne('App\ApiKey');
return $this->hasOne(ApiKey::class);
}
public function externalAccount()
{
return $this->hasOne('App\ExternalAccount');
return $this->hasOne(ExternalAccount::class);
}
public function contacts()
{
return $this->belongsToMany('App\Account', 'contacts', 'account_id', 'contact_id');
return $this->belongsToMany(Account::class, 'contacts', 'account_id', 'contact_id');
}
public function emailChanged()
{
return $this->hasOne('App\EmailChanged');
return $this->hasOne(EmailChanged::class);
}
public function nonces()
{
return $this->hasMany('App\DigestNonce');
return $this->hasMany(DigestNonce::class);
}
public function authTokens()
{
return $this->hasMany('App\AuthToken');
return $this->hasMany(AuthToken::class);
}
public function passwords()
{
return $this->hasMany('App\Password');
return $this->hasMany(Password::class);
}
public function phoneChangeCode()
{
return $this->hasOne('App\PhoneChangeCode');
return $this->hasOne(PhoneChangeCode::class);
}
public function types()
{
return $this->belongsToMany('App\AccountType');
return $this->belongsToMany(AccountType::class);
}
/**
@ -202,6 +203,19 @@ class Account extends Authenticatable
return null;
}
public function setPhoneAttribute(?string $phone)
{
$this->alias()->delete();
if (!empty($phone)) {
$alias = new Alias;
$alias->alias = $phone;
$alias->domain = config('app.sip_domain');
$alias->account_id = $this->id;
$alias->save();
}
}
public function getConfirmationKeyExpiresAttribute()
{
if ($this->activationExpiration) {
@ -302,9 +316,20 @@ class Account extends Authenticatable
return $this->provisioning_token;
}
public function isAdmin()
public function getAdminAttribute(): bool
{
return ($this->admin);
return ($this->admin()->exists());
}
public function setAdminAttribute(bool $isAdmin)
{
$this->admin()->delete();
if ($isAdmin) {
$admin = new Admin;
$admin->account_id = $this->id;
$admin->save();
}
}
public function hasTombstone()
@ -325,6 +350,14 @@ class Account extends Authenticatable
$password->save();
}
public function fillPassword(Request $request)
{
if ($request->filled('password')) {
$this->algorithm = $request->has('password_sha256') ? 'SHA-256' : 'MD5';
$this->updatePassword($request->get('password'), $this->algorithm);
}
}
public function toVcard4()
{
$vcard = 'BEGIN:VCARD

View file

@ -30,6 +30,6 @@ class AccountAction extends Model
public function account()
{
return $this->belongsTo('App\Account');
return $this->belongsTo(Account::class);
}
}

View file

@ -0,0 +1,43 @@
<?php
/*
Flexisip Account Manager is a set of tools to manage SIP accounts.
Copyright (C) 2023 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/>.
*/
namespace App;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class AccountCreationRequestToken extends Model
{
use HasFactory;
protected $hidden = ['id', 'updated_at', 'created_at'];
protected $appends = ['validation_url'];
public function accountCreationToken()
{
return $this->belongsTo(AccountCreationToken::class, 'acc_creation_token_id');
}
public function getValidationUrlAttribute(): ?string
{
return $this->validated_at == null
? route('account.creation_request_token.check', $this->token)
: null;
}
}

View file

@ -25,4 +25,11 @@ use Illuminate\Database\Eloquent\Model;
class AccountCreationToken extends Model
{
use HasFactory;
protected $hidden = ['id', 'updated_at', 'created_at'];
public function accountCreationRequestToken()
{
return $this->hasOne(AccountCreationRequestToken::class, 'acc_creation_token_id');
}
}

View file

@ -30,6 +30,6 @@ class AccountType extends Model
public function accounts()
{
return $this->belongsToMany('App\Account');
return $this->belongsToMany(Account::class);
}
}

View file

@ -33,7 +33,7 @@ class ActivationExpiration extends Model
public function account()
{
return $this->belongsTo('App\Account');
return $this->belongsTo(Account::class);
}
public function isExpired()

View file

@ -30,4 +30,16 @@ class Alias extends Model
{
return $this->belongsTo('App\Account');
}
public function scopeSip($query, string $sip)
{
if (\str_contains($sip, '@')) {
list($usernane, $domain) = explode('@', $sip);
return $query->where('alias', $usernane)
->where('domain', $domain);
};
return $query->where('id', '<', 0);
}
}

View file

@ -22,6 +22,7 @@ use Illuminate\Support\Str;
use App\Account;
use App\DigestNonce;
use App\ExternalAccount;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension;
@ -119,3 +120,13 @@ function isRegularExpression($string): bool
return $isRegularExpression;
}
function resolveDomain(Request $request): string
{
return $request->has('domain')
&& $request->user()
&& $request->user()->admin
&& config('app.admins_manage_multi_domains')
? $request->get('domain')
: config('app.sip_domain');
}

View file

@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers\Account;
use App\AccountCreationRequestToken;
use App\Http\Controllers\Controller;
use App\Rules\AccountCreationRequestToken as RulesAccountCreationRequestToken;
use Carbon\Carbon;
use Illuminate\Http\Request;
class CreationRequestTokenController extends Controller
{
public function check(Request $request, string $creationRequestToken)
{
$request->merge(['account_creation_request_token' => $creationRequestToken]);
$request->validate([
'account_creation_request_token' => [
'required',
new RulesAccountCreationRequestToken
]
]);
$accountCreationRequestToken = AccountCreationRequestToken::where('token', $request->get('account_creation_request_token'))->firstOrFail();
return view('account.creation_request_token.check', [
'account_creation_request_token' => $accountCreationRequestToken
]);
}
public function validateToken(Request $request)
{
$request->validate([
'account_creation_request_token' => [
'required',
new RulesAccountCreationRequestToken
],
'g-recaptcha-response' => 'required|captcha',
]);
$accountCreationRequestToken = AccountCreationRequestToken::where('token', $request->get('account_creation_request_token'))->firstOrFail();
$accountCreationRequestToken->validated_at = Carbon::now();
$accountCreationRequestToken->save();
return view('account.creation_request_token.valid');
}
}

View file

@ -46,7 +46,7 @@ class ProvisioningController extends Controller
$params['reset_password'] = true;
}
$url = route('provisioning.show', $params);
$url = route('provisioning.provision', $params);
$result = Builder::create()
->writer(new PngWriter())
@ -73,7 +73,7 @@ class ProvisioningController extends Controller
$account = $authToken->account;
$authToken->delete();
return $this->show($request, null, $account);
return $this->generateProvisioning($request, $account);
}
abort(404);
@ -84,10 +84,38 @@ class ProvisioningController extends Controller
*/
public function me(Request $request)
{
return $this->show($request, null, $request->user());
return $this->generateProvisioning($request, $request->user());
}
public function show(Request $request, $provisioningToken = null, Account $requestAccount = null)
/**
* Get the base provisioning, with authentication
*/
public function show(Request $request)
{
return $this->generateProvisioning($request);
}
/**
* Provisioning Token based provisioning
*/
public function provision(Request $request, string $provisioningToken)
{
$account = Account::withoutGlobalScopes()
->where('provisioning_token', $provisioningToken)
->firstOrFail();
if ($account->activationExpired() || ($provisioningToken != $account->provisioning_token)) {
abort(404);
}
$account->activated = true;
$account->provisioning_token = null;
$account->save();
return $this->generateProvisioning($request, $account);
}
private function generateProvisioning(Request $request, Account $account = null)
{
// Load the hooks if they exists
$provisioningHooks = config_path('provisioning_hooks.php');
@ -132,19 +160,8 @@ class ProvisioningController extends Controller
}
}
$account = null;
// Account handling
if ($requestAccount) {
$account = $requestAccount;
} elseif ($provisioningToken) {
$account = Account::withoutGlobalScopes()
->where('provisioning_token', $provisioningToken)
->first();
}
// Password reset
if ($request->has('reset_password')) {
if ($account && $request->has('reset_password')) {
$account->updatePassword(Str::random(10));
}
@ -157,7 +174,7 @@ class ProvisioningController extends Controller
$config->appendChild($section);
if ($account && !$account->activationExpired()) {
if ($account) {
$externalAccount = $account->externalAccount;
$section = $dom->createElement('section');
@ -224,18 +241,6 @@ class ProvisioningController extends Controller
$authInfoIndex++;
}
if ($provisioningToken) {
// Activate the account
if ($account->activated == false
&& $provisioningToken == $account->provisioning_token
) {
$account->activated = true;
}
$account->provisioning_token = null;
$account->save();
}
$proxyConfigIndex++;
// External Account handling

View file

@ -24,6 +24,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Carbon\Carbon;
@ -67,7 +68,7 @@ class RegisterController extends Controller
public function storeEmail(Request $request)
{
$request->validate([
$validator = Validator::make($request->all(), [
'terms' => 'accepted',
'privacy' => 'accepted',
'username' => [
@ -91,6 +92,15 @@ class RegisterController extends Controller
: 'required|email|confirmed',
]);
// Weird workaround to force the injections of the validation errors,
// the redirection seems to clear them when the captcha is used
if ($validator->fails()) {
return view('account.register.email', [
'errors' => $validator->messages(),
'domain' => '@' . config('app.sip_domain')
]);
}
$account = new Account;
$account->username = $request->get('username');
$account->email = $request->get('email');
@ -117,7 +127,7 @@ class RegisterController extends Controller
public function storePhone(Request $request)
{
$request->validate([
$validator = Validator::make($request->all(), [
'terms' => 'accepted',
'privacy' => 'accepted',
'username' => [
@ -145,6 +155,15 @@ class RegisterController extends Controller
'g-recaptcha-response' => 'required|captcha',
]);
// Weird workaround to force the injections of the validation errors,
// the redirection seems to clear them when the captcha is used
if ($validator->fails()) {
return view('account.register.phone', [
'errors' => $validator->messages(),
'domain' => '@' . config('app.sip_domain')
]);
}
$account = new Account;
$account->username = !empty($request->get('username'))
? $request->get('username')

View file

@ -21,7 +21,6 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log;
use Carbon\Carbon;
@ -31,13 +30,6 @@ use App\Alias;
use App\ExternalAccount;
use App\Http\Requests\CreateAccountRequest;
use App\Http\Requests\UpdateAccountRequest;
use App\Http\Controllers\Account\AuthenticateController as WebAuthenticateController;
use App\Rules\BlacklistedUsername;
use App\Rules\IsNotPhoneNumber;
use App\Rules\NoUppercase;
use App\Rules\SIPUsername;
use App\Rules\WithoutSpaces;
use Illuminate\Validation\Rule;
class AccountController extends Controller
{
@ -73,45 +65,19 @@ class AccountController extends Controller
public function store(CreateAccountRequest $request)
{
$request->validate([
'username' => [
'required',
new NoUppercase,
new IsNotPhoneNumber,
new BlacklistedUsername,
new SIPUsername,
Rule::unique('accounts', 'username')->where(function ($query) use ($request) {
$query->where('domain', $this->resolveDomain($request));
}),
'filled',
],
'dtmf_protocol' => 'nullable|in:' . Account::dtmfProtocolsRule(),
'email' => [
'nullable',
'email',
config('app.account_email_unique') ? Rule::unique('accounts', 'email') : null
],
'phone' => [
'nullable',
'unique:aliases,alias',
'unique:accounts,username',
new WithoutSpaces, 'starts_with:+'
]
]);
$account = new Account;
$account->username = $request->get('username');
$account->email = $request->get('email');
$account->display_name = $request->get('display_name');
$account->domain = $this->resolveDomain($request);
$account->domain = resolveDomain($request);
$account->ip_address = $request->ip();
$account->creation_time = Carbon::now();
$account->user_agent = config('app.name');
$account->dtmf_protocol = $request->get('dtmf_protocol');
$account->save();
$this->fillPassword($request, $account);
$this->fillPhone($request, $account);
$account->phone = $request->get('phone');
$account->fillPassword($request);
Log::channel('events')->info('Web Admin: Account created', ['id' => $account->identifier]);
@ -128,32 +94,6 @@ class AccountController extends Controller
public function update(UpdateAccountRequest $request, $id)
{
$request->validate([
'username' => [
'required',
new NoUppercase,
new IsNotPhoneNumber,
new BlacklistedUsername,
new SIPUsername,
Rule::unique('accounts', 'username')->where(function ($query) use ($request) {
$query->where('domain', $this->resolveDomain($request));
})->ignore($id),
'filled',
],
'dtmf_protocol' => 'nullable|in:' . Account::dtmfProtocolsRule(),
'email' => [
'nullable',
'email',
config('app.account_email_unique') ? Rule::unique('accounts', 'email')->ignore($id) : null
],
'phone' => [
'nullable',
'unique:aliases,alias',
'unique:accounts,username',
new WithoutSpaces, 'starts_with:+'
]
]);
$account = Account::findOrFail($id);
$account->username = $request->get('username');
$account->email = $request->get('email');
@ -161,8 +101,8 @@ class AccountController extends Controller
$account->dtmf_protocol = $request->get('dtmf_protocol');
$account->save();
$this->fillPassword($request, $account);
$this->fillPhone($request, $account);
$account->phone = $request->get('phone');
$account->fillPassword($request);
Log::channel('events')->info('Web Admin: Account updated', ['id' => $account->identifier]);
@ -264,25 +204,4 @@ class AccountController extends Controller
return redirect()->route('admin.account.index');
}
private function fillPassword(Request $request, Account $account)
{
if ($request->filled('password')) {
$algorithm = $request->has('password_sha256') ? 'SHA-256' : 'MD5';
$account->updatePassword($request->get('password'), $algorithm);
}
}
private function fillPhone(Request $request, Account $account)
{
if ($request->filled('phone')) {
$account->alias()->delete();
$alias = new Alias;
$alias->alias = $request->get('phone');
$alias->domain = config('app.sip_domain');
$alias->account_id = $account->id;
$alias->save();
}
}
}

View file

@ -17,7 +17,7 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace App\Http\Controllers\Api;
namespace App\Http\Controllers\Api\Account;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
@ -28,12 +28,13 @@ use App\Http\Controllers\Controller;
use Carbon\Carbon;
use App\Account;
use App\AccountTombstone;
use App\AccountCreationToken;
use App\AccountTombstone;
use App\Alias;
use App\Http\Controllers\Account\AuthenticateController as WebAuthenticateController;
use App\Libraries\OvhSMS;
use App\Mail\RegisterConfirmation;
use App\Rules\AccountCreationToken as RulesAccountCreationToken;
use App\Rules\BlacklistedUsername;
use App\Rules\IsNotPhoneNumber;
use App\Rules\NoUppercase;
@ -70,11 +71,13 @@ class AccountController extends Controller
$alias = Alias::where('alias', $phone)->first();
$account = $alias
? $alias->account
: Account::sip($phone)->firstOrFail();
// Injecting the default sip domain to try to resolve the account
: Account::sip($phone . '@' . config('app.sip_domain'))->firstOrFail();
return \response()->json([
'activated' => $account->activated,
'realm' => $account->realm
'realm' => $account->realm,
'phone' => (bool)$alias
]);
}
@ -88,9 +91,8 @@ class AccountController extends Controller
$request->validate([
'username' => [
'prohibits:phone',
'required_without:phone',
new NoUppercase,
new IsNotPhoneNumber,
new BlacklistedUsername,
new SIPUsername,
Rule::unique('accounts', 'username')->where(function ($query) use ($request) {
@ -109,10 +111,14 @@ class AccountController extends Controller
: 'required_without:phone|email',
'phone' => [
'required_without:email',
'prohibits:username',
'required_without:username',
'unique:aliases,alias',
'unique:accounts,username',
new WithoutSpaces, 'starts_with:+'
],
'account_creation_token' => [
'required',
new RulesAccountCreationToken
]
]);
@ -127,12 +133,18 @@ class AccountController extends Controller
: config('app.sip_domain');
$account->ip_address = $request->ip();
$account->creation_time = Carbon::now();
$account->user_agent = config('app.name');
$account->user_agent = $request->header('User-Agent') ?? config('app.name');
$account->provision();
$account->save();
$account->updatePassword($request->get('password'), $request->get('algorithm'));
$token = AccountCreationToken::where('token', $request->get('account_creation_token'))->first();
$token->used = true;
$token->account_id = $account->id;
$token->save();
Log::channel('events')->info('API: AccountCreationToken redeemed', ['token' => $request->get('account_creation_token')]);
Log::channel('events')->info('API: Account created using the public endpoint', ['id' => $account->identifier]);
// Send validation by phone
@ -178,17 +190,27 @@ class AccountController extends Controller
$request->validate([
'phone' => [
'required', new WithoutSpaces, 'starts_with:+'
],
'account_creation_token' => [
'required',
new RulesAccountCreationToken
]
]);
$alias = Alias::where('alias', $request->get('phone'))->first();
$account = $alias
? $alias->account
: Account::sip($request->get('phone'))->firstOrFail();
: Account::sip($request->get('phone') . '@' . config('app.sip_domain'))->firstOrFail();
$account->confirmation_key = generatePin();
$account->save();
$token = AccountCreationToken::where('token', $request->get('account_creation_token'))->first();
$token->used = true;
$token->account_id = $account->id;
$token->save();
Log::channel('events')->info('API: AccountCreationToken redeemed', ['token' => $request->get('account_creation_token')]);
Log::channel('events')->info('API: Account recovery by phone', ['id' => $account->identifier]);
$ovhSMS = new OvhSMS;
@ -202,9 +224,12 @@ class AccountController extends Controller
{
if (!config('app.dangerous_endpoints')) return abort(404);
$account = Account::sip($sip)
->where('confirmation_key', $recoveryKey)
->firstOrFail();
$alias = Alias::sip($sip)->first();
$account = $alias
? $alias->account
: Account::sip($sip)->firstOrFail();
if ($account->confirmation_key != $recoveryKey) abort(404);
if ($account->activationExpired()) abort(403, 'Activation expired');
@ -240,29 +265,14 @@ class AccountController extends Controller
'password' => 'required|filled',
'dtmf_protocol' => 'nullable|in:' . Account::dtmfProtocolsRule(),
'account_creation_token' => [
'required_without:token',
Rule::exists('account_creation_tokens', 'token')->where(function ($query) {
$query->where('used', false);
}),
'size:' . WebAuthenticateController::$emailCodeSize
'required',
new RulesAccountCreationToken
],
'email' => config('app.account_email_unique')
? 'nullable|email|unique:accounts,email'
: 'nullable|email',
// For retro-compatibility
'token' => [
'required_without:account_creation_token',
Rule::exists('account_creation_tokens', 'token')->where(function ($query) {
$query->where('used', false);
}),
'size:' . WebAuthenticateController::$emailCodeSize
],
]);
$token = AccountCreationToken::where('token', $request->get('token') ?? $request->get('account_creation_token'))->first();
$token->used = true;
$token->save();
$account = new Account;
$account->username = $request->get('username');
$account->email = $request->get('email');
@ -277,6 +287,11 @@ class AccountController extends Controller
$account->updatePassword($request->get('password'), $request->get('algorithm'));
$token = AccountCreationToken::where('token', $request->get('account_creation_token'))->first();
$token->used = true;
$token->account_id = $account->id;
$token->save();
Log::channel('events')->info('API: Account created', ['id' => $account->identifier]);
// Full reload
@ -285,12 +300,17 @@ class AccountController extends Controller
public function activateEmail(Request $request, string $sip)
{
// For retro-compatibility
if ($request->has('code')) {
$request->merge(['confirmation_key' => $request->get('code')]);
}
$request->validate([
'code' => 'required|size:' . WebAuthenticateController::$emailCodeSize
'confirmation_key' => 'required|size:' . WebAuthenticateController::$emailCodeSize
]);
$account = Account::sip($sip)
->where('confirmation_key', $request->get('code'))
->where('confirmation_key', $request->get('confirmation_key'))
->firstOrFail();
if ($account->activationExpired()) abort(403, 'Activation expired');
@ -306,12 +326,17 @@ class AccountController extends Controller
public function activatePhone(Request $request, string $sip)
{
// For retro-compatibility
if ($request->has('code')) {
$request->merge(['confirmation_key' => $request->get('code')]);
}
$request->validate([
'code' => 'required|digits:4'
'confirmation_key' => 'required|digits:4'
]);
$account = Account::sip($sip)
->where('confirmation_key', $request->get('code'))
->where('confirmation_key', $request->get('confirmation_key'))
->firstOrFail();
if ($account->activationExpired()) abort(403, 'Activation expired');

View file

@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\Api;
namespace App\Http\Controllers\Api\Account;
use App\AuthToken;
use App\Http\Controllers\Controller;

View file

@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\Api;
namespace App\Http\Controllers\Api\Account;
use App\AuthToken;
use App\Http\Controllers\Controller;

View file

@ -17,13 +17,13 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace App\Http\Controllers\Api;
namespace App\Http\Controllers\Api\Account;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
class AccountContactController extends Controller
class ContactController extends Controller
{
private $selected = ['id', 'username', 'domain', 'activated', 'dtmf_protocol'];

View file

@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers\Api\Account;
use App\AccountCreationRequestToken;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use App\Http\Controllers\Account\AuthenticateController as WebAuthenticateController;
use App\Http\Controllers\Controller;
class CreationRequestToken extends Controller
{
public function create(Request $request)
{
$creationRequestToken = new AccountCreationRequestToken;
$creationRequestToken->token = Str::random(WebAuthenticateController::$emailCodeSize);
$creationRequestToken->save();
return $creationRequestToken;
}
}

View file

@ -17,18 +17,21 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace App\Http\Controllers\Api;
namespace App\Http\Controllers\Api\Account;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log;
use Carbon\Carbon;
use App\AccountCreationToken;
use App\Libraries\FlexisipPusherConnector;
use App\AccountCreationRequestToken;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Account\AuthenticateController as WebAuthenticateController;
use App\Libraries\FlexisipPusherConnector;
use App\Rules\AccountCreationRequestToken as RulesAccountCreationRequestToken;
class AccountCreationTokenController extends Controller
class CreationTokenController extends Controller
{
public function sendByPush(Request $request)
{
@ -38,6 +41,19 @@ class AccountCreationTokenController extends Controller
'pn_prid' => 'required',
]);
$last = AccountCreationToken::where('pn_provider', $request->get('pn_provider'))
->where('pn_param', $request->get('pn_param'))
->where('pn_prid', $request->get('pn_prid'))
->where('created_at', '>=', Carbon::now()->subMinutes(config('app.account_creation_token_retry_minutes'))->toDateTimeString())
->where('used', true)
->latest()
->first();
if ($last) {
Log::channel('events')->info('API: Token throttled', ['token' => $last->token]);
abort(429, 'Last token requested too recently');
}
$token = new AccountCreationToken;
$token->token = Str::random(WebAuthenticateController::$emailCodeSize);
$token->pn_provider = $request->get('pn_provider');
@ -55,4 +71,32 @@ class AccountCreationTokenController extends Controller
abort(503, "Token not sent");
}
public function usingAccountRequestToken(Request $request)
{
$request->validate([
'account_creation_request_token' => [
'required',
new RulesAccountCreationRequestToken
]
]);
$creationRequestToken = AccountCreationRequestToken::where('token', $request->get('account_creation_request_token'))
->where('used', false)
->first();
if ($creationRequestToken && $creationRequestToken->validated_at != null) {
$accountCreationToken = new AccountCreationToken;
$accountCreationToken->token = Str::random(WebAuthenticateController::$emailCodeSize);
$accountCreationToken->save();
$creationRequestToken->used = true;
$creationRequestToken->acc_creation_token_id = $accountCreationToken->id;
$creationRequestToken->save();
return $accountCreationToken;
}
return abort(404);
}
}

View file

@ -17,7 +17,7 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace App\Http\Controllers\Api;
namespace App\Http\Controllers\Api\Account;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

View file

@ -17,7 +17,7 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace App\Http\Controllers\Api;
namespace App\Http\Controllers\Api\Account;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

View file

@ -17,7 +17,7 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace App\Http\Controllers\Api;
namespace App\Http\Controllers\Api\Account;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

View file

@ -17,7 +17,7 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace App\Http\Controllers\Api;
namespace App\Http\Controllers\Api\Account;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
@ -29,7 +29,7 @@ use App\Libraries\OvhSMS;
use App\PhoneChangeCode;
use App\Alias;
class AccountPhoneController extends Controller
class PhoneController extends Controller
{
public function requestUpdate(Request $request)
{

View file

@ -22,7 +22,6 @@ namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Support\Facades\Log;
use Carbon\Carbon;
@ -33,11 +32,10 @@ use App\ActivationExpiration;
use App\Admin;
use App\Alias;
use App\Http\Controllers\Account\AuthenticateController as WebAuthenticateController;
use App\Rules\BlacklistedUsername;
use App\Rules\IsNotPhoneNumber;
use App\Rules\NoUppercase;
use App\Rules\SIPUsername;
use App\Rules\WithoutSpaces;
use App\Http\Requests\CreateAccountRequest;
use App\Http\Requests\UpdateAccountRequest;
use App\Mail\PasswordAuthentication;
use Illuminate\Support\Facades\Mail;
class AccountController extends Controller
{
@ -56,6 +54,11 @@ class AccountController extends Controller
return Account::sip($sip)->firstOrFail();
}
public function searchByEmail(Request $request, string $email)
{
return Account::where('email', $email)->firstOrFail();
}
public function destroy($id)
{
$account = Account::findOrFail($id);
@ -105,36 +108,15 @@ class AccountController extends Controller
return $account->makeVisible(['provisioning_token']);
}
public function store(Request $request)
public function store(CreateAccountRequest $request)
{
$request->validate([
'username' => [
'required',
new NoUppercase,
new IsNotPhoneNumber,
new BlacklistedUsername,
new SIPUsername,
Rule::unique('accounts', 'username')->where(function ($query) use ($request) {
$query->where('domain', $this->resolveDomain($request));
}),
'filled',
],
'algorithm' => 'required|in:SHA-256,MD5',
'password' => 'required|filled',
'admin' => 'boolean|nullable',
'activated' => 'boolean|nullable',
'dtmf_protocol' => 'nullable|in:' . Account::dtmfProtocolsRule(),
'confirmation_key_expires' => [
'date_format:Y-m-d H:i:s',
'nullable',
],
'email' => config('app.account_email_unique')
? 'nullable|email|unique:accounts,email'
: 'nullable|email',
'phone' => [
'unique:aliases,alias',
'unique:accounts,username',
new WithoutSpaces, 'starts_with:+'
]
]);
@ -146,7 +128,7 @@ class AccountController extends Controller
$account->ip_address = $request->ip();
$account->dtmf_protocol = $request->get('dtmf_protocol');
$account->creation_time = Carbon::now();
$account->domain = $this->resolveDomain($request);
$account->domain = resolveDomain($request);
$account->user_agent = $request->header('User-Agent') ?? config('app.name');
if (!$request->has('activated') || !(bool)$request->get('activated')) {
@ -165,27 +147,45 @@ class AccountController extends Controller
}
$account->updatePassword($request->get('password'), $request->get('algorithm'));
if ($request->has('admin') && (bool)$request->get('admin')) {
$admin = new Admin;
$admin->account_id = $account->id;
$admin->save();
}
if ($request->has('phone')) {
$alias = new Alias;
$alias->alias = $request->get('phone');
$alias->domain = config('app.sip_domain');
$alias->account_id = $account->id;
$alias->save();
}
$account->admin = $request->has('admin') && (bool)$request->get('admin');
$account->phone = $request->get('phone');
// Full reload
$account = Account::withoutGlobalScopes()->find($account->id);
Log::channel('events')->info('API: Admin: Account created', ['id' => $account->identifier]);
Log::channel('events')->info('API Admin: Account created', ['id' => $account->identifier]);
return response()->json($account->makeVisible(['confirmation_key', 'provisioning_token']));
return $account->makeVisible(['confirmation_key', 'provisioning_token']);
}
public function update(UpdateAccountRequest $request, int $accountId)
{
$request->validate([
'algorithm' => 'required|in:SHA-256,MD5',
'admin' => 'boolean|nullable',
'activated' => 'boolean|nullable'
]);
$account = Account::findOrFail($accountId);
$account->username = $request->get('username');
$account->email = $request->get('email');
$account->display_name = $request->get('display_name');
$account->dtmf_protocol = $request->get('dtmf_protocol');
$account->domain = resolveDomain($request);
$account->user_agent = $request->header('User-Agent') ?? config('app.name');
$account->save();
$account->updatePassword($request->get('password'), $request->get('algorithm'));
$account->admin = $request->has('admin') && (bool)$request->get('admin');
$account->phone = $request->get('phone');
// Full reload
$account = Account::withoutGlobalScopes()->find($account->id);
Log::channel('events')->info('API Admin: Account updated', ['id' => $account->identifier]);
return $account->makeVisible(['confirmation_key', 'provisioning_token']);
}
public function typeAdd(int $id, int $typeId)
@ -207,4 +207,18 @@ class AccountController extends Controller
return Account::findOrFail($id)->types()->detach($typeId);
}
public function recoverByEmail(int $id)
{
$account = Account::findOrFail($id);
$account->provision();
$account->confirmation_key = Str::random(WebAuthenticateController::$emailCodeSize);
$account->save();
Log::channel('events')->info('API Admin: Sending recovery email', ['id' => $account->identifier]);
Mail::to($account)->send(new PasswordAuthentication($account));
return $account->makeVisible(['confirmation_key', 'provisioning_token']);
}
}

View file

@ -17,7 +17,7 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace App\Http\Controllers\Api;
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

View file

@ -41,7 +41,7 @@ class AccountTypeController extends Controller
public function store(Request $request)
{
$request->validate([
'key' => ['required', 'alpha_dash', new NoUppercase],
'key' => ['required', 'alpha_dash', new NoUppercase, 'unique:account_types,key'],
]);
$accountType = new AccountType;

View file

@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\Api;
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;

View file

@ -11,14 +11,4 @@ use Illuminate\Routing\Controller as BaseController;
class Controller extends BaseController
{
use AuthorizesRequests, DispatchesJobs, ValidatesRequests;
protected function resolveDomain(Request $request): string
{
return $request->has('domain')
&& $request->user()
&& $request->user()->isAdmin()
&& config('app.admins_manage_multi_domains')
? $request->get('domain')
: config('app.sip_domain');
}
}

View file

@ -19,7 +19,7 @@ class AuthenticateAdmin
return redirect()->route('account.login');
}
if (!$request->user()->isAdmin()) {
if (!$request->user()->admin) {
return abort(403, 'Unauthorized area');
}

View file

@ -29,13 +29,14 @@ class CreateAccountRequest extends FormRequest
new BlacklistedUsername,
new SIPUsername,
Rule::unique('accounts', 'username')->where(function ($query) {
$query->where('domain', config('app.sip_domain'));
$query->where('domain', resolveDomain($this));
}),
'filled',
],
'domain' => config('app.admins_manage_multi_domains') ? 'required' : '',
'password' => 'required|min:3',
'email' => 'nullable|email',
'email' => config('app.account_email_unique')
? 'nullable|email|unique:accounts,email'
: 'nullable|email',
'dtmf_protocol' => 'nullable|in:' . Account::dtmfProtocolsRule(),
'phone' => [
'nullable',

View file

@ -33,8 +33,11 @@ class UpdateAccountRequest extends FormRequest
})->ignore($this->route('id'), 'id'),
'filled',
],
'domain' => config('app.admins_manage_multi_domains') ? 'required' : '',
'email' => 'nullable|email',
'email' => [
'nullable',
'email',
config('app.account_email_unique') ? Rule::unique('accounts', 'email')->ignore($this->route('id')) : null
],
'password_sha256' => 'nullable|min:3',
'dtmf_protocol' => 'nullable|in:' . Account::dtmfProtocolsRule(),
'phone' => [

View file

@ -41,7 +41,7 @@ class OvhSMS
);
try {
$smsServices = $this->api->get('/sms/');
$smsServices = $this->api->get('/sms');
if (!empty($smsServices)) {
$this->smsService = $smsServices[0];
@ -71,6 +71,8 @@ class OvhSMS
'validityPeriod' => 2880
];
Log::channel('events')->info('OVH SMS sending', ['to' => $to, 'message' => $message]);
try {
$this->api->post('/sms/'. $this->smsService . '/jobs', $content);
// One credit removed

View file

@ -46,7 +46,7 @@ class PasswordAuthentication extends Mailable
: 'mails.authentication_text')
->with([
'link' => route('account.authenticate.email_confirm', [$this->account->confirmation_key]),
'provisioning_link' => route('provisioning.show', [
'provisioning_link' => route('provisioning.provision', [
'provisioning_token' => $this->account->provisioning_token,
'reset_password' => true
]),

View file

@ -0,0 +1,38 @@
<?php
/*
Flexisip Account Manager is a set of tools to manage SIP accounts.
Copyright (C) 2023 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/>.
*/
namespace App\Rules;
use App\AccountCreationRequestToken as AppAccountCreationRequestToken;
use App\Http\Controllers\Account\AuthenticateController;
use Illuminate\Contracts\Validation\Rule;
class AccountCreationRequestToken implements Rule
{
public function passes($attribute, $value)
{
return AppAccountCreationRequestToken::where('token', $value)->where('used', false)->exists()
&& strlen($value) == AuthenticateController::$emailCodeSize;
}
public function message()
{
return 'Please provide a valid account_creation_request_token';
}
}

View file

@ -0,0 +1,38 @@
<?php
/*
Flexisip Account Manager is a set of tools to manage SIP accounts.
Copyright (C) 2023 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/>.
*/
namespace App\Rules;
use App\AccountCreationToken as AppAccountCreationToken;
use App\Http\Controllers\Account\AuthenticateController;
use Illuminate\Contracts\Validation\Rule;
class AccountCreationToken implements Rule
{
public function passes($attribute, $value)
{
return AppAccountCreationToken::where('token', $value)->where('used', false)->exists()
&& strlen($value) == AuthenticateController::$emailCodeSize;
}
public function message()
{
return 'Please provide a valid account_creation_token';
}
}

View file

@ -1,4 +1,21 @@
<?php
/*
Flexisip Account Manager is a set of tools to manage SIP accounts.
Copyright (C) 2023 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/>.
*/
namespace App\Rules;

View file

@ -26,7 +26,7 @@ class SIPUsername implements Rule
{
public function passes($attribute, $value)
{
return Validator::regex('/^[a-z0-9+_.-]*$/')->validate($value);
return Validator::regex('/' . config('app.account_username_regex') . '/')->validate($value);
}
public function message()

1195
flexiapi/composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -31,12 +31,18 @@ return [
'account_email_unique' => env('ACCOUNT_EMAIL_UNIQUE', false),
'consume_external_account_on_create' => env('ACCOUNT_CONSUME_EXTERNAL_ACCOUNT_ON_CREATE', false),
'blacklisted_usernames' => env('ACCOUNT_BLACKLISTED_USERNAMES', ''),
'account_username_regex' => env('ACCOUNT_USERNAME_REGEX', '^[a-z0-9+_.-]*$'),
/**
* Time limit before the API Key and related cookie are expired
*/
'api_key_expiration_minutes' => env('APP_API_KEY_EXPIRATION_MINUTES', 60),
/**
* Amount of minutes before re-authorizing the generation of a new account creation token
*/
'account_creation_token_retry_minutes' => env('APP_API_ACCOUNT_CREATION_TOKEN_RETRY_MINUTES', 60),
/**
* External interfaces
*/

View file

@ -24,6 +24,7 @@ use Illuminate\Support\Str;
use App\AccountCreationToken;
use App\Http\Controllers\Account\AuthenticateController as WebAuthenticateController;
use Illuminate\Support\Carbon;
class AccountCreationTokenFactory extends Factory
{
@ -36,7 +37,8 @@ class AccountCreationTokenFactory extends Factory
'pn_param' => $this->faker->uuid,
'pn_prid' => $this->faker->uuid,
'token' => Str::random(WebAuthenticateController::$emailCodeSize),
'used' => false
'used' => false,
'created_at' => Carbon::now()
];
}
}

View file

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddEmailIndexToAccountsTable extends Migration
{
public function up()
{
Schema::table('accounts', function (Blueprint $table) {
$table->index('email');
});
}
public function down()
{
Schema::table('accounts', function (Blueprint $table) {
$table->dropIndex('accounts_email_index');
});
}
}

View file

@ -0,0 +1,29 @@
<?php
use App\AccountCreationToken;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class MakePnAttributesNullableAccountCreationTokensTable extends Migration
{
public function up()
{
Schema::table('account_creation_tokens', function (Blueprint $table) {
$table->string('pn_provider')->nullable(true)->change();
$table->string('pn_param')->nullable(true)->change();
$table->string('pn_prid')->nullable(true)->change();
});
}
public function down()
{
AccountCreationToken::whereNull('pn_provider')->delete();
Schema::table('account_creation_tokens', function (Blueprint $table) {
$table->string('pn_provider')->nullable(false)->change();
$table->string('pn_param')->nullable(false)->change();
$table->string('pn_prid')->nullable(false)->change();
});
}
}

View file

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateAccountCreationRequestTokensTable extends Migration
{
public function up()
{
Schema::create('account_creation_request_tokens', function (Blueprint $table) {
$table->id();
$table->string('token', 16)->index();
$table->boolean('used')->default(false);
$table->dateTime('validated_at')->nullable();
$table->bigInteger('acc_creation_token_id')->unsigned()->nullable();
$table->foreign('acc_creation_token_id')->references('id')
->on('account_creation_tokens')->onDelete('cascade');
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('account_creation_request_tokens');
}
}

View file

@ -1,23 +0,0 @@
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Handle Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
RewriteBase /flexiapi/
</IfModule>

View file

@ -29,6 +29,10 @@ body > div {
max-width: 800px;
}
.container.large {
max-width: 1024px;
}
body > footer::before {
background-color: white;
background-position: bottom center;

View file

@ -0,0 +1,13 @@
@extends('layouts.main')
@section('content')
<div class="card mt-3">
<div class="card-body">
{!! Form::open(['route' => 'account.creation_request_token.validate']) !!}
{!! Form::hidden('account_creation_request_token', $account_creation_request_token->token) !!}
@include('parts.captcha')
{!! Form::submit('I\'m not a robot', ['class' => 'btn btn-primary btn-centered']) !!}
{!! Form::close() !!}
</div>
</div>
@endsection

View file

@ -0,0 +1,6 @@
@extends('layouts.main')
@section('content')
<h3 class="text-center mt-5">Thanks for the validation</h3>
<p class="text-center">You can now continue your registration process in the application</p>
@endsection

View file

@ -45,7 +45,7 @@
</a>
</div>
@if($account->isAdmin())
@if($account->admin)
<h3>Admin area</h3>
<div class="list-group mb-3">
<a href="{{ route('admin.account.index') }}" class="list-group-item list-group-item-action">

View file

@ -95,4 +95,4 @@
{!! Form::submit(($account->id) ? 'Update' : 'Create', ['class' => 'btn btn-success btn-centered']) !!}
{!! Form::close() !!}
@endsection
@endsection

View file

@ -1,4 +1,4 @@
@extends('layouts.main')
@extends('layouts.main', ['large' => true])
@section('content')
{{-- This view is only a wrapper for the markdown page --}}

View file

@ -78,9 +78,19 @@ You can find more documentation on the related [IETF RFC-7616](https://tools.iet
<span class="badge badge-success">Public</span>
Returns `pong`
## Account Creation Request Tokens
An `account_creation_request_token` is a unique token that can be validated and then used to generate a valid `account_creation_token`.
### `POST /account_creation_request_tokens`
<span class="badge badge-success">Public</span>
Create and return an `account_creation_request_token` that should then be validated to be used.
## Account Creation Tokens
An account creation token is a unique token that allow the creation of a **unique** account.
An `account_creation_token` is a unique token that allow the creation of a **unique** account.
### `POST /account_creation_tokens/send-by-push`
<span class="badge badge-success">Public</span>
@ -94,6 +104,16 @@ JSON parameters:
* `pn_param` the push notification parameter
* `pn_prid` the push notification unique id
### `POST /account_creation_tokens/using-account-creation-request-token`
<span class="badge badge-success">Public</span>
Create an `account_creation_token` using an `account_creation_request_token`.
Return an `account_creation_token`.
Return `404` if the `account_creation_request_token` provided is not valid or expired otherwise.
JSON parameters:
* `account_creation_request_token` required
### `POST /account_creation_tokens`
<span class="badge badge-warning">Admin</span>
@ -134,6 +154,7 @@ JSON parameters:
* `domain` if not set the value is enforced to the default registration domain set in the global configuration
* `email` optional if `phone` set, an email, set an email to the account, must be unique if `ACCOUNT_EMAIL_UNIQUE` is set to `true`
* `phone` required if `username` not set, optional if `email` set, a phone number, set a phone number to the account
* `account_creation_token` the unique `account_creation_token`
### `POST /accounts/with-account-creation-token`
<span class="badge badge-success">Public</span>
@ -146,7 +167,7 @@ JSON parameters:
* `password` required minimum 6 characters
* `algorithm` required, values can be `SHA-256` or `MD5`
* `account_creation_token` the unique `account_creation_token`
* `dtmf_protocol` optional, values must be `sipinfo` or `rfc2833`
* `dtmf_protocol` optional, values must be `sipinfo`, `sipmessage` or `rfc2833`
### `GET /accounts/{sip}/info`
<span class="badge badge-success">Public</span>
@ -162,6 +183,8 @@ Return `404` if the account doesn't exists.
Retrieve public information about the account.
Return `404` if the account doesn't exists.
Return `phone: true` if the returned account has a phone number.
### `POST /accounts/recover-by-phone`
@if(config('app.dangerous_endpoints'))**Enabled on this instance**@else**Not enabled on this instance**@endif
@ -174,6 +197,7 @@ Return `404` if the account doesn't exists.
JSON parameters:
* `phone` required the phone number to send the SMS to
* `account_creation_token` the unique `account_creation_token`
### `GET /accounts/{sip}/recover/{recover_key}`
@if(config('app.dangerous_endpoints'))**Enabled on this instance**@else**Not enabled on this instance**@endif
@ -182,6 +206,9 @@ JSON parameters:
<span class="badge badge-success">Public</span>
<span class="badge badge-warning">Unsecure endpoint</span>
Activate the account if the correct `recover_key` is provided.
The `sip` parameter can be the default SIP account or the phone based one.
Return the account information (including the hashed password) if valid.
Return `404` if the account doesn't exists.
@ -192,7 +219,7 @@ Activate an account using a secret code received by email.
Return `404` if the account doesn't exists or if the code is incorrect, the validated account otherwise.
JSON parameters:
* `code` the code
* `confirmation_key` the confirmation key
### `POST /accounts/{sip}/activate/phone`
<span class="badge badge-success">Public</span>
@ -200,7 +227,7 @@ Activate an account using a pin code received by phone.
Return `404` if the account doesn't exists or if the code is incorrect, the validated account otherwise.
JSON parameters:
* `code` the PIN code
* `confirmation_key` the PIN code
### `GET /accounts/me/api_key/{auth_token}`
<span class="badge badge-success">Public</span>
@ -261,9 +288,24 @@ JSON parameters:
* `email` optional, must be an email, must be unique if `ACCOUNT_EMAIL_UNIQUE` is set to `true`
* `admin` optional, a boolean, set to `false` by default, create an admin account
* `phone` optional, a phone number, set a phone number to the account
* `dtmf_protocol` optional, values must be `sipinfo` or `rfc2833`
* `dtmf_protocol` optional, values must be `sipinfo`, `sipmessage` or `rfc2833`
* `confirmation_key_expires` optional, a datetime of this format: Y-m-d H:i:s. Only used when `activated` is not used or `false`. Enforces an expiration date on the returned `confirmation_key`. After that datetime public email or phone activation endpoints will return `403`.
### `PUT /accounts/{id}`
<span class="badge badge-warning">Admin</span>
Update an existing account.
JSON parameters:
* `username` unique username, minimum 6 characters
* `password` required minimum 6 characters
* `algorithm` required, values can be `SHA-256` or `MD5`
* `display_name` optional, string
* `email` optional, must be an email, must be unique if `ACCOUNT_EMAIL_UNIQUE` is set to `true`
* `admin` optional, a boolean, set to `false` by default, create an admin account
* `phone` optional, a phone number, set a phone number to the account
* `dtmf_protocol` optional, values must be `sipinfo`, `sipmessage` or `rfc2833`
### `GET /accounts`
<span class="badge badge-warning">Admin</span>
Retrieve all the accounts, paginated.
@ -272,10 +314,18 @@ Retrieve all the accounts, paginated.
<span class="badge badge-warning">Admin</span>
Retrieve a specific account.
### `POST /accounts/{id}/recover-by-email`
<span class="badge badge-warning">Admin</span>
Send the account recovery email containing a fresh `provisioning_token` and `confirmation_key`
### `GET /accounts/{sip}/search`
<span class="badge badge-warning">Admin</span>
Search for a specific account by sip address.
### `GET /accounts/{email}/search-by-email`
<span class="badge badge-warning">Admin</span>
Search for a specific account by email.
### `DELETE /accounts/{id}`
<span class="badge badge-warning">Admin</span>
Delete a specific account and its related information.
@ -447,25 +497,37 @@ The following URLs are **not API endpoints** they are not returning `JSON` conte
## Provisioning
When an account is having an available `provisioning_token` it can be provisioned using the two following URL.
When an account is having an available `provisioning_token` it can be provisioned using the following URLs.
### `GET /provisioning`
<span class="badge badge-success">Public</span>
Return the provisioning information available in the liblinphone configuration file (if correctly configured).
### `GET /provisioning/{provisioning_token}?reset_password`
<span class="badge badge-success">Public</span>
Return the provisioning information available in the liblinphone configuration file.
If the `provisioning_token` is valid the related account information are added to the returned XML. The account is then considered as "provisioned" and those account related information will be removed in the upcoming requests (the content will be the same as the previous url).
### `GET /provisioning/auth_token/{auth_token}`
If the account is not activated and the `provisioning_token` is valid. The account will be activated.
<span class="badge badge-success">Public</span>
Return the provisioning information available linked to the account that was attached to the `auth_token`.
### `GET /provisioning/{provisioning_token}?reset_password`
<span class="badge badge-success">Public</span>
Return the provisioning information available linked to the account related to the `provisioning_token`.
Return `404` if the `provisioning_token` provided is not valid or expired otherwise.
If the account is not activated the account will be activated. The account is then considered as "provisioned".
URL parameters:
* `reset_password` optional, reset the password while doing the provisioning
### `GET /provisioning/qrcode/{provisioning_token}?reset_password`
<span class="badge badge-success">Public</span>
Return a QRCode that points to the provisioning URL.
URL parameters:
@ -473,7 +535,11 @@ URL parameters:
* `reset_password` optional, reset the password while doing the provisioning
### `GET /provisioning/me`
<span class="badge badge-info">User</span>
Authenticated endpoint, see [API About & Auth]({{ route('api') }}#about--auth)
Return the same base content as the previous URL and the account related information, similar to the `provisioning_token` endpoint. However this endpoint will always return those information.
## Contacts list

View file

@ -33,7 +33,7 @@
@endsection
@section('body')
<div class="container pt-4">
<div class="container @if (isset($large) && $large) large @endif pt-4">
@include('parts.errors')
@yield('content')
</div>

View file

@ -26,69 +26,74 @@ Route::middleware('auth:api')->get('/user', function (Request $request) {
});
Route::get('ping', 'Api\PingController@ping');
Route::post('account_creation_tokens/send-by-push', 'Api\AccountCreationTokenController@sendByPush');
// Old URL, for retro-compatibility
Route::post('tokens', 'Api\AccountCreationTokenController@sendByPush');
Route::get('accounts/{sip}/info', 'Api\AccountController@info');
Route::post('account_creation_request_tokens', 'Api\Account\CreationRequestToken@create');
Route::post('account_creation_tokens/send-by-push', 'Api\Account\CreationTokenController@sendByPush');
Route::post('account_creation_tokens/using-account-creation-request-token', 'Api\Account\CreationTokenController@usingAccountRequestToken');
Route::post('accounts/with-account-creation-token', 'Api\Account\AccountController@store');
Route::post('accounts/with-account-creation-token', 'Api\AccountController@store');
// Old URL, for retro-compatibility
Route::post('accounts/with-token', 'Api\AccountController@store');
Route::get('accounts/{sip}/info', 'Api\Account\AccountController@info');
Route::post('accounts/{sip}/activate/email', 'Api\AccountController@activateEmail');
Route::post('accounts/{sip}/activate/phone', 'Api\AccountController@activatePhone');
Route::post('accounts/{sip}/activate/email', 'Api\Account\AccountController@activateEmail');
Route::post('accounts/{sip}/activate/phone', 'Api\Account\AccountController@activatePhone');
// /!\ Dangerous endpoints
Route::post('accounts/public', 'Api\AccountController@storePublic');
Route::get('accounts/{sip}/recover/{recovery_key}', 'Api\AccountController@recoverUsingKey');
Route::post('accounts/recover-by-phone', 'Api\AccountController@recoverByPhone');
Route::get('accounts/{phone}/info-by-phone', 'Api\AccountController@phoneInfo');
Route::post('accounts/public', 'Api\Account\AccountController@storePublic');
Route::get('accounts/{sip}/recover/{recovery_key}', 'Api\Account\AccountController@recoverUsingKey');
Route::post('accounts/recover-by-phone', 'Api\Account\AccountController@recoverByPhone');
Route::get('accounts/{phone}/info-by-phone', 'Api\Account\AccountController@phoneInfo');
Route::post('accounts/auth_token', 'Api\AuthTokenController@store');
Route::post('accounts/auth_token', 'Api\Account\AuthTokenController@store');
Route::get('accounts/me/api_key/{auth_token}', 'Api\ApiKeyController@generateFromToken')->middleware('cookie', 'cookie.encrypt');
Route::get('accounts/me/api_key/{auth_token}', 'Api\Account\ApiKeyController@generateFromToken')->middleware('cookie', 'cookie.encrypt');
Route::group(['middleware' => ['auth.digest_or_key']], function () {
Route::get('statistic/month', 'Api\StatisticController@month');
Route::get('statistic/week', 'Api\StatisticController@week');
Route::get('statistic/day', 'Api\StatisticController@day');
Route::get('accounts/auth_token/{auth_token}/attach', 'Api\AuthTokenController@attach');
Route::get('accounts/auth_token/{auth_token}/attach', 'Api\Account\AuthTokenController@attach');
Route::get('accounts/me/api_key', 'Api\ApiKeyController@generate')->middleware('cookie', 'cookie.encrypt');
Route::get('accounts/me/api_key', 'Api\Account\ApiKeyController@generate')->middleware('cookie', 'cookie.encrypt');
Route::get('accounts/me', 'Api\AccountController@show');
Route::delete('accounts/me', 'Api\AccountController@delete');
Route::get('accounts/me/provision', 'Api\AccountController@provision');
Route::get('accounts/me', 'Api\Account\AccountController@show');
Route::delete('accounts/me', 'Api\Account\AccountController@delete');
Route::get('accounts/me/provision', 'Api\Account\AccountController@provision');
Route::post('accounts/me/phone/request', 'Api\AccountPhoneController@requestUpdate');
Route::post('accounts/me/phone', 'Api\AccountPhoneController@update');
Route::post('accounts/me/phone/request', 'Api\Account\PhoneController@requestUpdate');
Route::post('accounts/me/phone', 'Api\Account\PhoneController@update');
Route::get('accounts/me/devices', 'Api\DeviceController@index');
Route::delete('accounts/me/devices/{uuid}', 'Api\DeviceController@destroy');
Route::get('accounts/me/devices', 'Api\Account\DeviceController@index');
Route::delete('accounts/me/devices/{uuid}', 'Api\Account\DeviceController@destroy');
Route::post('accounts/me/email/request', 'Api\EmailController@requestUpdate');
Route::post('accounts/me/password', 'Api\PasswordController@update');
Route::post('accounts/me/email/request', 'Api\Account\EmailController@requestUpdate');
Route::post('accounts/me/password', 'Api\Account\PasswordController@update');
Route::get('accounts/me/contacts/{sip}', 'Api\AccountContactController@show');
Route::get('accounts/me/contacts', 'Api\AccountContactController@index');
Route::get('accounts/me/contacts/{sip}', 'Api\Account\ContactController@show');
Route::get('accounts/me/contacts', 'Api\Account\ContactController@index');
Route::group(['middleware' => ['auth.admin']], function () {
if (!empty(config('app.linphone_daemon_unix_pipe'))) {
Route::post('messages', 'Api\MessageController@send');
Route::post('messages', 'Api\Admin\MessageController@send');
}
// Account creation token
Route::post('account_creation_tokens', 'Api\Admin\AccountCreationTokenController@create');
// Accounts
Route::get('accounts/{id}/activate', 'Api\Admin\AccountController@activate');
Route::get('accounts/{id}/deactivate', 'Api\Admin\AccountController@deactivate');
Route::get('accounts/{id}/provision', 'Api\Admin\AccountController@provision');
Route::post('accounts/{id}/recover-by-email', 'Api\Admin\AccountController@recoverByEmail');
Route::post('accounts', 'Api\Admin\AccountController@store');
Route::put('accounts/{id}', 'Api\Admin\AccountController@update');
Route::get('accounts', 'Api\Admin\AccountController@index');
Route::get('accounts/{sip}/search', 'Api\Admin\AccountController@search');
Route::get('accounts/{id}', 'Api\Admin\AccountController@show');
Route::delete('accounts/{id}', 'Api\Admin\AccountController@destroy');
Route::get('accounts/{sip}/search', 'Api\Admin\AccountController@search');
Route::get('accounts/{email}/search-by-email', 'Api\Admin\AccountController@searchByEmail');
// Account actions
Route::get('accounts/{id}/actions', 'Api\Admin\AccountActionController@index');

View file

@ -17,6 +17,8 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
use \Illuminate\Support\Facades\Route;
Route::get('/', 'Account\AccountController@home')->name('account.home');
Route::get('documentation', 'Account\AccountController@documentation')->name('account.documentation');
@ -29,13 +31,18 @@ if (config('app.web_panel')) {
Route::get('authenticate/email/check/{sip}', 'Account\AuthenticateController@checkEmail')->name('account.check.email');
Route::get('authenticate/email/{code}', 'Account\AuthenticateController@validateEmail')->name('account.authenticate.email_confirm');
Route::get('login/phone', 'Account\AuthenticateController@loginPhone')->name('account.login_phone');
Route::post('authenticate/phone', 'Account\AuthenticateController@authenticatePhone')->name('account.authenticate.phone');
Route::post('authenticate/phone/confirm', 'Account\AuthenticateController@validatePhone')->name('account.authenticate.phone_confirm');
if (config('app.phone_authentication')) {
Route::get('login/phone', 'Account\AuthenticateController@loginPhone')->name('account.login_phone');
Route::post('authenticate/phone', 'Account\AuthenticateController@authenticatePhone')->name('account.authenticate.phone');
Route::post('authenticate/phone/confirm', 'Account\AuthenticateController@validatePhone')->name('account.authenticate.phone_confirm');
}
Route::get('authenticate/qrcode/{token?}', 'Account\AuthenticateController@loginAuthToken')->name('account.authenticate.auth_token');
}
Route::get('creation_token/check/{token}', 'Account\CreationRequestTokenController@check')->name('account.creation_request_token.check');
Route::post('creation_token/validate', 'Account\CreationRequestTokenController@validateToken')->name('account.creation_request_token.validate');
Route::group(['middleware' => 'auth.digest_or_key'], function () {
Route::get('provisioning/me', 'Account\ProvisioningController@me')->name('provisioning.me');
@ -44,9 +51,13 @@ Route::group(['middleware' => 'auth.digest_or_key'], function () {
Route::get('contacts/vcard', 'Account\ContactVcardController@index')->name('account.contacts.vcard.index');
});
Route::get('provisioning/auth_token/{auth_token}', 'Account\ProvisioningController@authToken')->name('provisioning.auth_token');
Route::get('provisioning/qrcode/{provisioning_token}', 'Account\ProvisioningController@qrcode')->name('provisioning.qrcode');
Route::get('provisioning/{provisioning_token?}', 'Account\ProvisioningController@show')->name('provisioning.show');
Route::name('provisioning.')->prefix('provisioning')->controller('Account\ProvisioningController')->group(function () {
Route::get('documentation', 'documentation')->name('documentation');
Route::get('auth_token/{auth_token}', 'authToken')->name('auth_token');
Route::get('qrcode/{provisioning_token}', 'qrcode')->name('qrcode');
Route::get('{provisioning_token}', 'provision')->name('provision');
Route::get('/', 'show')->name('show');
});
if (publicRegistrationEnabled()) {
if (config('app.phone_authentication')) {

0
flexiapi/storage/app/.gitignore vendored Normal file → Executable file
View file

0
flexiapi/storage/app/public/.gitignore vendored Normal file → Executable file
View file

0
flexiapi/storage/framework/.gitignore vendored Normal file → Executable file
View file

0
flexiapi/storage/framework/cache/.gitignore vendored Normal file → Executable file
View file

0
flexiapi/storage/framework/cache/data/.gitignore vendored Normal file → Executable file
View file

0
flexiapi/storage/framework/sessions/.gitignore vendored Normal file → Executable file
View file

0
flexiapi/storage/framework/testing/.gitignore vendored Normal file → Executable file
View file

0
flexiapi/storage/framework/views/.gitignore vendored Normal file → Executable file
View file

View file

@ -104,7 +104,7 @@ class AccountProvisioningTest extends TestCase
$currentPassword = $password->password;
$provioningUrl = route(
'provisioning.show',
'provisioning.provision',
[
'provisioning_token' => $password->account->provisioning_token,
'reset_password' => true
@ -132,9 +132,7 @@ class AccountProvisioningTest extends TestCase
public function testConfirmationKeyProvisioning()
{
$response = $this->get($this->route . '/1234');
$response->assertStatus(200);
$response->assertHeader('Content-Type', 'application/xml');
$response->assertDontSee('ha1');
$response->assertStatus(404);
$password = Password::factory()->create();
$password->account->generateApiKey();
@ -152,9 +150,7 @@ class AccountProvisioningTest extends TestCase
// And then twice
$response = $this->get($this->route . '/' . $password->account->provisioning_token)
->assertStatus(200)
->assertHeader('Content-Type', 'application/xml')
->assertDontSee('ha1');
->assertStatus(404);
$password->account->refresh();
@ -188,9 +184,9 @@ class AccountProvisioningTest extends TestCase
->assertStatus(201)
->assertJson([
'token' => true
])->content();
]);
$authToken = json_decode($response)->token;
$authToken = $response->json('token');
$password = Password::factory()->create();
$password->account->generateApiKey();

View file

@ -64,9 +64,9 @@ class ApiAccountApiKeyTest extends TestCase
->assertStatus(201)
->assertJson([
'token' => true
])->content();
]);
$authToken = json_decode($response)->token;
$authToken = $response->json('token');
// Try to retrieve an API key from the un-attached auth_token
$response = $this->json($this->method, $this->route . '/' . $authToken)
@ -95,9 +95,9 @@ class ApiAccountApiKeyTest extends TestCase
->assertStatus(200)
->assertJson([
'api_key' => true
])->content();
]);
$apiKey = json_decode($response)->api_key;
$apiKey = $response->json('api_key');
// Re-retrieve
$this->json($this->method, $this->route . '/' . $authToken)
@ -106,8 +106,7 @@ class ApiAccountApiKeyTest extends TestCase
// Check the if the API key can be used for the account
$response = $this->withHeaders(['x-api-key' => $apiKey])
->json($this->method, '/api/accounts/me')
->assertStatus(200)
->content();
->assertStatus(200);
// Try with a wrong From
$response = $this->withHeaders([
@ -115,10 +114,9 @@ class ApiAccountApiKeyTest extends TestCase
'From' => 'sip:baduser@server.tld'
])
->json($this->method, '/api/accounts/me')
->assertStatus(200)
->content();
->assertStatus(200);
// Check if the account was correctly attached
$this->assertEquals(json_decode($response)->email, $password->account->email);
$this->assertEquals($response->json('email'), $password->account->email);
}
}

View file

@ -19,84 +19,93 @@
namespace Tests\Feature;
use App\Account;
use App\AccountCreationRequestToken;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use App\AccountCreationToken;
use App\Admin;
use Carbon\Carbon;
class ApiAccountCreationTokenTest extends TestCase
{
use RefreshDatabase;
protected $tokenRoute = '/api/account_creation_tokens/send-by-push';
protected $tokenRequestRoute = '/api/account_creation_request_tokens';
protected $tokenUsingCreationTokenRoute = '/api/account_creation_tokens/using-account-creation-request-token';
protected $accountRoute = '/api/accounts/with-account-creation-token';
protected $adminRoute = '/api/account_creation_tokens';
protected $method = 'POST';
protected $pnProvider = 'provider';
protected $pnParam = 'param';
protected $pnPrid = 'id';
public function testMandatoryParameters()
{
$response = $this->json($this->method, $this->tokenRoute);
$response->assertStatus(422);
}
public function testCorrectParameters()
{
$response = $this->json($this->method, $this->tokenRoute, [
$this->assertSame(AccountCreationToken::count(), 0);
$this->json($this->method, $this->tokenRoute, [
'pn_provider' => $this->pnProvider,
'pn_param' => $this->pnParam,
'pn_prid' => $this->pnPrid,
])->assertStatus(503);
}
public function testMandatoryParameters()
{
$this->json($this->method, $this->tokenRoute)->assertStatus(422);
$this->json($this->method, $this->tokenRoute, [
'pn_provider' => null,
'pn_param' => null,
'pn_prid' => null,
])->assertStatus(422);
}
public function testThrottling()
{
AccountCreationToken::factory()->create([
'pn_provider' => $this->pnProvider,
'pn_param' => $this->pnParam,
'pn_prid' => $this->pnPrid,
]);
$response->assertStatus(503);
$this->json($this->method, $this->tokenRoute, [
'pn_provider' => $this->pnProvider,
'pn_param' => $this->pnParam,
'pn_prid' => $this->pnPrid,
])->assertStatus(503);
// Redeem all the tokens
AccountCreationToken::where('used', false)->update(['used' => true]);
$this->json($this->method, $this->tokenRoute, [
'pn_provider' => $this->pnProvider,
'pn_param' => $this->pnParam,
'pn_prid' => $this->pnPrid,
])->assertStatus(429);
}
/**
* For retro-compatibility only
*/
public function testRetrocopatibilityToken()
public function testAdminEndpoint()
{
$token = AccountCreationToken::factory()->create();
$admin = Admin::factory()->create();
$admin->account->generateApiKey();
$response = $this->json($this->method, '/api/tokens', [
'pn_provider' => $token->pn_provider,
'pn_param' => $token->pn_param,
'pn_prid' => $token->pn_prid
$response = $this->keyAuthenticated($admin->account)
->json($this->method, $this->adminRoute)
->assertStatus(201);
$this->assertDatabaseHas('account_creation_tokens', [
'token' => $response->json()['token']
]);
$response->assertStatus(503);
}
public function testInvalidToken()
{
$token = AccountCreationToken::factory()->create();
// Valid token
$response = $this->json($this->method, '/api/accounts/with-token', [
'username' => 'username',
'algorithm' => 'SHA-256',
'password' => '2',
'token' => $token->token
]);
$response->assertStatus(200);
// Expired token
$response = $this->json($this->method, '/api/accounts/with-token', [
'username' => 'username2',
'algorithm' => 'SHA-256',
'password' => '2',
'token' => $token->token
]);
$response->assertStatus(422);
}
/**
* For retrocompatibility only
*/
public function testRetrocompatibilityInvalidToken()
{
$token = AccountCreationToken::factory()->create();
// Invalid token
$response = $this->json($this->method, $this->accountRoute, [
'username' => 'username',
@ -123,11 +132,13 @@ class ApiAccountCreationTokenTest extends TestCase
'account_creation_token' => $token->token
]);
$response->assertStatus(422);
$this->assertDatabaseHas('account_creation_tokens', [
'used' => true,
'account_id' => Account::where('username', 'username')->first()->id,
]);
}
/**
* Test username blacklist
*/
public function testBlacklistedUsername()
{
$token = AccountCreationToken::factory()->create();
@ -165,4 +176,36 @@ class ApiAccountCreationTokenTest extends TestCase
$response->assertStatus(200);
}
public function testAccountCreationRequestToken()
{
$response = $this->json($this->method, $this->tokenRequestRoute);
$response->assertStatus(201);
$creationRequestToken = $response->json()['token'];
$this->assertSame($response->json()['validation_url'], route('account.creation_request_token.check', $creationRequestToken));
// Validate the creation request token
AccountCreationRequestToken::where('token', $creationRequestToken)->update(['validated_at' => Carbon::now()]);
$response = $this->json($this->method, $this->tokenUsingCreationTokenRoute, [
'account_creation_request_token' => $creationRequestToken
])->assertStatus(201);
$creationToken = $response->json()['token'];
$this->assertDatabaseHas('account_creation_request_tokens', [
'token' => $creationRequestToken,
'used' => true
]);
$this->assertDatabaseHas('account_creation_tokens', [
'token' => $creationToken
]);
$this->assertSame(
AccountCreationRequestToken::where('token', $creationRequestToken)->first()->accountCreationToken->id,
AccountCreationToken::where('token', $creationToken)->first()->id
);
}
}

View file

@ -21,6 +21,7 @@ namespace Tests\Feature;
use App\Password;
use App\Account;
use App\AccountCreationToken;
use App\AccountTombstone;
use App\ActivationExpiration;
use App\Admin;
@ -49,7 +50,7 @@ class ApiAccountTest extends TestCase
$password = Password::factory()->create();
$response0 = $this->generateFirstResponse($password);
$response1 = $this->generateSecondResponse($password, $response0)
->json($this->method, $this->route);
->json($this->method, $this->route);
$response1->assertStatus(403);
}
@ -90,12 +91,12 @@ class ApiAccountTest extends TestCase
$domain = 'example.com';
$response = $this->keyAuthenticated($password->account)
->json($this->method, $this->route, [
'username' => $username,
'domain' => $domain,
'algorithm' => 'SHA-256',
'password' => '123456',
]);
->json($this->method, $this->route, [
'username' => $username,
'domain' => $domain,
'algorithm' => 'SHA-256',
'password' => '123456',
]);
$response->assertJsonValidationErrors(['username']);
}
@ -110,28 +111,35 @@ class ApiAccountTest extends TestCase
$username = 'blabla🔥';
$domain = 'example.com';
$response = $this->keyAuthenticated($password->account)
->json($this->method, $this->route, [
'username' => $username,
'domain' => $domain,
'algorithm' => 'SHA-256',
'password' => '123456',
]);
$this->keyAuthenticated($password->account)
->json($this->method, $this->route, [
'username' => $username,
'domain' => $domain,
'algorithm' => 'SHA-256',
'password' => '123456',
])->assertJsonValidationErrors(['username']);
$response->assertJsonValidationErrors(['username']);
// Change the regex
config()->set('app.account_username_regex', '^[a-z0-9🔥+_.-]*$');
$this->keyAuthenticated($password->account)
->json($this->method, $this->route, [
'username' => $username,
'domain' => $domain,
'algorithm' => 'SHA-256',
'password' => '123456',
])->assertStatus(200);
$username = 'blabla hop';
$domain = 'example.com';
$response = $this->keyAuthenticated($password->account)
->json($this->method, $this->route, [
'username' => $username,
'domain' => $domain,
'algorithm' => 'SHA-256',
'password' => '123456',
]);
$response->assertJsonValidationErrors(['username']);
$this->keyAuthenticated($password->account)
->json($this->method, $this->route, [
'username' => $username,
'domain' => $domain,
'algorithm' => 'SHA-256',
'password' => '123456',
])->assertJsonValidationErrors(['username']);
}
public function testDomain()
@ -318,7 +326,7 @@ class ApiAccountTest extends TestCase
->json($this->method, $this->route, [
'username' => $username,
'algorithm' => 'SHA-256',
'password' => '2',
'password' => 'blabla',
'admin' => true,
]);
@ -348,7 +356,7 @@ class ApiAccountTest extends TestCase
->json($this->method, $this->route, [
'username' => $username,
'algorithm' => 'SHA-256',
'password' => '2',
'password' => 'blabla',
'activated' => true,
]);
@ -378,7 +386,7 @@ class ApiAccountTest extends TestCase
->json($this->method, $this->route, [
'username' => $username,
'algorithm' => 'SHA-256',
'password' => '2',
'password' => 'blabla',
'activated' => false,
]);
@ -407,7 +415,7 @@ class ApiAccountTest extends TestCase
/**
* Public information
*/
$this->get($this->route.'/'.$password->account->identifier.'/info')
$this->get($this->route . '/' . $password->account->identifier . '/info')
->assertStatus(200)
->assertJson([
'activated' => false,
@ -421,7 +429,7 @@ class ApiAccountTest extends TestCase
* Retrieve the authenticated account
*/
$this->keyAuthenticated($password->account)
->get($this->route.'/me')
->get($this->route . '/me')
->assertStatus(200)
->assertJson([
'username' => $password->account->username,
@ -433,14 +441,14 @@ class ApiAccountTest extends TestCase
* Retrieve the authenticated account
*/
$this->keyAuthenticated($password->account)
->delete($this->route.'/me')
->delete($this->route . '/me')
->assertStatus(200);
/**
* Check again
*/
$this->get($this->route.'/'.$password->account->identifier.'/info')
->assertStatus(404);
$this->get($this->route . '/' . $password->account->identifier . '/info')
->assertStatus(404);
}
public function testActivateEmail()
@ -457,36 +465,36 @@ class ApiAccountTest extends TestCase
$expiration->expires = Carbon::now()->subYear();
$expiration->save();
$this->get($this->route.'/'.$password->account->identifier.'/info')
$this->get($this->route . '/' . $password->account->identifier . '/info')
->assertStatus(200)
->assertJson([
'activated' => false
]);
$this->keyAuthenticated($password->account)
->json($this->method, $this->route.'/blabla/activate/email', [
'code' => $confirmationKey
->json($this->method, $this->route . '/blabla/activate/email', [
'confirmation_key' => $confirmationKey
])
->assertStatus(404);
$activateEmailRoute = $this->route.'/'.$password->account->identifier.'/activate/email';
$activateEmailRoute = $this->route . '/' . $password->account->identifier . '/activate/email';
$this->keyAuthenticated($password->account)
->json($this->method, $activateEmailRoute, [
'code' => $confirmationKey.'longer'
'confirmation_key' => $confirmationKey . 'longer'
])
->assertStatus(422);
$this->keyAuthenticated($password->account)
->json($this->method, $activateEmailRoute, [
'code' => 'X123456789abc'
'confirmation_key' => 'X123456789abc'
])
->assertStatus(404);
// Expired
$this->keyAuthenticated($password->account)
->json($this->method, $activateEmailRoute, [
'code' => $confirmationKey
'confirmation_key' => $confirmationKey
])
->assertStatus(403);
@ -494,11 +502,11 @@ class ApiAccountTest extends TestCase
$this->keyAuthenticated($password->account)
->json($this->method, $activateEmailRoute, [
'code' => $confirmationKey
'confirmation_key' => $confirmationKey
])
->assertStatus(200);
$this->get($this->route.'/'.$password->account->identifier.'/info')
$this->get($this->route . '/' . $password->account->identifier . '/info')
->assertStatus(200)
->assertJson([
'activated' => true
@ -532,6 +540,75 @@ class ApiAccountTest extends TestCase
->assertJsonValidationErrors(['email']);
}
public function testNonAsciiPasswordAdmin()
{
$admin = Admin::factory()->create();
$admin->account->generateApiKey();
$admin->account->save();
$username = 'username';
$response = $this->generateFirstResponse($admin->account->passwords()->first(), $this->method, $this->route);
$this->generateSecondResponse($admin->account->passwords()->first(), $response)
->json($this->method, $this->route, [
'username' => $username,
'email' => 'email@test.com',
'domain' => 'server.com',
'algorithm' => 'SHA-256',
'password' => 'nonascii€',
])
->assertStatus(200);
$password = Account::where('username', $username)->first()->passwords()->first();
$response = $this->generateFirstResponse($password, 'GET', '/api/accounts/me');
$response = $this->generateSecondResponse($password, $response)
->json('GET', '/api/accounts/me');
}
public function testEditAdmin()
{
$password = Password::factory()->create();
$account = $password->account;
$admin = Admin::factory()->create();
$admin->account->generateApiKey();
$admin->account->save();
$username = 'changed';
$algorithm = 'MD5';
$password = 'other';
$this->keyAuthenticated($admin->account)
->json('PUT', $this->route . '/1234')
->assertStatus(422)
->assertJsonValidationErrors(['username']);
$this->keyAuthenticated($admin->account)
->json('PUT', $this->route . '/1234', [
'username' => 'good'
])
->assertStatus(422);
$this->keyAuthenticated($admin->account)
->json('PUT', $this->route . '/' . $account->id, [
'username' => $username,
'algorithm' => $algorithm,
'password' => $password,
])
->assertStatus(200);
$this->assertDatabaseHas('accounts', [
'id' => $account->id,
'username' => $username
]);
$this->assertDatabaseHas('passwords', [
'account_id' => $account->id,
'algorithm' => $algorithm
]);
}
/**
* /!\ Dangerous endpoints
*/
@ -552,21 +629,44 @@ class ApiAccountTest extends TestCase
'activated' => false
]);
$this->get($this->route.'/'.$password->account->identifier.'/recover/'.$confirmationKey)
$this->get($this->route . '/' . $password->account->identifier . '/recover/' . $confirmationKey)
->assertJson(['passwords' => [[
'password' => $password->password,
'algorithm' => $password->algorithm
]]])
->assertStatus(200);
$this->json('GET', $this->route.'/'.$password->account->identifier.'/recover/'.$confirmationKey)
$this->json('GET', $this->route . '/' . $password->account->identifier . '/recover/' . $confirmationKey)
->assertStatus(404);
$this->assertDatabaseHas('accounts', [
'username' => $password->account->username,
'domain' => $password->account->domain,
'confirmation_key' => null,
'activated' => true
]);
// Recover by alias
$newConfirmationKey = '1345';
$password->account->confirmation_key = $newConfirmationKey;
$password->account->save();
$phone = '+1234';
$alias = new AppAlias;
$alias->alias = $phone;
$alias->domain = $password->account->domain;
$alias->account_id = $password->account->id;
$alias->save();
$this->get($this->route . '/' . $phone . '@' . $alias->domain . '/recover/' . $newConfirmationKey)
->assertJson(['passwords' => [[
'password' => $password->password,
'algorithm' => $password->algorithm
]]])
->assertStatus(200);
}
/**
@ -589,36 +689,79 @@ class ApiAccountTest extends TestCase
$alias->account_id = $password->account->id;
$alias->save();
$this->json($this->method, $this->route.'/recover-by-phone', [
'phone' => $phone
])
$this->json($this->method, $this->route . '/recover-by-phone', [
'phone' => $phone
])
->assertStatus(422)
->assertJsonValidationErrors(['account_creation_token']);
$this->json($this->method, $this->route . '/recover-by-phone', [
'phone' => $phone,
'account_creation_token' => 'wrong'
])
->assertStatus(422)
->assertJsonValidationErrors(['account_creation_token']);
$token = AccountCreationToken::factory()->create();
$this->json($this->method, $this->route . '/recover-by-phone', [
'phone' => $phone,
'account_creation_token' => $token->token
])
->assertStatus(200);
$password->account->refresh();
$this->get($this->route.'/'.$password->account->identifier.'/recover/'.$password->account->confirmation_key)
// Use the token a second time
$this->json($this->method, $this->route . '/recover-by-phone', [
'phone' => $phone,
'account_creation_token' => $token->token
])
->assertStatus(422);
$this->get($this->route . '/' . $password->account->identifier . '/recover/' . $password->account->confirmation_key)
->assertStatus(200)
->assertJson([
'activated' => true
]);
$this->get($this->route.'/'.$phone.'/info-by-phone')
$this->get($this->route . '/' . $phone . '/info-by-phone')
->assertStatus(200)
->assertJson([
'activated' => true
'activated' => true,
'phone' => true
]);
$this->get($this->route.'/+1234/info-by-phone')
$this->get($this->route . '/+1234/info-by-phone')
->assertStatus(404);
$this->json('GET', $this->route.'/'.$password->account->identifier.'/info-by-phone')
$this->json('GET', $this->route . '/' . $password->account->identifier . '/info-by-phone')
->assertStatus(422)
->assertJsonValidationErrors(['phone']);
// Check the mixed username/phone resolution...
$password->account->username = $phone;
$password->account->save();
$alias->delete();
$this->get($this->route . '/' . $phone . '/info-by-phone')
->assertStatus(200)
->assertJson([
'activated' => true,
'phone' => false
]);
$this->assertDatabaseHas('account_creation_tokens', [
'used' => true,
'account_id' => $password->account->id,
]);
}
/**
* /!\ Dangerous endpoints
*/
public function testCreatePublic()
{
$username = 'publicuser';
@ -626,50 +769,83 @@ class ApiAccountTest extends TestCase
config()->set('app.dangerous_endpoints', true);
// Missing email
$this->json($this->method, $this->route.'/public', [
$this->json($this->method, $this->route . '/public', [
'username' => $username,
'algorithm' => 'SHA-256',
'password' => '2',
])
->assertStatus(422)
->assertJsonValidationErrors(['email']);
->assertStatus(422)
->assertJsonValidationErrors(['email']);
$this->json($this->method, $this->route.'/public', [
$this->json($this->method, $this->route . '/public', [
'username' => $username,
'algorithm' => 'SHA-256',
'password' => '2',
'email' => 'john@doe.tld',
])
->assertStatus(200)
->assertJson([
'activated' => false
]);
->assertStatus(422)
->assertJsonValidationErrors(['account_creation_token']);
$token = AccountCreationToken::factory()->create();
$userAgent = 'User Agent Test';
$this->withHeaders([
'User-Agent' => $userAgent,
])->json($this->method, $this->route . '/public', [
'username' => $username,
'algorithm' => 'SHA-256',
'password' => '2',
'email' => 'john@doe.tld',
'account_creation_token' => $token->token
])
->assertStatus(200)
->assertJson([
'activated' => false
]);
// Re-use the token
$this->withHeaders([
'User-Agent' => $userAgent,
])->json($this->method, $this->route . '/public', [
'username' => $username . 'foo',
'algorithm' => 'SHA-256',
'password' => '2',
'email' => 'john@doe.tld',
'account_creation_token' => $token->token
])
->assertStatus(422);
// Already created
$this->json($this->method, $this->route.'/public', [
$this->json($this->method, $this->route . '/public', [
'username' => $username,
'algorithm' => 'SHA-256',
'password' => '2',
'email' => 'john@doe.tld',
])
->assertStatus(422)
->assertJsonValidationErrors(['username']);
->assertStatus(422)
->assertJsonValidationErrors(['username']);
// Email is now unique
config()->set('app.account_email_unique', true);
$this->json($this->method, $this->route.'/public', [
$this->json($this->method, $this->route . '/public', [
'username' => 'johndoe',
'algorithm' => 'SHA-256',
'password' => '2',
'email' => 'john@doe.tld',
])
->assertStatus(422)
->assertJsonValidationErrors(['email']);
->assertStatus(422)
->assertJsonValidationErrors(['email']);
$this->assertDatabaseHas('accounts', [
'username' => $username,
'domain' => config('app.sip_domain')
'domain' => config('app.sip_domain'),
'user_agent' => $userAgent
]);
$this->assertDatabaseHas('account_creation_tokens', [
'used' => true,
'account_id' => Account::where('username', $username)->first()->id,
]);
}
@ -679,47 +855,39 @@ class ApiAccountTest extends TestCase
config()->set('app.dangerous_endpoints', true);
// Username and phone
$this->json($this->method, $this->route.'/public', [
'username' => 'myusername',
'phone' => $phone,
'algorithm' => 'SHA-256',
'password' => '2',
'email' => 'john@doe.tld',
])
->assertStatus(422)
->assertJsonValidationErrors(['phone', 'username']);
// Bad phone format
$this->json($this->method, $this->route.'/public', [
$this->json($this->method, $this->route . '/public', [
'phone' => 'username',
'algorithm' => 'SHA-256',
'password' => '2',
'email' => 'john@doe.tld',
])
->assertStatus(422)
->assertJsonValidationErrors(['phone']);
->assertStatus(422)
->assertJsonValidationErrors(['phone']);
$this->json($this->method, $this->route.'/public', [
$token = AccountCreationToken::factory()->create();
$this->json($this->method, $this->route . '/public', [
'phone' => $phone,
'algorithm' => 'SHA-256',
'password' => '2',
'email' => 'john@doe.tld',
'account_creation_token' => $token->token
])
->assertStatus(200)
->assertJson([
'activated' => false
]);
->assertStatus(200)
->assertJson([
'activated' => false
]);
// Already exists
$this->json($this->method, $this->route.'/public', [
$this->json($this->method, $this->route . '/public', [
'phone' => $phone,
'algorithm' => 'SHA-256',
'password' => '2',
'email' => 'john@doe.tld',
])
->assertStatus(422)
->assertJsonValidationErrors(['phone']);
->assertStatus(422)
->assertJsonValidationErrors(['phone']);
$this->assertDatabaseHas('accounts', [
'username' => $phone,
@ -746,7 +914,7 @@ class ApiAccountTest extends TestCase
$expiration->expires = Carbon::now()->subYear();
$expiration->save();
$this->get($this->route.'/'.$password->account->identifier.'/info')
$this->get($this->route . '/' . $password->account->identifier . '/info')
->assertStatus(200)
->assertJson([
'activated' => false
@ -754,16 +922,16 @@ class ApiAccountTest extends TestCase
// Expired
$this->keyAuthenticated($password->account)
->json($this->method, $this->route.'/'.$password->account->identifier.'/activate/phone', [
'code' => $confirmationKey
->json($this->method, $this->route . '/' . $password->account->identifier . '/activate/phone', [
'confirmation_key' => $confirmationKey
])
->assertStatus(403);
$expiration->delete();
$this->keyAuthenticated($password->account)
->json($this->method, $this->route.'/'.$password->account->identifier.'/activate/phone', [
'code' => $confirmationKey
->json($this->method, $this->route . '/' . $password->account->identifier . '/activate/phone', [
'confirmation_key' => $confirmationKey
])
->assertStatus(200);
@ -783,27 +951,27 @@ class ApiAccountTest extends TestCase
// Bad email
$this->keyAuthenticated($password->account)
->json($this->method, $this->route.'/me/email/request', [
->json($this->method, $this->route . '/me/email/request', [
'email' => 'gnap'
])
->assertStatus(422);
// Same email
$this->keyAuthenticated($password->account)
->json($this->method, $this->route.'/me/email/request', [
->json($this->method, $this->route . '/me/email/request', [
'email' => $password->account->email
])
->assertStatus(422);
// Correct email
$this->keyAuthenticated($password->account)
->json($this->method, $this->route.'/me/email/request', [
->json($this->method, $this->route . '/me/email/request', [
'email' => $newEmail
])
->assertStatus(200);
$this->keyAuthenticated($password->account)
->get($this->route.'/me')
->get($this->route . '/me')
->assertStatus(200)
->assertJson([
'username' => $password->account->username,
@ -816,7 +984,7 @@ class ApiAccountTest extends TestCase
config()->set('app.account_email_unique', true);
$this->keyAuthenticated($password->account)
->json($this->method, $this->route.'/me/email/request', [
->json($this->method, $this->route . '/me/email/request', [
'email' => $otherAccount->account->email
])
->assertStatus(422)
@ -834,7 +1002,7 @@ class ApiAccountTest extends TestCase
// Wrong algorithm
$this->keyAuthenticated($account)
->json($this->method, $this->route.'/me/password', [
->json($this->method, $this->route . '/me/password', [
'algorithm' => '123',
'password' => $password
])
@ -843,7 +1011,7 @@ class ApiAccountTest extends TestCase
// Fresh password without an old one
$this->keyAuthenticated($account)
->json($this->method, $this->route.'/me/password', [
->json($this->method, $this->route . '/me/password', [
'algorithm' => $algorithm,
'password' => $password
])
@ -851,7 +1019,7 @@ class ApiAccountTest extends TestCase
// First check
$this->keyAuthenticated($account)
->get($this->route.'/me')
->get($this->route . '/me')
->assertStatus(200)
->assertJson([
'username' => $account->username,
@ -862,7 +1030,7 @@ class ApiAccountTest extends TestCase
// Set new password without old one
$this->keyAuthenticated($account)
->json($this->method, $this->route.'/me/password', [
->json($this->method, $this->route . '/me/password', [
'algorithm' => $newAlgorithm,
'password' => $newPassword
])
@ -871,7 +1039,7 @@ class ApiAccountTest extends TestCase
// Set the new password with incorrect old password
$this->keyAuthenticated($account)
->json($this->method, $this->route.'/me/password', [
->json($this->method, $this->route . '/me/password', [
'algorithm' => $newAlgorithm,
'old_password' => 'blabla',
'password' => $newPassword
@ -881,7 +1049,7 @@ class ApiAccountTest extends TestCase
// Set the new password
$this->keyAuthenticated($account)
->json($this->method, $this->route.'/me/password', [
->json($this->method, $this->route . '/me/password', [
'algorithm' => $newAlgorithm,
'old_password' => $password,
'password' => $newPassword
@ -890,7 +1058,7 @@ class ApiAccountTest extends TestCase
// Second check
$this->keyAuthenticated($account)
->get($this->route.'/me')
->get($this->route . '/me')
->assertStatus(200)
->assertJson([
'username' => $account->username,
@ -909,28 +1077,28 @@ class ApiAccountTest extends TestCase
// deactivate
$this->keyAuthenticated($admin->account)
->get($this->route.'/'.$password->account->id.'/deactivate')
->get($this->route . '/' . $password->account->id . '/deactivate')
->assertStatus(200)
->assertJson([
'activated' => false
]);
$this->keyAuthenticated($admin->account)
->get($this->route.'/'.$password->account->id)
->get($this->route . '/' . $password->account->id)
->assertStatus(200)
->assertJson([
'activated' => false
]);
$this->keyAuthenticated($admin->account)
->get($this->route.'/'.$password->account->id.'/activate')
->get($this->route . '/' . $password->account->id . '/activate')
->assertStatus(200)
->assertJson([
'activated' => true
]);
$this->keyAuthenticated($admin->account)
->get($this->route.'/'.$password->account->id)
->get($this->route . '/' . $password->account->id)
->assertStatus(200)
->assertJson([
'activated' => true
@ -938,12 +1106,44 @@ class ApiAccountTest extends TestCase
// Search feature
$this->keyAuthenticated($admin->account)
->get($this->route.'/'.$password->account->identifier.'/search')
->get($this->route . '/' . $password->account->identifier . '/search')
->assertStatus(200)
->assertJson([
'id' => $password->account->id,
'activated' => true
]);
$this->keyAuthenticated($admin->account)
->get($this->route . '/' . $password->account->email . '/search-by-email')
->assertStatus(200)
->assertJson([
'id' => $password->account->id,
'activated' => true
]);
$this->keyAuthenticated($admin->account)
->get($this->route . '/wrong@email.com/search-by-email')
->assertStatus(404);
}
public function testRecoverByEmail()
{
$email = 'collision@email.com';
$account = Password::factory()->create();
$account->account->email = $email;
$account->account->save();
$admin = Admin::factory()->create();
$admin->account->generateApiKey();
$admin->account->save();
$response = $this->keyAuthenticated($admin->account)
->post($this->route . '/' . $account->id . '/recover-by-email')
->assertStatus(200);
$this->assertNotEquals($response->json('confirmation_key'), $account->confirmation_key);
$this->assertNotEquals($response->json('provisioning_token'), $account->provisioning_token);
}
public function testGetAll()
@ -963,7 +1163,7 @@ class ApiAccountTest extends TestCase
// /accounts/id
$this->keyAuthenticated($admin->account)
->get($this->route.'/'.$admin->id)
->get($this->route . '/' . $admin->id)
->assertStatus(200)
->assertJson([
'id' => 1,
@ -1024,13 +1224,13 @@ class ApiAccountTest extends TestCase
$admin->account->generateApiKey();
$this->keyAuthenticated($admin->account)
->delete($this->route.'/'.$password->account->id)
->delete($this->route . '/' . $password->account->id)
->assertStatus(200);
$this->assertEquals(1, AccountTombstone::count());
$this->keyAuthenticated($admin->account)
->get($this->route.'/'.$password->account->id)
->get($this->route . '/' . $password->account->id)
->assertStatus(404);
}
}

View file

@ -47,17 +47,26 @@ class ApiAccountTypeTest extends TestCase
$this->assertEquals(1, AccountType::count());
// Same key
$this->keyAuthenticated($admin->account)
->json($this->method, $this->route, [
'key' => 'phone',
])
->assertJsonValidationErrorFor('key')
->assertStatus(422);
// Missing key
$this->keyAuthenticated($admin->account)
->json($this->method, $this->route, [])
->assertStatus(422);
->json($this->method, $this->route, [])
->assertStatus(422);
// Invalid key
$this->keyAuthenticated($admin->account)
->json($this->method, $this->route, [
'key' => 'Abc1234',
])
->assertStatus(422);
->json($this->method, $this->route, [
'key' => 'Abc1234',
])
->assertStatus(422);
$this->keyAuthenticated($admin->account)
->get($this->route)
@ -83,7 +92,7 @@ class ApiAccountTypeTest extends TestCase
$accountType = AccountType::first();
$this->keyAuthenticated($admin->account)
->delete($this->route.'/'.$accountType->id)
->delete($this->route . '/' . $accountType->id)
->assertStatus(200);
$this->assertEquals(0, AccountType::count());
@ -104,7 +113,7 @@ class ApiAccountTypeTest extends TestCase
$accountType = AccountType::first();
$this->keyAuthenticated($admin->account)
->json('PUT', $this->route.'/'.$accountType->id, [
->json('PUT', $this->route . '/' . $accountType->id, [
'key' => 'door',
])
->assertStatus(200);
@ -137,34 +146,34 @@ class ApiAccountTypeTest extends TestCase
$password = Password::factory()->create();
$this->keyAuthenticated($admin->account)
->json($this->method, '/api/accounts/'.$password->account->id.'/types/'.$accountType->id)
->json($this->method, '/api/accounts/' . $password->account->id . '/types/' . $accountType->id)
->assertStatus(200);
$this->keyAuthenticated($admin->account)
->json($this->method, '/api/accounts/'.$password->account->id.'/types/'.$accountType->id)
->json($this->method, '/api/accounts/' . $password->account->id . '/types/' . $accountType->id)
->assertStatus(403);
$this->keyAuthenticated($admin->account)
->get('/api/accounts/'.$password->account->id)
->assertJson([
'types' => [
[
'id' => $accountType->id,
'key' => $accountType->key
->get('/api/accounts/' . $password->account->id)
->assertJson([
'types' => [
[
'id' => $accountType->id,
'key' => $accountType->key
]
]
]
]);
]);
// Remove
$this->keyAuthenticated($admin->account)
->delete('/api/accounts/'.$password->account->id.'/types/'.$accountType->id)
->delete('/api/accounts/' . $password->account->id . '/types/' . $accountType->id)
->assertStatus(200);
$this->assertEquals(0, DB::table('account_account_type')->count());
// Retry
$this->keyAuthenticated($admin->account)
->delete('/api/accounts/'.$password->account->id.'/types/'.$accountType->id)
->delete('/api/accounts/' . $password->account->id . '/types/' . $accountType->id)
->assertStatus(403);
$this->assertEquals(0, DB::table('account_account_type')->count());
}

View file

@ -30,6 +30,9 @@ abstract class TestCase extends BaseTestCase
const ALGORITHMS = ['md5' => 'MD5', 'sha256' => 'SHA-256'];
protected $route = '/api/accounts/me';
protected $method = 'GET';
protected function keyAuthenticated(Account $account)
{
return $this->withHeaders([
@ -37,11 +40,11 @@ abstract class TestCase extends BaseTestCase
]);
}
protected function generateFirstResponse(Password $password)
protected function generateFirstResponse(Password $password, ?string $method = null, ?string $route = null)
{
return $this->withHeaders([
'From' => 'sip:'.$password->account->identifier
])->json($this->method, $this->route);
])->json($method ?? $this->method, $route ?? $this->route);
}
protected function generateSecondResponse(Password $password, $firstResponse)