Skip to content

[6.x] Two-Factor Authentication #11664

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 139 commits into from
May 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
139 commits
Select commit Hold shift + click to select a range
2e4606e
Bring Mity Digital's 2FA addon into Core
duncanmcclean Apr 2, 2025
78baa37
Hide "Two Factor" fieldtype in field selector
duncanmcclean Apr 2, 2025
7649f76
Make the two factor field listable
duncanmcclean Apr 2, 2025
2efbc0f
Hide two factor field in user creation wizard
duncanmcclean Apr 2, 2025
0d804d0
Translations.
duncanmcclean Apr 2, 2025
0b1cc73
Code formatting
duncanmcclean Apr 2, 2025
5cfdd2b
simplify recovery codes notification
duncanmcclean Apr 2, 2025
dcbe25d
wip
duncanmcclean Apr 2, 2025
8e7be9b
Prevent two_factor field being returned by GraphQL
duncanmcclean Apr 2, 2025
daaa0e5
Wire up the event listener
duncanmcclean Apr 4, 2025
5b8021d
Copy *most* of the tests from Mity's 2FA addon
duncanmcclean Apr 4, 2025
7be3350
Filter out two factor field when outputting user form fields
duncanmcclean Apr 7, 2025
6bc3bed
auth/auth
duncanmcclean Apr 7, 2025
ef4102c
Finish copying over tests from two factor addon
duncanmcclean Apr 8, 2025
d4443e3
Code formatting
duncanmcclean Apr 8, 2025
7c945d3
Remove blueprint option, we can hard code it.
duncanmcclean Apr 8, 2025
a76d82c
Two Factor should always be enabled.
duncanmcclean Apr 8, 2025
113a65f
Refactor how we determine if 2FA is required for a user
duncanmcclean Apr 8, 2025
bca0990
Refactor fieldtype preload
duncanmcclean Apr 9, 2025
4f4ed42
Rework the setup process on the user publish form
duncanmcclean Apr 9, 2025
b056f7f
Rework how you view recovery codes in the CP
duncanmcclean Apr 9, 2025
294f9d7
Rework the disable process
duncanmcclean Apr 11, 2025
82e61a8
Fix the index fieldtype
duncanmcclean Apr 14, 2025
c4e40a4
Improve empty state when viewing 2FA status of other users
duncanmcclean Apr 14, 2025
6f82d7d
You shouldn't be able to cancel out of setup page anymore.
duncanmcclean Apr 14, 2025
3a1f67f
Improve the index fieldtype
duncanmcclean Apr 14, 2025
7af9013
Remove event listener
duncanmcclean Apr 14, 2025
78ba11b
Removed locked feature
duncanmcclean Apr 14, 2025
406b2c4
Actually remove `enabled` option from config
duncanmcclean Apr 14, 2025
0293fe8
rename environment variable
duncanmcclean Apr 14, 2025
8b413de
Move methods from `StatamicTwoFactorUser` to user classes
duncanmcclean Apr 14, 2025
03b0aaf
wip
duncanmcclean Apr 14, 2025
f4e8a15
Persist `confirmed_at` and `completed` as timestamps
duncanmcclean Apr 14, 2025
69ec982
Fix index fieldtype
duncanmcclean Apr 14, 2025
d52dd7f
wip
duncanmcclean Apr 14, 2025
1380b91
wip
duncanmcclean Apr 16, 2025
828f50f
fieldtype improvements
duncanmcclean Apr 16, 2025
b95388e
Users should complete the challenge before being logged in
duncanmcclean Apr 17, 2025
da473b9
Various refactorings
duncanmcclean Apr 17, 2025
815f0c5
Throw auth failed event when user can't be found
duncanmcclean Apr 17, 2025
99d3a3f
We'll handle this in a middleware shortly.
duncanmcclean Apr 17, 2025
89eafd2
Re-implement validity check
duncanmcclean Apr 17, 2025
df85319
Remove the middleware
duncanmcclean Apr 17, 2025
d5a6eda
wip
duncanmcclean Apr 17, 2025
84840f8
fix tests
duncanmcclean Apr 17, 2025
05c1cb8
Rework the setup process to re-use code from the fieldtype
duncanmcclean Apr 22, 2025
09d3dab
wip
duncanmcclean Apr 22, 2025
84aa506
Simplify controllers & combine controller and action tests
duncanmcclean Apr 22, 2025
a7bee1b
Types
duncanmcclean Apr 22, 2025
8f05266
Add `#[\SensitiveParameter]` attribute to authentication provider
duncanmcclean Apr 22, 2025
72562be
Merge remote-tracking branch 'origin/master' into two-factor-auth
duncanmcclean Apr 22, 2025
ef7d1fc
wip
duncanmcclean Apr 22, 2025
a3fc0a6
Events
duncanmcclean Apr 22, 2025
47f89fc
Merge remote-tracking branch 'origin/master' into two-factor-auth
duncanmcclean Apr 23, 2025
affe2df
Fix failing tests
duncanmcclean Apr 23, 2025
b37f2e2
Merge remote-tracking branch 'origin/master' into two-factor-auth
duncanmcclean Apr 23, 2025
5e126a0
Exception isn't needed anymore.
duncanmcclean Apr 23, 2025
3c6207c
Wordsmithing.
duncanmcclean Apr 23, 2025
77d82b5
Translations
duncanmcclean Apr 23, 2025
23ceb32
wip
duncanmcclean Apr 23, 2025
f3d1689
Implement rate limiting around 2FA challenge
duncanmcclean Apr 23, 2025
ff52b59
Add a couple more events
duncanmcclean Apr 23, 2025
8e5df7b
Ensure users are redirected to challenge when logging in via `{{ user…
duncanmcclean Apr 23, 2025
b6bd21b
Ensure users are redirected to 2FA setup when logging in via `{{ user…
duncanmcclean Apr 23, 2025
0d3002a
Do away with the `two_factor_completed` timestamp
duncanmcclean Apr 23, 2025
d33a6af
Make sure we're always dealing with a statamic user here.
duncanmcclean Apr 23, 2025
4b5e321
We don't need the `two_factor` JSON column anymore.
duncanmcclean Apr 23, 2025
140ccb1
Publish migration to add `two_factor` columns during upgrade
duncanmcclean Apr 23, 2025
ed05960
Code formatting.
duncanmcclean Apr 23, 2025
238cfd2
Fake notifications when testing recovery code replacement.
duncanmcclean Apr 23, 2025
717d254
Refactor how we handle invalid credentials in login form controller.
duncanmcclean Apr 23, 2025
b4abba0
Add test to ensure `{{ user:login_form }}` redirects to 2FA challenge…
duncanmcclean Apr 24, 2025
0370fbf
Make sure we're dealing with a Statamic user here
duncanmcclean Apr 24, 2025
4e8e8ca
Call `$user->set()` instead
duncanmcclean Apr 24, 2025
4dbb8e6
Hide "Disable two factor auth" button when user is missing permissions
duncanmcclean Apr 24, 2025
db99e06
Fix failing tests in `FrontendTest`
duncanmcclean Apr 24, 2025
897a254
Present two-factor challenge during session expiry login flow
duncanmcclean Apr 24, 2025
362571f
Formatting.
duncanmcclean Apr 24, 2025
104e0de
A last couple tweaks...
duncanmcclean Apr 25, 2025
39b1d24
Formatting.
duncanmcclean Apr 25, 2025
fd20c80
wip
duncanmcclean Apr 25, 2025
bf940c5
Mock roles, instead of actually writing to disk.
duncanmcclean Apr 25, 2025
90fcc03
Merge remote-tracking branch 'origin/master' into two-factor-auth
duncanmcclean Apr 28, 2025
a889346
Require elevated sessions before viewing recovery codes or enabling/d…
duncanmcclean Apr 28, 2025
ada9578
This should be `close`
duncanmcclean Apr 28, 2025
87293b1
Throw an exception if 2FA is already enabled.
duncanmcclean Apr 28, 2025
3726fd9
Simplify config
duncanmcclean Apr 28, 2025
97ec618
Formatting.
duncanmcclean Apr 28, 2025
e329cf1
say something if they decline to enter password
jasonvarga Apr 28, 2025
be741a4
nitpick
jasonvarga Apr 28, 2025
e5b41ac
Show button or error if you cant copy to clipboard
jasonvarga Apr 28, 2025
ea157fc
Merge branch 'master' into two-factor-auth
jasonvarga Apr 28, 2025
f3986f0
remove
jasonvarga Apr 28, 2025
497b183
group
jasonvarga Apr 28, 2025
ff78d60
nitpick
jasonvarga Apr 28, 2025
537debb
Merge branch 'master' into two-factor-auth
jasonvarga Apr 29, 2025
e7fdf37
DRY up the two login controllers with a trait
jasonvarga Apr 29, 2025
0e2dfd2
Elevate session when using 2fa
jasonvarga Apr 29, 2025
d03ecb5
Add a test
jasonvarga Apr 29, 2025
679455b
there too
jasonvarga Apr 29, 2025
1a1800f
Move and split tests
jasonvarga Apr 29, 2025
fddfc09
Avoid needing a fieldtype
jasonvarga Apr 29, 2025
97a638c
I shouldnt have removed this bit in e7fdf37a
jasonvarga Apr 29, 2025
b651e08
Doesnt need to be red
jasonvarga Apr 30, 2025
b46f642
autocomplete as one time codes
jasonvarga Apr 30, 2025
12483e8
dry
jasonvarga Apr 30, 2025
e62c540
Change prop since we cant update prop.
jasonvarga Apr 30, 2025
3518974
oops
jasonvarga Apr 30, 2025
dbf08b3
change to composition api, and fix busy states not working on login c…
jasonvarga Apr 30, 2025
c8f2637
icon not needed now that fieldtype is gone
jasonvarga Apr 30, 2025
6b18e1f
nothing to do with reset. copy paste issue.
jasonvarga Apr 30, 2025
e9136ba
Move the challenge form into the component ...
jasonvarga Apr 30, 2025
c7426b7
nitpick
jasonvarga Apr 30, 2025
8d5f90b
Extract an interface
jasonvarga Apr 30, 2025
24a3c2c
cpp
jasonvarga Apr 30, 2025
ab47433
Avoid forcing this middleware group ...
jasonvarga Apr 30, 2025
c367aec
Smaller title and dont show by default
jasonvarga Apr 30, 2025
2e06122
same a couple of translations, just have an icon
jasonvarga Apr 30, 2025
4b0993c
translations
jasonvarga Apr 30, 2025
4fca53c
Avoid sending notification. Devs can set this up by listening for the…
jasonvarga Apr 30, 2025
bd04d72
Dispatch event from the method. If you called it directly it wouldn't…
jasonvarga Apr 30, 2025
a94da27
simplify using what fortify does
jasonvarga Apr 30, 2025
0b0ce19
Avoid sending notification. Devs can set this up by listening for the…
jasonvarga Apr 30, 2025
0e29f78
group tests
jasonvarga Apr 30, 2025
c50e1e2
Separate routes for frontend
jasonvarga May 1, 2025
6a8bc09
Fix disable route not being protected by elevated session middleware
jasonvarga May 1, 2025
8f9ebd0
dry up the middleware
jasonvarga May 1, 2025
5b06996
Add an action to disable 2fa in preparation for removing the ability …
jasonvarga May 1, 2025
c689977
Adjust 2fa routes to not include the user. You can only disable 2fa f…
jasonvarga May 1, 2025
53a888e
Adjust the other routes for not being able to do stuff for other users.
jasonvarga May 1, 2025
c353a5e
Disable 2fa action should require elevated session
jasonvarga May 1, 2025
43d7860
make button look buttony
jasonvarga May 1, 2025
c5bd58e
Only show for users
jasonvarga May 1, 2025
60f4c1e
More places where user is no longer needed
jasonvarga May 1, 2025
95dab18
test coverage for frontend routes
jasonvarga May 1, 2025
7d32ee4
Should be using the policy method
jasonvarga May 1, 2025
48ace16
Remove more other-user logic
jasonvarga May 1, 2025
0c7b5fa
prettier
jasonvarga May 1, 2025
32b53cc
fix test
jasonvarga May 1, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"require": {
"ext-json": "*",
"ajthinking/archetype": "^1.0.3 || ^2.0",
"bacon/bacon-qr-code": "^3.0",
"composer/semver": "^3.4",
"guzzlehttp/guzzle": "^6.3 || ^7.0",
"james-heinrich/getid3": "^1.9.21",
Expand All @@ -23,6 +24,7 @@
"michelf/php-smartypants": "^1.8.1",
"nesbot/carbon": "^3.0",
"pixelfear/composer-dist-plugin": "^0.1.4",
"pragmarx/google2fa": "^8.0",
"rebing/graphql-laravel": "^9.8",
"rhukster/dom-sanitizer": "^1.0.6",
"spatie/blink": "^1.3",
Expand Down
13 changes: 13 additions & 0 deletions config/users.php
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,19 @@

'elevated_session_duration' => 15,

/*
|--------------------------------------------------------------------------
| Enforce Two-Factor Authentication
|--------------------------------------------------------------------------
|
| Specify which user roles should be required to enable two-factor
| authentication. Use "*" to enforce 2FA for all users, or "super_users"
| to enforce it for super users.
|
*/

'two_factor_enforced_roles' => [],

/*
|--------------------------------------------------------------------------
| Default Sorting
Expand Down
4 changes: 4 additions & 0 deletions resources/js/bootstrap/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import GlobalSearch from '../components/GlobalSearch.vue';
import GlobalSiteSelector from '../components/GlobalSiteSelector.vue';
import DarkModeToggle from '../components/DarkModeToggle.vue';
import Login from '../components/login/Login.vue';
import TwoFactorChallenge from '../components/login/TwoFactorChallenge.vue';
import EnableTwoFactorAuthentication from '../components/login/EnableTwoFactorAuthentication.vue';
import BaseEntryCreateForm from '../components/entries/BaseCreateForm.vue';
import BaseTermCreateForm from '../components/terms/BaseCreateForm.vue';
import CreateTermButton from '../components/terms/CreateTermButton.vue';
Expand Down Expand Up @@ -51,6 +53,8 @@ export default {
GlobalSiteSelector,
DarkModeToggle,
Login,
TwoFactorChallenge,
EnableTwoFactorAuthentication,
BaseEntryCreateForm,
BaseTermCreateForm,
CreateTermButton,
Expand Down
4 changes: 4 additions & 0 deletions resources/js/bootstrap/statamic.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ export default {
return this.$app.config.globalProperties.$date;
},

get $progress() {
return this.$app.config.globalProperties.$progress;
},

get darkMode() {
return darkMode;
},
Expand Down
124 changes: 120 additions & 4 deletions resources/js/components/SessionExpiry.vue
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,84 @@
</div>
</div>
</modal>

<modal name="session-timeout-login" v-if="isShowingTwoFactorChallenge" height="auto" :width="500">
<div class="-max-h-screen-px">
<div
class="flex items-center justify-between rounded-t-lg border-b bg-gray-200 px-5 py-3 text-lg font-semibold dark:border-dark-900 dark:bg-dark-550"
>
{{ __('Resume Your Session') }}
</div>

<div class="publish-fields p-2">
<div v-if="twoFactorMode === 'code'" class="form-group w-full">
<label v-text="__('messages.session_expiry_enter_two_factor_code')" />
<small class="help-block text-red-500" v-if="errors.code" v-text="errors.code[0]" />
<div class="flex items-center">
<input
type="text"
name="code"
v-model="twoFactorCode"
ref="twoFactorCode"
class="input-text"
tabindex="1"
pattern="[0-9]*"
maxlength="6"
inputmode="numeric"
autofocus
autocomplete="one-time-code"
@keydown.enter.prevent="submitTwoFactorChallenge"
/>
</div>
</div>

<div v-if="twoFactorMode === 'recovery_code'" class="form-group w-full">
<label v-text="__('messages.session_expiry_enter_two_factor_recovery_code')" />
<small
class="help-block text-red-500"
v-if="errors.recovery_code"
v-text="errors.recovery_code[0]"
/>
<div class="flex items-center">
<input
type="text"
name="recovery_code"
v-model="twoFactorRecoveryCode"
ref="twoFactorRecoveryCode"
class="input-text"
tabindex="1"
maxlength="21"
autofocus
autocomplete="off"
@keydown.enter.prevent="submitTwoFactorChallenge"
/>
</div>
</div>
</div>

<div
class="flex items-center justify-end border-t bg-gray-200 p-4 text-sm dark:border-dark-900 dark:bg-dark-550"
>
<button
v-if="twoFactorMode === 'code'"
class="text-gray hover:text-gray-900 dark:text-dark-150 dark:hover:text-dark-100"
@click="twoFactorMode = 'recovery_code'"
v-text="__('Use recovery code')"
/>
<button
v-if="twoFactorMode === 'recovery_code'"
class="text-gray hover:text-gray-900 dark:text-dark-150 dark:hover:text-dark-100"
@click="twoFactorMode = 'code'"
v-text="__('Use one-time code')"
/>
<button
class="btn-primary ltr:ml-4 rtl:mr-4"
@click="submitTwoFactorChallenge"
v-text="__('Continue')"
/>
</div>
</div>
</modal>
</div>
</template>

Expand All @@ -58,10 +136,14 @@ export default {
data() {
return {
isShowingLogin: false,
isShowingTwoFactorChallenge: false,
count: this.lifetime, // The timer used in vue
remaining: this.lifetime, // The actual time remaining as per server responses
errors: {},
password: null,
twoFactorCode: null,
twoFactorRecoveryCode: null,
twoFactorMode: 'code',
pinging: false,
lastCount: new Date(),
isPageHidden: false,
Expand Down Expand Up @@ -92,7 +174,7 @@ export default {

watch: {
count(count) {
this.isShowingLogin = this.auth.enabled && this.remaining <= 0;
this.isShowingLogin = this.auth.enabled && !this.isShowingTwoFactorChallenge && this.remaining <= 0;

// While we're in the warning period, we'll check every second so that any
// activity in another tab is picked up and the count will get restarted.
Expand Down Expand Up @@ -175,9 +257,37 @@ export default {
this.errors = {};
this.password = null;
this.isShowingLogin = false;
this.$toast.success(__('Logged in'));
this.restartCountdown();
this.updateCsrfToken();

if (response.data.two_factor) {
this.isShowingTwoFactorChallenge = true;
return;
}

this.loginComplete();
})
.catch((e) => {
if (e.response.status === 422) {
this.errors = e.response.data.errors;
this.$toast.error(e.response.data.message);
} else {
this.$toast.error(__('Something went wrong'));
}
});
},

submitTwoFactorChallenge() {
this.$axios
.post(cp_url('auth/two-factor-challenge'), {
code: this.twoFactorCode,
recovery_code: this.twoFactorRecoveryCode,
})
.then((response) => {
this.errors = {};
this.twoFactorCode = null;
this.twoFactorRecoveryCode = null;
this.twoFactorMode = 'code';
this.isShowingTwoFactorChallenge = false;
this.loginComplete();
})
.catch((e) => {
if (e.response.status === 422) {
Expand All @@ -194,6 +304,12 @@ export default {
this.remaining = this.lifetime;
});
},

loginComplete() {
this.$toast.success(__('Logged in'));
this.restartCountdown();
this.updateCsrfToken();
},
},
};
</script>
27 changes: 27 additions & 0 deletions resources/js/components/login/EnableTwoFactorAuthentication.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<script setup>
import TwoFactorSetup from '@statamic/components/two-factor/Setup.vue';
import { ref } from 'vue';

const props = defineProps({
routes: Object,
redirect: String,
});

const setupModalOpen = ref(false);

function setupComplete() {
window.location.href = props.redirect;
}
</script>

<template>
<button type="button" class="btn-primary" @click="setupModalOpen = true">{{ __('Set up') }}</button>

<TwoFactorSetup
v-if="setupModalOpen"
:enable-url="routes.enable"
:recovery-code-urls="routes.recovery_codes"
@close="setupModalOpen = false"
@setup-complete="setupComplete"
/>
</template>
8 changes: 7 additions & 1 deletion resources/js/components/login/Login.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<slot v-bind="$props"></slot>
<slot v-bind="{ showEmailLogin, hasError, busy, setBusy }"></slot>
</template>

<script>
Expand All @@ -24,5 +24,11 @@ export default {
this.$el.parentElement.parentElement.classList.add('animation-shake');
}
},

methods: {
setBusy(state = null) {
this.busy = state ?? true;
},
},
};
</script>
107 changes: 107 additions & 0 deletions resources/js/components/login/TwoFactorChallenge.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<script setup>
import { ref, onMounted } from 'vue';

const props = defineProps({
initialMode: {
type: String,
default: 'code',
},
errors: {
type: Object,
},
formAction: {
type: String,
},
csrfToken: {
type: String,
},
redirect: {
type: String,
},
});

const formEl = ref(null);
const busy = ref(false);
const mode = ref(props.initialMode);

onMounted(() => {
if (hasErrors()) {
formEl.value.parentElement.parentElement.classList.add('animation-shake');
}
});

function hasErrors() {
return Object.keys(props.errors).length > 0;
}

function hasError(field) {
return !!props.errors[field];
}

function errorFor(field) {
return props.errors[field][0];
}
</script>

<template>
<form ref="formEl" method="POST" :action="formAction" class="email-login select-none" @submit="busy = true">
<input type="hidden" name="_token" :value="csrfToken" />
<input v-if="redirect" type="hidden" name="redirect" :value="redirect" />

<h1 class="mb-2 text-lg text-gray-800 dark:text-dark-175">
{{ __('Two Factor Authentication') }}
</h1>
<p v-if="mode === 'code'" class="mb-4 text-sm text-gray dark:text-dark-175">
{{ __('statamic::messages.two_factor_challenge_code_instructions') }}
</p>
<p v-if="mode === 'recovery_code'" class="mb-4 text-sm text-gray dark:text-dark-175">
{{ __('statamic::messages.two_factor_recovery_code_instructions') }}
</p>

<div v-if="mode === 'code'" class="mb-8">
<label class="mb-2" for="input-code">{{ __('Code') }}</label>
<input
type="text"
class="input-text"
name="code"
pattern="[0-9]*"
maxlength="6"
inputmode="numeric"
autofocus
autocomplete="one-time-code"
id="input-code"
/>
<div class="mt-2 text-xs text-red-500" v-if="hasError('code')" v-text="errorFor('code')" />
</div>

<div v-if="mode === 'recovery_code'" class="mb-8">
<label class="mb-2" for="input-recovery-code">{{ __('Recovery Code') }}</label>
<input
type="text"
class="input-text"
name="recovery_code"
maxlength="21"
autofocus
autocomplete="off"
id="input-recovery-code"
/>
<div
class="mt-2 text-xs text-red-500"
v-if="hasError('recovery_code')"
v-text="errorFor('recovery_code')"
/>
</div>

<div class="flex items-center justify-between">
<button v-if="mode === 'code'" class="text-btn text-xs" type="button" @click="mode = 'recovery_code'">
{{ __('Use recovery code') }}
</button>

<button v-if="mode === 'recovery_code'" class="text-btn text-xs" type="button" @click="mode = 'code'">
{{ __('Use one-time code') }}
</button>

<button type="submit" class="btn-primary" :disabled="busy">{{ __('Continue') }}</button>
</div>
</form>
</template>
Loading
Loading