浏览代码

Merge pull request #469 from pixelfed/frontend-ui-refactor

Add 2FA
daniel 6 年之前
父节点
当前提交
ee6348a873
共有 33 个文件被更改,包括 1164 次插入275 次删除
  1. 25 0
      app/Http/Controllers/AccountController.php
  2. 2 1
      app/Http/Controllers/AdminController.php
  3. 153 0
      app/Http/Controllers/Settings/HomeSettings.php
  4. 127 0
      app/Http/Controllers/Settings/PrivacySettings.php
  5. 139 0
      app/Http/Controllers/Settings/SecuritySettings.php
  6. 10 241
      app/Http/Controllers/SettingsController.php
  7. 1 0
      app/Http/Controllers/TimelineController.php
  8. 1 0
      app/Http/Kernel.php
  9. 32 0
      app/Http/Middleware/TwoFactorAuth.php
  10. 5 0
      app/ImportJob.php
  11. 1 1
      app/User.php
  12. 2 0
      app/Util/Lexer/RestrictedNames.php
  13. 19 0
      resources/lang/cs/auth.php
  14. 14 0
      resources/lang/cs/navmenu.php
  15. 10 0
      resources/lang/cs/notification.php
  16. 19 0
      resources/lang/cs/pagination.php
  17. 22 0
      resources/lang/cs/passwords.php
  18. 12 0
      resources/lang/cs/profile.php
  19. 7 0
      resources/lang/cs/timeline.php
  20. 122 0
      resources/lang/cs/validation.php
  21. 49 0
      resources/views/auth/checkpoint.blade.php
  22. 1 1
      resources/views/layouts/partial/nav.blade.php
  23. 9 12
      resources/views/settings/partial/sidebar.blade.php
  24. 20 4
      resources/views/settings/security.blade.php
  25. 82 0
      resources/views/settings/security/2fa/edit.blade.php
  26. 18 0
      resources/views/settings/security/2fa/partial/disabled-panel.blade.php
  27. 30 0
      resources/views/settings/security/2fa/partial/edit-panel.blade.php
  28. 42 0
      resources/views/settings/security/2fa/partial/log-panel.blade.php
  29. 22 0
      resources/views/settings/security/2fa/recovery-codes.blade.php
  30. 134 0
      resources/views/settings/security/2fa/setup.blade.php
  31. 0 4
      routes/api.php
  32. 0 4
      routes/console.php
  33. 34 7
      routes/web.php

+ 25 - 0
app/Http/Controllers/AccountController.php

@@ -17,6 +17,7 @@ use Carbon\Carbon;
 use Illuminate\Http\Request;
 use Mail;
 use Redis;
+use PragmaRX\Google2FA\Google2FA;
 
 class AccountController extends Controller
 {
@@ -301,4 +302,28 @@ class AccountController extends Controller
                 ->withErrors(['password' => __('auth.failed')]);
         }
     }
+
+    public function twoFactorCheckpoint(Request $request)
+    {
+        return view('auth.checkpoint');
+    }
+
+    public function twoFactorVerify(Request $request)
+    {
+        $this->validate($request, [
+            'code'  => 'required|string|max:32'
+        ]);
+        $user = Auth::user();
+        $code = $request->input('code');
+        $google2fa = new Google2FA();
+        $verify = $google2fa->verifyKey($user->{'2fa_secret'}, $code);
+        if($verify) {
+            $request->session()->push('2fa.session.active', true);
+            return redirect('/');
+        } else {
+            return redirect()->back()->withErrors([
+                'code' => 'Invalid code'
+            ]);
+        }
+    }
 }

+ 2 - 1
app/Http/Controllers/AdminController.php

@@ -19,7 +19,8 @@ class AdminController extends Controller
 
     public function __construct()
     {
-        return $this->middleware('admin');
+        $this->middleware('admin');
+        $this->middleware('twofactor');
     }
 
     public function home()

+ 153 - 0
app/Http/Controllers/Settings/HomeSettings.php

@@ -0,0 +1,153 @@
+<?php
+
+namespace App\Http\Controllers\Settings;
+
+use App\AccountLog;
+use App\EmailVerification;
+use App\Media;
+use App\Profile;
+use App\User;
+use App\UserFilter;
+use App\Util\Lexer\PrettyNumber;
+use Auth;
+use DB;
+use Illuminate\Http\Request;
+
+trait HomeSettings
+{
+
+    public function home()
+    {
+        $id = Auth::user()->profile->id;
+        $storage = [];
+        $used = Media::whereProfileId($id)->sum('size');
+        $storage['limit'] = config('pixelfed.max_account_size') * 1024;
+        $storage['used'] = $used;
+        $storage['percentUsed'] = ceil($storage['used'] / $storage['limit'] * 100);
+        $storage['limitPretty'] = PrettyNumber::size($storage['limit']);
+        $storage['usedPretty'] = PrettyNumber::size($storage['used']);
+
+        return view('settings.home', compact('storage'));
+    }
+
+    public function homeUpdate(Request $request)
+    {
+        $this->validate($request, [
+        'name'    => 'required|string|max:'.config('pixelfed.max_name_length'),
+        'bio'     => 'nullable|string|max:'.config('pixelfed.max_bio_length'),
+        'website' => 'nullable|url',
+        'email'   => 'nullable|email',
+      ]);
+
+        $changes = false;
+        $name = $request->input('name');
+        $bio = $request->input('bio');
+        $website = $request->input('website');
+        $email = $request->input('email');
+        $user = Auth::user();
+        $profile = $user->profile;
+
+        $validate = config('pixelfed.enforce_email_verification');
+
+        if ($user->email != $email) {
+            $changes = true;
+            $user->email = $email;
+
+            if ($validate) {
+                $user->email_verified_at = null;
+                // Prevent old verifications from working
+                EmailVerification::whereUserId($user->id)->delete();
+            }
+
+            $log = new AccountLog();
+            $log->user_id = $user->id;
+            $log->item_id = $user->id;
+            $log->item_type = 'App\User';
+            $log->action = 'account.edit.email';
+            $log->message = 'Email changed';
+            $log->link = null;
+            $log->ip_address = $request->ip();
+            $log->user_agent = $request->userAgent();
+            $log->save();
+        }
+
+        // Only allow email to be updated if not yet verified
+        if (!$validate || !$changes && $user->email_verified_at) {
+            if ($profile->name != $name) {
+                $changes = true;
+                $user->name = $name;
+                $profile->name = $name;
+            }
+
+            if (!$profile->website || $profile->website != $website) {
+                $changes = true;
+                $profile->website = $website;
+            }
+
+            if (!$profile->bio || !$profile->bio != $bio) {
+                $changes = true;
+                $profile->bio = $bio;
+            }
+        }
+
+        if ($changes === true) {
+            $user->save();
+            $profile->save();
+
+            return redirect('/settings/home')->with('status', 'Profile successfully updated!');
+        }
+
+        return redirect('/settings/home');
+    }
+
+    public function password()
+    {
+        return view('settings.password');
+    }
+
+    public function passwordUpdate(Request $request)
+    {
+        $this->validate($request, [
+        'current'                => 'required|string',
+        'password'               => 'required|string',
+        'password_confirmation'  => 'required|string',
+      ]);
+
+        $current = $request->input('current');
+        $new = $request->input('password');
+        $confirm = $request->input('password_confirmation');
+
+        $user = Auth::user();
+
+        if (password_verify($current, $user->password) && $new === $confirm) {
+            $user->password = bcrypt($new);
+            $user->save();
+
+            $log = new AccountLog();
+            $log->user_id = $user->id;
+            $log->item_id = $user->id;
+            $log->item_type = 'App\User';
+            $log->action = 'account.edit.password';
+            $log->message = 'Password changed';
+            $log->link = null;
+            $log->ip_address = $request->ip();
+            $log->user_agent = $request->userAgent();
+            $log->save();
+
+            return redirect('/settings/home')->with('status', 'Password successfully updated!');
+        }
+
+        return redirect('/settings/home')->with('error', 'There was an error with your request!');
+    }
+
+    public function email()
+    {
+        return view('settings.email');
+    }
+
+    public function avatar()
+    {
+        return view('settings.avatar');
+    }
+
+}

