Skip to content

Commit

Permalink
Adding OTP Support for Email Login BookStackApp#5468
Browse files Browse the repository at this point in the history
  • Loading branch information
Emirhan Uysal committed Feb 3, 2025
1 parent 786a434 commit 32294eb
Show file tree
Hide file tree
Showing 8 changed files with 215 additions and 2 deletions.
120 changes: 120 additions & 0 deletions app/Access/Controllers/EmailController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<?php

namespace BookStack\Access\Controllers;

use BookStack\Access\LoginService;
use BookStack\Access\Mfa\BackupCodeService;
use BookStack\Access\Mfa\MfaSession;
use BookStack\Access\Mfa\MfaValue;
use BookStack\Activity\ActivityType;
use BookStack\Exceptions\NotFoundException;
use BookStack\Http\Controller;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Str;

class EmailController extends Controller
{
use HandlesPartialLogins;

protected const SETUP_SECRET_SESSION_KEY = 'mfa-setup-email';

/**
* Show a view that generates and displays backup codes.
*/
public function generate(BackupCodeService $codeService)
{
/* $codes = $codeService->generateNewSet();
session()->put(self::SETUP_SECRET_SESSION_KEY, encrypt($codes));
$downloadUrl = 'data:application/octet-stream;base64,' . base64_encode(implode("\n\n", $codes));
$this->setPageTitle(trans('auth.mfa_gen_backup_codes_title'));
return view('mfa.backup-codes-generate', [
'codes' => $codes,
'downloadUrl' => $downloadUrl,
]); */

$code = Str::random(6);
session()->put(self::SETUP_SECRET_SESSION_KEY, encrypt($code));

$this->setPageTitle(trans('auth.mfa_gen_email_title'));

return view('mfa.email-generate');
}

/**
* Confirm the setup of backup codes, storing them against the user.
*
* @throws Exception
*/
public function confirm()
{
/* if (!session()->has(self::SETUP_SECRET_SESSION_KEY)) {
return response('No generated codes found in the session', 500);
}
$codes = decrypt(session()->pull(self::SETUP_SECRET_SESSION_KEY));
MfaValue::upsertWithValue($this->currentOrLastAttemptedUser(), MfaValue::METHOD_BACKUP_CODES, json_encode($codes));
$this->logActivity(ActivityType::MFA_SETUP_METHOD, 'backup-codes');
if (!auth()->check()) {
$this->showSuccessNotification(trans('auth.mfa_setup_login_notification'));
return redirect('/login');
}
return redirect('/mfa/setup'); */

if (!session()->has(self::SETUP_SECRET_SESSION_KEY)) {
return response('No generated codes found in the session', 500);
}

$validcode = decrypt(session()->pull(self::SETUP_SECRET_SESSION_KEY));
MfaValue::upsertWithValue($this->currentOrLastAttemptedUser(), MfaValue::METHOD_EMAIL, $validcode);

$this->logActivity(ActivityType::MFA_SETUP_METHOD, 'email');

if (!auth()->check()) {
$this->showSuccessNotification(trans('auth.mfa_setup_login_notification'));

return redirect('/login');
}

return redirect('/mfa/setup');
}

/**
* Verify the MFA method submission on check.
*
* @throws NotFoundException
* @throws ValidationException
*/
public function verify(Request $request, MfaSession $mfaSession, LoginService $loginService)
{
$user = $this->currentOrLastAttemptedUser();
$validcode = MfaValue::getValueForUser($user, MfaValue::METHOD_EMAIL) ?? '';

$this->validate($request, [
'code' => [
'required', 'max:12', 'min:6',
function ($attribute, $value, $fail) use ($validcode) {
if($value !== $validcode) {
$fail(trans('validation.email_code_wrong'));
}
},
],
]);

$newCode = Str::random(6);
MfaValue::upsertWithValue($user, MfaValue::METHOD_EMAIL, $newCode);

$mfaSession->markVerifiedForUser($user);
$loginService->reattemptLoginFor($user);

return redirect()->intended();
}
}
15 changes: 15 additions & 0 deletions app/Access/Controllers/MfaController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use BookStack\Activity\ActivityType;
use BookStack\Http\Controller;
use Illuminate\Http\Request;
use BookStack\Access\Notifications\EmailCodeNotification;

class MfaController extends Controller
{
Expand All @@ -23,8 +24,15 @@ public function setup()

$this->setPageTitle(trans('auth.mfa_setup'));

$userMethodList = ['email'];
$user = $this->currentOrLastAttemptedUser();
if ($user->hasSystemRole('admin')) {
$userMethodList = MfaValue::allMethods();
}

return view('mfa.setup', [
'userMethods' => $userMethods,
'userMethodList' => $userMethodList,
]);
}

Expand Down Expand Up @@ -64,6 +72,13 @@ public function verify(Request $request)
return $method !== $userMethod;
})->all();

