diff --git a/src/.env.example b/src/.env.example --- a/src/.env.example +++ b/src/.env.example @@ -66,6 +66,11 @@ MAIL_REPLYTO_ADDRESS=null MAIL_REPLYTO_NAME=null +DNS_TTL=3600 +DNS_SPF="v=spf1 mx -all" +DNS_STATIC="%s. MX 10 ext-mx01.mykolab.com." +DNS_COPY_FROM=null + AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 diff --git a/src/app/Domain.php b/src/app/Domain.php --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -28,6 +28,10 @@ // zone registered externally public const TYPE_EXTERNAL = 1 << 2; + public const HASH_CODE = 1; + public const HASH_TEXT = 2; + public const HASH_CNAME = 3; + public $incrementing = false; protected $keyType = 'bigint'; @@ -201,14 +205,14 @@ return true; } - $hash = $this->hash(); + $hash = $this->hash(self::HASH_TEXT); $confirmed = false; // Get DNS records and find a matching TXT entry $records = \dns_get_record($this->namespace, DNS_TXT); if ($records === false) { - throw new \Exception("Failed to get DNS record for $domain"); + throw new \Exception("Failed to get DNS record for {$this->namespace}"); } foreach ($records as $record) { @@ -223,7 +227,7 @@ // so we need to define left and right side of the CNAME record // i.e.: kolab-verify IN CNAME .domain.tld. if (!$confirmed) { - $cname = $this->hash(true) . '.' . $this->namespace; + $cname = $this->hash(self::HASH_CODE) . '.' . $this->namespace; $records = \dns_get_record('kolab-verify.' . $this->namespace, DNS_CNAME); if ($records === false) { @@ -249,15 +253,21 @@ /** * Generate a verification hash for this domain * - * @param bool $short Return short version (with kolab-verify= prefix) + * @param int $mod One of: HASH_CNAME, HASH_CODE (Default), HASH_TEXT * * @return string Verification hash */ - public function hash($short = false): string + public function hash($mod = null): string { - $hash = \md5('hkccp-verify-' . $this->namespace . $this->id); + $cname = 'kolab-verify'; + + if ($mod === self::HASH_CNAME) { + return $cname; + } + + $hash = \md5($cname . '-' . $this->namespace); - return $short ? $hash : "kolab-verify=$hash"; + return $mod === self::HASH_TEXT ? "$cname=$hash" : $hash; } /** diff --git a/src/app/Http/Controllers/API/DomainsController.php b/src/app/Http/Controllers/API/DomainsController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/DomainsController.php @@ -0,0 +1,213 @@ +confirm()) { + return response()->json(['status' => 'error']); + } + + return response()->json(['status' => 'success']); + } + + /** + * Remove the specified resource from storage. + * + * @param int $id + * + * @return \Illuminate\Http\Response + */ + public function destroy($id) + { + // + } + + /** + * Show the form for editing the specified resource. + * + * @param int $id + * + * @return \Illuminate\Http\Response + */ + public function edit($id) + { + // + } + + /** + * Store a newly created resource in storage. + * + * @param \Illuminate\Http\Request $request + * + * @return \Illuminate\Http\Response + */ + public function store(Request $request) + { + // + } + + /** + * Get the information about the specified domain. + * + * @param int $id Domain identifier + * + * @return \Illuminate\Http\Response + */ + public function show($id) + { + $domain = Domain::findOrFail($id); + + // Only owner (or admin) has access to the domain + if (!self::hasAccess($domain)) { + return abort(403); + } + + $response = $domain->toArray(); + + // Add hash information to the response + $response['hash_text'] = $domain->hash(Domain::HASH_TEXT); + $response['hash_cname'] = $domain->hash(Domain::HASH_CNAME); + $response['hash_code'] = $domain->hash(Domain::HASH_CODE); + + // Add DNS/MX configuration for the domain + $response['dns'] = self::getDNSConfig($domain); + $response['config'] = self::getMXConfig($domain->namespace); + + $response['confirmed'] = $domain->isConfirmed(); + + return response()->json($response); + } + + /** + * Update the specified resource in storage. + * + * @param \Illuminate\Http\Request $request + * @param int $id + * + * @return \Illuminate\Http\Response + */ + public function update(Request $request, $id) + { + // + } + + /** + * Provide DNS MX information to configure specified domain for + */ + protected static function getMXConfig(string $namespace): array + { + $entries = []; + + // copy MX entries from an existing domain + if ($master = \config('dns.copyfrom')) { + // TODO: cache this lookup + foreach ((array) dns_get_record($master, DNS_MX) as $entry) { + $entries[] = sprintf( + "@\t%s\t%s\tMX\t%d %s.", + \config('dns.ttl', $entry['ttl']), + $entry['class'], + $entry['pri'], + $entry['target'] + ); + } + } elseif ($static = \config('dns.static')) { + $entries[] = strtr($static, array('\n' => "\n", '%s' => $namespace)); + } + + // display SPF settings + if ($spf = \config('dns.spf')) { + $entries[] = ';'; + foreach (['TXT', 'SPF'] as $type) { + $entries[] = sprintf( + "@\t%s\tIN\t%s\t\"%s\"", + \config('dns.ttl'), + $type, + $spf + ); + } + } + + return $entries; + } + + /** + * Provide sample DNS config for domain confirmation + */ + protected static function getDNSConfig(Domain $domain): array + { + $serial = date('Ymd01'); + $hash_txt = $domain->hash(Domain::HASH_TEXT); + $hash_cname = $domain->hash(Domain::HASH_CNAME); + $hash = $domain->hash(Domain::HASH_CODE); + + return [ + "@ IN SOA ns1.dnsservice.com. hostmaster.{$domain->namespace}. (", + " {$serial} 10800 3600 604800 86400 )", + ";", + "@ IN A ", + "www IN A ", + ";", + "{$hash_cname}.{$domain->namespace}. IN CNAME {$hash}.{$domain->namespace}.", + "@ 3600 TXT \"{$hash_txt}\"", + ]; + } + + /** + * Check if the current user has access to the domain + * + * @param \App\Domain Domain + * + * @return bool True if current user has access, False otherwise + */ + protected static function hasAccess(Domain $domain): bool + { + $user = Auth::guard()->user(); + $entitlement = $domain->entitlement()->first(); + + // TODO: Admins + + return $entitlement && $entitlement->owner_id == $user->id; + } +} diff --git a/src/config/dns.php b/src/config/dns.php new file mode 100644 --- /dev/null +++ b/src/config/dns.php @@ -0,0 +1,8 @@ + env('DNS_TTL', 3600), + 'spf' => env('DNS_SPF', null), + 'static' => env('DNS_STATIC', null), + 'copyfrom' => env('DNS_COPY_FROM', null), +]; 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 @@ -85,11 +85,14 @@ $(form).find('.invalid-feedback').remove() }, // Set user state to "logged in" - loginUser(token) { + loginUser(token, dashboard) { store.commit('loginUser') localStorage.setItem('token', token) axios.defaults.headers.common.Authorization = 'Bearer ' + token - router.push({ name: 'dashboard' }) + + if (dashboard !== false) { + router.push({ name: 'dashboard' }) + } }, // Set user state to "not logged in" logoutUser() { diff --git a/src/resources/sass/_variables.scss b/src/resources/sass/_variables.scss --- a/src/resources/sass/_variables.scss +++ b/src/resources/sass/_variables.scss @@ -17,3 +17,6 @@ $green: #38c172; $teal: #4dc0b5; $cyan: #6cb2eb; + +// App colors +$menu-bg-color: #f6f5f3; diff --git a/src/resources/sass/app.scss b/src/resources/sass/app.scss --- a/src/resources/sass/app.scss +++ b/src/resources/sass/app.scss @@ -20,6 +20,10 @@ margin-top: 120px; } +#app { + margin-bottom: 2rem; +} + #error-page { align-items: center; display: flex; @@ -57,3 +61,9 @@ color: #b2aa99; } } + +pre { + margin: 1rem 0; + padding: 1rem; + background-color: $menu-bg-color; +} diff --git a/src/resources/vue/components/App.vue b/src/resources/vue/components/App.vue --- a/src/resources/vue/components/App.vue +++ b/src/resources/vue/components/App.vue @@ -21,7 +21,7 @@ this.$store.state.authInfo = response.data this.isLoading = false this.$root.stopLoading() - this.$root.loginUser(token) + this.$root.loginUser(token, false) }) .catch(error => { this.isLoading = false 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 @@ -4,7 +4,7 @@
Dashboard
-