+ 127 - 0
app/Http/Controllers/Settings/PrivacySettings.php

@@ -0,0 +1,127 @@
+<?php
+
+namespace App\Http\Controllers\Settings;
+
+use App\AccountLog;
+use App\EmailVerification;
+use App\Media;
+use App\Profile;
+use App\User;
+use App\UserFilter;
+use App\Util\Lexer\PrettyNumber;
+use Auth;
+use DB;
+use Illuminate\Http\Request;
+
+trait PrivacySettings
+{
+
+    public function privacy()
+    {
+        $settings = Auth::user()->settings;
+        $is_private = Auth::user()->profile->is_private;
+        $settings['is_private'] = (bool) $is_private;
+
+        return view('settings.privacy', compact('settings'));
+    }
+
+    public function privacyStore(Request $request)
+    {
+        $settings = Auth::user()->settings;
+        $profile = Auth::user()->profile;
+        $fields = [
+          'is_private',
+          'crawlable',
+          'show_profile_follower_count',
+          'show_profile_following_count',
+      ];
+        foreach ($fields as $field) {
+            $form = $request->input($field);
+            if ($field == 'is_private') {
+                if ($form == 'on') {
+                    $profile->{$field} = true;
+                    $settings->show_guests = false;
+                    $settings->show_discover = false;
+                    $profile->save();
+                } else {
+                    $profile->{$field} = false;
+                    $profile->save();
+                }
+            } elseif ($field == 'crawlable') {
+                if ($form == 'on') {
+                    $settings->{$field} = false;
+                } else {
+                    $settings->{$field} = true;
+                }
+            } else {
+                if ($form == 'on') {
+                    $settings->{$field} = true;
+                } else {
+                    $settings->{$field} = false;
+                }
+            }
+            $settings->save();
+        }
+
+        return redirect(route('settings.privacy'))->with('status', 'Settings successfully updated!');
+    }
+
+    public function mutedUsers()
+    {   
+        $pid = Auth::user()->profile->id;
+        $ids = (new UserFilter())->mutedUserIds($pid);
+        $users = Profile::whereIn('id', $ids)->simplePaginate(15);
+        return view('settings.privacy.muted', compact('users'));
+    }
+
+    public function mutedUsersUpdate(Request $request)
+    {   
+        $this->validate($request, [
+            'profile_id' => 'required|integer|min:1'
+        ]);
+        $fid = $request->input('profile_id');
+        $pid = Auth::user()->profile->id;
+        DB::transaction(function () use ($fid, $pid) {
+            $filter = UserFilter::whereUserId($pid)
+                ->whereFilterableId($fid)
+                ->whereFilterableType('App\Profile')
+                ->whereFilterType('mute')
+                ->firstOrFail();
+            $filter->delete();
+        });
+        return redirect()->back();
+    }
+
+    public function blockedUsers()
+    {
+        $pid = Auth::user()->profile->id;
+        $ids = (new UserFilter())->blockedUserIds($pid);
+        $users = Profile::whereIn('id', $ids)->simplePaginate(15);
+        return view('settings.privacy.blocked', compact('users'));
+    }
+
+
+    public function blockedUsersUpdate(Request $request)
+    {   
+        $this->validate($request, [
+            'profile_id' => 'required|integer|min:1'
+        ]);
+        $fid = $request->input('profile_id');
+        $pid = Auth::user()->profile->id;
+        DB::transaction(function () use ($fid, $pid) {
+            $filter = UserFilter::whereUserId($pid)
+                ->whereFilterableId($fid)
+                ->whereFilterableType('App\Profile')
+                ->whereFilterType('block')
+                ->firstOrFail();
+            $filter->delete();
+        });
+        return redirect()->back();
+    }
+
+    public function blockedInstances()
+    {
+        $settings = Auth::user()->settings;
+        return view('settings.privacy.blocked-instances');
+    }
+}

+ 139 - 0
app/Http/Controllers/Settings/SecuritySettings.php

@@ -0,0 +1,139 @@
+<?php
+
+namespace App\Http\Controllers\Settings;
+
+use App\AccountLog;
+use App\EmailVerification;
+use App\Media;
+use App\Profile;
+use App\User;
+use App\UserFilter;
+use App\Util\Lexer\PrettyNumber;
+use Auth;
+use DB;
+use Carbon\Carbon;
+use Illuminate\Http\Request;
+use PragmaRX\Google2FA\Google2FA;
+
+trait SecuritySettings
+{
+
+	public function security()
+	{
+		$sessions = DB::table('sessions')
+			->whereUserId(Auth::id())
+			->limit(20)
+			->get();
+
+		$activity = AccountLog::whereUserId(Auth::id())
+			->orderBy('created_at', 'desc')
+			->limit(20)
+			->get();
+
+		$user = Auth::user();
+
+		return view('settings.security', compact('sessions', 'activity', 'user'));
+	}
+
+	public function securityTwoFactorSetup(Request $request)
+	{
+		$user = Auth::user();
+		if($user->{'2fa_enabled'} && $user->{'2fa_secret'}) {
+			return redirect(route('account.security'));
+		}
+		$backups = $this->generateBackupCodes();
+		$google2fa = new Google2FA();
+		$key = $google2fa->generateSecretKey(32);
+		$qrcode = $google2fa->getQRCodeInline(
+		    config('pixelfed.domain.app'),
+		    $user->email,
+		    $key,
+		    500
+		);
+		$user->{'2fa_secret'} = $key;
+		$user->{'2fa_backup_codes'} = json_encode($backups);
+		$user->save();
+		return view('settings.security.2fa.setup', compact('user', 'qrcode', 'backups'));
+	}
+
+	protected function generateBackupCodes()
+	{
+		$keys = [];
+		for ($i=0; $i < 11; $i++) { 
+			$key = str_random(24);
+			$keys[] = $key;
+		}
+		return $keys;
+	}
+
+	public function securityTwoFactorSetupStore(Request $request)
+	{
+		$user = Auth::user();
+		if($user->{'2fa_enabled'} && $user->{'2fa_secret'}) {
+			abort(403, 'Two factor auth is already setup.');
+		}
+		$this->validate($request, [
+			'code' => 'required|integer'
+		]);
+		$code = $request->input('code');
+		$google2fa = new Google2FA();
+		$verify = $google2fa->verifyKey($user->{'2fa_secret'}, $code);
+		if($verify) {
+			$user->{'2fa_enabled'} = true;
+			$user->{'2fa_setup_at'} = Carbon::now();
+			$user->save();
+			return response()->json(['msg'=>'success']);
+		} else {
+			return response()->json(['msg'=>'fail'], 403);
+		}
+	}
+
+	public function securityTwoFactorEdit(Request $request)
+	{
+		$user = Auth::user();
+
+		if(!$user->{'2fa_enabled'} || !$user->{'2fa_secret'}) {
+			abort(403);
+		}
+
+		return view('settings.security.2fa.edit', compact('user'));
+	}
+
+	public function securityTwoFactorRecoveryCodes(Request $request)
+	{
+		$user = Auth::user();
+
+		if(!$user->{'2fa_enabled'} || !$user->{'2fa_secret'} || !$user->{'2fa_backup_codes'}) {
+			abort(403);
+		}
+		$codes = json_decode($user->{'2fa_backup_codes'}, true);
+		return view('settings.security.2fa.recovery-codes', compact('user', 'codes'));
+	}
+
+	public function securityTwoFactorUpdate(Request $request)
+	{
+		$user = Auth::user();
+
+		if(!$user->{'2fa_enabled'} || !$user->{'2fa_secret'} || !$user->{'2fa_backup_codes'}) {
+			abort(403);
+		}
+
+		$this->validate($request, [
+			'action'	=> 'required|string|max:12'
+		]);
+		
+		if($request->action !== 'remove') {
+			abort(403);
+		}
+
+		$user->{'2fa_enabled'} = false;
+		$user->{'2fa_secret'} = null;
+		$user->{'2fa_backup_codes'} = null;
+		$user->{'2fa_setup_at'} = null;
+		$user->save();
+
+		return response()->json([
+			'msg' => 'Successfully removed 2fa device'
+		], 200);
+	}
+}

