diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php index 9ae8cf69..89dae2b0 100644 --- a/src/app/Http/Controllers/API/SignupController.php +++ b/src/app/Http/Controllers/API/SignupController.php @@ -1,384 +1,347 @@ map(function ($plan) use (&$plans) { $plans[] = [ 'title' => $plan->title, 'name' => $plan->name, 'button' => __('app.planbutton', ['plan' => $plan->name]), 'description' => $plan->description, ]; }); return response()->json(['status' => 'success', 'plans' => $plans]); } /** * Starts signup process. * * Verifies user name and email/phone, sends verification email/sms message. * Returns the verification code. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function init(Request $request) { // Check required fields $v = Validator::make( $request->all(), [ 'email' => 'required', 'name' => 'required|max:512', 'plan' => 'nullable|alpha_num|max:128', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Validate user email (or phone) if ($error = $this->validatePhoneOrEmail($request->email, $is_phone)) { - return response()->json(['status' => 'error', 'errors' => ['email' => __($error)]], 422); + return response()->json(['status' => 'error', 'errors' => ['email' => $error]], 422); } // Generate the verification code $code = SignupCode::create([ 'data' => [ 'email' => $request->email, 'name' => $request->name, 'plan' => $request->plan, ] ]); // Send email/sms message if ($is_phone) { SignupVerificationSMS::dispatch($code); } else { SignupVerificationEmail::dispatch($code); } return response()->json(['status' => 'success', 'code' => $code->code]); } /** * Validation of the verification code. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function verify(Request $request) { // Validate the request args $v = Validator::make( $request->all(), [ 'code' => 'required', 'short_code' => 'required', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Validate the verification code $code = SignupCode::find($request->code); if ( empty($code) || $code->isExpired() || Str::upper($request->short_code) !== Str::upper($code->short_code) ) { $errors = ['short_code' => "The code is invalid or expired."]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } // For signup last-step mode remember the code object, so we can delete it // with single SQL query (->delete()) instead of two (::destroy()) $this->code = $code; $has_domain = $this->getPlan()->hasDomain(); // Return user name and email/phone from the codes database, // domains list for selection and "plan type" flag return response()->json([ 'status' => 'success', 'email' => $code->data['email'], 'name' => $code->data['name'], 'is_domain' => $has_domain, 'domains' => $has_domain ? [] : Domain::getPublicDomains(), ]); } /** * Finishes the signup process by creating the user account. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function signup(Request $request) { // Validate input $v = Validator::make( $request->all(), [ 'login' => 'required|min:2', 'password' => 'required|min:4|confirmed', 'domain' => 'required', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Validate verification codes (again) $v = $this->verify($request); if ($v->status() !== 200) { return $v; } // Get the plan $plan = $this->getPlan(); $is_domain = $plan->hasDomain(); $login = $request->login; $domain = $request->domain; // Validate login - if ($errors = $this->validateLogin($login, $domain, $is_domain)) { - $errors = $this->resolveErrors($errors); + if ($errors = self::validateLogin($login, $domain, $is_domain)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Get user name/email from the verification code database $code_data = $v->getData(); $user_name = $code_data->name; $user_email = $code_data->email; // We allow only ASCII, so we can safely lower-case the email address $login = Str::lower($login); $domain = Str::lower($domain); DB::beginTransaction(); // Create user record $user = User::create([ 'name' => $user_name, 'email' => $login . '@' . $domain, 'password' => $request->password, ]); // Create domain record // FIXME: Should we do this in UserObserver::created()? if ($is_domain) { $domain = Domain::create([ 'namespace' => $domain, 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); } $user->assignPlan($plan, $domain); // Save the external email and plan in user settings $user->setSetting('external_email', $user_email); // Remove the verification code $this->code->delete(); DB::commit(); return UsersController::logonResponse($user); } /** - * Checks if the input string is a valid email address or a phone number - * - * @param string $input Email address or phone number - * @param bool $is_phone Will have been set to True if the string is valid phone number + * Returns plan for the signup process * - * @return string Error message label on validation error + * @returns \App\Plan Plan object selected for current signup process */ - protected function validatePhoneOrEmail($input, &$is_phone = false) + protected function getPlan() { - $is_phone = false; - - return $this->validateEmail($input); - - // TODO: Phone number support -/* - if (strpos($input, '@')) { - return $this->validateEmail($input); - } + if (!$this->plan) { + // Get the plan if specified and exists... + if ($this->code && $this->code->data['plan']) { + $plan = Plan::where('title', $this->code->data['plan'])->first(); + } - $input = str_replace(array('-', ' '), '', $input); + // ...otherwise use the default plan + if (empty($plan)) { + // TODO: Get default plan title from config + $plan = Plan::where('title', 'individual')->first(); + } - if (!preg_match('/^\+?[0-9]{9,12}$/', $input)) { - return 'validation.noemailorphone'; + $this->plan = $plan; } - $is_phone = true; -*/ + return $this->plan; } /** - * Email address validation + * Checks if the input string is a valid email address or a phone number * - * @param string $email Email address + * @param string $input Email address or phone number + * @param bool $is_phone Will have been set to True if the string is valid phone number * - * @return string Error message label on validation error + * @return string Error message on validation error */ - protected function validateEmail($email) + protected static function validatePhoneOrEmail($input, &$is_phone = false): ?string { - $v = Validator::make(['email' => $email], ['email' => 'required|email']); + $is_phone = false; + + $v = Validator::make( + ['email' => $input], + ['email' => ['required', 'string', new ExternalEmail()]] + ); if ($v->fails()) { - return 'validation.emailinvalid'; + return $v->errors()->toArray()['email'][0]; } - list($local, $domain) = explode('@', $email); + // TODO: Phone number support +/* + $input = str_replace(array('-', ' '), '', $input); - // don't allow @localhost and other no-fqdn - if (strpos($domain, '.') === false) { - return 'validation.emailinvalid'; + if (!preg_match('/^\+?[0-9]{9,12}$/', $input)) { + return \trans('validation.noemailorphone'); } + + $is_phone = true; +*/ + return null; } /** * Login (kolab identity) validation * * @param string $login Login (local part of an email address) * @param string $domain Domain name * @param bool $external Enables additional checks for domain part * * @return array Error messages on validation error */ - protected function validateLogin($login, $domain, $external = false) + protected static function validateLogin($login, $domain, $external = false): ?array { - // don't allow @localhost and other no-fqdn - if (empty($domain) || strpos($domain, '.') === false || stripos($domain, 'www.') === 0) { - return ['domain' => 'validation.domaininvalid']; - } + // Validate login part alone + $v = Validator::make( + ['login' => $login], + ['login' => ['required', 'string', new UserEmailLocal($external)]] + ); - // Local part validation - if (!preg_match('/^[A-Za-z0-9_.-]+$/', $login)) { - return ['login' => 'validation.logininvalid']; + if ($v->fails()) { + return ['login' => $v->errors()->toArray()['login'][0]]; } - $domain = Str::lower($domain); + $domains = $external ? null : Domain::getPublicDomains(); - if (!$external) { - // Check if the local part is not one of exceptions - $exceptions = '/^(admin|administrator|sales|root)$/i'; - if (preg_match($exceptions, $login)) { - return ['login' => 'validation.loginexists']; - } + // Validate the domain + $v = Validator::make( + ['domain' => $domain], + ['domain' => ['required', 'string', new UserEmailDomain($domains)]] + ); - // Check if specified domain is allowed for signup - if (!in_array($domain, Domain::getPublicDomains())) { - return ['domain' => 'validation.domaininvalid']; - } - } else { - // Use email validator to validate the domain part - $v = Validator::make(['email' => 'user@' . $domain], ['email' => 'required|email']); - if ($v->fails()) { - return ['domain' => 'validation.domaininvalid']; - } + if ($v->fails()) { + return ['domain' => $v->errors()->toArray()['domain'][0]]; + } - // TODO: DNS registration check - maybe after signup? + $domain = Str::lower($domain); - // Check if domain is already registered with us + // Check if domain is already registered with us + if ($external) { if (Domain::where('namespace', $domain)->first()) { - return ['domain' => 'validation.domainexists']; + return ['domain' => \trans('validation.domainexists')]; } } // Check if user with specified login already exists - // TODO: Aliases $email = $login . '@' . $domain; if (User::findByEmail($email)) { - return ['login' => 'validation.loginexists']; - } - } - - /** - * Returns plan for the signup process - * - * @returns \App\Plan Plan object selected for current signup process - */ - protected function getPlan() - { - if (!$this->plan) { - // Get the plan if specified and exists... - if ($this->code && $this->code->data['plan']) { - $plan = Plan::where('title', $this->code->data['plan'])->first(); - } - - // ...otherwise use the default plan - if (empty($plan)) { - // TODO: Get default plan title from config - $plan = Plan::where('title', 'individual')->first(); - } - - $this->plan = $plan; - } - - return $this->plan; - } - - /** - * Convert error labels to actual (localized) text - */ - protected function resolveErrors(array $errors): array - { - $result = []; - - foreach ($errors as $idx => $label) { - $result[$idx] = __($label); + return ['login' => \trans('validation.loginexists')]; } - return $result; + return null; } } diff --git a/src/app/Http/Controllers/API/UsersController.php b/src/app/Http/Controllers/API/UsersController.php index ffb3382a..5fe3293d 100644 --- a/src/app/Http/Controllers/API/UsersController.php +++ b/src/app/Http/Controllers/API/UsersController.php @@ -1,348 +1,545 @@ middleware('auth:api', ['except' => ['login']]); } /** * Helper method for other controllers with user auto-logon * functionality * * @param \App\User $user User model object */ public static function logonResponse(User $user) { $token = auth()->login($user); return response()->json([ 'status' => 'success', 'access_token' => $token, 'token_type' => 'bearer', 'expires_in' => Auth::guard()->factory()->getTTL() * 60, ]); } /** * Display a listing of the resources. * * The user themself, and other user entitlements. * * @return \Illuminate\Http\JsonResponse */ public function index() { $user = Auth::user(); if (!$user) { return response()->json(['error' => 'unauthorized'], 401); } $result = [$user]; $user->entitlements()->each( function ($entitlement) { $result[] = User::find($entitlement->user_id); } ); return response()->json($result); } /** * Get the authenticated User * * @return \Illuminate\Http\JsonResponse */ public function info() { $user = $this->guard()->user(); - $response = $user->toArray(); - - // Settings - // TODO: It might be reasonable to limit the list of settings here to these - // that are safe and are used in the UI - $response['settings'] = []; - foreach ($user->settings as $item) { - $response['settings'][$item->key] = $item->value; - } - - // Status info - $response['statusInfo'] = self::statusInfo($user); + $response = $this->userResponse($user); return response()->json($response); } /** * Get a JWT token via given credentials. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse */ public function login(Request $request) { $v = Validator::make( $request->all(), [ 'email' => 'required|min:2', 'password' => 'required|min:4', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } - $credentials = $request->only('email', 'password'); if ($token = $this->guard()->attempt($credentials)) { return $this->respondWithToken($token); } return response()->json(['status' => 'error', 'message' => __('auth.failed')], 401); } /** * Log the user out (Invalidate the token) * * @return \Illuminate\Http\JsonResponse */ public function logout() { $this->guard()->logout(); return response()->json([ 'status' => 'success', 'message' => __('auth.logoutsuccess') ]); } /** * Refresh a token. * * @return \Illuminate\Http\JsonResponse */ public function refresh() { return $this->respondWithToken($this->guard()->refresh()); } /** * Get the token array structure. * * @param string $token Respond with this token. * * @return \Illuminate\Http\JsonResponse */ protected function respondWithToken($token) { return response()->json( [ 'access_token' => $token, 'token_type' => 'bearer', 'expires_in' => $this->guard()->factory()->getTTL() * 60 ] ); } /** * Display information on the user account specified by $id. * * @param int $id The account to show information for. * * @return \Illuminate\Http\JsonResponse|void */ public function show($id) { if (!$this->hasAccess($id)) { return $this->errorResponse(403); } $user = User::find($id); if (empty($user)) { - return $this->errorResponse(404); + return $this->errorResponse(404); } - return response()->json($user); + $response = $this->userResponse($user); + + return response()->json($response); } /** * 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()) { + if ($domain && !$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; $step = [ 'label' => $step_name, 'title' => __("app.process-{$step_name}"), 'state' => is_bool($func) ? $func : $object->{$func}(), ]; if ($step_name == 'domain-confirmed' && !$step['state']) { $step['link'] = "/domain/{$domain->id}"; } $process[] = $step; } return [ 'process' => $process, 'status' => $status, ]; } /** * Create a new user record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { - // TODO + if ($this->guard()->user()->controller()->id !== $this->guard()->user()->id) { + return $this->errorResponse(403); + } + + if ($error_response = $this->validateUserRequest($request, null, $settings)) { + return $error_response; + } + + $user_name = !empty($settings['first_name']) ? $settings['first_name'] : ''; + if (!empty($settings['last_name'])) { + $user_name .= ' ' . $settings['last_name']; + } + + DB::beginTransaction(); + + // Create user record + $user = User::create([ + 'name' => $user_name, + 'email' => $request->email, + 'password' => $request->password, + ]); + + if (!empty($settings)) { + $user->setSettings($settings); + } + + // TODO: Assign package + + // Add aliases + if (!empty($request->aliases)) { + $user->setAliases($request->aliases); + } + + DB::commit(); + + return response()->json([ + 'status' => 'success', + 'message' => __('app.user-create-success'), + ]); } /** * Update user data. * * @param \Illuminate\Http\Request $request The API request. * @params string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { if (!$this->hasAccess($id)) { return $this->errorResponse(403); } $user = User::find($id); if (empty($user)) { return $this->errorResponse(404); } - $rules = [ - 'external_email' => 'nullable|email', - 'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/', - 'first_name' => 'string|nullable|max:512', - 'last_name' => 'string|nullable|max:512', - 'billing_address' => 'string|nullable|max:1024', - 'country' => 'string|nullable|alpha|size:2', - 'currency' => 'string|nullable|alpha|size:3', - ]; - - if (!empty($request->password) || !empty($request->password_confirmation)) { - $rules['password'] = 'required|min:4|max:2048|confirmed'; + if ($error_response = $this->validateUserRequest($request, $user, $settings)) { + return $error_response; } - // Validate input - $v = Validator::make($request->all(), $rules); - - if ($v->fails()) { - return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); - } - - // Update user settings - $settings = $request->only(array_keys($rules)); - unset($settings['password']); + DB::beginTransaction(); if (!empty($settings)) { $user->setSettings($settings); } // Update user password - if (!empty($rules['password'])) { + if (!empty($request->password)) { $user->password = $request->password; $user->save(); } + // Update aliases + if (isset($request->aliases)) { + $user->setAliases($request->aliases); + } + + DB::commit(); + return response()->json([ 'status' => 'success', 'message' => __('app.user-update-success'), ]); } /** * Get the guard to be used during authentication. * * @return \Illuminate\Contracts\Auth\Guard */ public function guard() { return Auth::guard(); } /** * Check if the current user has access to the specified user * * @param int $user_id User identifier * * @return bool True if current user has access, False otherwise */ protected function hasAccess($user_id): bool { $current_user = $this->guard()->user(); // TODO: Admins, other users // FIXME: This probably should be some kind of middleware/guard return $current_user->id == $user_id; } + + /** + * Create a response data array for specified user. + * + * @param \App\User $user User object + * + * @return array Response data + */ + protected function userResponse(User $user): array + { + $response = $user->toArray(); + + // Settings + // TODO: It might be reasonable to limit the list of settings here to these + // that are safe and are used in the UI + $response['settings'] = []; + foreach ($user->settings as $item) { + $response['settings'][$item->key] = $item->value; + } + + // Aliases + $response['aliases'] = []; + foreach ($user->aliases as $item) { + $response['aliases'][] = $item->alias; + } + + // Status info + $response['statusInfo'] = self::statusInfo($user); + + return $response; + } + + /** + * Validate user input + * + * @param \Illuminate\Http\Request $request The API request. + * @param \App\User|null $user User identifier + * @param array $settings User settings (from the request) + * + * @return \Illuminate\Http\JsonResponse The response on error + */ + protected function validateUserRequest(Request $request, $user, &$settings = []) + { + $rules = [ + 'external_email' => 'nullable|email', + 'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/', + 'first_name' => 'string|nullable|max:512', + 'last_name' => 'string|nullable|max:512', + 'billing_address' => 'string|nullable|max:1024', + 'country' => 'string|nullable|alpha|size:2', + 'currency' => 'string|nullable|alpha|size:3', + 'aliases' => 'array|nullable', + ]; + + if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) { + $rules['password'] = 'required|min:4|max:2048|confirmed'; + } + + $errors = []; + + // Validate input + $v = Validator::make($request->all(), $rules); + + if ($v->fails()) { + $errors = $v->errors()->toArray(); + } + + $controller = $user ? $user->controller() : $this->guard()->user(); + + // For new user validate email address + if (empty($user)) { + $email = $request->email; + + if (empty($email)) { + $errors['email'] = \trans('validation.required', ['attribute' => 'email']); + } elseif ($error = self::validateEmail($email, $controller, false)) { + $errors['email'] = $error; + } + } + + // Validate aliases input + if (isset($request->aliases)) { + $aliases = []; + $existing_aliases = $user ? $user->aliases()->get()->pluck('alias')->toArray() : []; + + foreach ($request->aliases as $idx => $alias) { + if (is_string($alias) && !empty($alias)) { + // Alias cannot be the same as the email address (new user) + if (!empty($email) && Str::lower($alias) == Str::lower($email)) { + continue; + } + + // validate new aliases + if ( + !in_array($alias, $existing_aliases) + && ($error = self::validateEmail($alias, $controller, true)) + ) { + if (!isset($errors['aliases'])) { + $errors['aliases'] = []; + } + $errors['aliases'][$idx] = $error; + continue; + } + + $aliases[] = $alias; + } + } + + $request->aliases = $aliases; + } + + if (!empty($errors)) { + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + // Update user settings + $settings = $request->only(array_keys($rules)); + unset($settings['password'], $settings['aliases'], $settings['email']); + } + + /** + * Email address (login or alias) validation + * + * @param string $email Email address + * @param \App\User $user The account owner + * @param bool $is_alias The email is an alias + * + * @return string Error message on validation error + */ + protected static function validateEmail(string $email, User $user, bool $is_alias = false): ?string + { + $attribute = $is_alias ? 'alias' : 'email'; + + if (strpos($email, '@') === false) { + return \trans('validation.entryinvalid', ['attribute' => $attribute]); + } + + list($login, $domain) = explode('@', $email); + + // Check if domain exists + $domain = Domain::where('namespace', Str::lower($domain))->first(); + + if (empty($domain)) { + return \trans('validation.domaininvalid'); + } + + // Validate login part alone + $v = Validator::make( + [$attribute => $login], + [$attribute => ['required', new UserEmailLocal(!$domain->isPublic())]] + ); + + if ($v->fails()) { + return $v->errors()->toArray()[$attribute][0]; + } + + // Check if it is one of domains available to the user + // TODO: We should have a helper that returns "flat" array with domain names + // I guess we could use pluck() somehow + $domains = array_map( + function ($domain) { + return $domain->namespace; + }, + $user->domains() + ); + + if (!in_array($domain->namespace, $domains)) { + return \trans('validation.entryexists', ['attribute' => 'domain']); + } + + // Check if user with specified address already exists + if (User::findByEmail($email)) { + return \trans('validation.entryexists', ['attribute' => $attribute]); + } + + return null; + } } diff --git a/src/app/Observers/UserAliasObserver.php b/src/app/Observers/UserAliasObserver.php new file mode 100644 index 00000000..1e372317 --- /dev/null +++ b/src/app/Observers/UserAliasObserver.php @@ -0,0 +1,88 @@ +alias = \strtolower($alias->alias); + + if (User::where('email', $alias->alias)->first()) { + \Log::error("Failed creating alias {$alias->alias}. User exists."); + return false; + } + } + + /** + * Handle the user alias "created" event. + * + * @param \App\UserAlias $alias User email alias + * + * @return void + */ + public function created(UserAlias $alias) + { + \App\Jobs\UserUpdate::dispatch($alias->user); + } + + /** + * Handle the user setting "updated" event. + * + * @param \App\UserAlias $alias User email alias + * + * @return void + */ + public function updated(UserAlias $alias) + { + \App\Jobs\UserUpdate::dispatch($alias->user); + } + + /** + * Handle the user setting "deleted" event. + * + * @param \App\UserAlias $alias User email alias + * + * @return void + */ + public function deleted(UserAlias $alias) + { + \App\Jobs\UserUpdate::dispatch($alias->user); + } + + /** + * Handle the user alias "restored" event. + * + * @param \App\UserAlias $alias User email alias + * + * @return void + */ + public function restored(UserAlias $alias) + { + // not used + } + + /** + * Handle the user alias "force deleted" event. + * + * @param \App\UserAlias $alias User email alias + * + * @return void + */ + public function forceDeleted(UserAlias $alias) + { + // not used + } +} diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php index dc66c7b8..2a15f1bd 100644 --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -1,46 +1,47 @@ sql, implode(', ', $query->bindings))); }); } } } diff --git a/src/app/Rules/ExternalEmail.php b/src/app/Rules/ExternalEmail.php new file mode 100644 index 00000000..de112d2a --- /dev/null +++ b/src/app/Rules/ExternalEmail.php @@ -0,0 +1,52 @@ + $email], ['email' => 'required|email']); + + if ($v->fails()) { + $this->message = \trans('validation.emailinvalid'); + return false; + } + + list($local, $domain) = explode('@', $email); + + // don't allow @localhost and other no-fqdn + if (strpos($domain, '.') === false) { + $this->message = \trans('validation.emailinvalid'); + return false; + } + + return true; + } + + /** + * Get the validation error message. + * + * @return string + */ + public function message(): ?string + { + return $this->message; + } +} diff --git a/src/app/Rules/UserEmailDomain.php b/src/app/Rules/UserEmailDomain.php new file mode 100644 index 00000000..7dbecd3a --- /dev/null +++ b/src/app/Rules/UserEmailDomain.php @@ -0,0 +1,70 @@ +domains = $domains; + } + + /** + * Determine if the validation rule passes. + * + * Validation of local part of an email address that's + * going to be user's login. + * + * @param string $attribute Attribute name + * @param mixed $domain Domain part of email address + * + * @return bool + */ + public function passes($attribute, $domain): bool + { + // don't allow @localhost and other no-fqdn + if (empty($domain) || strpos($domain, '.') === false || stripos($domain, 'www.') === 0) { + $this->message = \trans('validation.domaininvalid'); + return false; + } + + $domain = Str::lower($domain); + + // Use email validator to validate the domain part + $v = Validator::make(['email' => 'user@' . $domain], ['email' => 'required|email']); + if ($v->fails()) { + $this->message = \trans('validation.domaininvalid'); + return false; + } + + // Check if specified domain is allowed for signup + if (is_array($this->domains) && !in_array($domain, $this->domains)) { + $this->message = \trans('validation.domaininvalid'); + return false; + } + + return true; + } + + /** + * Get the validation error message. + * + * @return string + */ + public function message(): ?string + { + return $this->message; + } +} diff --git a/src/app/Rules/UserEmailLocal.php b/src/app/Rules/UserEmailLocal.php new file mode 100644 index 00000000..894c04a6 --- /dev/null +++ b/src/app/Rules/UserEmailLocal.php @@ -0,0 +1,72 @@ +external = $external; + } + + /** + * Determine if the validation rule passes. + * + * Validation of local part of an email address that's + * going to be user's login. + * + * @param string $attribute Attribute name + * @param mixed $login Local part of email address + * + * @return bool + */ + public function passes($attribute, $login): bool + { + // Strict validation + if (!preg_match('/^[A-Za-z0-9_.-]+$/', $login)) { + $this->message = \trans('validation.entryinvalid', ['attribute' => $attribute]); + return false; + } + + // Standard email address validation + $v = Validator::make([$attribute => $login . '@test.com'], [$attribute => 'required|email']); + if ($v->fails()) { + $this->message = \trans('validation.entryinvalid', ['attribute' => $attribute]); + return false; + } + + // Check if the local part is not one of exceptions + // (when signing up for an account in public domain + if (!$this->external) { + $exceptions = '/^(admin|administrator|sales|root)$/i'; + + if (preg_match($exceptions, $login)) { + $this->message = \trans('validation.entryexists', ['attribute' => $attribute]); + return false; + } + } + + return true; + } + + /** + * Get the validation error message. + * + * @return string + */ + public function message(): ?string + { + return $this->message; + } +} diff --git a/src/app/Traits/UserAliasesTrait.php b/src/app/Traits/UserAliasesTrait.php new file mode 100644 index 00000000..d0323dff --- /dev/null +++ b/src/app/Traits/UserAliasesTrait.php @@ -0,0 +1,41 @@ + 'some@other.erg']); + * $user->setAliases(['alias1@other.org', 'alias2@other.org']); + * ``` + * + * @param array $aliases An array of email addresses + * + * @return void + */ + public function setAliases(array $aliases): void + { + $existing_aliases = $this->aliases()->get()->map(function ($alias) { + return $alias->alias; + })->toArray(); + + $aliases = array_map('strtolower', $aliases); + $aliases = array_unique($aliases); + + foreach (array_diff($aliases, $existing_aliases) as $alias) { + $this->aliases()->create(['alias' => $alias]); + } + + foreach (array_diff($existing_aliases, $aliases) as $alias) { + $this->aliases()->where('alias', $alias)->delete(); + } + } +} diff --git a/src/app/User.php b/src/app/User.php index f3bebc1c..822eb9a4 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,400 +1,448 @@ 'datetime', ]; /** * Any wallets on which this user is a controller. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function accounts() { return $this->belongsToMany( 'App\Wallet', // The foreign object definition 'user_accounts', // The table name 'user_id', // The local foreign key 'wallet_id' // The remote foreign key ); } + /** + * Email aliases of this user. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function aliases() + { + return $this->hasMany('App\UserAlias', 'user_id'); + } + /** * Assign a package to a user. The user should not have any existing entitlements. * * @param \App\Package $package The package to assign. * @param \App\User|null $user Assign the package to another user. * * @return \App\User */ public function assignPackage($package, $user = null) { if (!$user) { $user = $this; } $wallet_id = $this->wallets()->get()[0]->id; foreach ($package->skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { \App\Entitlement::create( [ 'owner_id' => $this->id, 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'entitleable_id' => $user->id, 'entitleable_type' => User::class ] ); } } return $user; } + /** + * Returns user controlling the current user (or self when it's the account owner) + * + * @return \App\User A user object + */ + public function controller(): User + { + // FIXME: This is most likely not the best way to do this + $entitlement = \App\Entitlement::where([ + 'entitleable_id' => $this->id, + 'entitleable_type' => User::class + ])->first(); + + if ($entitlement && $entitlement->owner_id != $this->id) { + return $entitlement->owner; + } + + return $this; + } + public function assignPlan($plan, $domain = null) { $this->setSetting('plan_id', $plan->id); foreach ($plan->packages as $package) { if ($package->isDomain()) { $domain->assignPackage($package, $this); } else { $this->assignPackage($package); } } } /** * List the domains to which this user is entitled. * * @return Domain[] */ public function domains() { $dbdomains = Domain::whereRaw( sprintf( '(type & %s) AND (status & %s)', Domain::TYPE_PUBLIC, Domain::STATUS_ACTIVE ) )->get(); $domains = []; foreach ($dbdomains as $dbdomain) { $domains[] = $dbdomain; } $entitlements = Entitlement::where('owner_id', $this->id)->get(); foreach ($entitlements as $entitlement) { if ($entitlement->entitleable instanceof Domain) { $domain = $entitlement->entitleable; \Log::info("Found domain for {$this->email}: {$domain->namespace} (owned)"); $domains[] = $domain; } } foreach ($this->accounts as $wallet) { foreach ($wallet->entitlements as $entitlement) { if ($entitlement->entitleable instanceof Domain) { $domain = $entitlement->entitleable; \Log::info("Found domain {$this->email}: {$domain->namespace} (charged)"); $domains[] = $domain; } } } return $domains; } public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } /** * Entitlements for this user. * * Note that these are entitlements that apply to the user account, and not entitlements that * this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany('App\Entitlement', 'entitleable_id', 'id'); } public function addEntitlement($entitlement) { if (!$this->entitlements->contains($entitlement)) { return $this->entitlements()->save($entitlement); } } /** * Helper to find user by email address, whether it is * main email address, alias or external email * * @param string $email Email address * - * @return \App\User User model object + * @return \App\User User model object if found */ public static function findByEmail(string $email): ?User { if (strpos($email, '@') === false) { return null; } + $email = \strtolower($email); + $user = self::where('email', $email)->first(); - // TODO: Aliases, External email + if ($user) { + return $user; + } - return $user; + $alias = UserAlias::where('alias', $email)->first(); + + if ($alias) { + return $alias->user; + } + + // TODO: External email + + return null; } 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) > 0; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * 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) > 0; } /** * Returns whether this user is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this user is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * Any (additional) properties of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function settings() { return $this->hasMany('App\UserSetting', 'user_id'); } /** * Verification codes for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function verificationcodes() { return $this->hasMany('App\VerificationCode', 'user_id', 'id'); } /** * Wallets this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function wallets() { return $this->hasMany('App\Wallet'); } /** * User password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordAttribute($password) { if (!empty($password)) { $this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } /** * User LDAP password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordLdapAttribute($password) { if (!empty($password)) { $this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } /** * 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/app/UserAlias.php b/src/app/UserAlias.php new file mode 100644 index 00000000..caae7739 --- /dev/null +++ b/src/app/UserAlias.php @@ -0,0 +1,33 @@ +belongsTo( + '\App\User', + 'user_id', /* local */ + 'id' /* remote */ + ); + } +} diff --git a/src/database/migrations/2020_02_26_000000_create_user_aliases_table.php b/src/database/migrations/2020_02_26_000000_create_user_aliases_table.php new file mode 100644 index 00000000..bfa07e6b --- /dev/null +++ b/src/database/migrations/2020_02_26_000000_create_user_aliases_table.php @@ -0,0 +1,39 @@ +bigIncrements('id'); + $table->bigInteger('user_id'); + $table->string('alias')->unique(); + $table->timestamps(); + + $table->foreign('user_id')->references('id')->on('users') + ->onDelete('cascade')->onUpdate('cascade'); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('user_aliases'); + } +} diff --git a/src/database/seeds/UserSeeder.php b/src/database/seeds/UserSeeder.php index 32828f74..75f4d901 100644 --- a/src/database/seeds/UserSeeder.php +++ b/src/database/seeds/UserSeeder.php @@ -1,78 +1,82 @@ 'kolab.org', 'status' => Domain::STATUS_NEW + Domain::STATUS_ACTIVE + Domain::STATUS_CONFIRMED + Domain::STATUS_VERIFIED, 'type' => Domain::TYPE_EXTERNAL ] ); $john = User::create( [ 'name' => 'John Doe', 'email' => 'john@kolab.org', 'password' => 'simple123', 'email_verified_at' => now() ] ); $john->setSettings( [ 'first_name' => 'John', 'last_name' => 'Doe', 'currency' => 'USD', 'country' => 'US', 'billing_address' => "601 13th Street NW\nSuite 900 South\nWashington, DC 20005", 'external_email' => 'john.doe.external@gmail.com', 'phone' => '+1 509-248-1111', ] ); + $john->setAliases(['john.doe@kolab.org']); + $user_wallets = $john->wallets()->get(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $package_kolab = \App\Package::where('title', 'kolab')->first(); $domain->assignPackage($package_domain, $john); $john->assignPackage($package_kolab); $jack = User::create( [ 'name' => 'Jack Daniels', 'email' => 'jack@kolab.org', 'password' => 'simple123', 'email_verified_at' => now() ] ); $jack->setSettings( [ 'first_name' => 'Jack', 'last_name' => 'Daniels', 'currency' => 'USD', 'country' => 'US' ] ); + $jack->setAliases(['jack.daniels@kolab.org']); + $john->assignPackage($package_kolab, $jack); factory(User::class, 10)->create(); } } diff --git a/src/resources/js/app.js b/src/resources/js/app.js index 148a7ba1..c4607432 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,150 +1,179 @@ /** * First we will load all of this project's JavaScript dependencies which * includes Vue and other libraries. It is a great starting point when * building robust, powerful web applications using Vue and Laravel. */ require('./bootstrap') window.Vue = require('vue') import AppComponent from '../vue/components/App' import MenuComponent from '../vue/components/Menu' import router from '../vue/js/routes.js' import store from '../vue/js/store' import VueToastr from '@deveodk/vue-toastr' // Add a response interceptor for general/validation error handler // This have to be before Vue and Router setup. Otherwise we would // not be able to handle axios responses initiated from inside // components created/mounted handlers (e.g. signup code verification link) window.axios.interceptors.response.use( response => { // Do nothing return response }, error => { var error_msg if (error.response && error.response.status == 422) { error_msg = "Form validation error" $.each(error.response.data.errors || {}, (idx, msg) => { $('form').each((i, form) => { const input_name = ($(form).data('validation-prefix') || '') + idx const input = $('#' + input_name) if (input.length) { - input.addClass('is-invalid') - input.parent().find('.invalid-feedback').remove() - input.parent().append($('
Primary Email | |
---|---|