{{ data }}

+
{{ data }}
diff --git a/src/resources/vue/components/Domain.vue b/src/resources/vue/components/Domain.vue new file mode 100644 --- /dev/null +++ b/src/resources/vue/components/Domain.vue @@ -0,0 +1,80 @@ + + + diff --git a/src/resources/vue/js/routes.js b/src/resources/vue/js/routes.js --- a/src/resources/vue/js/routes.js +++ b/src/resources/vue/js/routes.js @@ -4,6 +4,7 @@ Vue.use(VueRouter) import DashboardComponent from '../components/Dashboard' +import DomainComponent from '../components/Domain' import Error404Component from '../components/404' import LoginComponent from '../components/Login' import LogoutComponent from '../components/Logout' @@ -23,6 +24,12 @@ component: DashboardComponent, meta: { requiresAuth: true } }, + { + path: '/domain/:domain', + name: 'domain', + component: DomainComponent, + meta: { requiresAuth: true } + }, { path: '/login', name: 'login', diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -41,6 +41,9 @@ 'prefix' => 'v4' ], function () { + Route::apiResource('domains', API\DomainsController::class); + Route::get('domains/{id}/confirm', 'API\DomainsController@confirm'); + Route::apiResource('entitlements', API\EntitlementsController::class); Route::apiResource('users', API\UsersController::class); Route::apiResource('wallets', API\WalletsController::class); diff --git a/src/tests/Feature/Controller/DomainsTest.php b/src/tests/Feature/Controller/DomainsTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Controller/DomainsTest.php @@ -0,0 +1,112 @@ +delete(); + Domain::where('namespace', 'domainscontroller.com')->delete(); + } + + /** + * Test domain confirm request + */ + public function testConfirm(): void + { + $sku_domain = Sku::where('title', 'domain')->first(); + $user = $this->getTestUser('test1@domainscontroller.com'); + $domain = $this->getTestDomain('domainscontroller.com', [ + 'status' => Domain::STATUS_NEW | Domain::STATUS_VERIFIED, + 'type' => Domain::TYPE_EXTERNAL, + ]); + + // No entitlement (user has no access to this domain), expect 403 + $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/confirm"); + $response->assertStatus(403); + + Entitlement::create([ + 'owner_id' => $user->id, + 'wallet_id' => $user->wallets()->first()->id, + 'sku_id' => $sku_domain->id, + 'entitleable_id' => $domain->id, + 'entitleable_type' => Domain::class + ]); + + $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/confirm"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals('error', $json['status']); + + $domain->status |= Domain::STATUS_CONFIRMED; + $domain->save(); + + $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/confirm"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals('success', $json['status']); + } + + /** + * Test fetching domain info + */ + public function testShow(): void + { + $sku_domain = Sku::where('title', 'domain')->first(); + $user = $this->getTestUser('test1@domainscontroller.com'); + $domain = $this->getTestDomain('domainscontroller.com', [ + 'status' => Domain::STATUS_NEW | Domain::STATUS_VERIFIED, + 'type' => Domain::TYPE_EXTERNAL, + ]); + + // No entitlement (user has no access to this domain), expect 403 + $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}"); + $response->assertStatus(403); + + Entitlement::create([ + 'owner_id' => $user->id, + 'wallet_id' => $user->wallets()->first()->id, + 'sku_id' => $sku_domain->id, + 'entitleable_id' => $domain->id, + 'entitleable_type' => Domain::class + ]); + + $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals($domain->id, $json['id']); + $this->assertEquals($domain->namespace, $json['namespace']); + $this->assertEquals($domain->status, $json['status']); + $this->assertEquals($domain->type, $json['type']); + $this->assertTrue($json['confirmed'] === false); + $this->assertSame($domain->hash(Domain::HASH_TEXT), $json['hash_text']); + $this->assertSame($domain->hash(Domain::HASH_CNAME), $json['hash_cname']); + $this->assertSame($domain->hash(Domain::HASH_CODE), $json['hash_code']); + $this->assertCount(4, $json['config']); + $this->assertTrue(strpos(implode("\n", $json['config']), $domain->namespace) !== false); + $this->assertCount(8, $json['dns']); + $this->assertTrue(strpos(implode("\n", $json['dns']), $domain->namespace) !== false); + $this->assertTrue(strpos(implode("\n", $json['dns']), $domain->hash()) !== false); + } +} 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 @@ -74,7 +74,7 @@ $this->markTestIncomplete(); } - public function testShow(): void + public function testStatusInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ diff --git a/src/tests/Unit/DomainTest.php b/src/tests/Unit/DomainTest.php --- a/src/tests/Unit/DomainTest.php +++ b/src/tests/Unit/DomainTest.php @@ -96,14 +96,22 @@ 'status' => Domain::STATUS_NEW, ]); - $hash1 = $domain->hash(true); + $hash_code = $domain->hash(); - $this->assertRegExp('/^[a-f0-9]{32}$/', $hash1); + $this->assertRegExp('/^[a-f0-9]{32}$/', $hash_code); - $hash2 = $domain->hash(); + $hash_text = $domain->hash(Domain::HASH_TEXT); - $this->assertRegExp('/^kolab-verify=[a-f0-9]{32}$/', $hash2); + $this->assertRegExp('/^kolab-verify=[a-f0-9]{32}$/', $hash_text); - $this->assertSame($hash1, str_replace('kolab-verify=', '', $hash2)); + $this->assertSame($hash_code, str_replace('kolab-verify=', '', $hash_text)); + + $hash_cname = $domain->hash(Domain::HASH_CNAME); + + $this->assertSame('kolab-verify', $hash_cname); + + $hash_code2 = $domain->hash(Domain::HASH_CODE); + + $this->assertSame($hash_code, $hash_code2); } }