+ 10 - 241
app/Http/Controllers/SettingsController.php

@@ -3,135 +3,27 @@
 namespace App\Http\Controllers;
 
 use App\AccountLog;
-use App\EmailVerification;
-use App\Media;
-use App\Profile;
-use App\User;
-use App\UserFilter;
-use App\Util\Lexer\PrettyNumber;
+
 use Auth;
 use DB;
 use Illuminate\Http\Request;
+use App\Http\Controllers\Settings\{
+    HomeSettings,
+    PrivacySettings,
+    SecuritySettings
+};
 
 class SettingsController extends Controller
 {
+    use HomeSettings,
+    PrivacySettings,
+    SecuritySettings;
+
     public function __construct()
     {
         $this->middleware('auth');
     }
 
-    public function home()
-    {
-        $id = Auth::user()->profile->id;
-        $storage = [];
-        $used = Media::whereProfileId($id)->sum('size');
-        $storage['limit'] = config('pixelfed.max_account_size') * 1024;
-        $storage['used'] = $used;
-        $storage['percentUsed'] = ceil($storage['used'] / $storage['limit'] * 100);
-        $storage['limitPretty'] = PrettyNumber::size($storage['limit']);
-        $storage['usedPretty'] = PrettyNumber::size($storage['used']);
-
-        return view('settings.home', compact('storage'));
-    }
-
-    public function homeUpdate(Request $request)
-    {
-        $this->validate($request, [
-        'name'    => 'required|string|max:'.config('pixelfed.max_name_length'),
-        'bio'     => 'nullable|string|max:'.config('pixelfed.max_bio_length'),
-        'website' => 'nullable|url',
-        'email'   => 'nullable|email',
-      ]);
-
-        $changes = false;
-        $name = $request->input('name');
-        $bio = $request->input('bio');
-        $website = $request->input('website');
-        $email = $request->input('email');
-        $user = Auth::user();
-        $profile = $user->profile;
-
-        $validate = config('pixelfed.enforce_email_verification');
-
-        if ($user->email != $email) {
-            $changes = true;
-            $user->email = $email;
-
-            if ($validate) {
-                $user->email_verified_at = null;
-                // Prevent old verifications from working
-                EmailVerification::whereUserId($user->id)->delete();
-            }
-        }
-
-        // Only allow email to be updated if not yet verified
-        if (!$validate || !$changes && $user->email_verified_at) {
-            if ($profile->name != $name) {
-                $changes = true;
-                $user->name = $name;
-                $profile->name = $name;
-            }
-
-            if (!$profile->website || $profile->website != $website) {
-                $changes = true;
-                $profile->website = $website;
-            }
-
-            if (!$profile->bio || !$profile->bio != $bio) {
-                $changes = true;
-                $profile->bio = $bio;
-            }
-        }
-
-        if ($changes === true) {
-            $user->save();
-            $profile->save();
-
-            return redirect('/settings/home')->with('status', 'Profile successfully updated!');
-        }
-
-        return redirect('/settings/home');
-    }
-
-    public function password()
-    {
-        return view('settings.password');
-    }
-
-    public function passwordUpdate(Request $request)
-    {
-        $this->validate($request, [
-        'current'                => 'required|string',
-        'password'               => 'required|string',
-        'password_confirmation'  => 'required|string',
-      ]);
-
-        $current = $request->input('current');
-        $new = $request->input('password');
-        $confirm = $request->input('password_confirmation');
-
-        $user = Auth::user();
-
-        if (password_verify($current, $user->password) && $new === $confirm) {
-            $user->password = bcrypt($new);
-            $user->save();
-
-            return redirect('/settings/home')->with('status', 'Password successfully updated!');
-        }
-
-        return redirect('/settings/home')->with('error', 'There was an error with your request!');
-    }
-
-    public function email()
-    {
-        return view('settings.email');
-    }
-
-    public function avatar()
-    {
-        return view('settings.avatar');
-    }
-
     public function accessibility()
     {
         $settings = Auth::user()->settings;
@@ -167,70 +59,6 @@ class SettingsController extends Controller
         return view('settings.notifications');
     }
 
-    public function privacy()
-    {
-        $settings = Auth::user()->settings;
-        $is_private = Auth::user()->profile->is_private;
-        $settings['is_private'] = (bool) $is_private;
-
-        return view('settings.privacy', compact('settings'));
-    }
-
-    public function privacyStore(Request $request)
-    {
-        $settings = Auth::user()->settings;
-        $profile = Auth::user()->profile;
-        $fields = [
-          'is_private',
-          'crawlable',
-          'show_profile_follower_count',
-          'show_profile_following_count',
-      ];
-        foreach ($fields as $field) {
-            $form = $request->input($field);
-            if ($field == 'is_private') {
-                if ($form == 'on') {
-                    $profile->{$field} = true;
-                    $settings->show_guests = false;
-                    $settings->show_discover = false;
-                    $profile->save();
-                } else {
-                    $profile->{$field} = false;
-                    $profile->save();
-                }
-            } elseif ($field == 'crawlable') {
-                if ($form == 'on') {
-                    $settings->{$field} = false;
-                } else {
-                    $settings->{$field} = true;
-                }
-            } else {
-                if ($form == 'on') {
-                    $settings->{$field} = true;
-                } else {
-                    $settings->{$field} = false;
-                }
-            }
-            $settings->save();
-        }
-
-        return redirect(route('settings.privacy'))->with('status', 'Settings successfully updated!');
-    }
-
-    public function security()
-    {
-        $sessions = DB::table('sessions')
-        ->whereUserId(Auth::id())
-        ->limit(20)
-        ->get();
-        $activity = AccountLog::whereUserId(Auth::id())
-      ->orderBy('created_at', 'desc')
-      ->limit(50)
-      ->get();
-
-        return view('settings.security', compact('sessions', 'activity'));
-    }
-
     public function applications()
     {
         return view('settings.applications');
@@ -255,64 +83,5 @@ class SettingsController extends Controller
     {
         return view('settings.developers');
     }
-
-    public function mutedUsers()
-    {   
-        $pid = Auth::user()->profile->id;
-        $ids = (new UserFilter())->mutedUserIds($pid);
-        $users = Profile::whereIn('id', $ids)->simplePaginate(15);
-        return view('settings.privacy.muted', compact('users'));
-    }
-
-    public function mutedUsersUpdate(Request $request)
-    {   
-        $this->validate($request, [
-            'profile_id' => 'required|integer|min:1'
-        ]);
-        $fid = $request->input('profile_id');
-        $pid = Auth::user()->profile->id;
-        DB::transaction(function () use ($fid, $pid) {
-            $filter = UserFilter::whereUserId($pid)
-                ->whereFilterableId($fid)
-                ->whereFilterableType('App\Profile')
-                ->whereFilterType('mute')
-                ->firstOrFail();
-            $filter->delete();
-        });
-        return redirect()->back();
-    }
-
-    public function blockedUsers()
-    {
-        $pid = Auth::user()->profile->id;
-        $ids = (new UserFilter())->blockedUserIds($pid);
-        $users = Profile::whereIn('id', $ids)->simplePaginate(15);
-        return view('settings.privacy.blocked', compact('users'));
-    }
-
-
-    public function blockedUsersUpdate(Request $request)
-    {   
-        $this->validate($request, [
-            'profile_id' => 'required|integer|min:1'
-        ]);
-        $fid = $request->input('profile_id');
-        $pid = Auth::user()->profile->id;
-        DB::transaction(function () use ($fid, $pid) {
-            $filter = UserFilter::whereUserId($pid)
-                ->whereFilterableId($fid)
-                ->whereFilterableType('App\Profile')
-                ->whereFilterType('block')
-                ->firstOrFail();
-            $filter->delete();
-        });
-        return redirect()->back();
-    }
-
-    public function blockedInstances()
-    {
-        $settings = Auth::user()->settings;
-        return view('settings.privacy.blocked-instances');
-    }
 }
 

+ 1 - 0
app/Http/Controllers/TimelineController.php

@@ -14,6 +14,7 @@ class TimelineController extends Controller
     public function __construct()
     {
         $this->middleware('auth');
+        $this->middleware('twofactor');
     }
 
     public function personal()

+ 1 - 0
app/Http/Kernel.php

@@ -61,6 +61,7 @@ class Kernel extends HttpKernel
         'guest'         => \App\Http\Middleware\RedirectIfAuthenticated::class,
         'signed'        => \Illuminate\Routing\Middleware\ValidateSignature::class,
         'throttle'      => \Illuminate\Routing\Middleware\ThrottleRequests::class,
+        'twofactor'     => \App\Http\Middleware\TwoFactorAuth::class,
         'validemail'    => \App\Http\Middleware\EmailVerificationCheck::class,
     ];
 }

+ 32 - 0
app/Http/Middleware/TwoFactorAuth.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Http\Middleware;
+
+use Auth;
+use Closure;
+
+class TwoFactorAuth
+{
+    /**
+     * Handle an incoming request.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \Closure  $next
+     * @return mixed
+     */
+    public function handle($request, Closure $next)
+    {
+        if($request->user()) {
+            $user = $request->user();
+            $enabled = (bool) $user->{'2fa_enabled'};
+            if($enabled != false) {
+                $checkpoint = 'i/auth/checkpoint';
+                if($request->session()->has('2fa.session.active') !== true && !$request->is($checkpoint))
+                {
+                    return redirect('/i/auth/checkpoint');
+                }
+            }
+        }
+        return $next($request);
+    }
+}

+ 5 - 0
app/ImportJob.php

@@ -6,6 +6,11 @@ use Illuminate\Database\Eloquent\Model;
 
 class ImportJob extends Model
 {
+    public function profile()
+    {
+        return $this->belongsTo(Profile::class, 'profile_id');
+    }
+    
     public function url()
     {
     	return url("/i/import/job/{$this->uuid}/{$this->stage}");

+ 1 - 1
app/User.php

@@ -16,7 +16,7 @@ class User extends Authenticatable
      *
      * @var array
      */
-    protected $dates = ['deleted_at', 'email_verified_at'];
+    protected $dates = ['deleted_at', 'email_verified_at', '2fa_setup_at'];
 
     /**
      * The attributes that are mass assignable.

+ 2 - 0
app/Util/Lexer/RestrictedNames.php

@@ -113,6 +113,7 @@ class RestrictedNames
     public static $reserved = [
      // Reserved for instance admin
      'admin',
+     'administrator',
 
      // Static Assets
      'assets',
@@ -126,6 +127,7 @@ class RestrictedNames
      'api',
      'auth',
      'css',
+     'checkpoint',
      'c',
      'i',
      'dashboard',

+ 19 - 0
resources/lang/cs/auth.php

@@ -0,0 +1,19 @@
+<?php
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | Authentication Language Lines
+    |--------------------------------------------------------------------------
+    |
+    | The following language lines are used during authentication for various
+    | messages that we need to display to the user. You are free to modify
+    | these language lines according to your application's requirements.
+    |
+    */
+
+    'failed'   => 'Tyto přihlašovací údaje se neshodují s našemi záznamy.',
+    'throttle' => 'Příliš mnoho pokusů o přihlášení. Prosím zkuste to znovu za :seconds sekund.',
+
+];

+ 14 - 0
resources/lang/cs/navmenu.php

@@ -0,0 +1,14 @@
+<?php
+
+return [
+
+    'viewMyProfile'  => 'Zobrazit můj profil',
+    'myTimeline'     => 'Moje časová osa',
+    'publicTimeline' => 'Veřejná časová osa',
+    'remoteFollow'   => 'Vzdálené sledování',
+    'settings'       => 'Nastavení',
+    'admin'          => 'Administrace',
+    'logout'         => 'Odhlásit',
+    'directMessages' => 'Přímé zprávy',
+
+];

+ 10 - 0
resources/lang/cs/notification.php

@@ -0,0 +1,10 @@
+<?php
+
+return [
+
+  'likedPhoto'          => 'si oblíbil/a vaši fotku.',
+  'startedFollowingYou' => 'vás začal/a sledovat.',
+  'commented'           => 'okomentoval/a vaši fotku.',
+  'mentionedYou'        => 'vás zmínil/a.',
+
+];

+ 19 - 0
resources/lang/cs/pagination.php

@@ -0,0 +1,19 @@
+<?php
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | Pagination Language Lines
+    |--------------------------------------------------------------------------
+    |
+    | The following language lines are used by the paginator library to build
+    | the simple pagination links. You are free to change them to anything
+    | you want to customize your views to better match your application.
+    |
+    */
+
+    'previous' => '« Předchozí',
+    'next' => 'Další »',
+
+];

+ 22 - 0
resources/lang/cs/passwords.php

@@ -0,0 +1,22 @@
+<?php
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | Password Reset Language Lines
+    |--------------------------------------------------------------------------
+    |
+    | The following language lines are the default lines which match reasons
+    | that are given by the password broker for a password update attempt
+    | has failed, such as for an invalid token or invalid new password.
+    |
+    */
+
+    'password' => 'Hesla musí být alespoň šest znaků dlouhá a shodovat se s potvrzením.',
+    'reset'    => 'Vaše heslo bylo obnoveno!',
+    'sent'     => 'Poslali jsme vám e-mailem odkaz pro obnovu hesla!',
+    'token'    => 'Tento token pro obnovu hesla je neplatný.',
+    'user'     => "Nemůžeme najít uživatele s touto e-mailovou adresou.",
+
+];

+ 12 - 0
resources/lang/cs/profile.php

