diff --git a/backend/src/baserow/api/v0/jwt.py b/backend/src/baserow/api/v0/jwt.py deleted file mode 100644 index fa8e89522..000000000 --- a/backend/src/baserow/api/v0/jwt.py +++ /dev/null @@ -1,8 +0,0 @@ -from .serializers.user import UserSerializer - - -def jwt_response_payload_handler(token, user=None, request=None): - return { - 'token': token, - 'data': UserSerializer(user, context={'request': request}).data - } diff --git a/backend/src/baserow/config/settings/base.py b/backend/src/baserow/config/settings/base.py index 707d4e08e..e8ee59640 100644 --- a/backend/src/baserow/config/settings/base.py +++ b/backend/src/baserow/config/settings/base.py @@ -129,8 +129,7 @@ CORS_ORIGIN_WHITELIST = ( ) JWT_AUTH = { - 'JWT_EXPIRATION_DELTA': datetime.timedelta(seconds=300), + 'JWT_EXPIRATION_DELTA': datetime.timedelta(seconds=30), 'JWT_ALLOW_REFRESH': True, - 'JWT_REFRESH_EXPIRATION_DELTA': datetime.timedelta(days=7), - 'JWT_RESPONSE_PAYLOAD_HANDLER': 'baserow.api.v0.jwt.jwt_response_payload_handler' + 'JWT_REFRESH_EXPIRATION_DELTA': datetime.timedelta(days=7) } diff --git a/web-frontend/config/nuxt.config.base.js b/web-frontend/config/nuxt.config.base.js index ddaa1e6a8..8cc9e28cd 100644 --- a/web-frontend/config/nuxt.config.base.js +++ b/web-frontend/config/nuxt.config.base.js @@ -30,24 +30,14 @@ export default { /* ** Nuxt.js modules */ - modules: [ - // Doc: https://axios.nuxtjs.org/usage - '@nuxtjs/axios' - ], + modules: ['@nuxtjs/axios', 'cookie-universal-nuxt'], - /* - ** Axios module configuration - */ - axios: { - // See https://github.com/nuxt-community/axios-module#options + router: { + middleware: 'authentication' }, env: { // The API base url, this will be prepended to the urls of the remote calls. - baseUrl: 'http://localhost:8000', - - // The JWT token expire time in seconds, when this time passes after a login - // or refresh, the token will be refreshed. - JWTTokenExpire: 300 + baseUrl: 'http://localhost:8000' } } diff --git a/web-frontend/middleware/.gitkeep b/web-frontend/middleware/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/web-frontend/middleware/authenticated.js b/web-frontend/middleware/authenticated.js new file mode 100644 index 000000000..7dd268242 --- /dev/null +++ b/web-frontend/middleware/authenticated.js @@ -0,0 +1,13 @@ +/** + * If this middleware is added to a page, it will redirect back to the login + * page if the user is not authenticated. + */ +export default function({ req, store, redirect }) { + // If nuxt generate, pass this middleware + if (process.server && !req) return + + // If the user is not authenticated we will redirect him to the login page. + if (!store.getters['auth/isAuthenticated']) { + redirect('/login') + } +} diff --git a/web-frontend/middleware/authentication.js b/web-frontend/middleware/authentication.js new file mode 100644 index 000000000..7506dfbed --- /dev/null +++ b/web-frontend/middleware/authentication.js @@ -0,0 +1,16 @@ +import { getToken } from '@/utils/auth' + +export default function({ store, req, app }) { + // If nuxt generate, pass this middleware + if (process.server && !req) return + + // Load the token + const token = getToken(app.$cookies) + + // If there already is a token we will refresh it to check if it is valid and + // to get fresh user information. This will probably happen on the server + // side. + if (token && !store.getters['auth/isAuthenticated']) { + return store.dispatch('auth/refresh', token) + } +} diff --git a/web-frontend/package.json b/web-frontend/package.json index e18578437..1356f7334 100644 --- a/web-frontend/package.json +++ b/web-frontend/package.json @@ -15,8 +15,10 @@ }, "dependencies": { "@fortawesome/fontawesome-free": "^5.8.2", - "@nuxtjs/axios": "^5.3.6", + "@nuxtjs/axios": "^5.5.4", + "cookie-universal-nuxt": "^2.0.16", "cross-env": "^5.2.0", + "jwt-decode": "^2.2.0", "lodash.merge": "^4.6.1", "node-sass": "^4.12.0", "normalize-scss": "^7.0.1", diff --git a/web-frontend/pages/index.vue b/web-frontend/pages/index.vue index adbd0f460..db76e6c69 100644 --- a/web-frontend/pages/index.vue +++ b/web-frontend/pages/index.vue @@ -1,5 +1,16 @@ <template> <div> <h1>Baserow</h1> + <p>authenticated: {{ isAuthenticated }}</p> + <nuxt-link :to="{ name: 'login' }">Login</nuxt-link> </div> </template> + +<script> +import { mapGetters } from 'vuex' + +export default { + middleware: 'authenticated', + computed: { ...mapGetters({ isAuthenticated: 'auth/isAuthenticated' }) } +} +</script> diff --git a/web-frontend/pages/login/index.vue b/web-frontend/pages/login/index.vue index 251333f62..38233fabc 100644 --- a/web-frontend/pages/login/index.vue +++ b/web-frontend/pages/login/index.vue @@ -12,7 +12,6 @@ The provided e-mail address or password is incorrect. </p> </div> - authenticated: {{ loggedIn }} <form @submit.prevent="login"> <div class="control"> <label class="control-label">E-mail address</label> @@ -53,6 +52,11 @@ Sign up </nuxt-link> </li> + <li> + <nuxt-link :to="{ name: 'index' }"> + Index + </nuxt-link> + </li> </ul> <button :class="{ 'button-loading': loading }" @@ -67,7 +71,6 @@ </template> <script> -import { mapGetters } from 'vuex' import { required, email } from 'vuelidate/lib/validators' export default { @@ -87,7 +90,6 @@ export default { } } }, - computed: { ...mapGetters({ loggedIn: 'auth/loggedIn' }) }, validations: { credentials: { email: { required, email }, diff --git a/web-frontend/plugins/auth.js b/web-frontend/plugins/auth.js index 8577adaa8..7ee29cba2 100644 --- a/web-frontend/plugins/auth.js +++ b/web-frontend/plugins/auth.js @@ -1,11 +1,24 @@ -export default function({ store }) { - if (!process.browser) { - return - } +import { client } from '@/services/client' - const user = JSON.parse(localStorage.getItem('user')) - if (user) { - store.commit('auth/SET_USER_DATA', user) - store.dispatch('auth/refresh') +export default function({ store }) { + // Create a request interceptor to add the authorization token to every + // request if the user is authenticated. + client.interceptors.request.use(config => { + if (store.getters['auth/isAuthenticated']) { + const token = store.getters['auth/token'] + config.headers.Authorization = `JWT: ${token}` + } + return config + }) + + // If the user is authenticated, but is not refreshing in the browser means + // that the refresh was done on the server side, so we need to manually start + // the refreshing timeout here. + if ( + store.getters['auth/isAuthenticated'] && + !store.getters['auth/isRefreshing'] && + process.browser + ) { + store.dispatch('auth/startRefreshTimeout') } } diff --git a/web-frontend/services/authService.js b/web-frontend/services/auth.js similarity index 100% rename from web-frontend/services/authService.js rename to web-frontend/services/auth.js diff --git a/web-frontend/store/auth.js b/web-frontend/store/auth.js index 010765bc6..5d6def1d0 100644 --- a/web-frontend/store/auth.js +++ b/web-frontend/store/auth.js @@ -1,56 +1,98 @@ -import AuthService from '@/services/authService.js' -import { client } from '@/services/client.js' +import jwtDecode from 'jwt-decode' + +import AuthService from '@/services/auth' +import { setToken, unsetToken } from '@/utils/auth' export const state = () => ({ + refreshing: false, + token: null, user: null }) export const mutations = { - SET_USER_DATA(state, data) { - state.user = data - localStorage.setItem('user', JSON.stringify(data)) - client.defaults.headers.common.Authorization = `JWT ${data.token}` + SET_USER_DATA(state, token) { + state.token = token + state.user = jwtDecode(token) }, CLEAR_USER_DATA(state) { + state.token = null state.user = null - localStorage.removeItem('user') - client.defaults.headers.common.pop('Authorization') + }, + SET_REFRESHING(state, refreshing) { + state.refreshing = refreshing } } export const actions = { + /** + * Authenticate a user by his email and password. If successful commit the + * token to the state and start the refresh timeout to stay authenticated. + */ login({ commit, dispatch }, { email, password }) { return AuthService.login(email, password).then(({ data }) => { - commit('SET_USER_DATA', data) + setToken(data.token, this.app.$cookies) + commit('SET_USER_DATA', data.token) dispatch('startRefreshTimeout') }) }, - refresh({ commit, state, dispatch }) { - return AuthService.refresh(state.user.token) + /** + * Refresh the existing token. If successful commit the new token and start a + * new refresh timeout. If unsuccessful the existing cookie and user data is + * cleared. + */ + refresh({ commit, state, dispatch }, token) { + return AuthService.refresh(token) .then(({ data }) => { - commit('SET_USER_DATA', data) + setToken(data.token, this.app.$cookies) + commit('SET_USER_DATA', data.token) dispatch('startRefreshTimeout') }) .catch(() => { // The token could not be refreshed, this means the token is no longer // valid and the user not logged in anymore. + unsetToken(this.app.$cookies) commit('CLEAR_USER_DATA') + + // @TODO we might want to do something here, trigger some event, show + // show the user a login popup or redirect to the login page. }) }, /** * Because the token expires within a configurable time, we need to keep - * refreshing the token before that happens. + * refreshing the token before that happens. This process may only happen in + * the browser because that is where we measure if the user still has the + * application open. */ - startRefreshTimeout({ dispatch }) { + startRefreshTimeout({ getters, commit, dispatch }) { + if (!process.browser) return + clearTimeout(this.refreshTimeout) + commit('SET_REFRESHING', true) + this.refreshTimeout = setTimeout(() => { - dispatch('refresh') - }, (process.env.JWTTokenExpire - 2) * 1000) + dispatch('refresh', getters.token) + commit('SET_REFRESHING', false) + }, (getters.tokenExpireSeconds - 10) * 1000) } } export const getters = { - loggedIn(state) { + isAuthenticated(state) { return !!state.user + }, + isRefreshing(state) { + return state.refresh + }, + token(state) { + return state.token + }, + /** + * Returns the amount of seconds it will take before the tokes expires. + * @TODO figure out what happens if the browser and server time and not very + * much in sync. + */ + tokenExpireSeconds(state) { + const now = Math.ceil(new Date().getTime() / 1000) + return state.user.exp - now } } diff --git a/web-frontend/utils/auth.js b/web-frontend/utils/auth.js new file mode 100644 index 000000000..f8aba08f4 --- /dev/null +++ b/web-frontend/utils/auth.js @@ -0,0 +1,15 @@ +const cookieTokenName = 'jwt_token' + +export const setToken = (token, cookie) => { + if (process.SERVER_BUILD) return + cookie.set(cookieTokenName, token) +} + +export const unsetToken = cookie => { + if (process.SERVER_BUILD) return + cookie.remove(cookieTokenName) +} + +export const getToken = cookie => { + return cookie.get(cookieTokenName) +} diff --git a/web-frontend/yarn.lock b/web-frontend/yarn.lock index 191230b1e..20fb13959 100644 --- a/web-frontend/yarn.lock +++ b/web-frontend/yarn.lock @@ -1102,15 +1102,15 @@ webpack-node-externals "^1.7.2" webpackbar "^3.2.0" -"@nuxtjs/axios@^5.3.6": - version "5.5.2" - resolved "https://registry.yarnpkg.com/@nuxtjs/axios/-/axios-5.5.2.tgz#b6447bb12707b56b16b942ae6c737a0b051cecba" - integrity sha512-S5+IkUjCSSFeMVZp/JAzjoit9P7Di2QM4beAlFbHXbOEG+/vSaZReW8l817u9WC6nuKa3x6HhZfWD3tJDTvljg== +"@nuxtjs/axios@^5.5.4": + version "5.5.4" + resolved "https://registry.yarnpkg.com/@nuxtjs/axios/-/axios-5.5.4.tgz#c4aee2322901b19d4072670c03144662a73ea6f4" + integrity sha512-/Ljsyh5VIc9paXGrQue7RQ+PpBNES1oit0g4l+ya1tfyKnZMpHSbghuLcv0xq+BpXlSEr690uemHbz54/N6U5w== dependencies: "@nuxtjs/proxy" "^1.3.3" - axios "^0.18.0" + axios "^0.19.0" axios-retry "^3.1.2" - consola "^2.6.2" + consola "^2.7.1" "@nuxtjs/eslint-config@^0.0.1": version "0.0.1" @@ -1167,6 +1167,11 @@ dependencies: "@babel/types" "^7.3.0" +"@types/cookie@^0.3.1": + version "0.3.3" + resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.3.3.tgz#85bc74ba782fb7aa3a514d11767832b0e3bc6803" + integrity sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow== + "@types/events@*": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7" @@ -1869,13 +1874,13 @@ axios-retry@^3.1.2: dependencies: is-retry-allowed "^1.1.0" -axios@^0.18.0: - version "0.18.0" - resolved "https://registry.yarnpkg.com/axios/-/axios-0.18.0.tgz#32d53e4851efdc0a11993b6cd000789d70c05102" - integrity sha1-MtU+SFHv3AoRmTts0AB4nXDAUQI= +axios@^0.19.0: + version "0.19.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.19.0.tgz#8e09bff3d9122e133f7b8101c8fbdd00ed3d2ab8" + integrity sha512-1uvKqKQta3KBxIz14F2v06AEHZ/dIoeKfbTRkK1E5oqjDnuEerLmYTgJB5AiQZHJcljpg1TuRzdjDR06qNk0DQ== dependencies: - follow-redirects "^1.3.0" - is-buffer "^1.1.5" + follow-redirects "1.5.10" + is-buffer "^2.0.2" babel-code-frame@^6.26.0: version "6.26.0" @@ -2845,11 +2850,16 @@ connect@^3.6.6: parseurl "~1.3.3" utils-merge "1.0.1" -consola@^2.3.0, consola@^2.5.6, consola@^2.6.0, consola@^2.6.1, consola@^2.6.2: +consola@^2.3.0, consola@^2.5.6, consola@^2.6.0, consola@^2.6.1: version "2.7.1" resolved "https://registry.yarnpkg.com/consola/-/consola-2.7.1.tgz#3f7f7c53eb44362240c3aee41b9bb2641d5ca32e" integrity sha512-u7JYs+HnMbZPD2cEuS1XHsLeqtazA0kd5lAk8r8DnnGdgNhOdb7DSubJ+QLdQkbtpmmxgp7gs8Ug44sCyY4FCQ== +consola@^2.7.1: + version "2.9.0" + resolved "https://registry.yarnpkg.com/consola/-/consola-2.9.0.tgz#57760e3a65a53ec27337f4add31505802d902278" + integrity sha512-34Iue+LRcWbndFIfZc5boNizWlsrRjqIBJZTe591vImgbnq7nx2EzlrLtANj9TH2Fxm7puFJBJAOk5BhvZOddQ== + console-browserify@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.1.0.tgz#f0241c45730a9fc6323b206dbf38edc741d0bb10" @@ -2903,6 +2913,22 @@ cookie-signature@1.0.6: resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw= +cookie-universal-nuxt@^2.0.16: + version "2.0.16" + resolved "https://registry.yarnpkg.com/cookie-universal-nuxt/-/cookie-universal-nuxt-2.0.16.tgz#8d528098c973162b352199240e40da0e5429b13f" + integrity sha512-wRK2zw8w+a5xPehb5kLbgOic/4mbjl2exUCxWZwGuttcwsFgOymiwDrCOzmQslqrDevPDL2SsBbH6wtOm7dB9g== + dependencies: + "@types/cookie" "^0.3.1" + cookie-universal "^2.0.16" + +cookie-universal@^2.0.16: + version "2.0.16" + resolved "https://registry.yarnpkg.com/cookie-universal/-/cookie-universal-2.0.16.tgz#ec8b55789b502a377ef02ad230923c1dfa5c1061" + integrity sha512-EHtQ5Tg3UoUHG7LmeV3rlV3iYthkhUuYZ0y86EseypxGcUuvzxuHExEb6mHKDhDPrIrdewAHdG/aCHuG/T4zEg== + dependencies: + "@types/cookie" "^0.3.1" + cookie "^0.3.1" + cookie@0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba" @@ -3340,6 +3366,13 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9: dependencies: ms "2.0.0" +debug@=3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" + integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== + dependencies: + ms "2.0.0" + debug@^3.1.0, debug@^3.2.6: version "3.2.6" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" @@ -4378,7 +4411,14 @@ flush-write-stream@^1.0.0: inherits "^2.0.3" readable-stream "^2.3.6" -follow-redirects@^1.0.0, follow-redirects@^1.3.0: +follow-redirects@1.5.10: + version "1.5.10" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.10.tgz#7b7a9f9aea2fdff36786a94ff643ed07f4ff5e2a" + integrity sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ== + dependencies: + debug "=3.1.0" + +follow-redirects@^1.0.0: version "1.7.0" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.7.0.tgz#489ebc198dc0e7f64167bd23b03c4c19b5784c76" integrity sha512-m/pZQy4Gj287eNy94nivy5wchN3Kp+Q5WgUPNy5lJSZ3sgkVKSYV/ZChMAQVIgx1SqfZ2zBZtPA2YlXIWxxJOQ== @@ -5260,7 +5300,7 @@ is-buffer@^1.1.5: resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== -is-buffer@^2.0.0: +is-buffer@^2.0.0, is-buffer@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.3.tgz#4ecf3fcf749cbd1e472689e109ac66261a25e725" integrity sha512-U15Q7MXTuZlrbymiz95PJpZxu8IlipAp4dtS3wOdgPXx3mqBnslrWU14kxfHB+Py/+2PVKSr37dMAgM2A4uArw== @@ -6114,6 +6154,11 @@ jsprim@^1.2.2: json-schema "0.2.3" verror "1.10.0" +jwt-decode@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-2.2.0.tgz#7d86bd56679f58ce6a84704a657dd392bba81a79" + integrity sha1-fYa9VmefWM5qhHBKZX3TkruoGnk= + kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"