$user = $this->currentOrLastAttemptedUser();
$code = '';
if($method == 'email') {
$validcode = MfaValue::getValueForUser($user, MfaValue::METHOD_EMAIL) ?? '';
$user->notify(new EmailCodeNotification($validcode));
}

return view('mfa.verify', [
'userMethods' => $userMethods,
'method' => $method,
Expand Down
3 changes: 2 additions & 1 deletion app/Access/Mfa/MfaValue.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,14 @@ class MfaValue extends Model

const METHOD_TOTP = 'totp';
const METHOD_BACKUP_CODES = 'backup_codes';
const METHOD_EMAIL = 'email';

/**
* Get all the MFA methods available.
*/
public static function allMethods(): array
{
return [self::METHOD_TOTP, self::METHOD_BACKUP_CODES];
return [self::METHOD_TOTP, self::METHOD_BACKUP_CODES, self::METHOD_EMAIL];
}

/**
Expand Down
32 changes: 32 additions & 0 deletions app/Access/Notifications/EmailCodeNotification.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

namespace BookStack\Access\Notifications;

use BookStack\App\MailNotification;
use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;

class EmailCodeNotification extends MailNotification
{
public function __construct(
public string $validcode
) {
}

public function toMail(User $notifiable): MailMessage
{
$locale = $notifiable->getLocale();

$listLines = array_filter([
$locale->trans('notifications.detail_created_by') => $this->validcode,
]);

return $this->newMailMessage()
->subject(trans('auth.email_reset_subject', ['appName' => setting('app-name')]))
->line(trans('auth.email_reset_text'))
->line(new ListMessageLine($listLines))
->action(trans('auth.login'), url('login'))
->line(trans('auth.email_reset_not_requested'));
}
}
21 changes: 21 additions & 0 deletions resources/views/mfa/email-generate.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
@extends('layouts.simple')

@section('body')
<div class="container very-small py-xl">

<div class="card content-wrap auto-height">
<h1 class="list-heading">{{ trans('auth.mfa_verify_access') }}</h1>
<p class="mb-none">{{ trans('auth.mfa_verify_access_desc') }}</p>

<hr class="my-l">

<form action="{{ url('/mfa/email/confirm') }}" method="POST">
{{ csrf_field() }}
<div class="mt-s text-right">
<a href="{{ url('/login') }}" class="button outline">{{ trans('common.cancel') }}</a>
<button class="button">{{ trans('auth.mfa_gen_confirm_and_enable') }}</button>
</div>
</form>
</div>
</div>
@stop
21 changes: 21 additions & 0 deletions resources/views/mfa/parts/verify-email.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@

<div class="setting-list-label">{{ trans('auth.mfa_verify_backup_code') }}</div>

<p class="small mb-m">{{ trans('auth.mfa_verify_backup_code_desc') }}</p>

<form action="{{ url('/mfa/email/verify') }}" method="POST">
{{ csrf_field() }}
<input type="text"
name="code"
aria-labelledby="totp-verify-input-details"
placeholder="{{ trans('auth.mfa_gen_totp_provide_code_here') }}"
class="input-fill-width {{ $errors->has('code') ? 'neg' : '' }}">

@if($errors->has('code'))
<div class="text-neg text-small px-xs">{{ $errors->first('code') }}</div>
@endif

<div class="mt-s text-right">
<button class="button">{{ trans('common.confirm') }}</button>
</div>
</form>
2 changes: 1 addition & 1 deletion resources/views/mfa/setup.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<p class="mb-none"> {{ trans('auth.mfa_setup_desc') }}</p>

<div class="setting-list">
@foreach(['totp', 'backup_codes'] as $method)
@foreach($userMethodList as $method)
@include('mfa.parts.setup-method-row', ['method' => $method])
@endforeach
</div>
Expand Down
3 changes: 3 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -307,11 +307,14 @@
Route::post('/mfa/totp/confirm', [AccessControllers\MfaTotpController::class, 'confirm']);
Route::get('/mfa/backup_codes/generate', [AccessControllers\MfaBackupCodesController::class, 'generate']);
Route::post('/mfa/backup_codes/confirm', [AccessControllers\MfaBackupCodesController::class, 'confirm']);
Route::get('/mfa/email/generate', [AccessControllers\EmailController::class, 'generate']);
Route::post('/mfa/email/confirm', [AccessControllers\EmailController::class, 'confirm']);
});
Route::middleware('guest')->group(function () {
Route::get('/mfa/verify', [AccessControllers\MfaController::class, 'verify']);
Route::post('/mfa/totp/verify', [AccessControllers\MfaTotpController::class, 'verify']);
Route::post('/mfa/backup_codes/verify', [AccessControllers\MfaBackupCodesController::class, 'verify']);
Route::post('/mfa/email/verify', [AccessControllers\EmailController::class, 'verify']);
});
Route::delete('/mfa/{method}/remove', [AccessControllers\MfaController::class, 'remove'])->middleware('auth');

Expand Down

0 comments on commit 32294eb

Please sign in to comment.