@@ -0,0 +1,12 @@
+<?php
+
+return [
+  'emptyTimeline'         => 'Tento uživatel ještě nemá žádné příspěvky!',
+  'emptyFollowers'        => 'Tento uživatel ještě nemá žádné sledovatele!',
+  'emptyFollowing'        => 'Tento uživatel ještě nikoho nesleduje!',
+  'emptySaved'            => 'Ještě jste neuložil/a žádné příspěvky!',
+  'savedWarning'          => 'Pouze vy můžete vidět, co máte uložené',
+  'privateProfileWarning' => 'Tento účet je soukromý',
+  'alreadyFollow'         => 'Již uživatele :username sledujete?',
+  'loginToSeeProfile'     => 'pro zobrazení jeho/jejích fotek a videí.',
+];

+ 7 - 0
resources/lang/cs/timeline.php

@@ -0,0 +1,7 @@
+<?php
+
+return [
+
+  'emptyPersonalTimeline' => 'Vaše časová osa je prázdná.',
+
+];

+ 122 - 0
resources/lang/cs/validation.php

@@ -0,0 +1,122 @@
+<?php
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | Validation Language Lines
+    |--------------------------------------------------------------------------
+    |
+    | The following language lines contain the default error messages used by
+    | the validator class. Some of these rules have multiple versions such
+    | as the size rules. Feel free to tweak each of these messages here.
+    |
+    */
+
+    'accepted'             => ':attribute musí být akceptován.',
+    'active_url'           => ':attribute není platná URL adresa.',
+    'after'                => ':attribute musí být datum po :date.',
+    'after_or_equal'       => ':attribute musí být datum po nebo rovný datu :date.',
+    'alpha'                => ':attribute musí obsahovat pouze písmena.',
+    'alpha_dash'           => ':attribute musí obsahovat pouze písmena, číslice a podtržítka.',
+    'alpha_num'            => ':attribute musí obsahovat pouze písmena a číslice.',
+    'array'                => ':attribute musí být pole.',
+    'before'               => ':attribute musí být datum před :date.',
+    'before_or_equal'      => ':attribute musí být datum před nebo rovný datu :date.',
+    'between'              => [
+        'numeric' => ':attribute musí být mezi :min a :max.',
+        'file'    => ':attribute musí být mezi :min a :max kilobyty.',
+        'string'  => ':attribute musí být mezi :min a :max znaky.',
+        'array'   => ':attribute musí mít mezi :min a :max položkami.',
+    ],
+    'boolean'              => 'Pole :attribute musí být true nebo false.',
+    'confirmed'            => 'Potvrzení :attribute se neshoduje.',
+    'date'                 => ':attribute není platné datum.',
+    'date_format'          => ':attribute se neshoduje s formátem :format.',
+    'different'            => ':attribute a :other musí být jiné.',
+    'digits'               => ':attribute musí mít :digits číslic.',
+    'digits_between'       => ':attribute musí mít mezi :min a :max číslicemi.',
+    'dimensions'           => ':attribute má neplatné rozměry obrázku.',
+    'distinct'             => 'Pole :attribute má duplicitní hodnotu.',
+    'email'                => ':attribute musí být platná e-mailová adresa.',
+    'exists'               => 'Zvolený :attribute je neplatný.',
+    'file'                 => ':attribute musí být soubor.',
+    'filled'               => 'Pole :attribute musí mít hodnotu.',
+    'image'                => ':attribute musí být obrázek.',
+    'in'                   => 'Zvolený :attribute je neplatný.',
+    'in_array'             => 'Pole :attribute neexistuje v :other.',
+    'integer'              => ':attribute musí být celé číslo.',
+    'ip'                   => ':attribute musí být platná IP adresa.',
+    'ipv4'                 => ':attribute musí být platná IPv4 adresa.',
+    'ipv6'                 => ':attribute musí být platná IPv6 adresa.',
+    'json'                 => ':attribute musí být platný řetězec JSON.',
+    'max'                  => [
+        'numeric' => ':attribute nesmí být větší než :max.',
+        'file'    => ':attribute nesmí být větší než :max kilobytů.',
+        'string'  => ':attribute nesmí být větší než :max znaků.',
+        'array'   => ':attribute nesmí mít více než :max položek.',
+    ],
+    'mimes'                => ':attribute musí být soubor typu: :values.',
+    'mimetypes'            => ':attribute musí být soubor typu: :values.',
+    'min'                  => [
+        'numeric' => ':attribute musí být alespoň :min.',
+        'file'    => ':attribute musí být alespoň :min kilobytů.',
+        'string'  => ':attribute musí být alespoň :min znaků.',
+        'array'   => ':attribute musí mít alespoň :min položek.',
+    ],
+    'not_in'               => 'Zvolený :attribute je neplatný.',
+    'not_regex'            => 'Formát :attribute je neplatný.',
+    'numeric'              => ':attribute musí být číslo.',
+    'present'              => 'Pole :attribute musí být přítomné.',
+    'regex'                => 'Formát :attribute je neplatný.',
+    'required'             => 'Pole :attribute je vyžadováno.',
+    'required_if'          => 'Pole :attribute je vyžadováno, pokud je :other :value.',
+    'required_unless'      => 'Pole :attribute je vyžadováno, pokud není :other v :values.',
+    'required_with'        => 'Pole :attribute je vyžadováno, pokud je přítomno :values.',
+    'required_with_all'    => 'Pole :attribute je vyžadováno, pokud je přítomno :values.',
+    'required_without'     => 'Pole :attribute je vyžadováno, pokud není přítomno :values.',
+    'required_without_all' => 'Pole :attribute je vyžadováno, pokud není přítomno žádné z :values.',
+    'same'                 => ':attribute a :other se musí shodovat.',
+    'size'                 => [
+        'numeric' => ':attribute musí být :size.',
+        'file'    => ':attribute musí být :size kilobytů.',
+        'string'  => ':attribute musí být :size znaků.',
+        'array'   => ':attribute musí obsahovat :size položek.',
+    ],
+    'string'               => ':attribute musí být řetězec.',
+    'timezone'             => ':attribute musí být platná zóna.',
+    'unique'               => ':attribute je již zabráno.',
+    'uploaded'             => 'Nahrávání :attribute selhalo.',
+    'url'                  => 'Formát :attribute je neplatný.',
+
+    /*
+    |--------------------------------------------------------------------------
+    | Custom Validation Language Lines
+    |--------------------------------------------------------------------------
+    |
+    | Here you may specify custom validation messages for attributes using the
+    | convention "attribute.rule" to name the lines. This makes it quick to
+    | specify a specific custom language line for a given attribute rule.
+    |
+    */
+
+    'custom' => [
+        'attribute-name' => [
+            'rule-name' => 'custom-message',
+        ],
+    ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | Custom Validation Attributes
+    |--------------------------------------------------------------------------
+    |
+    | The following language lines are used to swap attribute place-holders
+    | with something more reader friendly such as E-Mail Address instead
+    | of "email". This simply helps us make messages a little cleaner.
+    |
+    */
+
+    'attributes' => [],
+
+];

+ 49 - 0
resources/views/auth/checkpoint.blade.php

@@ -0,0 +1,49 @@
+@extends('layouts.blank')
+
+@section('content')
+<div class="container mt-5">
+    <div class="row justify-content-center">
+        <div class="col-lg-5">
+            <div class="text-center">
+                <img src="/img/pixelfed-icon-color.svg" height="60px">
+                <p class="font-weight-light h3 py-4">Verify 2FA Code to continue</p>
+            </div>
+            <div class="card">
+                <div class="card-body">
+                    <form method="POST">
+                        @csrf
+
+                        <div class="form-group row">
+
+                            <div class="col-md-12">
+                                <input id="code" type="code" class="form-control{{ $errors->has('code') ? ' is-invalid' : '' }}" name="code" placeholder="{{__('Two-Factor Authentication Code')}}" required autocomplete="off">
+
+                                @if ($errors->has('code'))
+                                    <span class="invalid-feedback">
+                                        <strong>{{ $errors->first('code') }}</strong>
+                                    </span>
+                                @endif
+                            </div>
+                        </div>
+
+                        @if(config('pixelfed.recaptcha'))
+                        <div class="row my-3">
+                            {!! Recaptcha::render() !!}
+                        </div>
+                        @endif
+
+                        <div class="form-group row mb-0">
+                            <div class="col-md-12">
+                                <button type="submit" class="btn btn-success btn-block  font-weight-bold">
+                                    {{ __('Verify') }}
+                                </button>
+
+                            </div>
+                        </div>
+                    </form>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+@endsection

