Merge branch 'feature/2fa' into 'develop'

adds 2FA/two_factor_authentication support

See merge request pleroma/pleroma-fe!556
This commit is contained in:
HJ 2019-06-12 20:16:55 +00:00
commit e53f11c30f
32 changed files with 1657 additions and 439 deletions

View File

@ -15,6 +15,7 @@
"lint-fix": "eslint --fix --ext .js,.vue src test/unit/specs test/e2e/specs" "lint-fix": "eslint --fix --ext .js,.vue src test/unit/specs test/e2e/specs"
}, },
"dependencies": { "dependencies": {
"@chenfengyuan/vue-qrcode": "^1.0.0",
"babel-plugin-add-module-exports": "^0.2.1", "babel-plugin-add-module-exports": "^0.2.1",
"babel-plugin-lodash": "^3.2.11", "babel-plugin-lodash": "^3.2.11",
"chromatism": "^3.0.0", "chromatism": "^3.0.0",

View File

@ -92,6 +92,7 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
? 0 ? 0
: config.logoMargin : config.logoMargin
}) })
store.commit('authFlow/setInitialStrategy', config.loginMethod)
copyInstanceOption('redirectRootNoLogin') copyInstanceOption('redirectRootNoLogin')
copyInstanceOption('redirectRootLogin') copyInstanceOption('redirectRootLogin')
@ -100,7 +101,6 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
copyInstanceOption('formattingOptionsEnabled') copyInstanceOption('formattingOptionsEnabled')
copyInstanceOption('hideMutedPosts') copyInstanceOption('hideMutedPosts')
copyInstanceOption('collapseMessageWithSubject') copyInstanceOption('collapseMessageWithSubject')
copyInstanceOption('loginMethod')
copyInstanceOption('scopeCopy') copyInstanceOption('scopeCopy')
copyInstanceOption('subjectLineBehavior') copyInstanceOption('subjectLineBehavior')
copyInstanceOption('postContentType') copyInstanceOption('postContentType')

View File

@ -13,7 +13,7 @@ import FollowRequests from 'components/follow_requests/follow_requests.vue'
import OAuthCallback from 'components/oauth_callback/oauth_callback.vue' import OAuthCallback from 'components/oauth_callback/oauth_callback.vue'
import UserSearch from 'components/user_search/user_search.vue' import UserSearch from 'components/user_search/user_search.vue'
import Notifications from 'components/notifications/notifications.vue' import Notifications from 'components/notifications/notifications.vue'
import LoginForm from 'components/login_form/login_form.vue' import AuthForm from 'components/auth_form/auth_form.js'
import ChatPanel from 'components/chat_panel/chat_panel.vue' import ChatPanel from 'components/chat_panel/chat_panel.vue'
import WhoToFollow from 'components/who_to_follow/who_to_follow.vue' import WhoToFollow from 'components/who_to_follow/who_to_follow.vue'
import About from 'components/about/about.vue' import About from 'components/about/about.vue'
@ -42,7 +42,7 @@ export default (store) => {
{ name: 'friend-requests', path: '/friend-requests', component: FollowRequests }, { name: 'friend-requests', path: '/friend-requests', component: FollowRequests },
{ name: 'user-settings', path: '/user-settings', component: UserSettings }, { name: 'user-settings', path: '/user-settings', component: UserSettings },
{ name: 'notifications', path: '/:username/notifications', component: Notifications }, { name: 'notifications', path: '/:username/notifications', component: Notifications },
{ name: 'login', path: '/login', component: LoginForm }, { name: 'login', path: '/login', component: AuthForm },
{ name: 'chat', path: '/chat', component: ChatPanel, props: () => ({ floating: false }) }, { name: 'chat', path: '/chat', component: ChatPanel, props: () => ({ floating: false }) },
{ name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) }, { name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) },
{ name: 'user-search', path: '/user-search', component: UserSearch, props: (route) => ({ query: route.query.query }) }, { name: 'user-search', path: '/user-search', component: UserSearch, props: (route) => ({ query: route.query.query }) },

View File

@ -0,0 +1,26 @@
import LoginForm from '../login_form/login_form.vue'
import MFARecoveryForm from '../mfa_form/recovery_form.vue'
import MFATOTPForm from '../mfa_form/totp_form.vue'
import { mapGetters } from 'vuex'
const AuthForm = {
name: 'AuthForm',
render (createElement) {
return createElement('component', { is: this.authForm })
},
computed: {
authForm () {
if (this.requiredTOTP) { return 'MFATOTPForm' }
if (this.requiredRecovery) { return 'MFARecoveryForm' }
return 'LoginForm'
},
...mapGetters('authFlow', ['requiredTOTP', 'requiredRecovery'])
},
components: {
MFARecoveryForm,
MFATOTPForm,
LoginForm
}
}
export default AuthForm

View File

