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: vikunja/frontend#109
This commit is contained in:
konrad 2020-04-17 23:46:07 +00:00
parent a75670e4f0
commit 99c10d49be
7 changed files with 220 additions and 31 deletions

View File

@ -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()
}
},

View File

@ -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) {

View File

@ -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')
}
}

View File

@ -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
View File

@ -0,0 +1,11 @@
import AbstractModel from './abstractModel'
export default class TotpModel extends AbstractModel {
defaults() {
return {
secret: '',
enabled: false,
url: '',
}
}
}

View File

@ -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
View 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]))
})
}
}