+ 1 - 1
resources/views/layouts/partial/nav.blade.php

@@ -24,7 +24,7 @@
                     </li>
                     <li class="nav-item px-2">
                         <a class="nav-link nav-notification" href="{{route('notifications')}}" title="Notifications" data-toggle="tooltip" data-placement="bottom">
-                            <i class="far fa-heart fa-lg text"></i>
+                            <i class="fas fa-inbox fa-lg text"></i>
                         </a>
                     </li>
                     <li class="nav-item px-2">

+ 9 - 12
resources/views/settings/partial/sidebar.blade.php

@@ -3,29 +3,26 @@
       <li class="nav-item pl-3 {{request()->is('settings/home')?'active':''}}">
         <a class="nav-link font-weight-light  text-muted" href="{{route('settings')}}">Profile</a>
       </li>
-{{--       <li class="nav-item pl-3 {{request()->is('settings/avatar')?'active':''}}">
-        <a class="nav-link font-weight-light text-muted" href="{{route('settings.avatar')}}">Avatar</a>
-      </li> --}}
       <li class="nav-item pl-3 {{request()->is('settings/password')?'active':''}}">
         <a class="nav-link font-weight-light text-muted" href="{{route('settings.password')}}">Password</a>
       </li>
-  {{--     <li class="nav-item pl-3 {{request()->is('settings/email')?'active':''}}">
+      {{-- <li class="nav-item pl-3 {{request()->is('settings/email')?'active':''}}">
         <a class="nav-link font-weight-light text-muted" href="{{route('settings.email')}}">Email</a>
       </li>
       <li class="nav-item pl-3 {{request()->is('settings/notifications')?'active':''}}">
         <a class="nav-link font-weight-light text-muted" href="{{route('settings.notifications')}}">Notifications</a>
-      </li>
-   --}}
+      </li> --}}
+  
       <li class="nav-item pl-3 {{request()->is('settings/privacy*')?'active':''}}">
         <a class="nav-link font-weight-light text-muted" href="{{route('settings.privacy')}}">Privacy</a>
       </li>