@ -1,28 +1,44 @@
import { mapState, mapGetters, mapActions, mapMutations } from 'vuex'
import oauthApi from '../../services/new_api/oauth.js' import oauthApi from '../../services/new_api/oauth.js'
const LoginForm = { const LoginForm = {
data: () => ({ data: () => ({
user: {}, user: {},
authError: false error: false
}), }),
computed: { computed: {
loginMethod () { return this.$store.state.instance.loginMethod }, isPasswordAuth () { return this.requiredPassword },
loggingIn () { return this.$store.state.users.loggingIn }, isTokenAuth () { return this.requiredToken },
registrationOpen () { return this.$store.state.instance.registrationOpen } ...mapState({
registrationOpen: state => state.instance.registrationOpen,
instance: state => state.instance,
loggingIn: state => state.users.loggingIn,
oauth: state => state.oauth
}),
...mapGetters(
'authFlow', ['requiredPassword', 'requiredToken', 'requiredMFA']
)
}, },
methods: { methods: {
oAuthLogin () { ...mapMutations('authFlow', ['requireMFA']),
...mapActions({ login: 'authFlow/login' }),
submit () {
this.isTokenMethod ? this.submitToken() : this.submitPassword()
},
submitToken () {
oauthApi.login({ oauthApi.login({
oauth: this.$store.state.oauth, oauth: this.oauth,
instance: this.$store.state.instance.server, instance: this.instance.server,
commit: this.$store.commit commit: this.$store.commit
}) })
}, },
submit () { submitPassword () {
const data = { const data = {
oauth: this.$store.state.oauth, oauth: this.oauth,
instance: this.$store.state.instance.server instance: this.instance.server
} }
this.clearError() this.error = false
oauthApi.getOrCreateApp(data).then((app) => { oauthApi.getOrCreateApp(data).then((app) => {
oauthApi.getTokenWithCredentials( oauthApi.getTokenWithCredentials(
{ {
@ -31,24 +47,27 @@ const LoginForm = {
username: this.user.username, username: this.user.username,
password: this.user.password password: this.user.password
} }
).then(async (result) => { ).then((result) => {
if (result.error) { if (result.error) {
this.authError = result.error if (result.error === 'mfa_required') {
this.user.password = '' this.requireMFA({app: app, settings: result})
} else {
this.error = result.error
this.focusOnPasswordInput()
}
return return
} }
this.$store.commit('setToken', result.access_token) this.login(result).then(() => {
try {
await this.$store.dispatch('loginUser', result.access_token)
this.$router.push({name: 'friends'}) this.$router.push({name: 'friends'})
} catch (e) { })
console.log(e)
}
}) })
}) })
}, },
clearError () { clearError () { this.error = false },
this.authError = false focusOnPasswordInput () {
let passwordInput = this.$refs.passwordInput
passwordInput.focus()
passwordInput.setSelectionRange(0, passwordInput.value.length)
} }
} }
} }

View File

@ -1,47 +1,53 @@
<template> <template>
<div class="login panel panel-default"> <div class="login panel panel-default">
<!-- Default panel contents --> <!-- Default panel contents -->
<div class="panel-heading">
{{$t('login.login')}} <div class="panel-heading">{{$t('login.login')}}</div>
</div>
<div class="panel-body"> <div class="panel-body">
<form v-if="loginMethod == 'password'" v-on:submit.prevent='submit(user)' class='login-form'> <form class='login-form' @submit.prevent='submit'>
<template v-if="isPasswordAuth">
<div class='form-group'> <div class='form-group'>
<label for='username'>{{$t('login.username')}}</label> <label for='username'>{{$t('login.username')}}</label>
<input :disabled="loggingIn" v-model='user.username' class='form-control' id='username' v-bind:placeholder="$t('login.placeholder')"> <input :disabled="loggingIn" v-model='user.username'
class='form-control' id='username'
:placeholder="$t('login.placeholder')">
</div> </div>
<div class='form-group'> <div class='form-group'>
<label for='password'>{{$t('login.password')}}</label> <label for='password'>{{$t('login.password')}}</label>
<input :disabled="loggingIn" v-model='user.password' class='form-control' id='password' type='password'> <input :disabled="loggingIn" v-model='user.password'
ref='passwordInput' class='form-control' id='password' type='password'>
</div> </div>
<div class='form-group'> </template>
<div class='login-bottom'>
<div><router-link :to="{name: 'registration'}" v-if='registrationOpen' class='register'>{{$t('login.register')}}</router-link></div>
<button :disabled="loggingIn" type='submit' class='btn btn-default'>{{$t('login.login')}}</button>
</div>
</div>
</form>
<form v-if="loginMethod == 'token'" v-on:submit.prevent='oAuthLogin' class="login-form"> <div class="form-group" v-if="isTokenAuth">
<div class="form-group"> <p>{{$t('login.description')}}</p>
<p>{{$t('login.description')}}</p> </div>
</div>
<div class='form-group'> <div class='form-group'>
<div class='login-bottom'> <div class='login-bottom'>
<div><router-link :to="{name: 'registration'}" v-if='registrationOpen' class='register'>{{$t('login.register')}}</router-link></div> <div>
<button :disabled="loggingIn" type='submit' class='btn btn-default'>{{$t('login.login')}}</button> <router-link :to="{name: 'registration'}"
v-if='registrationOpen'
class='register'>
{{$t('login.register')}}
</router-link>
</div> </div>
</div> <button :disabled="loggingIn" type='submit' class='btn btn-default'>
</form> {{$t('login.login')}}
</button>
<div v-if="authError" class='form-group'>
<div class='alert error'>
{{authError}}
<i class="button-icon icon-cancel" @click="clearError"></i>
</div> </div>
</div> </div>
</form>
</div>
<div v-if="error" class='form-group'>
<div class='alert error'>
{{error}}
<i class="button-icon icon-cancel" @click="clearError"></i>
</div> </div>
</div> </div>
</div>
</template> </template>
<script src="./login_form.js" ></script> <script src="./login_form.js" ></script>

View File

@ -0,0 +1,41 @@
import mfaApi from '../../services/new_api/mfa.js'
import { mapState, mapGetters, mapActions, mapMutations } from 'vuex'
export default {
data: () => ({
code: null,
error: false
}),
computed: {
...mapGetters({
authApp: 'authFlow/app',
authSettings: 'authFlow/settings'
}),
...mapState({ instance: 'instance' })
},
methods: {
...mapMutations('authFlow', ['requireTOTP', 'abortMFA']),
...mapActions({ login: 'authFlow/login' }),
clearError () { this.error = false },
submit () {
const data = {
app: this.authApp,
instance: this.instance.server,
mfaToken: this.authSettings.mfa_token,
code: this.code
}
mfaApi.verifyRecoveryCode(data).then((result) => {
if (result.error) {
this.error = result.error
this.code = null
return
}
this.login(result).then(() => {
this.$router.push({name: 'friends'})
})
})
}
}
}

View File

@ -0,0 +1,42 @@
<template>
<div class="login panel panel-default">
<!-- Default panel contents -->
<div class="panel-heading">{{$t('login.heading.recovery')}}</div>
<div class="panel-body">
<form class='login-form' @submit.prevent='submit'>
<div class='form-group'>
<label for='code'>{{$t('login.recovery_code')}}</label>
<input v-model='code' class='form-control' id='code'>
</div>
<div class='form-group'>
<div class='login-bottom'>
<div>
<a href="#" @click.prevent="requireTOTP">
{{$t('login.enter_two_factor_code')}}
</a>
<br />
<a href="#" @click.prevent="abortMFA">
{{$t('general.cancel')}}
</a>
</div>
<button type='submit' class='btn btn-default'>
{{$t('general.verify')}}
</button>
</div>
</div>
</form>
</div>
<div v-if="error" class='form-group'>
<div class='alert error'>
{{error}}
<i class="button-icon icon-cancel" @click="clearError"></i>
</div>
</div>
</div>
</template>
<script src="./recovery_form.js" ></script>

View File

@ -0,0 +1,40 @@
import mfaApi from '../../services/new_api/mfa.js'
import { mapState, mapGetters, mapActions, mapMutations } from 'vuex'
export default {
data: () => ({
code: null,
error: false
}),
computed: {
...mapGetters({
authApp: 'authFlow/app',
authSettings: 'authFlow/settings'
}),
...mapState({ instance: 'instance' })
},
methods: {
...mapMutations('authFlow', ['requireRecovery', 'abortMFA']),
...mapActions({ login: 'authFlow/login' }),
clearError () { this.error = false },
submit () {
const data = {
app: this.authApp,
instance: this.instance.server,
mfaToken: this.authSettings.mfa_token,
code: this.code
}
mfaApi.verifyOTPCode(data).then((result) => {
if (result.error) {
this.error = result.error
this.code = null
return
}
this.login(result).then(() => {
this.$router.push({name: 'friends'})
})
})
}
}
}

View File

@ -0,0 +1,45 @@
<template>
<div class="login panel panel-default">
<!-- Default panel contents -->
<div class="panel-heading">
{{$t('login.heading.totp')}}
</div>
<div class="panel-body">
<form class='login-form' @submit.prevent='submit'>
<div class='form-group'>
<label for='code'>
{{$t('login.authentication_code')}}
</label>
<input v-model='code' class='form-control' id='code'>
</div>
<div class='form-group'>
<div class='login-bottom'>
<div>
<a href="#" @click.prevent="requireRecovery">
{{$t('login.enter_recovery_code')}}
</a>
<br />
<a href="#" @click.prevent="abortMFA">
{{$t('general.cancel')}}
</a>
</div>
<button type='submit' class='btn btn-default'>
{{$t('general.verify')}}
</button>
</div>
</div>
</form>
</div>
<div v-if="error" class='form-group'>
<div class='alert error'>
{{error}}
<i class="button-icon icon-cancel" @click="clearError"></i>
</div>
</div>
</div>
</template>
<script src="./totp_form.js"></script>

View File

@ -1,13 +1,15 @@
import LoginForm from '../login_form/login_form.vue' import AuthForm from '../auth_form/auth_form.js'
import PostStatusForm from '../post_status_form/post_status_form.vue' import PostStatusForm from '../post_status_form/post_status_form.vue'
import UserCard from '../user_card/user_card.vue' import UserCard from '../user_card/user_card.vue'
import { mapState } from 'vuex'
const UserPanel = { const UserPanel = {
computed: { computed: {
user () { return this.$store.state.users.currentUser } signedIn () { return this.user },
...mapState({ user: state => state.users.currentUser })
}, },
components: { components: {
LoginForm, AuthForm,
PostStatusForm, PostStatusForm,
UserCard UserCard
} }

View File

@ -1,13 +1,20 @@
<template> <template>
<div class="user-panel"> <div class="user-panel">
<div v-if='user' class="panel panel-default" style="overflow: visible;">
<div v-if="signedIn" key="user-panel" class="panel panel-default signed-in">
<UserCard :user="user" :hideBio="true" rounded="top"/> <UserCard :user="user" :hideBio="true" rounded="top"/>
<div class="panel-footer"> <div class="panel-footer">
<post-status-form v-if='user'></post-status-form> <post-status-form v-if='user'></post-status-form>
</div> </div>
</div> </div>
<login-form v-if='!user'></login-form> <auth-form v-else key="user-panel"/>
</div> </div>
</template> </template>
<script src="./user_panel.js"></script> <script src="./user_panel.js"></script>
<style lang="scss">
.user-panel .signed-in {
overflow: visible;
}
</style>

View File

@ -0,0 +1,9 @@
const Confirm = {
props: ['disabled'],
data: () => ({}),
methods: {
confirm () { this.$emit('confirm') },
cancel () { this.$emit('cancel') }
}
}
export default Confirm

View File

@ -0,0 +1,14 @@
<template>
<div>
<slot></slot>
<button class="btn btn-default" @click="confirm" :disabled="disabled">
{{$t('general.confirm')}}
</button>
<button class="btn btn-default" @click="cancel" :disabled="disabled">
{{$t('general.cancel')}}
</button>
</div>
</template>
<script src="./confirm.js">
</script>

View File

@ -0,0 +1,152 @@
import RecoveryCodes from './mfa_backup_codes.vue'
import TOTP from './mfa_totp.vue'
import Confirm from './confirm.vue'
import VueQrcode from '@chenfengyuan/vue-qrcode'
import { mapState } from 'vuex'
const Mfa = {
data: () => ({
settings: { // current settings of MFA
enabled: false,
totp: false
},
setupState: { // setup mfa
state: '', // state of setup. '' -> 'getBackupCodes' -> 'setupOTP' -> 'complete'
setupOTPState: '' // state of setup otp. '' -> 'prepare' -> 'confirm' -> 'complete'
},
backupCodes: {
getNewCodes: false,
inProgress: false, // progress of fetch codes
codes: []
},
otpSettings: { // pre-setup setting of OTP. secret key, qrcode url.
provisioning_uri: '',
key: ''
},
currentPassword: null,
otpConfirmToken: null,
error: null,
readyInit: false
}),
components: {
'recovery-codes': RecoveryCodes,
'totp-item': TOTP,
'qrcode': VueQrcode,
'confirm': Confirm
},
computed: {
canSetupOTP () {
return (
(this.setupInProgress && this.backupCodesPrepared) ||
this.settings.enabled
) && !this.settings.totp && !this.setupOTPInProgress
},
setupInProgress () {
return this.setupState.state !== '' && this.setupState.state !== 'complete'
},
setupOTPInProgress () {
return this.setupState.state === 'setupOTP' && !this.completedOTP
},
prepareOTP () {
return this.setupState.setupOTPState === 'prepare'
},
confirmOTP () {
return this.setupState.setupOTPState === 'confirm'
},
completedOTP () {
return this.setupState.setupOTPState === 'completed'
},
backupCodesPrepared () {
return !this.backupCodes.inProgress && this.backupCodes.codes.length > 0
},
confirmNewBackupCodes () {
return this.backupCodes.getNewCodes
},
...mapState({
backendInteractor: (state) => state.api.backendInteractor
})
},
methods: {
activateOTP () {
if (!this.settings.enabled) {
this.setupState.state = 'getBackupcodes'
this.fetchBackupCodes()
}
},
fetchBackupCodes () {
this.backupCodes.inProgress = true
this.backupCodes.codes = []
return this.backendInteractor.generateMfaBackupCodes()
.then((res) => {
this.backupCodes.codes = res.codes
this.backupCodes.inProgress = false
})
},
getBackupCodes () { // get a new backup codes
this.backupCodes.getNewCodes = true
},
confirmBackupCodes () { // confirm getting new backup codes
this.fetchBackupCodes().then((res) => {
this.backupCodes.getNewCodes = false
})
},
cancelBackupCodes () { // cancel confirm form of new backup codes
this.backupCodes.getNewCodes = false
},
// Setup OTP
setupOTP () { // prepare setup OTP
this.setupState.state = 'setupOTP'
this.setupState.setupOTPState = 'prepare'
this.backendInteractor.mfaSetupOTP()
.then((res) => {
this.otpSettings = res
this.setupState.setupOTPState = 'confirm'
})
},
doConfirmOTP () { // handler confirm enable OTP
this.error = null
this.backendInteractor.mfaConfirmOTP({
token: this.otpConfirmToken,
password: this.currentPassword
})
.then((res) => {
if (res.error) {
this.error = res.error
return
}
this.completeSetup()
})
},
completeSetup () {
this.setupState.setupOTPState = 'complete'
this.setupState.state = 'complete'
this.currentPassword = null
this.error = null
this.fetchSettings()
},
cancelSetup () { // cancel setup
this.setupState.setupOTPState = ''
this.setupState.state = ''
this.currentPassword = null
this.error = null
},
// end Setup OTP
// fetch settings from server
async fetchSettings () {
let result = await this.backendInteractor.fetchSettingsMFA()
this.settings = result.settings
return result
}
},
mounted () {
this.fetchSettings().then(() => {
this.readyInit = true
})
}
}
export default Mfa

View File

@ -0,0 +1,121 @@
<template>
<div class="setting-item mfa-settings" v-if="readyInit">
<div class="mfa-heading">
<h2>{{$t('settings.mfa.title')}}</h2>
</div>
<div>
<div class="setting-item" v-if="!setupInProgress">
<!-- Enabled methods -->
<h3>{{$t('settings.mfa.authentication_methods')}}</h3>
<totp-item :settings="settings" @deactivate="fetchSettings" @activate="activateOTP"/>
<br />
<div v-if="settings.enabled"> <!-- backup codes block-->
<recovery-codes :backup-codes="backupCodes" v-if="!confirmNewBackupCodes" />
<button class="btn btn-default" @click="getBackupCodes" v-if="!confirmNewBackupCodes">
{{$t('settings.mfa.generate_new_recovery_codes')}}
</button>
<div v-if="confirmNewBackupCodes">
<confirm @confirm="confirmBackupCodes" @cancel="cancelBackupCodes"
:disabled="backupCodes.inProgress">
<p class="warning">{{$t('settings.mfa.warning_of_generate_new_codes')}}</p>
</confirm>
</div>
</div>
</div>
<div v-if="setupInProgress"> <!-- setup block-->
<h3>{{$t('settings.mfa.setup_otp')}}</h3>
<recovery-codes :backup-codes="backupCodes" v-if="!setupOTPInProgress"/>
<button class="btn btn-default" @click="cancelSetup" v-if="canSetupOTP">
{{$t('general.cancel')}}
</button>
<button class="btn btn-default" v-if="canSetupOTP" @click="setupOTP">
{{$t('settings.mfa.setup_otp')}}
</button>
<template v-if="setupOTPInProgress">
<i v-if="prepareOTP">{{$t('settings.mfa.wait_pre_setup_otp')}}</i>
<div v-if="confirmOTP">
<div class="setup-otp">
<div class="qr-code">
<h4>{{$t('settings.mfa.scan.title')}}</h4>
<p>{{$t('settings.mfa.scan.desc')}}</p>
<qrcode :value="otpSettings.provisioning_uri" :options="{ width: 200 }"></qrcode>
<p>
{{$t('settings.mfa.scan.secret_code')}}:
{{otpSettings.key}}
</p>
</div>
<div class="verify">
<h4>{{$t('general.verify')}}</h4>
<p>{{$t('settings.mfa.verify.desc')}}</p>
<input type="text" v-model="otpConfirmToken">
<p>{{$t('settings.enter_current_password_to_confirm')}}:</p>
<input type="password" v-model="currentPassword">
<div class="confirm-otp-actions">
<button class="btn btn-default" @click="doConfirmOTP">
{{$t('settings.mfa.confirm_and_enable')}}
</button>
<button class="btn btn-default" @click="cancelSetup">
{{$t('general.cancel')}}
</button>
</div>
<div class="alert error" v-if="error">{{error}}</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
<script src="./mfa.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.warning {
color: $fallback--cOrange;
color: var(--cOrange, $fallback--cOrange);
}
.mfa-settings {
.mfa-heading, .method-item {
overflow: hidden;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: baseline;
}
.setup-otp {
display: flex;
justify-content: center;
flex-wrap: wrap;
.qr-code {
flex: 1;
padding-right: 10px;
}
.verify { flex: 1; }
.error { margin: 4px 0 0 0; }
.confirm-otp-actions {
button {
width: 15em;
margin-top: 5px;
}
}
}
}
</style>

View File

@ -0,0 +1,17 @@
export default {
props: {
backupCodes: {
type: Object,
default: () => ({
inProgress: false,
codes: []
})
}
},
data: () => ({}),
computed: {
inProgress () { return this.backupCodes.inProgress },
ready () { return this.backupCodes.codes.length > 0 },
displayTitle () { return this.inProgress || this.ready }
}
}

View File

@ -0,0 +1,22 @@
<template>
<div>
<h4 v-if="displayTitle">{{$t('settings.mfa.recovery_codes')}}</h4>
<i v-if="inProgress">{{$t('settings.mfa.waiting_a_recovery_codes')}}</i>
<template v-if="ready">
<p class="alert warning">{{$t('settings.mfa.recovery_codes_warning')}}</p>
<ul class="backup-codes"><li v-for="code in backupCodes.codes">{{code}}</li></ul>
</template>
</div>
</template>
<script src="./mfa_backup_codes.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.warning {
color: $fallback--cOrange;
color: var(--cOrange, $fallback--cOrange);
}
.backup-codes {
font-family: var(--postCodeFont, monospace);
}
</style>

View File

@ -0,0 +1,49 @@
import Confirm from './confirm.vue'
import { mapState } from 'vuex'
export default {
props: ['settings'],
data: () => ({
error: false,
currentPassword: '',
deactivate: false,
inProgress: false // progress peform request to disable otp method
}),
components: {
'confirm': Confirm
},
computed: {
isActivated () {
return this.settings.totp
},
...mapState({
backendInteractor: (state) => state.api.backendInteractor
})
},
methods: {
doActivate () {
this.$emit('activate')
},
cancelDeactivate () { this.deactivate = false },
doDeactivate () {
this.error = null
this.deactivate = true
},
confirmDeactivate () { // confirm deactivate TOTP method
this.error = null
this.inProgress = true
this.backendInteractor.mfaDisableOTP({
password: this.currentPassword
})
.then((res) => {
this.inProgress = false
if (res.error) {
this.error = res.error
return
}
this.deactivate = false
this.$emit('deactivate')
})
}
}
}

View File

@ -0,0 +1,23 @@
<template>
<div>
<div class="method-item">
<strong>{{$t('settings.mfa.otp')}}</strong>
<button class="btn btn-default" v-if="!isActivated" @click="doActivate">
{{$t('general.enable')}}
</button>
<button class="btn btn-default" :disabled="deactivate" @click="doDeactivate"
v-if="isActivated">
{{$t('general.disable')}}
</button>
</div>
<confirm @confirm="confirmDeactivate" @cancel="cancelDeactivate"
:disabled="inProgress" v-if="deactivate">
{{$t('settings.enter_current_password_to_confirm')}}:
<input type="password" v-model="currentPassword">
</confirm>
<div class="alert error" v-if="error">{{error}}</div>
</div>
</template>
<script src="./mfa_totp.js"></script>

View File

@ -17,6 +17,7 @@ import Importer from '../importer/importer.vue'
import Exporter from '../exporter/exporter.vue' import Exporter from '../exporter/exporter.vue'
import withSubscription from '../../hocs/with_subscription/with_subscription' import withSubscription from '../../hocs/with_subscription/with_subscription'
import userSearchApi from '../../services/new_api/user_search.js' import userSearchApi from '../../services/new_api/user_search.js'
import Mfa from './mfa.vue'
const BlockList = withSubscription({ const BlockList = withSubscription({
fetch: (props, $store) => $store.dispatch('fetchBlocks'), fetch: (props, $store) => $store.dispatch('fetchBlocks'),
@ -75,7 +76,8 @@ const UserSettings = {
MuteCard, MuteCard,
ProgressButton, ProgressButton,
Importer, Importer,
Exporter Exporter,
Mfa
}, },
computed: { computed: {
user () { user () {

View File

@ -152,7 +152,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<mfa />
<div class="setting-item"> <div class="setting-item">
<h2>{{$t('settings.delete_account')}}</h2> <h2>{{$t('settings.delete_account')}}</h2>
<p v-if="!deletingAccount">{{$t('settings.delete_account_description')}}</p> <p v-if="!deletingAccount">{{$t('settings.delete_account_description')}}</p>

View File

@ -27,7 +27,11 @@
"optional": "optional", "optional": "optional",
"show_more": "Show more", "show_more": "Show more",
"show_less": "Show less", "show_less": "Show less",
"cancel": "Cancel" "cancel": "Cancel",
"disable": "Disable",
"enable": "Enable",
"confirm": "Confirm",
"verify": "Verify"
}, },
"image_cropper": { "image_cropper": {
"crop_picture": "Crop picture", "crop_picture": "Crop picture",
@ -48,7 +52,15 @@
"placeholder": "e.g. lain", "placeholder": "e.g. lain",
"register": "Register", "register": "Register",
"username": "Username", "username": "Username",
"hint": "Log in to join the discussion" "hint": "Log in to join the discussion",
"authentication_code": "Authentication code",
"enter_recovery_code": "Enter a recovery code",
"enter_two_factor_code": "Enter a two-factor code",
"recovery_code": "Recovery code",
"heading" : {
"totp" : "Two-factor authentication",
"recovery" : "Two-factor recovery"
}
}, },
"media_modal": { "media_modal": {
"previous": "Previous", "previous": "Previous",
@ -138,6 +150,29 @@
}, },
"settings": { "settings": {
"app_name": "App name", "app_name": "App name",
"security": "Security",
"enter_current_password_to_confirm": "Enter your current password to confirm your identity",
"mfa": {
"otp" : "OTP",
"setup_otp" : "Setup OTP",
"wait_pre_setup_otp" : "presetting OTP",
"confirm_and_enable" : "Confirm & enable OTP",
"title": "Two-factor Authentication",
"generate_new_recovery_codes" : "Generate new recovery codes",
"warning_of_generate_new_codes" : "When you generate new recovery codes, your old codes wont work anymore.",
"recovery_codes" : "Recovery codes.",
"waiting_a_recovery_codes": "Receiving backup codes...",
"recovery_codes_warning" : "Write the codes down or save them somewhere secure - otherwise you won't see them again. If you lose access to your 2FA app and recovery codes you'll be locked out of your account.",
"authentication_methods" : "Authentication methods",
"scan": {
"title": "Scan",
"desc": "Using your two-factor app, scan this QR code or enter text key:",
"secret_code": "Key"
},
"verify": {
"desc": "To enable two-factor authentication, enter the code from your two-factor app:"
}
},
"attachmentRadius": "Attachments", "attachmentRadius": "Attachments",
"attachments": "Attachments", "attachments": "Attachments",
"autoload": "Enable automatic loading when scrolled to the bottom", "autoload": "Enable automatic loading when scrolled to the bottom",

View File

@ -9,7 +9,11 @@
"general": { "general": {
"apply": "Применить", "apply": "Применить",
"submit": "Отправить", "submit": "Отправить",
"cancel": "Отмена" "cancel": "Отмена",
"disable": "Оключить",
"enable": "Включить",
"confirm": "Подтвердить",
"verify": "Проверить"
}, },
"login": { "login": {
"login": "Войти", "login": "Войти",
@ -17,7 +21,15 @@
"password": "Пароль", "password": "Пароль",
"placeholder": "e.c. lain", "placeholder": "e.c. lain",
"register": "Зарегистрироваться", "register": "Зарегистрироваться",
"username": "Имя пользователя" "username": "Имя пользователя",
"authentication_code": "Код аутентификации",
"enter_recovery_code": "Ввести код восстановления",
"enter_two_factor_code": "Ввести код аутентификации",
"recovery_code": "Код восстановления",
"heading" : {
"TotpForm" : "Двухфакторная аутентификация",
"RecoveryForm" : "Two-factor recovery"
}
}, },
"nav": { "nav": {
"back": "Назад", "back": "Назад",
@ -79,6 +91,28 @@
} }
}, },
"settings": { "settings": {
"enter_current_password_to_confirm": "Введите свой текущий пароль",
"mfa": {
"otp" : "OTP",
"setup_otp" : "Настройка OTP",
"wait_pre_setup_otp" : "предварительная настройка OTP",
"confirm_and_enable" : "Подтвердить и включить OTP",
"title": "Двухфакторная аутентификация",
"generate_new_recovery_codes" : "Получить новые коды востановления",
"warning_of_generate_new_codes" : "После получения новых кодов восстановления, старые больше не будут работать.",
"recovery_codes" : "Коды восстановления.",
"waiting_a_recovery_codes": "Получение кодов восстановления ...",
"recovery_codes_warning" : "Запишите эти коды и держите в безопасном месте - иначе вы их больше не увидите. Если вы потеряете доступ к OTP приложению - без резервных кодов вы больше не сможете залогиниться.",
"authentication_methods" : "Методы аутентификации",
"scan": {
"title": "Сканирование",
"desc": "Используйте приложение для двухэтапной аутентификации для сканирования этого QR-код или введите текстовый ключ:",
"secret_code": "Ключ"
},
"verify": {
"desc": "Чтобы включить двухэтапную аутентификации, введите код из вашего приложение для двухэтапной аутентификации:"
}
},
"attachmentRadius": "Прикреплённые файлы", "attachmentRadius": "Прикреплённые файлы",
"attachments": "Вложения", "attachments": "Вложения",
"autoload": "Включить автоматическую загрузку при прокрутке вниз", "autoload": "Включить автоматическую загрузку при прокрутке вниз",

View File

@ -10,6 +10,7 @@ import apiModule from './modules/api.js'
import configModule from './modules/config.js' import configModule from './modules/config.js'
import chatModule from './modules/chat.js' import chatModule from './modules/chat.js'
import oauthModule from './modules/oauth.js' import oauthModule from './modules/oauth.js'
import authFlowModule from './modules/auth_flow.js'
import mediaViewerModule from './modules/media_viewer.js' import mediaViewerModule from './modules/media_viewer.js'
import oauthTokensModule from './modules/oauth_tokens.js' import oauthTokensModule from './modules/oauth_tokens.js'
import reportsModule from './modules/reports.js' import reportsModule from './modules/reports.js'
@ -77,6 +78,7 @@ const persistedStateOptions = {
config: configModule, config: configModule,
chat: chatModule, chat: chatModule,
oauth: oauthModule, oauth: oauthModule,
authFlow: authFlowModule,
mediaViewer: mediaViewerModule, mediaViewer: mediaViewerModule,
oauthTokens: oauthTokensModule, oauthTokens: oauthTokensModule,
reports: reportsModule reports: reportsModule

89
src/modules/auth_flow.js Normal file
View File

@ -0,0 +1,89 @@
const PASSWORD_STRATEGY = 'password'
const TOKEN_STRATEGY = 'token'
// MFA strategies
const TOTP_STRATEGY = 'totp'
const RECOVERY_STRATEGY = 'recovery'
// initial state
const state = {
app: null,
settings: {},
strategy: PASSWORD_STRATEGY,
initStrategy: PASSWORD_STRATEGY // default strategy from config
}
const resetState = (state) => {
state.strategy = state.initStrategy
state.settings = {}
state.app = null
}
// getters
const getters = {
app: (state, getters) => {
return state.app
},
settings: (state, getters) => {
return state.settings
},
requiredPassword: (state, getters, rootState) => {
return state.strategy === PASSWORD_STRATEGY
},
requiredToken: (state, getters, rootState) => {
return state.strategy === TOKEN_STRATEGY
},
requiredTOTP: (state, getters, rootState) => {
return state.strategy === TOTP_STRATEGY
},
requiredRecovery: (state, getters, rootState) => {
return state.strategy === RECOVERY_STRATEGY
}
}
// mutations
const mutations = {
setInitialStrategy (state, strategy) {
if (strategy) {
state.initStrategy = strategy
state.strategy = strategy
}
},
requirePassword (state) {
state.strategy = PASSWORD_STRATEGY
},
requireToken (state) {
state.strategy = TOKEN_STRATEGY
},
requireMFA (state, {app, settings}) {
state.settings = settings
state.app = app
state.strategy = TOTP_STRATEGY // default strategy of MFA
},
requireRecovery (state) {
state.strategy = RECOVERY_STRATEGY
},
requireTOTP (state) {
state.strategy = TOTP_STRATEGY
},
abortMFA (state) {
resetState(state)
}
}
// actions
const actions = {
async login ({state, dispatch, commit}, {access_token}) {
commit('setToken', access_token, { root: true })
await dispatch('loginUser', access_token, { root: true })
resetState(state)
}
}
export default {
namespaced: true,
state,
getters,
mutations,
actions
}

View File

@ -27,7 +27,6 @@ const defaultState = {
scopeCopy: true, scopeCopy: true,
subjectLineBehavior: 'email', subjectLineBehavior: 'email',
postContentType: 'text/plain', postContentType: 'text/plain',
loginMethod: 'password',
nsfwCensorImage: undefined, nsfwCensorImage: undefined,
vapidPublicKey: undefined, vapidPublicKey: undefined,
noAttachmentLinks: false, noAttachmentLinks: false,

View File

@ -17,6 +17,13 @@ const ADMIN_USERS_URL = '/api/pleroma/admin/users'
const SUGGESTIONS_URL = '/api/v1/suggestions' const SUGGESTIONS_URL = '/api/v1/suggestions'
const NOTIFICATION_SETTINGS_URL = '/api/pleroma/notification_settings' const NOTIFICATION_SETTINGS_URL = '/api/pleroma/notification_settings'
const MFA_SETTINGS_URL = '/api/pleroma/profile/mfa'
const MFA_BACKUP_CODES_URL = '/api/pleroma/profile/mfa/backup_codes'
const MFA_SETUP_OTP_URL = '/api/pleroma/profile/mfa/setup/totp'
const MFA_CONFIRM_OTP_URL = '/api/pleroma/profile/mfa/confirm/totp'
const MFA_DISABLE_OTP_URL = '/api/pleroma/profile/mfa/totp'
const MASTODON_LOGIN_URL = '/api/v1/accounts/verify_credentials' const MASTODON_LOGIN_URL = '/api/v1/accounts/verify_credentials'
const MASTODON_USER_FAVORITES_TIMELINE_URL = '/api/v1/favourites' const MASTODON_USER_FAVORITES_TIMELINE_URL = '/api/v1/favourites'
const MASTODON_USER_NOTIFICATIONS_URL = '/api/v1/notifications' const MASTODON_USER_NOTIFICATIONS_URL = '/api/v1/notifications'
@ -649,6 +656,51 @@ const changePassword = ({credentials, password, newPassword, newPasswordConfirma
.then((response) => response.json()) .then((response) => response.json())
} }
const settingsMFA = ({credentials}) => {
return fetch(MFA_SETTINGS_URL, {
headers: authHeaders(credentials),
method: 'GET'
}).then((data) => data.json())
}
const mfaDisableOTP = ({credentials, password}) => {
const form = new FormData()
form.append('password', password)
return fetch(MFA_DISABLE_OTP_URL, {
body: form,
method: 'DELETE',
headers: authHeaders(credentials)
})
.then((response) => response.json())
}
const mfaConfirmOTP = ({credentials, password, token}) => {
const form = new FormData()
form.append('password', password)
form.append('code', token)
return fetch(MFA_CONFIRM_OTP_URL, {
body: form,
headers: authHeaders(credentials),
method: 'POST'
}).then((data) => data.json())
}
const mfaSetupOTP = ({credentials}) => {
return fetch(MFA_SETUP_OTP_URL, {
headers: authHeaders(credentials),
method: 'GET'
}).then((data) => data.json())
}
const generateMfaBackupCodes = ({credentials}) => {
return fetch(MFA_BACKUP_CODES_URL, {
headers: authHeaders(credentials),
method: 'GET'
}).then((data) => data.json())
}
const fetchMutes = ({credentials}) => { const fetchMutes = ({credentials}) => {
return promisedRequest({ url: MASTODON_USER_MUTES_URL, credentials }) return promisedRequest({ url: MASTODON_USER_MUTES_URL, credentials })
.then((users) => users.map(parseUser)) .then((users) => users.map(parseUser))
@ -776,6 +828,11 @@ const apiService = {
importFollows, importFollows,
deleteAccount, deleteAccount,
changePassword, changePassword,
settingsMFA,
mfaDisableOTP,
generateMfaBackupCodes,
mfaSetupOTP,
mfaConfirmOTP,
fetchFollowRequests, fetchFollowRequests,
approveUser, approveUser,
denyUser, denyUser,

View File

@ -116,6 +116,12 @@ const backendInteractorService = (credentials) => {
const deleteAccount = ({password}) => apiService.deleteAccount({credentials, password}) const deleteAccount = ({password}) => apiService.deleteAccount({credentials, password})
const changePassword = ({password, newPassword, newPasswordConfirmation}) => apiService.changePassword({credentials, password, newPassword, newPasswordConfirmation}) const changePassword = ({password, newPassword, newPasswordConfirmation}) => apiService.changePassword({credentials, password, newPassword, newPasswordConfirmation})
const fetchSettingsMFA = () => apiService.settingsMFA({credentials})
const generateMfaBackupCodes = () => apiService.generateMfaBackupCodes({credentials})
const mfaSetupOTP = () => apiService.mfaSetupOTP({credentials})
const mfaConfirmOTP = ({password, token}) => apiService.mfaConfirmOTP({credentials, password, token})
const mfaDisableOTP = ({password}) => apiService.mfaDisableOTP({credentials, password})
const fetchFavoritedByUsers = (id) => apiService.fetchFavoritedByUsers({id}) const fetchFavoritedByUsers = (id) => apiService.fetchFavoritedByUsers({id})
const fetchRebloggedByUsers = (id) => apiService.fetchRebloggedByUsers({id}) const fetchRebloggedByUsers = (id) => apiService.fetchRebloggedByUsers({id})
const reportUser = (params) => apiService.reportUser({credentials, ...params}) const reportUser = (params) => apiService.reportUser({credentials, ...params})
@ -166,6 +172,11 @@ const backendInteractorService = (credentials) => {
importFollows, importFollows,
deleteAccount, deleteAccount,
changePassword, changePassword,
fetchSettingsMFA,
generateMfaBackupCodes,
mfaSetupOTP,
mfaConfirmOTP,
mfaDisableOTP,
fetchFollowRequests, fetchFollowRequests,
approveUser, approveUser,
denyUser, denyUser,

View File

@ -0,0 +1,38 @@
const verifyOTPCode = ({app, instance, mfaToken, code}) => {
const url = `${instance}/oauth/mfa/challenge`
const form = new window.FormData()
form.append('client_id', app.client_id)
form.append('client_secret', app.client_secret)
form.append('mfa_token', mfaToken)
form.append('code', code)
form.append('challenge_type', 'totp')
return window.fetch(url, {
method: 'POST',
body: form
}).then((data) => data.json())
}
const verifyRecoveryCode = ({app, instance, mfaToken, code}) => {
const url = `${instance}/oauth/mfa/challenge`
const form = new window.FormData()
form.append('client_id', app.client_id)
form.append('client_secret', app.client_secret)
form.append('mfa_token', mfaToken)
form.append('code', code)
form.append('challenge_type', 'recovery')
return window.fetch(url, {
method: 'POST',
body: form
}).then((data) => data.json())
}
const mfa = {
verifyOTPCode,
verifyRecoveryCode
}
export default mfa

View File

@ -71,12 +71,45 @@ const getToken = ({app, instance, code}) => {
body: form body: form
}).then((data) => data.json()) }).then((data) => data.json())
} }
const verifyOTPCode = ({app, instance, mfaToken, code}) => {
const url = `${instance}/oauth/mfa/challenge`
const form = new window.FormData()
form.append('client_id', app.client_id)
form.append('client_secret', app.client_secret)
form.append('mfa_token', mfaToken)
form.append('code', code)
form.append('challenge_type', 'totp')
return window.fetch(url, {
method: 'POST',
body: form
}).then((data) => data.json())
}
const verifyRecoveryCode = ({app, instance, mfaToken, code}) => {
const url = `${instance}/oauth/mfa/challenge`
const form = new window.FormData()
form.append('client_id', app.client_id)
form.append('client_secret', app.client_secret)
form.append('mfa_token', mfaToken)
form.append('code', code)
form.append('challenge_type', 'recovery')
return window.fetch(url, {
method: 'POST',
body: form
}).then((data) => data.json())
}
const oauth = { const oauth = {
login, login,
getToken, getToken,
getTokenWithCredentials, getTokenWithCredentials,
getOrCreateApp getOrCreateApp,
verifyOTPCode,
verifyRecoveryCode
} }
export default oauth export default oauth

1022
yarn.lock

File diff suppressed because it is too large Load Diff