diff --git a/src/app/Console/Commands/Data/Import/SignupTokensCommand.php b/src/app/Console/Commands/Data/Import/SignupTokensCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Data/Import/SignupTokensCommand.php @@ -0,0 +1,110 @@ +getObject(Plan::class, $this->argument('plan'), 'title', false); + + if (!$plan) { + $this->error("Plan not found"); + return 1; + } + + if ($plan->mode != Plan::MODE_TOKEN) { + $this->error("The plan is not for tokens"); + return 1; + } + + $file = $this->argument('file'); + + if (!file_exists($file)) { + $this->error("File '$file' does not exist"); + return 1; + } + + $list = file($file); + + if (empty($list)) { + $this->error("File '$file' is empty"); + return 1; + } + + $bar = $this->createProgressBar(count($list), "Validating tokens"); + + $list = array_map('trim', $list); + $list = array_map('strtoupper', $list); + + // Validate tokens + foreach ($list as $idx => $token) { + if (!strlen($token)) { + // Skip empty lines + unset($list[$idx]); + } elseif (strlen($token) > 191) { + $bar->finish(); + $this->error("Token '$token' is too long"); + return 1; + } elseif (SignupToken::find($token)) { + // Skip existing tokens + unset($list[$idx]); + } + + $bar->advance(); + } + + $bar->finish(); + + $this->info("DONE"); + + if (empty($list)) { + $this->info("Nothing to import"); + return 0; + } + + $list = array_unique($list); // remove duplicated tokens + + $bar = $this->createProgressBar(count($list), "Importing tokens"); + + // Import tokens + foreach ($list as $token) { + $plan->signupTokens()->create([ + 'id' => $token, + // This allows us to update counter when importing old tokens in migration. + // It can be removed later + 'counter' => \App\UserSetting::where('key', 'signup_token') + ->whereRaw('UPPER(value) = ?', [$token])->count(), + ]); + + $bar->advance(); + } + + $bar->finish(); + + $this->info("DONE"); + } +} diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php --- a/src/app/Http/Controllers/API/SignupController.php +++ b/src/app/Http/Controllers/API/SignupController.php @@ -9,7 +9,7 @@ use App\Plan; use App\Providers\PaymentProvider; use App\Rules\SignupExternalEmail; -use App\Rules\SignupToken; +use App\Rules\SignupToken as SignupTokenRule; use App\Rules\Password; use App\Rules\UserEmailDomain; use App\Rules\UserEmailLocal; @@ -95,7 +95,7 @@ $plan = $this->getPlan(); if ($plan->mode == Plan::MODE_TOKEN) { - $rules['token'] = ['required', 'string', new SignupToken()]; + $rules['token'] = ['required', 'string', new SignupTokenRule($plan)]; } else { $rules['email'] = ['required', 'string', new SignupExternalEmail()]; } @@ -241,7 +241,9 @@ // Direct signup by token if ($request->token) { - $rules['token'] = ['required', 'string', new SignupToken()]; + // This will validate the token and the plan mode + $plan = $request->plan ? Plan::withEnvTenantContext()->where('title', $request->plan)->first() : null; + $rules['token'] = ['required', 'string', new SignupTokenRule($plan)]; } // Validate input @@ -254,13 +256,7 @@ $settings = []; if (!empty($request->token)) { - // Token mode, check the plan - $plan = $request->plan ? Plan::withEnvTenantContext()->where('title', $request->plan)->first() : null; - - if (!$plan || $plan->mode != Plan::MODE_TOKEN) { - $msg = self::trans('validation.exists', ['attribute' => 'plan']); - return response()->json(['status' => 'error', 'errors' => ['plan' => $msg]], 422); - } + $settings = ['signup_token' => strtoupper($request->token)]; } elseif (!empty($request->plan) && empty($request->code) && empty($request->invitation)) { // Plan parameter is required/allowed in mandate mode $plan = Plan::withEnvTenantContext()->where('title', $request->plan)->first(); @@ -315,7 +311,7 @@ ]; if ($plan->mode == Plan::MODE_TOKEN) { - $settings['signup_token'] = $code_data->email; + $settings['signup_token'] = strtoupper($code_data->email); } else { $settings['external_email'] = $code_data->email; } @@ -431,6 +427,11 @@ $request->code->save(); } + // Bump up counter on the signup token + if (!empty($request->settings['signup_token'])) { + \App\SignupToken::where('id', $request->settings['signup_token'])->increment('counter'); + } + DB::commit(); $response = AuthController::logonResponse($user, $request->password); diff --git a/src/app/Observers/SignupTokenObserver.php b/src/app/Observers/SignupTokenObserver.php new file mode 100644 --- /dev/null +++ b/src/app/Observers/SignupTokenObserver.php @@ -0,0 +1,18 @@ +id = strtoupper(trim($token->id)); + } +} diff --git a/src/app/Plan.php b/src/app/Plan.php --- a/src/app/Plan.php +++ b/src/app/Plan.php @@ -136,4 +136,14 @@ return false; } + + /** + * The relationship to signup tokens. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function signupTokens() + { + return $this->hasMany(SignupToken::class); + } } diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -56,6 +56,7 @@ \App\SharedFolderSetting::observe(\App\Observers\SharedFolderSettingObserver::class); \App\SignupCode::observe(\App\Observers\SignupCodeObserver::class); \App\SignupInvitation::observe(\App\Observers\SignupInvitationObserver::class); + \App\SignupToken::observe(\App\Observers\SignupTokenObserver::class); \App\Transaction::observe(\App\Observers\TransactionObserver::class); \App\User::observe(\App\Observers\UserObserver::class); \App\UserAlias::observe(\App\Observers\UserAliasObserver::class); diff --git a/src/app/Rules/SignupToken.php b/src/app/Rules/SignupToken.php --- a/src/app/Rules/SignupToken.php +++ b/src/app/Rules/SignupToken.php @@ -2,11 +2,23 @@ namespace App\Rules; +use App\Plan; use Illuminate\Contracts\Validation\Rule; class SignupToken implements Rule { protected $message; + protected $plan; + + /** + * Class constructor. + * + * @param ?Plan $plan Signup plan + */ + public function __construct($plan) + { + $this->plan = $plan; + } /** * Determine if the validation rule passes. @@ -24,32 +36,14 @@ return false; } - // Check the list of tokens for token existence - $file = storage_path('signup-tokens.txt'); - $list = []; - $token = \strtoupper($token); - - if (file_exists($file)) { - $list = file($file); - $list = array_map('trim', $list); - $list = array_map('strtoupper', $list); - } else { - \Log::error("Signup tokens file ({$file}) does not exist"); - } - - if (!in_array($token, $list)) { + // Sanity check on the plan + if (!$this->plan || $this->plan->mode != Plan::MODE_TOKEN) { $this->message = \trans('validation.signuptokeninvalid'); return false; } - // Check if the token has been already used for registration (exclude deleted users) - $used = \App\User::select() - ->join('user_settings', 'users.id', '=', 'user_settings.user_id') - ->where('user_settings.key', 'signup_token') - ->where('user_settings.value', $token) - ->exists(); - - if ($used) { + // Check the token existence + if (!$this->plan->signupTokens()->find($token)) { $this->message = \trans('validation.signuptokeninvalid'); return false; } diff --git a/src/app/SignupToken.php b/src/app/SignupToken.php new file mode 100644 --- /dev/null +++ b/src/app/SignupToken.php @@ -0,0 +1,49 @@ + The attributes that are mass assignable */ + protected $fillable = [ + 'plan_id', + 'id', + 'counter', + ]; + + /** @var array The attributes that should be cast */ + protected $casts = [ + 'created_at' => 'datetime:Y-m-d H:i:s', + 'counter' => 'integer', + ]; + + /** @var bool Indicates if the model should be timestamped. */ + public $timestamps = false; + + /** + * The plan this token applies to + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function plan() + { + return $this->belongsTo(Plan::class); + } +} diff --git a/src/database/migrations/2023_12_14_100000_create_signup_tokens_table.php b/src/database/migrations/2023_12_14_100000_create_signup_tokens_table.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2023_12_14_100000_create_signup_tokens_table.php @@ -0,0 +1,38 @@ +string('id')->primary(); + $table->string('plan_id', 36); + $table->integer('counter')->unsigned()->default(0); + $table->timestamp('created_at')->useCurrent(); + + $table->index('plan_id'); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('signup_tokens'); + } +}; diff --git a/src/tests/Browser/SignupTest.php b/src/tests/Browser/SignupTest.php --- a/src/tests/Browser/SignupTest.php +++ b/src/tests/Browser/SignupTest.php @@ -7,6 +7,7 @@ use App\Plan; use App\SignupCode; use App\SignupInvitation; +use App\SignupToken; use App\User; use Tests\Browser; use Tests\Browser\Components\Menu; @@ -32,6 +33,7 @@ $this->deleteTestDomain('user-domain-signup.com'); Plan::whereNot('mode', Plan::MODE_EMAIL)->update(['mode' => Plan::MODE_EMAIL]); + SignupToken::truncate(); } /** @@ -46,8 +48,7 @@ Plan::whereNot('mode', Plan::MODE_EMAIL)->update(['mode' => Plan::MODE_EMAIL]); Discount::where('discount', 100)->update(['code' => null]); - - @unlink(storage_path('signup-tokens.txt')); + SignupToken::truncate(); parent::tearDown(); } @@ -669,18 +670,18 @@ public function testSignupToken(): void { // Test the individual plan - Plan::where('title', 'individual')->update(['mode' => Plan::MODE_TOKEN]); + $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); + $plan->update(['mode' => Plan::MODE_TOKEN]); - // Register some valid tokens - $tokens = ['1234567890', 'abcdefghijk']; - file_put_contents(storage_path('signup-tokens.txt'), implode("\n", $tokens)); + // Register a valid token + $plan->signupTokens()->create(['id' => '1234567890']); - $this->browse(function (Browser $browser) use ($tokens) { + $this->browse(function (Browser $browser) { $browser->visit(new Signup()) ->waitFor('@step0 .plan-individual button') ->click('@step0 .plan-individual button') // Step 1 - ->whenAvailable('@step1', function ($browser) use ($tokens) { + ->whenAvailable('@step1', function ($browser) { $browser->assertSeeIn('.card-title', 'Sign Up - Step 1/2') ->type('#signup_first_name', 'Test') ->type('#signup_last_name', 'User') @@ -693,7 +694,7 @@ ->assertFocused('#signup_token') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // valid token - ->type('#signup_token', $tokens[0]) + ->type('#signup_token', '1234567890') ->click('[type=submit]'); }) // Step 2 @@ -718,18 +719,21 @@ }); $user = User::where('email', 'signuptestdusk@' . \config('app.domain'))->first(); - $this->assertSame($tokens[0], $user->getSetting('signup_token')); $this->assertSame(null, $user->getSetting('external_email')); // Test the group plan - Plan::where('title', 'group')->update(['mode' => Plan::MODE_TOKEN]); + $plan = Plan::withEnvTenantContext()->where('title', 'group')->first(); + $plan->update(['mode' => Plan::MODE_TOKEN]); - $this->browse(function (Browser $browser) use ($tokens) { + // Register a valid token + $plan->signupTokens()->create(['id' => 'abcdefghijk']); + + $this->browse(function (Browser $browser) { $browser->visit(new Signup()) ->waitFor('@step0 .plan-group button') ->click('@step0 .plan-group button') // Step 1 - ->whenAvailable('@step1', function ($browser) use ($tokens) { + ->whenAvailable('@step1', function ($browser) { $browser->assertSeeIn('.card-title', 'Sign Up - Step 1/2') ->type('#signup_first_name', 'Test') ->type('#signup_last_name', 'User') @@ -742,7 +746,7 @@ ->assertFocused('#signup_token') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // valid token - ->type('#signup_token', $tokens[1]) + ->type('#signup_token', 'abcdefghijk') ->click('[type=submit]'); }) // Step 2 @@ -762,7 +766,6 @@ }); $user = User::where('email', 'admin@user-domain-signup.com')->first(); - $this->assertSame($tokens[1], $user->getSetting('signup_token')); $this->assertSame(null, $user->getSetting('external_email')); } diff --git a/src/tests/Feature/Console/Data/Import/SignupTokensTest.php b/src/tests/Feature/Console/Data/Import/SignupTokensTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Console/Data/Import/SignupTokensTest.php @@ -0,0 +1,97 @@ +delete(); + SignupToken::truncate(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + Plan::where('title', 'test')->delete(); + SignupToken::truncate(); + + @unlink(storage_path('test-tokens.txt')); + + parent::tearDown(); + } + + /** + * Test the command + */ + public function testHandle(): void + { + $file = storage_path('test-tokens.txt'); + file_put_contents($file, ''); + + // Unknown plan + $code = \Artisan::call("data:import:signup-tokens unknown {$file}"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("Plan not found", $output); + + // Plan not for tokens + $code = \Artisan::call("data:import:signup-tokens individual {$file}"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("The plan is not for tokens", $output); + + $plan = Plan::create([ + 'title' => 'test', + 'name' => 'Test Account', + 'description' => 'Test', + 'mode' => Plan::MODE_TOKEN, + ]); + + // Non-existent input file + $code = \Artisan::call("data:import:signup-tokens {$plan->title} nofile.txt"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("File 'nofile.txt' does not exist", $output); + + // Empty input file + $code = \Artisan::call("data:import:signup-tokens {$plan->title} {$file}"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("File '{$file}' is empty", $output); + + // Valid tokens + file_put_contents($file, "12345\r\nabcde"); + $code = \Artisan::call("data:import:signup-tokens {$plan->id} {$file}"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertStringContainsString("Validating tokens... DONE", $output); + $this->assertStringContainsString("Importing tokens... DONE", $output); + $this->assertSame(['12345', 'ABCDE'], $plan->signupTokens()->orderBy('id')->pluck('id')->all()); + + // Attempt the same tokens again + $code = \Artisan::call("data:import:signup-tokens {$plan->id} {$file}"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertStringContainsString("Validating tokens... DONE", $output); + $this->assertStringContainsString("Nothing to import", $output); + $this->assertStringNotContainsString("Importing tokens...", $output); + } +} diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php --- a/src/tests/Feature/Controller/SignupTest.php +++ b/src/tests/Feature/Controller/SignupTest.php @@ -10,6 +10,7 @@ use App\Package; use App\SignupCode; use App\SignupInvitation as SI; +use App\SignupToken; use App\User; use App\VatRate; use Illuminate\Support\Facades\Queue; @@ -39,7 +40,9 @@ $this->deleteTestDomain('signup-domain.com'); $this->deleteTestGroup('group-test@kolabnow.com'); + SI::truncate(); + SignupToken::truncate(); Plan::where('title', 'test')->delete(); IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete(); VatRate::query()->delete(); @@ -59,13 +62,13 @@ $this->deleteTestDomain('signup-domain.com'); $this->deleteTestGroup('group-test@kolabnow.com'); + SI::truncate(); + SignupToken::truncate(); Plan::where('title', 'test')->delete(); IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete(); VatRate::query()->delete(); - @unlink(storage_path('signup-tokens.txt')); - parent::tearDown(); } @@ -953,18 +956,8 @@ $this->assertSame('error', $json['status']); $this->assertSame(['token' => ["The signup token is invalid."]], $json['errors']); - file_put_contents(storage_path('signup-tokens.txt'), "abc\n"); - - // Test invalid plan (existing plan with another mode) - $post['plan'] = 'individual'; - $response = $this->post('/api/auth/signup', $post); - $response->assertStatus(422); - $json = $response->json(); - - $this->assertSame('error', $json['status']); - $this->assertSame(['plan' => "The selected plan is invalid."], $json['errors']); - - // Test valid input + // Test valid token + $plan->signupTokens()->create(['id' => 'abc']); $post['plan'] = $plan->title; $response = $this->post('/api/auth/signup', $post); $response->assertStatus(200); @@ -976,9 +969,13 @@ // Check if the user has been created $user = User::where('email', 'test-inv@kolabnow.com')->first(); - $this->assertNotEmpty($user); $this->assertSame($plan->id, $user->getSetting('plan_id')); + $this->assertSame($plan->signupTokens()->first()->id, $user->getSetting('signup_token')); + $this->assertSame(null, $user->getSetting('external_email')); + + // Token's counter bumped up + $this->assertSame(1, $plan->signupTokens()->first()->counter); } /** diff --git a/src/tests/Unit/Rules/SignupTokenTest.php b/src/tests/Unit/Rules/SignupTokenTest.php --- a/src/tests/Unit/Rules/SignupTokenTest.php +++ b/src/tests/Unit/Rules/SignupTokenTest.php @@ -2,7 +2,9 @@ namespace Tests\Unit\Rules; -use App\Rules\SignupToken; +use App\Plan; +use App\Rules\SignupToken as SignupTokenRule; +use App\SignupToken; use Illuminate\Support\Facades\Validator; use Tests\TestCase; @@ -11,25 +13,44 @@ /** * {@inheritDoc} */ - public function tearDown(): void + public function setUp(): void { - @unlink(storage_path('signup-tokens.txt')); + parent::setUp(); - $john = $this->getTestUser('john@kolab.org'); - $john->settings()->where('key', 'signup_token')->delete(); + Plan::where('title', 'test-plan')->delete(); + SignupToken::truncate(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + Plan::where('title', 'test-plan')->delete(); + SignupToken::truncate(); parent::tearDown(); } /** - * Tests the resource name validator + * Tests the signup token validator */ public function testValidation(): void { - $tokens = ['1234567890', 'abcdefghijk']; - file_put_contents(storage_path('signup-tokens.txt'), implode("\n", $tokens)); + $tokens = ['abcdefghijk', 'T-abcdefghijk']; + + $plan = Plan::where('title', 'individual')->first(); + $tokenPlan = Plan::create([ + 'title' => 'test-plan', + 'description' => 'test', + 'name' => 'Test', + 'mode' => Plan::MODE_TOKEN, + ]); - $rules = ['token' => [new SignupToken()]]; + $plan->signupTokens()->create(['id' => $tokens[0]]); + $tokenPlan->signupTokens()->create(['id' => $tokens[1]]); + + $rules = ['token' => [new SignupTokenRule(null)]]; // Empty input $v = Validator::make(['token' => null], $rules); @@ -39,22 +60,28 @@ $v = Validator::make(['token' => str_repeat('a', 192)], $rules); $this->assertSame(['token' => ["The signup token is invalid."]], $v->errors()->toArray()); + // Valid token, but no plan + $v = Validator::make(['token' => $tokens[1]], $rules); + $this->assertSame(['token' => ["The signup token is invalid."]], $v->errors()->toArray()); + + $rules = ['token' => [new SignupTokenRule($plan)]]; + + // Plan that does not support tokens + $v = Validator::make(['token' => $tokens[0]], $rules); + $this->assertSame(['token' => ["The signup token is invalid."]], $v->errors()->toArray()); + + $rules = ['token' => [new SignupTokenRule($tokenPlan)]]; + // Non-existing token $v = Validator::make(['token' => '123'], $rules); $this->assertSame(['token' => ["The signup token is invalid."]], $v->errors()->toArray()); - // Valid tokens - $v = Validator::make(['token' => $tokens[0]], $rules); + // Valid token + $v = Validator::make(['token' => $tokens[1]], $rules); $this->assertSame([], $v->errors()->toArray()); + // Valid token (uppercase) $v = Validator::make(['token' => strtoupper($tokens[1])], $rules); $this->assertSame([], $v->errors()->toArray()); - - // Tokens already used - $john = $this->getTestUser('john@kolab.org'); - $john->setSetting('signup_token', $tokens[0]); - - $v = Validator::make(['token' => $tokens[0]], $rules); - $this->assertSame(['token' => ["The signup token is invalid."]], $v->errors()->toArray()); } }