Compare commits

...

25 commits

Author SHA1 Message Date
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
59 changed files with 1148 additions and 479 deletions

View file

@ -28,11 +28,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

@ -3,7 +3,7 @@ variables:
DEBIAN_11_IMAGE_VERSION: 20230322_172926_missing_tools
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:

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

@ -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

@ -144,7 +144,7 @@ class ProvisioningController extends Controller
}
// Password reset
if ($request->has('reset_password')) {
if ($account && $request->has('reset_password')) {
$account->updatePassword(Str::random(10));
}

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,17 @@ class AccountCreationTokenController extends Controller
'pn_prid' => 'required',
]);
$last = AccountCreationToken::where('pn_provider', $request->get('pn_provider'))
->where('pn_paparam', $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())
->latest()
->first();
if ($last) {
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 +69,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

@ -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()

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

@ -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.

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

@ -29,13 +29,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');

View file

@ -188,9 +188,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,86 @@
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,
]);
$response->assertStatus(503);
])->assertStatus(503);
}
/**
* For retro-compatibility only
*/
public function testRetrocopatibilityToken()
public function testMandatoryParameters()
{
$token = AccountCreationToken::factory()->create();
$this->json($this->method, $this->tokenRoute)->assertStatus(422);
$response = $this->json($this->method, '/api/tokens', [
'pn_provider' => $token->pn_provider,
'pn_param' => $token->pn_param,
'pn_prid' => $token->pn_prid
$this->json($this->method, $this->tokenRoute, [
'pn_provider' => null,
'pn_param' => null,
'pn_prid' => null,
])->assertStatus(422);
}
public function testExpiration()
{
$existing = AccountCreationToken::factory()->create();
$this->json($this->method, $this->tokenRoute, [
'pn_provider' => $this->pnProvider,
'pn_param' => $this->pnParam,
'pn_prid' => $this->pnPrid,
])->assertStatus(503);
$this->json($this->method, $this->tokenRoute, [
'pn_provider' => $existing->pnProvider,
'pn_param' => $existing->pnParam,
'pn_prid' => $existing->pnPrid,
])->assertStatus(422);
}
public function testAdminEndpoint()
{
$admin = Admin::factory()->create();
$admin->account->generateApiKey();
$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 +125,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 +169,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)