diff --git a/src/app/Backends/IMAP.php b/src/app/Backends/IMAP.php new file mode 100644 --- /dev/null +++ b/src/app/Backends/IMAP.php @@ -0,0 +1,22 @@ +morphOne('App\Entitlement', 'entitleable'); @@ -124,6 +124,16 @@ return $this->type & self::TYPE_PUBLIC; } + /** + * Returns whether this domain is registered in LDAP. + * + * @return bool + */ + public function isLdapReady(): bool + { + return $this->status & self::STATUS_LDAP_READY; + } + /** * Returns whether this domain is suspended. * @@ -134,6 +144,17 @@ return $this->status & self::STATUS_SUSPENDED; } + /** + * Returns whether this (external) domain has been verified + * to exist in DNS. + * + * @return bool + */ + public function isVerified(): bool + { + return $this->status & self::STATUS_VERIFIED; + } + /** * Domain status mutator * @@ -149,6 +170,8 @@ self::STATUS_CONFIRMED, self::STATUS_SUSPENDED, self::STATUS_DELETED, + self::STATUS_LDAP_READY, + self::STATUS_VERIFIED, ]; foreach ($allowed_values as $value) { @@ -164,4 +187,23 @@ $this->attributes['status'] = $new_status; } + + /** + * Verify if a domain exists in DNS + * + * @param string $domain Domain name + * + * @return bool True if registered, False otherwise + * @throws \Exception + */ + public static function verifyDomain(string $domain): bool + { + $record = dns_get_record($domain, DNS_SOA); + + if ($record === false) { + throw new \Exception("Failed to get DNS record for $domain"); + } + + return !empty($record); + } } diff --git a/src/app/Http/Controllers/API/UsersController.php b/src/app/Http/Controllers/API/UsersController.php --- a/src/app/Http/Controllers/API/UsersController.php +++ b/src/app/Http/Controllers/API/UsersController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\API; use App\Http\Controllers\Controller; +use App\Domain; use App\User; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -73,7 +74,12 @@ */ public function info() { - return response()->json($this->guard()->user()); + $user = $this->guard()->user(); + $response = $user->toArray(); + + $response['statusInfo'] = self::statusInfo($user); + + return response()->json($response); } /** @@ -170,6 +176,59 @@ return \App\User::find($id); } + /** + * User status (extended) information + * + * @param \App\User $user User object + * + * @return array Status information + */ + public static function statusInfo(User $user): array + { + $status = 'new'; + $process = []; + $steps = [ + 'user-new' => true, + 'user-ldap-ready' => 'isLdapReady', + 'user-imap-ready' => 'isImapReady', + ]; + + if ($user->isDeleted()) { + $status = 'deleted'; + } elseif ($user->isSuspended()) { + $status = 'suspended'; + } elseif ($user->isActive()) { + $status = 'active'; + } + + list ($local, $domain) = explode('@', $user->email); + $domain = Domain::where('namespace', $domain)->first(); + + // If that is not a public domain, add domain specific steps + if (!$domain->isPublic()) { + $steps['domain-new'] = true; + $steps['domain-ldap-ready'] = 'isLdapReady'; + $steps['domain-verified'] = 'isVerified'; + $steps['domain-confirmed'] = 'isConfirmed'; + } + + // Create a process check list + foreach ($steps as $step_name => $func) { + $object = strpos($step_name, 'user-') === 0 ? $user : $domain; + + $process[] = [ + 'label' => $step_name, + 'title' => __("app.process-{$step_name}"), + 'state' => is_bool($func) ? $func : $object->{$func}(), + ]; + } + + return [ + 'process' => $process, + 'status' => $status, + ]; + } + /** * Get the guard to be used during authentication. * diff --git a/src/app/Jobs/ProcessDomainCreate.php b/src/app/Jobs/ProcessDomainCreate.php --- a/src/app/Jobs/ProcessDomainCreate.php +++ b/src/app/Jobs/ProcessDomainCreate.php @@ -43,6 +43,11 @@ */ public function handle() { - LDAP::createDomain($this->domain); + if (!$this->domain->isLdapReady()) { + LDAP::createDomain($this->domain); + + $this->domain->status |= Domain::STATUS_LDAP_READY; + $this->domain->save(); + } } } diff --git a/src/app/Jobs/ProcessDomainCreate.php b/src/app/Jobs/ProcessDomainVerify.php copy from src/app/Jobs/ProcessDomainCreate.php copy to src/app/Jobs/ProcessDomainVerify.php --- a/src/app/Jobs/ProcessDomainCreate.php +++ b/src/app/Jobs/ProcessDomainVerify.php @@ -2,15 +2,14 @@ namespace App\Jobs; -use App\Backends\LDAP; use App\Domain; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; -use Illuminate\Queue\SerializesModels; use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\SerializesModels; -class ProcessDomainCreate implements ShouldQueue +class ProcessDomainVerify implements ShouldQueue { use Dispatchable; use InteractsWithQueue; @@ -43,6 +42,15 @@ */ public function handle() { - LDAP::createDomain($this->domain); + if (!$this->domain->isVerified()) { + if (Domain::verifyDomain($this->domain->namespace)) { + $this->domain->status |= Domain::STATUS_VERIFIED; + $this->domain->save(); + } + + // TODO: What should happen if the domain is not registered yet? + // Should we start a new job with some specified delay? + // Or we just give the user a button to start verification again? + } } } diff --git a/src/app/Jobs/ProcessUserCreate.php b/src/app/Jobs/ProcessUserCreate.php --- a/src/app/Jobs/ProcessUserCreate.php +++ b/src/app/Jobs/ProcessUserCreate.php @@ -44,6 +44,11 @@ */ public function handle() { - LDAP::createUser($this->user); + if (!$this->user->isLdapReady()) { + LDAP::createUser($this->user); + + $this->user->status |= User::STATUS_LDAP_READY; + $this->user->save(); + } } } diff --git a/src/app/Jobs/ProcessUserCreate.php b/src/app/Jobs/ProcessUserVerify.php copy from src/app/Jobs/ProcessUserCreate.php copy to src/app/Jobs/ProcessUserVerify.php --- a/src/app/Jobs/ProcessUserCreate.php +++ b/src/app/Jobs/ProcessUserVerify.php @@ -2,7 +2,6 @@ namespace App\Jobs; -use App\Backends\LDAP; use App\User; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -10,7 +9,7 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -class ProcessUserCreate implements ShouldQueue +class ProcessUserVerify implements ShouldQueue { use Dispatchable; use InteractsWithQueue; @@ -44,6 +43,12 @@ */ public function handle() { - LDAP::createUser($this->user); + if (!$this->user->isImapReady()) { + if (IMAP::verifyAccount($this->user->email)) { + $this->user->status |= User::STATUS_IMAP_READY; + $this->user->status |= User::STATUS_ACTIVE; + $this->user->save(); + } + } } } diff --git a/src/app/Observers/DomainObserver.php b/src/app/Observers/DomainObserver.php --- a/src/app/Observers/DomainObserver.php +++ b/src/app/Observers/DomainObserver.php @@ -22,6 +22,8 @@ break; } } + + $domain->status |= Domain::STATUS_NEW; } /** @@ -33,7 +35,12 @@ */ public function created(Domain $domain) { - \App\Jobs\ProcessDomainCreate::dispatch($domain); + // Create domain record in LDAP, then check if it exists in DNS + $chain = [ + new \App\Jobs\ProcessDomainVerify($domain), + ]; + + \App\Jobs\ProcessDomainCreate::withChain($chain)->dispatch($domain); } /** diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php --- a/src/app/Observers/UserObserver.php +++ b/src/app/Observers/UserObserver.php @@ -24,6 +24,9 @@ break; } } + + $user->status |= User::STATUS_NEW; + // can't dispatch job here because it'll fail serialization } @@ -54,7 +57,12 @@ $user->wallets()->create(); - \App\Jobs\ProcessUserCreate::dispatch($user); + // Create user record in LDAP, then check if the account is created in IMAP + $chain = [ + new \App\Jobs\ProcessUserVerify($user), + ]; + + \App\Jobs\ProcessUserCreate::withChain($chain)->dispatch($user); } /** diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -18,6 +18,20 @@ use NullableFields; use UserSettingsTrait; + // a new user, default on creation + public const STATUS_NEW = 1 << 0; + // it's been activated + public const STATUS_ACTIVE = 1 << 1; + // user has been suspended + public const STATUS_SUSPENDED = 1 << 2; + // user has been deleted + public const STATUS_DELETED = 1 << 3; + // user has been created in LDAP + public const STATUS_LDAP_READY = 1 << 4; + // user mailbox has been created in IMAP + public const STATUS_IMAP_READY = 1 << 5; + + // change the default primary key type public $incrementing = false; protected $keyType = 'bigint'; @@ -28,7 +42,11 @@ * @var array */ protected $fillable = [ - 'name', 'email', 'password', 'password_ldap' + 'name', + 'email', + 'password', + 'password_ldap', + 'status' ]; /** @@ -37,7 +55,9 @@ * @var array */ protected $hidden = [ - 'password', 'password_ldap', 'remember_token', + 'password', + 'password_ldap', + 'remember_token', ]; protected $nullable = [ @@ -150,6 +170,82 @@ return $user; } + public function getJWTIdentifier() + { + return $this->getKey(); + } + + public function getJWTCustomClaims() + { + return []; + } + + /** + * Returns whether this domain is active. + * + * @return bool + */ + public function isActive(): bool + { + return $this->status & self::STATUS_ACTIVE; + } + + /** + * Returns whether this domain is deleted. + * + * @return bool + */ + public function isDeleted(): bool + { + return $this->status & self::STATUS_DELETED; + } + + /** + * Returns whether this (external) domain has been verified + * to exist in DNS. + * + * @return bool + */ + public function isImapReady(): bool + { + return $this->status & self::STATUS_IMAP_READY; + } + + /** + * Returns whether this user is registered in LDAP. + * + * @return bool + */ + public function isLdapReady(): bool + { + return $this->status & self::STATUS_LDAP_READY; + } + + /** + * Returns whether this user is new. + * + * @return bool + */ + public function isNew(): bool + { + return $this->status & self::STATUS_NEW; + } + + /** + * Returns whether this domain is suspended. + * + * @return bool + */ + public function isSuspended(): bool + { + return $this->status & self::STATUS_SUSPENDED; + } + + /** + * Any (additional) properties of this user. + * + * @return \App\UserSetting[] + */ public function settings() { return $this->hasMany('App\UserSetting', 'user_id'); @@ -175,16 +271,6 @@ return $this->hasMany('App\Wallet'); } - public function getJWTIdentifier() - { - return $this->getKey(); - } - - public function getJWTCustomClaims() - { - return []; - } - public function setPasswordAttribute($password) { if (!empty($password)) { @@ -204,4 +290,36 @@ ); } } + + /** + * User status mutator + * + * @throws \Exception + */ + public function setStatusAttribute($status) + { + $new_status = 0; + + $allowed_values = [ + self::STATUS_NEW, + self::STATUS_ACTIVE, + self::STATUS_SUSPENDED, + self::STATUS_DELETED, + self::STATUS_LDAP_READY, + self::STATUS_IMAP_READY, + ]; + + foreach ($allowed_values as $value) { + if ($status & $value) { + $new_status |= $value; + $status ^= $value; + } + } + + if ($status > 0) { + throw new \Exception("Invalid user status: {$status}"); + } + + $this->attributes['status'] = $new_status; + } } diff --git a/src/database/migrations/2014_10_12_000000_create_users_table.php b/src/database/migrations/2014_10_12_000000_create_users_table.php --- a/src/database/migrations/2014_10_12_000000_create_users_table.php +++ b/src/database/migrations/2014_10_12_000000_create_users_table.php @@ -22,6 +22,7 @@ $table->timestamp('email_verified_at')->nullable(); $table->string('password')->nullable(); $table->string('password_ldap')->nullable(); + $table->smallinteger('status'); $table->rememberToken(); $table->timestamps(); diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -12,4 +12,11 @@ 'planbutton' => 'Choose :plan', + 'process-user-new' => 'User registered', + 'process-user-ldap-ready' => 'User created', + 'process-user-imap-ready' => 'User mailbox created', + 'process-domain-new' => 'Custom domain registered', + 'process-domain-ldap-ready' => 'Custom domain created', + 'process-domain-verified' => 'Custom domain verified', + 'process-domain-confirmed' => 'Custom domain ownership verified', ]; diff --git a/src/resources/vue/components/Dashboard.vue b/src/resources/vue/components/Dashboard.vue --- a/src/resources/vue/components/Dashboard.vue +++ b/src/resources/vue/components/Dashboard.vue @@ -1,12 +1,23 @@