TOTP (#109)
Fix not telling the user about invalid totp passcodes when logging in Add disabling totp authentication Add totp passcode when logging in Add totp settings Add general post method function Co-authored-by: kolaente <k@knt.li> Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/109
This commit is contained in:
parent
a75670e4f0
commit
99c10d49be
7 changed files with 220 additions and 31 deletions
13
src/App.vue
13
src/App.vue
|
@ -4,7 +4,7 @@
|
|||
<!-- This is a workaround to get the sw to "see" the to-be-cached version of the offline background image -->
|
||||
<div class="offline" style="height: 0;width: 0;"></div>
|
||||
<nav class="navbar main-theme is-fixed-top" role="navigation" aria-label="main navigation"
|
||||
v-if="user.authenticated && user.infos.type === authTypes.USER">
|
||||
v-if="user.authenticated && (userInfo && userInfo.type === authTypes.USER)">
|
||||
<div class="navbar-brand">
|
||||
<router-link :to="{name: 'home'}" class="navbar-item logo">
|
||||
<img src="/images/logo-full.svg" alt="Vikunja"/>
|
||||
|
@ -42,7 +42,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div v-if="user.authenticated && user.infos.type === authTypes.USER">
|
||||
<div v-if="user.authenticated && (userInfo && userInfo.type === authTypes.USER)">
|
||||
<a @click="mobileMenuActive = true" class="mobilemenu-show-button" v-if="!mobileMenuActive">
|
||||
<icon icon="bars"></icon>
|
||||
</a>
|
||||
|
@ -169,7 +169,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<!-- FIXME: This will only be triggered when the root component is already loaded before doing link share auth. Will "fix" itself once we use vuex. -->
|
||||
<div v-else-if="user.authenticated && user.infos.type === authTypes.LINK_SHARE">
|
||||
<div v-else-if="user.authenticated && (userInfo && userInfo.type === authTypes.LINK_SHARE)">
|
||||
<div class="container has-text-centered link-share-view">
|
||||
<div class="column is-10 is-offset-1">
|
||||
<img src="/images/logo-full.svg" alt="Vikunja" class="logo"/>
|
||||
|
@ -298,6 +298,11 @@
|
|||
// call the method again if the route changes
|
||||
'$route': 'doStuffAfterRoute'
|
||||
},
|
||||
computed: {
|
||||
userInfo() {
|
||||
return auth.getUserInfos()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
logout() {
|
||||
auth.logout()
|
||||
|
@ -313,7 +318,7 @@
|
|||
})
|
||||
},
|
||||
loadNamespacesIfNeeded(e) {
|
||||
if (auth.user.authenticated && auth.user.infos.type === authTypes.USER && (e.name === 'home' || this.namespaces.length === 0)) {
|
||||
if (auth.user.authenticated && (this.userInfo && this.userInfo.type === authTypes.USER) && (e.name === 'home' || this.namespaces.length === 0)) {
|
||||
this.loadNamespaces()
|
||||
}
|
||||
},
|
||||
|
|
|
@ -11,13 +11,19 @@ export default {
|
|||
infos: {},
|
||||
},
|
||||
|
||||
login(context, creds, redirect) {
|
||||
login(context, credentials, redirect = '') {
|
||||
localStorage.removeItem('token') // Delete an eventually preexisting old token
|
||||
|
||||
HTTP.post('login', {
|
||||
username: creds.username,
|
||||
password: creds.password
|
||||
})
|
||||
const data = {
|
||||
username: credentials.username,
|
||||
password: credentials.password
|
||||
}
|
||||
|
||||
if(credentials.totpPasscode) {
|
||||
data.totp_passcode = credentials.totpPasscode
|
||||
}
|
||||
|
||||
HTTP.post('login', data)
|
||||
.then(response => {
|
||||
// Save the token to local storage for later use
|
||||
localStorage.setItem('token', response.data.token)
|
||||
|
@ -25,28 +31,28 @@ export default {
|
|||
// Tell others the user is autheticated
|
||||
this.user.authenticated = true
|
||||
this.user.isLinkShareAuth = false
|
||||
const inf = this.getUserInfos()
|
||||
// eslint-disable-next-line
|
||||
console.log(inf)
|
||||
|
||||
// Hide the loader
|
||||
context.loading = false
|
||||
|
||||
// Redirect if nessecary
|
||||
if (redirect) {
|
||||
if (redirect !== '') {
|
||||
router.push({name: redirect})
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
// Hide the loader
|
||||
context.loading = false
|
||||
if (e.response) {
|
||||
if (e.response.data.code === 1017 && !credentials.totpPasscode) {
|
||||
context.needsTotpPasscode = true
|
||||
return
|
||||
}
|
||||
|
||||
context.errorMsg = e.response.data.message
|
||||
if (e.response.status === 401) {
|
||||
context.errorMsg = 'Wrong username or password.'
|
||||
}
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
context.loading = false
|
||||
})
|
||||
},
|
||||
|
||||
register(context, creds, redirect) {
|
||||
|
|
|
@ -18,6 +18,12 @@
|
|||
<input type="password" class="input" id="password" name="password" placeholder="e.g. ••••••••••••" ref="password" required/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field" v-if="needsTotpPasscode">
|
||||
<label class="label" for="totpPasscode">Two Factor Authentication Code</label>
|
||||
<div class="control">
|
||||
<input type="text" class="input" id="totpPasscode" placeholder="e.g. 123456" ref="totpPasscode" required v-focus/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
|
@ -45,7 +51,8 @@
|
|||
return {
|
||||
errorMsg: '',
|
||||
confirmedEmailSuccess: false,
|
||||
loading: false
|
||||
loading: false,
|
||||
needsTotpPasscode: false,
|
||||
}
|
||||
},
|
||||
beforeMount() {
|
||||
|
@ -83,6 +90,10 @@
|
|||
password: this.$refs.password.value,
|
||||
}
|
||||
|
||||
if(this.needsTotpPasscode) {
|
||||
credentials.totpPasscode = this.$refs.totpPasscode.value
|
||||
}
|
||||
|
||||
auth.login(this, credentials, 'home')
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="loader-container" v-bind:class="{ 'is-loading': passwordUpdateService.loading}">
|
||||
<div class="loader-container" :class="{ 'is-loading': passwordUpdateService.loading || emailUpdateService.loading || totpService.loading }">
|
||||
<div class="card">
|
||||
<header class="card-header">
|
||||
<p class="card-header-title">
|
||||
|
@ -75,6 +75,52 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<header class="card-header">
|
||||
<p class="card-header-title">
|
||||
Two Factor Authentication
|
||||
</p>
|
||||
</header>
|
||||
<div class="card-content">
|
||||
<a class="button is-primary" v-if="!totpEnrolled && totp.secret === ''" @click="totpEnroll()" :class="{ 'is-loading': totpService.loading }">Enroll</a>
|
||||
<div class="content" v-else-if="totp.secret !== '' && !totp.enabled">
|
||||
<p>
|
||||
To finish your setup, use this secret in your totp app (Google Authenticator or similar): <strong>{{ totp.secret }}</strong><br/>
|
||||
After that, enter a code from your app below.
|
||||
</p>
|
||||
<p>
|
||||
Alternatively you can scan this QR code:<br/>
|
||||
<img :src="totpQR" alt=""/>
|
||||
</p>
|
||||
<div class="field">
|
||||
<label class="label" for="totpConfirmPasscode">Passcode</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" id="totpConfirmPasscode" placeholder="A code generated by your totp application"
|
||||
v-model="totpConfirmPasscode" @keyup.enter="totpConfirm()"/>
|
||||
</div>
|
||||
</div>
|
||||
<a class="button is-primary" @click="totpConfirm()">Confirm</a>
|
||||
</div>
|
||||
<div class="content" v-else-if="totp.secret !== '' && totp.enabled">
|
||||
<p>
|
||||
You've sucessfully set up two factor authentication!
|
||||
</p>
|
||||
<p v-if="!totpDisableForm">
|
||||
<a class="button is-danger" @click="totpDisableForm = true">Disable</a>
|
||||
</p>
|
||||
<div v-if="totpDisableForm">
|
||||
<div class="field">
|
||||
<label class="label" for="currentPassword">Please Enter Your Password</label>
|
||||
<div class="control">
|
||||
<input class="input" type="password" id="currentPassword" placeholder="Your current password"
|
||||
v-model="totpDisablePassword" @keyup.enter="totpDisable" v-focus/>
|
||||
</div>
|
||||
</div>
|
||||
<a class="button is-danger" @click="totpDisable()">Disable two factor authentication</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -83,6 +129,8 @@
|
|||
import PasswordUpdateService from '../../services/passwordUpdateService'
|
||||
import EmailUpdateService from '../../services/emailUpdate'
|
||||
import EmailUpdateModel from '../../models/emailUpdate'
|
||||
import TotpModel from '../../models/totp'
|
||||
import TotpService from '../../services/totp'
|
||||
|
||||
export default {
|
||||
name: 'Settings',
|
||||
|
@ -94,14 +142,40 @@
|
|||
|
||||
emailUpdateService: EmailUpdateService,
|
||||
emailUpdate: EmailUpdateModel,
|
||||
|
||||
totpService: TotpService,
|
||||
totp: TotpModel,
|
||||
totpQR: '',
|
||||
totpEnrolled: false,
|
||||
totpConfirmPasscode: '',
|
||||
totpDisableForm: false,
|
||||
totpDisablePassword: '',
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.passwordUpdate = new PasswordUpdateModel()
|
||||
this.passwordUpdateService = new PasswordUpdateService()
|
||||
this.passwordUpdate = new PasswordUpdateModel()
|
||||
|
||||
this.emailUpdate = new EmailUpdateModel()
|
||||
this.emailUpdateService = new EmailUpdateService()
|
||||
this.emailUpdate = new EmailUpdateModel()
|
||||
|
||||
this.totpService = new TotpService()
|
||||
this.totp = new TotpModel()
|
||||
|
||||
this.totpService.get()
|
||||
.then(r => {
|
||||
this.$set(this, 'totp', r)
|
||||
this.totpSetQrCode()
|
||||
})
|
||||
.catch(e => {
|
||||
// Error code 1016 means totp is not enabled, we don't need an error in that case.
|
||||
if (e.response && e.response.data && e.response.data.code && e.response.data.code === 1016) {
|
||||
this.totpEnrolled = false
|
||||
return
|
||||
}
|
||||
|
||||
this.error(e, this)
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
updatePassword() {
|
||||
|
@ -123,6 +197,39 @@
|
|||
})
|
||||
.catch(e => this.error(e, this))
|
||||
},
|
||||
totpSetQrCode() {
|
||||
this.totpService.qrcode()
|
||||
.then(qr => {
|
||||
const urlCreator = window.URL || window.webkitURL
|
||||
this.totpQR = urlCreator.createObjectURL(qr)
|
||||
})
|
||||
},
|
||||
totpEnroll() {
|
||||
this.totpService.enroll()
|
||||
.then(r => {
|
||||
this.totpEnrolled = true
|
||||
this.$set(this, 'totp', r)
|
||||
this.totpSetQrCode()
|
||||
})
|
||||
.catch(e => this.error(e, this))
|
||||
},
|
||||
totpConfirm() {
|
||||
this.totpService.enable({passcode: this.totpConfirmPasscode})
|
||||
.then(() => {
|
||||
this.$set(this.totp, 'enabled', true)
|
||||
this.success({message: 'You\'ve successfully confirmed your totp setup and can use it from now on!'}, this)
|
||||
})
|
||||
.catch(e => this.error(e, this))
|
||||
},
|
||||
totpDisable() {
|
||||
this.totpService.disable({password: this.totpDisablePassword})
|
||||
.then(() => {
|
||||
this.totpEnrolled = false
|
||||
this.$set(this, 'totp', new TotpModel())
|
||||
this.success({message: 'Two factor authentication was sucessfully disabled.'}, this)
|
||||
})
|
||||
.catch(e => this.error(e, this))
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
11
src/models/totp.js
Normal file
11
src/models/totp.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
import AbstractModel from './abstractModel'
|
||||
|
||||
export default class TotpModel extends AbstractModel {
|
||||
defaults() {
|
||||
return {
|
||||
secret: '',
|
||||
enabled: false,
|
||||
url: '',
|
||||
}
|
||||
}
|
||||
}
|
|
@ -386,19 +386,16 @@ export default class AbstractService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Performs a post request to the update url
|
||||
* An abstract implementation to send post requests.
|
||||
* Services can use this to implement functions to do post requests other than using the update method.
|
||||
* @param url
|
||||
* @param model
|
||||
* @returns {Q.Promise<any>}
|
||||
* @returns {Q.Promise<unknown>}
|
||||
*/
|
||||
update(model) {
|
||||
if (this.paths.update === '') {
|
||||
return Promise.reject({message: 'This model is not able to update data.'})
|
||||
}
|
||||
|
||||
post(url, model) {
|
||||
const cancel = this.setLoading()
|
||||
const finalUrl = this.getReplacedRoute(this.paths.update, model)
|
||||
|
||||
return this.http.post(finalUrl, model)
|
||||
return this.http.post(url, model)
|
||||
.catch(error => {
|
||||
return this.errorHandler(error)
|
||||
})
|
||||
|
@ -410,6 +407,20 @@ export default class AbstractService {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a post request to the update url
|
||||
* @param model
|
||||
* @returns {Q.Promise<any>}
|
||||
*/
|
||||
update(model) {
|
||||
if (this.paths.update === '') {
|
||||
return Promise.reject({message: 'This model is not able to update data.'})
|
||||
}
|
||||
|
||||
const finalUrl = this.getReplacedRoute(this.paths.update, model)
|
||||
return this.post(finalUrl, model)
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a delete request to the update url
|
||||
* @param model
|
||||
|
|
38
src/services/totp.js
Normal file
38
src/services/totp.js
Normal file
|
@ -0,0 +1,38 @@
|
|||
import AbstractService from './abstractService'
|
||||
import TotpModel from "../models/totp";
|
||||
|
||||
export default class TotpService extends AbstractService {
|
||||
urlPrefix = '/user/settings/totp'
|
||||
|
||||
constructor() {
|
||||
super({})
|
||||
|
||||
this.paths.get = this.urlPrefix
|
||||
}
|
||||
|
||||
modelFactory(data) {
|
||||
return new TotpModel(data)
|
||||
}
|
||||
|
||||
enroll() {
|
||||
return this.post(`${this.urlPrefix}/enroll`, {})
|
||||
}
|
||||
|
||||
enable(model) {
|
||||
return this.post(`${this.urlPrefix}/enable`, model)
|
||||
}
|
||||
|
||||
disable(model) {
|
||||
return this.post(`${this.urlPrefix}/disable`, model)
|
||||
}
|
||||
|
||||
qrcode() {
|
||||
return this.http({
|
||||
url: `${this.urlPrefix}/qrcode`,
|
||||
method: 'GET',
|
||||
responseType: 'blob',
|
||||
}).then(response => {
|
||||
return Promise.resolve(new Blob([response.data]))
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue