diff --git a/docker/kolab/utils/15-create-hosted-domain.sh b/docker/kolab/utils/15-create-hosted-domain.sh --- a/docker/kolab/utils/15-create-hosted-domain.sh +++ b/docker/kolab/utils/15-create-hosted-domain.sh @@ -54,7 +54,7 @@ ( for role in "2fa-user" "activesync-user" "imap-user"; do - echo "cn=${role},${hosted_domain_rootdn}" + echo "dn: cn=${role},${hosted_domain_rootdn}" echo "cn: ${role}" echo "description: ${role} role" echo "objectclass: top" diff --git a/src/app/Auth/SecondFactor.php b/src/app/Auth/SecondFactor.php --- a/src/app/Auth/SecondFactor.php +++ b/src/app/Auth/SecondFactor.php @@ -231,7 +231,7 @@ if (!isset($this->cache[$key])) { $factors = $this->getFactors(); - $this->cache[$key] = $factors[$key]; + $this->cache[$key] = isset($factors[$key]) ? $factors[$key] : null; } return $this->cache[$key]; diff --git a/src/app/Backends/LDAP.php b/src/app/Backends/LDAP.php --- a/src/app/Backends/LDAP.php +++ b/src/app/Backends/LDAP.php @@ -309,7 +309,10 @@ } if (!array_key_exists('nsroledn', $oldEntry)) { - $oldEntry['nsroledn'] = (array)$ldap->get_entry_attributes($dn, ['nsroledn']); + $roles = $ldap->get_entry_attributes($dn, ['nsroledn']); + if (!empty($roles)) { + $oldEntry['nsroledn'] = (array)$roles['nsroledn']; + } } $newEntry = $oldEntry; @@ -385,56 +388,48 @@ $entry['mailquota'] = 0; - if (!array_key_exists('nsroledn', $entry)) { - $entry['nsroledn'] = []; - } else if (!is_array($entry['nsroledn'])) { - $entry['nsroledn'] = (array)$entry['nsroledn']; - } - $roles = []; foreach ($user->entitlements as $entitlement) { \Log::debug("Examining {$entitlement->sku->title}"); switch ($entitlement->sku->title) { + case "mailbox": + break; + case "storage": $entry['mailquota'] += 1048576; break; - } - $roles[] = $entitlement->sku->title; + default: + $roles[] = $entitlement->sku->title; + break; + } } $hostedRootDN = \config('ldap.hosted.root_dn'); + if (empty($roles)) { + if (array_key_exists('nsroledn', $entry)) { + unset($entry['nsroledn']); + } + + return; + } + + $entry['nsroledn'] = []; + if (in_array("2fa", $roles)) { $entry['nsroledn'][] = "cn=2fa-user,{$hostedRootDN}"; - } else { - $key = array_search("cn=2fa-user,{$hostedRootDN}", $entry['nsroledn']); - if ($key !== false) { - unset($entry['nsroledn'][$key]); - } } if (in_array("activesync", $roles)) { $entry['nsroledn'][] = "cn=activesync-user,{$hostedRootDN}"; - } else { - $key = array_search("cn=activesync-user,{$hostedRootDN}", $entry['nsroledn']); - if ($key !== false) { - unset($entry['nsroledn'][$key]); - } } if (!in_array("groupware", $roles)) { $entry['nsroledn'][] = "cn=imap-user,{$hostedRootDN}"; - } else { - $key = array_search("cn=imap-user,{$hostedRootDN}", $entry['nsroledn']); - if ($key !== false) { - unset($entry['nsroledn'][$key]); - } } - - $entry['nsroledn'] = array_unique($entry['nsroledn']); } /** diff --git a/src/app/Domain.php b/src/app/Domain.php --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -350,13 +350,22 @@ return true; } - $record = \dns_get_record($this->namespace, DNS_ANY); + $records = \dns_get_record($this->namespace, DNS_ANY); - if ($record === false) { + if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } - if (!empty($record)) { + // It may happen that result contains other domains depending on the host + // DNS setup + $hosts = array_map( + function ($record) { + return $record['host']; + }, + $records + ); + + if (in_array($this->namespace, $hosts)) { $this->status |= Domain::STATUS_VERIFIED; $this->save(); diff --git a/src/app/Http/Controllers/API/AuthController.php b/src/app/Http/Controllers/API/AuthController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/AuthController.php @@ -0,0 +1,133 @@ +guard()->user(); + $response = V4\UsersController::userResponse($user); + + return response()->json($response); + } + + /** + * 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, + ]); + } + + /** + * Get a JWT token via given credentials. + * + * @param \Illuminate\Http\Request $request The API request. + * + * @return \Illuminate\Http\JsonResponse + */ + public function login(Request $request) + { + // TODO: Redirect to dashboard if authenticated. + $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)) { + $sf = new \App\Auth\SecondFactor($this->guard()->user()); + + if ($response = $sf->requestHandler($request)) { + return $response; + } + + 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 + ] + ); + } + + /** + * Get the guard to be used during authentication. + * + * @return \Illuminate\Contracts\Auth\Guard + */ + public function guard() + { + return Auth::guard(); + } +} diff --git a/src/app/Http/Controllers/API/PasswordResetController.php b/src/app/Http/Controllers/API/PasswordResetController.php --- a/src/app/Http/Controllers/API/PasswordResetController.php +++ b/src/app/Http/Controllers/API/PasswordResetController.php @@ -138,6 +138,6 @@ // Remove the verification code $this->code->delete(); - return UsersController::logonResponse($user); + return AuthController::logonResponse($user); } } 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 @@ -232,7 +232,7 @@ DB::commit(); - return UsersController::logonResponse($user); + return AuthController::logonResponse($user); } /** diff --git a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php @@ -0,0 +1,7 @@ +get()->map(function ($user) { + $data = $user->toArray(); + $data = array_merge($data, self::userStatuses($user)); + return $data; + }); + + return response()->json($result); + } +} diff --git a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php @@ -0,0 +1,7 @@ +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, - ]); - } - - /** * Delete a user. * * @param int $id User identifier @@ -83,6 +53,7 @@ */ public function index() { + \Log::debug("Regular API"); $user = $this->guard()->user(); $result = $user->users()->orderBy('email')->get()->map(function ($user) { @@ -95,98 +66,6 @@ } /** - * Get the authenticated User - * - * @return \Illuminate\Http\JsonResponse - */ - public function info() - { - $user = $this->guard()->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)) { - $sf = new \App\Auth\SecondFactor($this->guard()->user()); - - if ($response = $sf->requestHandler($request)) { - return $response; - } - - 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. @@ -458,7 +337,7 @@ * * @return array Response data */ - protected function userResponse(User $user): array + public static function userResponse(User $user): array { $response = $user->toArray(); @@ -562,7 +441,7 @@ if (empty($email)) { $errors['email'] = \trans('validation.required', ['attribute' => 'email']); - } elseif ($error = self::validateEmail($email, $controller, false)) { + } elseif ($error = \App\Utils::validateEmail($email, $controller, false)) { $errors['email'] = $error; } } @@ -582,7 +461,7 @@ // validate new aliases if ( !in_array($alias, $existing_aliases) - && ($error = self::validateEmail($alias, $controller, true)) + && ($error = \App\Utils::validateEmail($alias, $controller, true)) ) { if (!isset($errors['aliases'])) { $errors['aliases'] = []; @@ -606,62 +485,4 @@ $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/Http/Controllers/API/WalletsController.php b/src/app/Http/Controllers/API/V4/WalletsController.php rename from src/app/Http/Controllers/API/WalletsController.php rename to src/app/Http/Controllers/API/V4/WalletsController.php --- a/src/app/Http/Controllers/API/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/WalletsController.php @@ -1,10 +1,6 @@ \App\Http\Middleware\AuthenticateAdmin::class, 'auth' => \App\Http\Middleware\Authenticate::class, 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, 'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class, @@ -73,9 +74,11 @@ protected $middlewarePriority = [ \Illuminate\Session\Middleware\StartSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class, + \App\Http\Middleware\AuthenticateAdmin::class, \App\Http\Middleware\Authenticate::class, \Illuminate\Session\Middleware\AuthenticateSession::class, \Illuminate\Routing\Middleware\SubstituteBindings::class, \Illuminate\Auth\Middleware\Authorize::class, + \App\Http\Middleware\AuthenticateAdmin::class, ]; } diff --git a/src/app/Http/Middleware/AuthenticateAdmin.php b/src/app/Http/Middleware/AuthenticateAdmin.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Middleware/AuthenticateAdmin.php @@ -0,0 +1,30 @@ +user(); + + if (!$user) { + abort(403, "Unauthorized"); + } + + if ($user->role !== "admin") { + abort(403, "Unauthorized"); + } + + return $next($request); + } +} diff --git a/src/app/Jobs/UserVerify.php b/src/app/Jobs/UserVerify.php --- a/src/app/Jobs/UserVerify.php +++ b/src/app/Jobs/UserVerify.php @@ -44,6 +44,26 @@ */ public function handle() { + // Verify a mailbox sku is among the user entitlements. + $skuMailbox = \App\Sku::where('title', 'mailbox')->first(); + + if (!$skuMailbox) { + return; + } + + $mailbox = \App\Entitlement::where( + [ + 'sku_id' => $skuMailbox->id, + 'entitleable_id' => $this->user->id, + 'entitleable_type' => User::class + ] + )->first(); + + if (!$mailbox) { + return; + } + + // The user has a mailbox if (!$this->user->isImapReady()) { if (IMAP::verifyAccount($this->user->email)) { $this->user->status |= User::STATUS_IMAP_READY; diff --git a/src/app/Jobs/WalletPayment.php b/src/app/Jobs/WalletPayment.php --- a/src/app/Jobs/WalletPayment.php +++ b/src/app/Jobs/WalletPayment.php @@ -3,7 +3,7 @@ namespace App\Jobs; use App\Wallet; -use App\Http\Controllers\API\PaymentsController; +use App\Http\Controllers\API\V4\PaymentsController; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -71,6 +71,7 @@ 'password', 'password_ldap', 'remember_token', + 'role' ]; protected $nullable = [ @@ -221,6 +222,10 @@ return false; } + if ($this->role == "admin") { + return true; + } + $wallet = $object->wallet(); // TODO: For now controller can delete/update the account owner, @@ -242,6 +247,10 @@ return false; } + if ($this->role == "admin") { + return true; + } + if ($object instanceof User && $this->id == $object->id) { return true; } @@ -264,6 +273,10 @@ return false; } + if ($this->role == "admin") { + return true; + } + if ($object instanceof User && $this->id == $object->id) { return true; } diff --git a/src/app/Utils.php b/src/app/Utils.php --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -2,6 +2,9 @@ namespace App; +use App\Rules\UserEmailLocal; +use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Str; use Ramsey\Uuid\Uuid; /** @@ -92,6 +95,69 @@ $countries = include resource_path('countries.php'); $env['countries'] = $countries ?: []; + $env['jsapp'] = strpos(request()->getHttpHost(), 'admin.') === 0 ? 'admin.js' : 'user.js'; + return $env; } + + /** + * 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 + */ + public static function validateEmail( + string $email, + \App\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/database/migrations/2020_03_27_134609_user_table_add_role_column.php b/src/database/migrations/2020_03_27_134609_user_table_add_role_column.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2020_03_27_134609_user_table_add_role_column.php @@ -0,0 +1,39 @@ +string('role')->nullable(); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table( + 'users', + function (Blueprint $table) { + $table->dropColumn('role'); + } + ); + } +} diff --git a/src/database/seeds/DomainSeeder.php b/src/database/seeds/DomainSeeder.php --- a/src/database/seeds/DomainSeeder.php +++ b/src/database/seeds/DomainSeeder.php @@ -36,6 +36,16 @@ ); } + if (!in_array(\config('app.domain'), $domains)) { + Domain::create( + [ + 'namespace' => \config('app.domain'), + 'status' => DOMAIN::STATUS_CONFIRMED + Domain::STATUS_ACTIVE, + 'type' => Domain::TYPE_PUBLIC + ] + ); + } + $domains = [ 'example.com', 'example.net', diff --git a/src/database/seeds/UserSeeder.php b/src/database/seeds/UserSeeder.php --- a/src/database/seeds/UserSeeder.php +++ b/src/database/seeds/UserSeeder.php @@ -132,5 +132,18 @@ $john->assignPackage($package_lite, $joe); factory(User::class, 10)->create(); + + $jeroen = User::create( + [ + 'name' => 'Jeroen van Meeuwen', + 'email' => 'jeroen@jeroen.jeroen', + 'password' => 'jeroen', + 'email_verified_at' => now() + ] + ); + + $jeroen->role = "admin"; + + $jeroen->save(); } } diff --git a/src/public/mix-manifest.json b/src/public/mix-manifest.json --- a/src/public/mix-manifest.json +++ b/src/public/mix-manifest.json @@ -1,4 +1,5 @@ { - "/js/app.js": "/js/app.js", + "/js/admin.js": "/js/admin.js", + "/js/user.js": "/js/user.js", "/css/app.css": "/css/app.css" } diff --git a/src/resources/js/admin.js b/src/resources/js/admin.js new file mode 100644 --- /dev/null +++ b/src/resources/js/admin.js @@ -0,0 +1,9 @@ +/** + * Application code for the admin UI + */ + +import router from './routes-admin' + +window.router = router + +require('./app') diff --git a/src/resources/js/app.js b/src/resources/js/app.js --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -8,7 +8,6 @@ import AppComponent from '../vue/App' import MenuComponent from '../vue/Menu' -import router from './routes' import store from './store' import FontAwesomeIcon from './fontawesome' import VueToastr from '@deveodk/vue-toastr' @@ -128,7 +127,7 @@ 'menu-component': MenuComponent }, store, - router, + router: window.router, data() { return { isLoading: true @@ -165,7 +164,7 @@ axios.defaults.headers.common.Authorization = 'Bearer ' + token if (dashboard !== false) { - router.push(store.state.afterLogin || { name: 'dashboard' }) + this.$router.push(store.state.afterLogin || { name: 'dashboard' }) } store.state.afterLogin = null @@ -175,7 +174,7 @@ store.commit('logoutUser') localStorage.setItem('token', '') delete axios.defaults.headers.common.Authorization - router.push({ name: 'login' }) + this.$router.push({ name: 'login' }) }, // Display "loading" overlay (to be used by route components) startLoading() { diff --git a/src/resources/js/routes-admin.js b/src/resources/js/routes-admin.js new file mode 100644 --- /dev/null +++ b/src/resources/js/routes-admin.js @@ -0,0 +1,67 @@ +import Vue from 'vue' +import VueRouter from 'vue-router' + +Vue.use(VueRouter) + +import DashboardComponent from '../vue/Admin/Dashboard' +import Error404Component from '../vue/404' +import LoginComponent from '../vue/Login' +import LogoutComponent from '../vue/Logout' +import PasswordResetComponent from '../vue/PasswordReset' + +import store from './store' + +const routes = [ + { + path: '/', + redirect: { name: 'dashboard' } + }, + { + path: '/dashboard', + name: 'dashboard', + component: DashboardComponent, + meta: { requiresAuth: true } + }, + { + path: '/login', + name: 'login', + component: LoginComponent + }, + { + path: '/logout', + name: 'logout', + component: LogoutComponent + }, + { + path: '/password-reset/:code?', + name: 'password-reset', + component: PasswordResetComponent + }, + { + name: '404', + path: '*', + component: Error404Component + } +] + +const router = new VueRouter({ + mode: 'history', + routes +}) + +router.beforeEach((to, from, next) => { + // check if the route requires authentication and user is not logged in + if (to.matched.some(route => route.meta.requiresAuth) && !store.state.isLoggedIn) { + // remember the original request, to use after login + store.state.afterLogin = to; + + // redirect to login page + next({ name: 'login' }) + + return + } + + next() +}) + +export default router diff --git a/src/resources/js/routes.js b/src/resources/js/routes-user.js rename from src/resources/js/routes.js rename to src/resources/js/routes-user.js diff --git a/src/resources/js/user.js b/src/resources/js/user.js new file mode 100644 --- /dev/null +++ b/src/resources/js/user.js @@ -0,0 +1,9 @@ +/** + * Application code for the user UI + */ + +import router from './routes-user' + +window.router = router + +require('./app') diff --git a/src/resources/views/layouts/app.blade.php b/src/resources/views/layouts/app.blade.php --- a/src/resources/views/layouts/app.blade.php +++ b/src/resources/views/layouts/app.blade.php @@ -17,6 +17,6 @@ - + diff --git a/src/resources/vue/Admin/Dashboard.vue b/src/resources/vue/Admin/Dashboard.vue new file mode 100644 --- /dev/null +++ b/src/resources/vue/Admin/Dashboard.vue @@ -0,0 +1,32 @@ + + + diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -19,11 +19,26 @@ 'prefix' => 'auth' ], function ($router) { - Route::get('info', 'API\UsersController@info'); - Route::post('login', 'API\UsersController@login'); - Route::post('logout', 'API\UsersController@logout'); - Route::post('refresh', 'API\UsersController@refresh'); + Route::post('login', 'API\AuthController@login'); + Route::group( + ['middleware' => 'auth:api'], + function ($router) { + Route::get('info', 'API\AuthController@info'); + Route::post('logout', 'API\AuthController@logout'); + Route::post('refresh', 'API\AuthController@refresh'); + } + ); + } +); + +Route::group( + [ + 'domain' => \config('app.domain'), + 'middleware' => 'api', + 'prefix' => 'auth' + ], + function ($router) { Route::post('password-reset/init', 'API\PasswordResetController@init'); Route::post('password-reset/verify', 'API\PasswordResetController@verify'); Route::post('password-reset', 'API\PasswordResetController@reset'); @@ -37,21 +52,47 @@ Route::group( [ + 'domain' => \config('app.domain'), 'middleware' => 'auth:api', 'prefix' => 'v4' ], function () { - Route::apiResource('domains', API\DomainsController::class); - Route::get('domains/{id}/confirm', 'API\DomainsController@confirm'); + Route::apiResource('domains', API\V4\DomainsController::class); + Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm'); - Route::apiResource('entitlements', API\EntitlementsController::class); - Route::apiResource('packages', API\PackagesController::class); - Route::apiResource('skus', API\SkusController::class); - Route::apiResource('users', API\UsersController::class); - Route::apiResource('wallets', API\WalletsController::class); + Route::apiResource('entitlements', API\V4\EntitlementsController::class); + Route::apiResource('packages', API\V4\PackagesController::class); + Route::apiResource('skus', API\V4\SkusController::class); + Route::apiResource('users', API\V4\UsersController::class); + Route::apiResource('wallets', API\V4\WalletsController::class); - Route::post('payments', 'API\PaymentsController@store'); + Route::post('payments', 'API\V4\PaymentsController@store'); } ); -Route::post('webhooks/payment/mollie', 'API\PaymentsController@webhook'); +Route::group( + [ + 'domain' => \config('app.domain'), + ], + function () { + Route::post('webhooks/payment/mollie', 'API\V4\PaymentsController@webhook'); + } +); + +Route::group( + [ + 'domain' => 'admin.' . \config('app.domain'), + 'middleware' => ['auth:api', 'admin'], + 'prefix' => 'v4', + ], + function () { + Route::apiResource('domains', API\V4\Admin\DomainsController::class); + Route::get('domains/{id}/confirm', 'API\V4\Admin\DomainsController@confirm'); + + Route::apiResource('entitlements', API\V4\Admin\EntitlementsController::class); + Route::apiResource('packages', API\V4\Admin\PackagesController::class); + Route::apiResource('skus', API\V4\Admin\SkusController::class); + Route::apiResource('users', API\V4\Admin\UsersController::class); + Route::apiResource('wallets', API\V4\Admin\WalletsController::class); + } +); diff --git a/src/tests/Browser/Admin/LogonTest.php b/src/tests/Browser/Admin/LogonTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Browser/Admin/LogonTest.php @@ -0,0 +1,161 @@ +browse(function (Browser $browser) { + $browser->visit(new Home()); + $browser->within(new Menu(), function ($browser) { + $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); + }); + }); + } + + /** + * Test redirect to /login if user is unauthenticated + */ + public function testLogonRedirect(): void + { + $this->browse(function (Browser $browser) { + $browser->visit('/dashboard'); + + // Checks if we're really on the login page + $browser->waitForLocation('/login') + ->on(new Home()); + }); + } + + /** + * Logon with wrong password/user test + */ + public function testLogonWrongCredentials(): void + { + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('jeroen@jeroen.jeroen', 'wrong'); + + // Error message + $browser->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { + $browser->assertToastTitle('Error') + ->assertToastMessage('Invalid username or password.') + ->closeToast(); + }); + + // Checks if we're still on the logon page + $browser->on(new Home()); + }); + } + + /** + * Successful logon test + */ + public function testLogonSuccessful(): void + { + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true); + + // Checks if we're really on Dashboard page + $browser->on(new Dashboard()) + ->within(new Menu(), function ($browser) { + $browser->assertMenuItems(['support', 'contact', 'webmail', 'logout']); + }) + ->assertUser('jeroen@jeroen.jeroen'); + + // Test that visiting '/' with logged in user does not open logon form + // but "redirects" to the dashboard + $browser->visit('/')->on(new Dashboard()); + }); + } + + /** + * Logout test + * + * @depends testLogonSuccessful + */ + public function testLogout(): void + { + $this->browse(function (Browser $browser) { + $browser->on(new Dashboard()); + + // Click the Logout button + $browser->within(new Menu(), function ($browser) { + $browser->click('.link-logout'); + }); + + // We expect the logon page + $browser->waitForLocation('/login') + ->on(new Home()); + + // with default menu + $browser->within(new Menu(), function ($browser) { + $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); + }); + + // Success toast message + $browser->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { + $browser->assertToastTitle('') + ->assertToastMessage('Successfully logged out') + ->closeToast(); + }); + }); + } + + /** + * Logout by URL test + */ + public function testLogoutByURL(): void + { + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true); + + // Checks if we're really on Dashboard page + $browser->on(new Dashboard()); + + // Use /logout url, and expect the logon page + $browser->visit('/logout') + ->waitForLocation('/login') + ->on(new Home()); + + // with default menu + $browser->within(new Menu(), function ($browser) { + $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); + }); + + // Success toast message + $browser->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { + $browser->assertToastTitle('') + ->assertToastMessage('Successfully logged out') + ->closeToast(); + }); + }); + } +} diff --git a/src/tests/Browser/Pages/Dashboard.php b/src/tests/Browser/Pages/Dashboard.php --- a/src/tests/Browser/Pages/Dashboard.php +++ b/src/tests/Browser/Pages/Dashboard.php @@ -27,7 +27,7 @@ { $browser->assertPathIs('/dashboard') ->waitUntilMissing('@app .app-loader') - ->assertVisible('@links'); + ->assertPresent('@links'); } /** diff --git a/src/tests/Browser/StatusTest.php b/src/tests/Browser/StatusTest.php --- a/src/tests/Browser/StatusTest.php +++ b/src/tests/Browser/StatusTest.php @@ -150,9 +150,9 @@ // Assert user status icons ->assertVisible('@table tbody tr:first-child td:first-child svg.fa-user.text-success') ->assertText('@table tbody tr:first-child td:first-child svg title', 'Active') - ->assertVisible('@table tbody tr:nth-child(2) td:first-child svg.fa-user.text-danger') - ->assertText('@table tbody tr:nth-child(2) td:first-child svg title', 'Not Ready') - ->click('@table tbody tr:nth-child(2) td:first-child a') + ->assertVisible('@table tbody tr:nth-child(3) td:first-child svg.fa-user.text-danger') + ->assertText('@table tbody tr:nth-child(3) td:first-child svg title', 'Not Ready') + ->click('@table tbody tr:nth-child(3) td:first-child a') ->on(new UserInfo()) ->with('@form', function (Browser $browser) { // Assert stet in the user edit form diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php --- a/src/tests/Browser/UsersTest.php +++ b/src/tests/Browser/UsersTest.php @@ -108,11 +108,13 @@ ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 4) ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org') - ->assertSeeIn('tbody tr:nth-child(2) a', 'john@kolab.org') - ->assertSeeIn('tbody tr:nth-child(3) a', 'ned@kolab.org') + ->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org') + ->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org') + ->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org') ->assertVisible('tbody tr:nth-child(1) button.button-delete') ->assertVisible('tbody tr:nth-child(2) button.button-delete') - ->assertVisible('tbody tr:nth-child(3) button.button-delete'); + ->assertVisible('tbody tr:nth-child(3) button.button-delete') + ->assertVisible('tbody tr:nth-child(4) button.button-delete'); }); }); } @@ -126,7 +128,7 @@ { $this->browse(function (Browser $browser) { $browser->on(new UserList()) - ->click('@table tr:nth-child(2) a') + ->click('@table tr:nth-child(3) a') ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'User account') ->with('@form', function (Browser $browser) { @@ -427,8 +429,8 @@ ->waitForLocation('/users') ->on(new UserList()) ->whenAvailable('@table', function (Browser $browser) { - $browser->assertElementsCount('tbody tr', 4) - ->assertSeeIn('tbody tr:nth-child(3) a', 'julia.roberts@kolab.org'); + $browser->assertElementsCount('tbody tr', 5) + ->assertSeeIn('tbody tr:nth-child(4) a', 'julia.roberts@kolab.org'); }); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); @@ -455,9 +457,9 @@ $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->whenAvailable('@table', function (Browser $browser) { - $browser->assertElementsCount('tbody tr', 4) - ->assertSeeIn('tbody tr:nth-child(3) a', 'julia.roberts@kolab.org') - ->click('tbody tr:nth-child(3) button.button-delete'); + $browser->assertElementsCount('tbody tr', 5) + ->assertSeeIn('tbody tr:nth-child(4) a', 'julia.roberts@kolab.org') + ->click('tbody tr:nth-child(4) button.button-delete'); }) ->with(new Dialog('#delete-warning'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Delete julia.roberts@kolab.org') @@ -467,7 +469,7 @@ ->click('@button-cancel'); }) ->whenAvailable('@table', function (Browser $browser) { - $browser->click('tbody tr:nth-child(3) button.button-delete'); + $browser->click('tbody tr:nth-child(4) button.button-delete'); }) ->with(new Dialog('#delete-warning'), function (Browser $browser) { $browser->click('@button-action'); @@ -478,10 +480,11 @@ ->closeToast(); }) ->with('@table', function (Browser $browser) { - $browser->assertElementsCount('tbody tr', 3) + $browser->assertElementsCount('tbody tr', 4) ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org') - ->assertSeeIn('tbody tr:nth-child(2) a', 'john@kolab.org') - ->assertSeeIn('tbody tr:nth-child(3) a', 'ned@kolab.org'); + ->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org') + ->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org') + ->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org'); }); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); @@ -490,7 +493,7 @@ // Test clicking Delete on the controller record redirects to /profile/delete $browser ->with('@table', function (Browser $browser) { - $browser->click('tbody tr:nth-child(2) button.button-delete'); + $browser->click('tbody tr:nth-child(3) button.button-delete'); }) ->waitForLocation('/profile/delete'); }); @@ -514,8 +517,8 @@ ->submitLogon('ned@kolab.org', 'simple123', true) ->visit(new UserList()) ->whenAvailable('@table', function (Browser $browser) { - $browser->assertElementsCount('tbody tr', 3) - ->assertElementsCount('tbody button.button-delete', 3); + $browser->assertElementsCount('tbody tr', 4) + ->assertElementsCount('tbody button.button-delete', 4); }); // TODO: Test the delete action in details diff --git a/src/tests/Feature/Controller/Admin/UsersTest.php b/src/tests/Feature/Controller/Admin/UsersTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Controller/Admin/UsersTest.php @@ -0,0 +1,49 @@ + str_replace('//', '//admin.', \config('app.url'))]); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + parent::tearDown(); + } + + /** + * Test (/api/v4/index) + */ + public function testIndex(): void + { + $user = $this->getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + + $response = $this->actingAs($user)->get("api/v4/users"); + $response->assertStatus(403); + + $response = $this->actingAs($admin)->get("api/v4/users"); + $response->assertStatus(200); + + // TODO: Test the response + $this->markTestIncomplete(); + } +} diff --git a/src/tests/Feature/Controller/AuthTest.php b/src/tests/Feature/Controller/AuthTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Controller/AuthTest.php @@ -0,0 +1,133 @@ +deleteTestUser('UsersControllerTest1@userscontroller.com'); + $this->deleteTestDomain('userscontroller.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); + $this->deleteTestDomain('userscontroller.com'); + + parent::tearDown(); + } + + /** + * Test fetching current user info (/api/auth/info) + */ + public function testInfo(): void + { + $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); + $domain = $this->getTestDomain('userscontroller.com', [ + 'status' => Domain::STATUS_NEW, + 'type' => Domain::TYPE_PUBLIC, + ]); + + $response = $this->actingAs($user)->get("api/auth/info"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals($user->id, $json['id']); + $this->assertEquals($user->email, $json['email']); + $this->assertEquals(User::STATUS_NEW | User::STATUS_ACTIVE, $json['status']); + $this->assertTrue(is_array($json['statusInfo'])); + $this->assertTrue(is_array($json['settings'])); + $this->assertTrue(is_array($json['aliases'])); + + // Note: Details of the content are tested in testUserResponse() + } + + /** + * Test /api/auth/login + */ + public function testLogin(): string + { + // Request with no data + $response = $this->post("api/auth/login", []); + $response->assertStatus(422); + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json['errors']); + $this->assertArrayHasKey('email', $json['errors']); + $this->assertArrayHasKey('password', $json['errors']); + + // Request with invalid password + $post = ['email' => 'john@kolab.org', 'password' => 'wrong']; + $response = $this->post("api/auth/login", $post); + $response->assertStatus(401); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertSame('Invalid username or password.', $json['message']); + + // Valid user+password + $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; + $response = $this->post("api/auth/login", $post); + $json = $response->json(); + + $response->assertStatus(200); + $this->assertTrue(!empty($json['access_token'])); + $this->assertEquals(\config('jwt.ttl') * 60, $json['expires_in']); + $this->assertEquals('bearer', $json['token_type']); + + // TODO: We have browser tests for 2FA but we should probably also test it here + + return $json['access_token']; + } + + /** + * Test /api/auth/logout + * + * @depends testLogin + */ + public function testLogout($token): void + { + // Request with no token, testing that it requires auth + $response = $this->post("api/auth/logout"); + $response->assertStatus(401); + + // Test the same using JSON mode + $response = $this->json('POST', "api/auth/logout", []); + $response->assertStatus(401); + + // Request with valid token + $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->post("api/auth/logout"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals('success', $json['status']); + $this->assertEquals('Successfully logged out.', $json['message']); + + // Check if it really destroyed the token? + $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->get("api/auth/info"); + $response->assertStatus(401); + } + + public function testRefresh(): void + { + // TODO + $this->markTestIncomplete(); + } +} diff --git a/src/tests/Feature/Controller/SkusTest.php b/src/tests/Feature/Controller/SkusTest.php --- a/src/tests/Feature/Controller/SkusTest.php +++ b/src/tests/Feature/Controller/SkusTest.php @@ -2,7 +2,7 @@ namespace Tests\Feature\Controller; -use App\Http\Controllers\API\SkusController; +use App\Http\Controllers\API\V4\SkusController; use App\Sku; use Tests\TestCase; diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -4,7 +4,7 @@ use App\Discount; use App\Domain; -use App\Http\Controllers\API\UsersController; +use App\Http\Controllers\API\V4\UsersController; use App\Package; use App\Sku; use App\User; @@ -56,32 +56,6 @@ } /** - * Test fetching current user info (/api/auth/info) - */ - public function testInfo(): void - { - $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); - $domain = $this->getTestDomain('userscontroller.com', [ - 'status' => Domain::STATUS_NEW, - 'type' => Domain::TYPE_PUBLIC, - ]); - - $response = $this->actingAs($user)->get("api/auth/info"); - $response->assertStatus(200); - - $json = $response->json(); - - $this->assertEquals($user->id, $json['id']); - $this->assertEquals($user->email, $json['email']); - $this->assertEquals(User::STATUS_NEW | User::STATUS_ACTIVE, $json['status']); - $this->assertTrue(is_array($json['statusInfo'])); - $this->assertTrue(is_array($json['settings'])); - $this->assertTrue(is_array($json['aliases'])); - - // Note: Details of the content are tested in testUserResponse() - } - - /** * Test user deleting (DELETE /api/v4/users/) */ public function testDestroy(): void @@ -235,81 +209,6 @@ $this->assertSame($ned->email, $json[3]['email']); } - /** - * Test /api/auth/login - */ - public function testLogin(): string - { - // Request with no data - $response = $this->post("api/auth/login", []); - $response->assertStatus(422); - $json = $response->json(); - - $this->assertSame('error', $json['status']); - $this->assertCount(2, $json['errors']); - $this->assertArrayHasKey('email', $json['errors']); - $this->assertArrayHasKey('password', $json['errors']); - - // Request with invalid password - $post = ['email' => 'john@kolab.org', 'password' => 'wrong']; - $response = $this->post("api/auth/login", $post); - $response->assertStatus(401); - - $json = $response->json(); - - $this->assertSame('error', $json['status']); - $this->assertSame('Invalid username or password.', $json['message']); - - // Valid user+password - $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; - $response = $this->post("api/auth/login", $post); - $json = $response->json(); - - $response->assertStatus(200); - $this->assertTrue(!empty($json['access_token'])); - $this->assertEquals(\config('jwt.ttl') * 60, $json['expires_in']); - $this->assertEquals('bearer', $json['token_type']); - - // TODO: We have browser tests for 2FA but we should probably also test it here - - return $json['access_token']; - } - - /** - * Test /api/auth/logout - * - * @depends testLogin - */ - public function testLogout($token): void - { - // Request with no token, testing that it requires auth - $response = $this->post("api/auth/logout"); - $response->assertStatus(401); - - // Test the same using JSON mode - $response = $this->json('POST', "api/auth/logout", []); - $response->assertStatus(401); - - // Request with valid token - $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->post("api/auth/logout"); - $response->assertStatus(200); - - $json = $response->json(); - - $this->assertEquals('success', $json['status']); - $this->assertEquals('Successfully logged out.', $json['message']); - - // Check if it really destroyed the token? - $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->get("api/auth/info"); - $response->assertStatus(401); - } - - public function testRefresh(): void - { - // TODO - $this->markTestIncomplete(); - } - public function testStatusInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); @@ -485,6 +384,7 @@ $secondfactor_sku = Sku::where('title', '2fa')->first(); $this->assertCount(5, $json['skus']); + $this->assertSame(2, $json['skus'][$storage_sku->id]['count']); $this->assertSame(1, $json['skus'][$groupware_sku->id]['count']); $this->assertSame(1, $json['skus'][$mailbox_sku->id]['count']); @@ -866,7 +766,7 @@ */ public function testValidateEmail($alias, $user, $is_alias, $expected_result): void { - $result = $this->invokeMethod(new UsersController(), 'validateEmail', [$alias, $user, $is_alias]); + $result = $this->invokeMethod(new \App\Utils(), 'validateEmail', [$alias, $user, $is_alias]); $this->assertSame($expected_result, $result); } diff --git a/src/webpack.mix.js b/src/webpack.mix.js --- a/src/webpack.mix.js +++ b/src/webpack.mix.js @@ -11,5 +11,6 @@ | */ -mix.js('resources/js/app.js', 'public/js') +mix.js('resources/js/user.js', 'public/js') + .js('resources/js/admin.js', 'public/js') .sass('resources/sass/app.scss', 'public/css');