-{{--       <li class="nav-item pl-3 {{request()->is('settings/security')?'active':''}}">
+      <li class="nav-item pl-3 {{request()->is('settings/security*')?'active':''}}">
         <a class="nav-link font-weight-light text-muted" href="{{route('settings.security')}}">Security</a>
       </li>
-      <li class="nav-item">
-      <hr>
+      {{-- <li class="nav-item">
+        <hr>
       </li>
-      <li class="nav-item pl-3 {{request()->is('settings/import*')?'active':''}}">
+      <li class="nav-item pl-3 {{request()->is('*import*')?'active':''}}">
         <a class="nav-link font-weight-light text-muted" href="{{route('settings.import')}}">Import</a>
       </li>
       <li class="nav-item pl-3 {{request()->is('settings/data-export')?'active':''}}">
@@ -36,10 +33,10 @@
       <hr>
       </li>
       <li class="nav-item pl-3 {{request()->is('settings/applications')?'active':''}}">
-        <a class="nav-link font-weight-light text-muted" href="{{route('settings.applications')}}">Applications</a>
+        <a class="nav-link font-weight-light text-muted" href="#">Applications</a>
       </li>
       <li class="nav-item pl-3 {{request()->is('settings/developers')?'active':''}}">
-        <a class="nav-link font-weight-light text-muted" href="{{route('settings.developers')}}">Developers</a>
+        <a class="nav-link font-weight-light text-muted" href="#">Developers</a>
       </li> --}}
     </ul>
   </div>

+ 20 - 4
resources/views/settings/security.blade.php

@@ -3,11 +3,27 @@
 @section('section')
 
   <div class="title">
-    <h3 class="font-weight-bold">Security Settings</h3>
+    <h3 class="font-weight-bold">Security</h3>
   </div>
   <hr>
-  <div class="alert alert-danger">
-    Coming Soon
-  </div>
+
+  <section class="pt-4">
+    <div class="mb-4 pb-4">
+      <div class="d-flex justify-content-between align-items-center">
+        <h4 class="font-weight-bold mb-0">Two-factor authentication</h4>
+        @if($user->{'2fa_enabled'})
+        <a class="btn btn-success btn-sm font-weight-bold" href="#">Enabled</a>
+        @endif
+      </div>
+      <hr>
+      @if($user->{'2fa_enabled'})
+      @include('settings.security.2fa.partial.edit-panel')
+      @else
+      @include('settings.security.2fa.partial.disabled-panel')
+      @endif
+    </div>
+
+    @include('settings.security.2fa.partial.log-panel')
+  </section>
 
 @endsection

+ 82 - 0
resources/views/settings/security/2fa/edit.blade.php

@@ -0,0 +1,82 @@
+@extends('settings.template')
+
+@section('section')
+
+  <div class="title">
+    <h3 class="font-weight-bold">Edit Two-Factor Authentication</h3>
+  </div>
+  
+  <hr>
+
+  <p class="lead pb-3">
+  	To register a new device, you have to remove any active devices.
+  </p>
+
+  <div class="card">
+  	<div class="card-header bg-light font-weight-bold">
+  		Authenticator App
+  	</div>
+  	<div class="card-body d-flex justify-content-between align-items-center">
+  		<i class="fas fa-lock fa-3x text-success"></i>
+  		<p class="font-weight-bold mb-0">
+  			Added {{$user->{'2fa_setup_at'}->diffForHumans()}}
+  		</p>
+  	</div>
+  	<div class="card-footer bg-white text-right">
+  		<a class="btn btn-outline-secondary btn-sm px-4 font-weight-bold mr-3" href="{{route('settings.security.2fa.recovery')}}">View Recovery Codes</a>
+  		<a class="btn btn-outline-danger btn-sm px-4 font-weight-bold remove-device" href="#">Remove</a>
+  	</div>
+  </div>
+
+@endsection
+
+@push('scripts')
+<script type="text/javascript">
+$(document).ready(function() {
+
+	$(document).on('click', '.remove-device', function(e) {
+		e.preventDefault();
+		swal({
+			title: 'Confirm Device Removal',
+			text: 'Are you sure you want to remove this two-factor authentication device from your account?',
+			icon: 'warning',
+			button: {
+				text: 'Confirm Removal',
+				className: 'btn-danger'
+			}
+		})
+		.then((value) => {
+			if(value == true) {
+				swal({
+					title: 'Are you really sure?',
+					text: 'Are you really sure you want to remove this two-factor authentication device from your account?',
+					icon: 'warning',
+					button: {
+						text: 'Confirm Removal',
+						className: 'btn-danger'
+					}
+				})
+				.then((value) => {
+					if(value == true) {
+						axios.post('/settings/security/2fa/edit', {
+							action: 'remove'
+						})
+						.then(function(res) {
+							window.location.href = '/settings/security';
+						})
+						.catch(function(res) {
+							swal(
+								'Oops!',
+								'Something went wrong. Please try again.',
+								'error'
+							);
+						})
+					}
+				});
+			};
+		});
+	});
+});
+
+</script>
+@endpush

+ 18 - 0
resources/views/settings/security/2fa/partial/disabled-panel.blade.php

@@ -0,0 +1,18 @@
+<ul class="list-group">
+	<li class="list-group-item bg-light">
+		<div class="text-center py-5 px-4">
+			<p class="text-muted">
+				<i class="fas fa-lock fa-2x"></i>
+			</p>
+			<p class="text-muted h4 font-weight-bold">
+				Two factor authentication is not enabled yet.
+			</p>
+			<p class="text-muted">
+				Two-factor authentication adds an additional layer of security to your account by requiring more than just a password to log in. <a href="#">Learn more</a>.
+			</p>
+			<p class="mb-0">
+				<a class="btn btn-success font-weight-bold" href="{{route('settings.security.2fa.setup')}}">Enable two-factor authentication</a>
+			</p>
+		</div>
+	</li>
+</ul>

+ 30 - 0
resources/views/settings/security/2fa/partial/edit-panel.blade.php

@@ -0,0 +1,30 @@
+<p>Two-factor authentication adds an additional layer of security to your account by requiring more than just a password to log in. <a href="#">Learn more</a>.</p>
+<div class="card mb-3">
+	<div class="card-header bg-light">
+		<span class="font-weight-bold">
+			Two-factor methods
+		</span>
+	</div>
+	<ul class="list-group list-group-flush">
+		<li class="list-group-item">
+			<div class="d-flex justify-content-between align-items-center py-2">
+				<div>Authenticator App</div>
+				<div><a class="btn btn-secondary btn-sm font-weight-bold" href="{{route('settings.security.2fa.edit')}}">Edit</a></div>
+			</div>
+		</li>
+	</ul>
+</div><div class="card mb-3">
+	<div class="card-header bg-light">
+		<span class="font-weight-bold">
+			Recovery Options
+		</span>
+	</div>
+	<ul class="list-group list-group-flush">
+		<li class="list-group-item">
+			<div class="d-flex justify-content-between align-items-center py-2">
+				<div>Recovery Codes</div>
+				<div><a class="btn btn-secondary btn-sm font-weight-bold" href="{{route('settings.security.2fa.recovery')}}">View</a></div>
+			</div>
+		</li>
+	</ul>
+</div>

+ 42 - 0
resources/views/settings/security/2fa/partial/log-panel.blade.php

@@ -0,0 +1,42 @@
+    <div class="mb-4 pb-4">
+      <h4 class="font-weight-bold">Account Log</h4>
+      <hr>
+      <ul class="list-group" style="max-height: 400px;overflow-y: scroll;">
+        @if($activity->count() == 0) 
+        <p class="alert alert-info font-weight-bold">No activity logs found!</p>
+        @endif
+        @foreach($activity as $log)
+        <li class="list-group-item">
+          <div class="media">
+            <div class="media-body">
+              <span class="my-0 font-weight-bold text-muted">
+              	{{$log->action}} - <span class="font-weight-normal">{{$log->message}}</span>
+              </span>
+              <span class="mb-0 text-muted float-right">
+              	{{$log->created_at->diffForHumans(null, false, false, false)}}
+              	<span class="pl-2" data-toggle="collapse" href="#log-details-{{$log->id}}" role="button" aria-expanded="false" aria-controls="log-details-{{$log->id}}">
+              		<i class="fas fa-ellipsis-v"></i>
+              	</span>
+              </span>
+              <div class="collapse" id="log-details-{{$log->id}}">
+              	<div class="py-2">
+              		<p class="mb-0">
+              			<span class="font-weight-bold">IP Address:</span>
+              			<span>
+              				{{$log->ip_address}}
+              			</span>
+              		</p>
+            		<p class="mb-0">
+              			<span class="font-weight-bold">User Agent:</span>
+              			<span>
+              				{{$log->user_agent}}
+              			</span>
+              		</p>
+              	</div>
+              </div>
+            </div>
+          </div>
+        </li>
+        @endforeach
+      </ul>
+    </div>

+ 22 - 0
resources/views/settings/security/2fa/recovery-codes.blade.php

@@ -0,0 +1,22 @@
+@extends('settings.template')
+
+@section('section')
+
+  <div class="title">
+    <h3 class="font-weight-bold">Two-Factor Authentication Recovery Codes</h3>
+  </div>
+
+  <hr>
+  
+  <p class="lead pb-3">
+  	Each code can only be used once.
+  </p>
+
+  <p class="lead"></p>
+  <ul class="list-group">
+  	@foreach($codes as $code)
+  	<li class="list-group-item"><code>{{$code}}</code></li>
+  	@endforeach
+  </ul>
+
+@endsection

+ 134 - 0
resources/views/settings/security/2fa/setup.blade.php

@@ -0,0 +1,134 @@
+@extends('settings.template')
+
+@section('section')
+
+  <div class="title">
+    <h3 class="font-weight-bold">Setup Two-Factor Authentication</h3>
+  </div>
+  <hr>
+  <div class="alert alert-info font-weight-light mb-3">
+  	We only support Two-Factor Authentication via TOTP mobile apps.
+  </div>
+  <section class="step-one pb-5">
+  	<div class="sub-title font-weight-bold h5" data-toggle="collapse" data-target="#step1" aria-expanded="true" aria-controls="step1" data-step="1">
+  		Step 1: Install compatible 2FA mobile app <i class="float-right fas fa-chevron-down"></i>
+  	</div>
+  	<hr>
+  	<div class="collapse show" id="step1">
+	  	<p>You will need to install a compatible mobile app, we recommend the following apps:</p>
+	  	<ul>
+	  		<li><a href="https://1password.com/downloads/" rel="nooopener nofollow">1Password</a></li>
+	  		<li><a href="https://authy.com/download/" rel="nooopener nofollow">Authy</a></li>
+	  		<li><a href="https://lastpass.com/auth/" rel="nooopener nofollow">LastPass Authenticator</a></li>
+	  		<li>
+	  			Google Authenticator 
+	  			<a class="small" href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en_CA" rel="nooopener nofollow">
+	  				(android)
+	  			</a>
+	  			<a class="small" href="https://itunes.apple.com/ca/app/google-authenticator/id388497605?mt=8" rel="nooopener nofollow">
+	  				(iOS)
+	  			</a>
+	  		</li>
+	  		<li><a href="https://www.microsoft.com/en-us/account/authenticator" rel="nooopener nofollow">Microsoft Authenticator</a></li>
+	  	</ul>
+  	</div>
+  </section>
+
+  <section class="step-two pb-5">
+  	<div class="sub-title font-weight-bold h5" data-toggle="collapse" data-target="#step2" aria-expanded="false" aria-controls="step2" data-step="2">
+  		Step 2: Scan QR Code and confirm <i class="float-right fas fa-chevron-down"></i>
+  	</div>
+  	<hr>
+  	<div class="collapse" id="step2">
+	  	<p>Please scan the QR code and then enter the 6 digit code in the form below. Keep in mind the code changes every 30 seconds, and is only good for 1 minute.</p>
+	  	<div class="card">
+	  		<div class="card-body text-center">
+	  			<img src="{{$qrcode}}">
+	  		</div>
+	  		<div class="card-body">
+	  			<form id="confirm-code">
+		  			<div class="form-group">
+		  				<label class="font-weight-bold small">Code</label>
+		  				<input type="text" name="code" id="verifyCode" class="form-control" placeholder="Code" autocomplete="off">
+		  			</div>
+		  			<button type="submit" class="btn btn-primary font-weight-bold">Submit</button>
+	  			</form>
+	  		</div>
+	  	</div>
+  	</div>
+  </section>
+
+  <section class="step-three pb-5">
+  	<div class="sub-title font-weight-bold h5" data-toggle="collapse" data-target="#step3" aria-expanded="true" aria-controls="step3" data-step="3">
+  		Step 3: Download Backup Codes <i class="float-right fas fa-chevron-down"></i>
+  	</div>
+  	<hr>
+  	<div class="collapse" id="step3">
+	  	<p>Please store the following codes in a safe place, each backup code can be used only once if you do not have access to your 2FA mobile app.</p>
+
+	  	<code>
+	  	@foreach($backups as $code)
+	  	<p class="mb-0">{{$code}}</p>
+	  	@endforeach
+	  	</code>
+  	</div>
+  </section>
+@endsection
+
+@push('scripts')
+<script type="text/javascript">
+$(document).ready(function() {
+	$('#step3').addClass('d-none');
+	window.twoFactor = {};
+	window.twoFactor.validated = false;
+
+	$(document).on('click', 'div[data-toggle=collapse]', function(e) {
+		let el = $(this);
+		let step = el.data('step');
+
+		switch(step) {
+			case 1:
+				$('#step2').collapse('hide');
+				$('#step3').collapse('hide');
+			break;
+			case 2:
+				$('#step1').collapse('hide');
+				$('#step3').collapse('hide');
+			break;
+			case 3:
+				if(twoFactor.validated == false) {
+					e.preventDefault();
+					return;
+				} else {
+					$('#step3').removeClass('d-none');
+					$('#step1').collapse('hide');
+					$('#step2').collapse('hide');
+				}
+			break;
+		}
+	});
+
+	$(document).on('submit', '#confirm-code', function(e) {
+		e.preventDefault();
+		let el = $(this);
+		let code = $('#verifyCode').val();
+		if(code.length < 5) {
+			swal('Oops!', 'You need to enter a valid code', 'error');
+			return;
+		}
+		axios.post(window.location.href, {
+			code: code
+		}).then((res) => {
+			twoFactor.validated = true;
+			$('#step3').removeClass('d-none');
+			$('#step3').collapse('show');
+			$('#step1').collapse('hide');
+			$('#step2').collapse('hide');
+		}).catch((res) => {
+			swal('Oops!', 'That was an invalid code, please try again.', 'error');
+			return;
+		});
+	});
+});
+</script>
+@endpush

+ 0 - 4
routes/api.php

@@ -12,7 +12,3 @@ use Illuminate\Http\Request;
 | is assigned the "api" middleware group. Enjoy building your API!
 |
 */
-
-Route::middleware('auth:api')->get('/user', function (Request $request) {
-    return $request->user();
-});

+ 0 - 4
routes/console.php

@@ -12,7 +12,3 @@ use Illuminate\Foundation\Inspiring;
 | simple approach to interacting with each command's IO methods.
 |
 */
-
-Artisan::command('inspire', function () {
-    $this->comment(Inspiring::quote());
-})->describe('Display an inspiring quote');

+ 34 - 7
routes/web.php

@@ -18,7 +18,7 @@ Route::domain(config('pixelfed.domain.admin'))->prefix('i/admin')->group(functio
     Route::get('media/list', 'AdminController@media')->name('admin.media');
 });
 
-Route::domain(config('pixelfed.domain.app'))->middleware('validemail')->group(function () {
+Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofactor'])->group(function () {
     Route::get('/', 'SiteController@home')->name('timeline.personal');
     Route::post('/', 'StatusController@store')->middleware('throttle:500,1440');
 
@@ -31,10 +31,6 @@ Route::domain(config('pixelfed.domain.app'))->middleware('validemail')->group(fu
 
     Route::get('discover', 'DiscoverController@home')->name('discover');
 
-    Route::get('search/hashtag/{tag}', function ($tag) {
-        return redirect('/discover/tags/'.$tag);
-    });
-
     Route::group(['prefix' => 'api'], function () {
         Route::get('search/{tag}', 'SearchController@searchAPI')
           ->where('tag', '[A-Za-z0-9]+');
@@ -64,12 +60,15 @@ Route::domain(config('pixelfed.domain.app'))->middleware('validemail')->group(fu
         Route::post('follow', 'FollowerController@store')->middleware('throttle:250,1440');
         Route::post('bookmark', 'BookmarkController@store')->middleware('throttle:250,1440');
         Route::get('lang/{locale}', 'SiteController@changeLocale');
+
         Route::get('verify-email', 'AccountController@verifyEmail');
         Route::post('verify-email', 'AccountController@sendVerifyEmail')->middleware('throttle:10,1440');
         Route::get('confirm-email/{userToken}/{randomToken}', 'AccountController@confirmVerifyEmail')->middleware('throttle:10,1440');
 
         Route::get('auth/sudo', 'AccountController@sudoMode');
         Route::post('auth/sudo', 'AccountController@sudoModeVerify');
+        Route::get('auth/checkpoint', 'AccountController@twoFactorCheckpoint');
+        Route::post('auth/checkpoint', 'AccountController@twoFactorVerify');
 
         Route::group(['prefix' => 'report'], function () {
             Route::get('/', 'ReportController@showForm')->name('report.form');
@@ -97,7 +96,8 @@ Route::domain(config('pixelfed.domain.app'))->middleware('validemail')->group(fu
 
     Route::group(['prefix' => 'settings'], function () {
         Route::redirect('/', '/settings/home');
-        Route::get('home', 'SettingsController@home')->name('settings');
+        Route::get('home', 'SettingsController@home')
+        ->name('settings');
         Route::post('home', 'SettingsController@homeUpdate')->middleware('throttle:25,1440');
         Route::get('avatar', 'SettingsController@avatar')->name('settings.avatar');
         Route::post('avatar', 'AvatarController@store')->middleware('throttle:5,1440');
@@ -112,7 +112,34 @@ Route::domain(config('pixelfed.domain.app'))->middleware('validemail')->group(fu
         Route::get('privacy/blocked-users', 'SettingsController@blockedUsers')->name('settings.privacy.blocked-users');
         Route::post('privacy/blocked-users', 'SettingsController@blockedUsersUpdate')->middleware('throttle:100,1440');
         Route::get('privacy/blocked-instances', 'SettingsController@blockedInstances')->name('settings.privacy.blocked-instances');
-        Route::get('security', 'SettingsController@security')->name('settings.security');
+
+        Route::group(['prefix' => 'security', 'middleware' => 'dangerzone'], function() {
+            Route::get(
+                '/', 
+                'SettingsController@security'
+            )->name('settings.security');
+            Route::get(
+                '2fa/setup', 
+                'SettingsController@securityTwoFactorSetup'
+            )->name('settings.security.2fa.setup');
+            Route::post(
+                '2fa/setup', 
+                'SettingsController@securityTwoFactorSetupStore'
+            );
+            Route::get(
+                '2fa/edit', 
+                'SettingsController@securityTwoFactorEdit'
+            )->name('settings.security.2fa.edit');
+            Route::post(
+                '2fa/edit', 
+                'SettingsController@securityTwoFactorUpdate'
+            );
+            Route::get(
+                '2fa/recovery-codes',
+                'SettingsController@securityTwoFactorRecoveryCodes'
+            )->name('settings.security.2fa.recovery');
+        });
+
         Route::get('applications', 'SettingsController@applications')->name('settings.applications');
         Route::get('data-export', 'SettingsController@dataExport')->name('settings.dataexport');
         Route::get('developers', 'SettingsController@developers')->name('settings.developers');