diff --git a/backend/src/baserow/config/settings/base.py b/backend/src/baserow/config/settings/base.py index 50b7f9e18..a882c87c4 100644 --- a/backend/src/baserow/config/settings/base.py +++ b/backend/src/baserow/config/settings/base.py @@ -1,4 +1,5 @@ import os +import datetime BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -21,10 +22,12 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', - 'rest_framework' + 'rest_framework', + 'corsheaders' ] MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -118,3 +121,13 @@ REST_FRAMEWORK = { 'rest_framework.authentication.BasicAuthentication', ), } + +CORS_ORIGIN_WHITELIST = ( + 'http://localhost:3000', +) + +JWT_AUTH = { + 'JWT_EXPIRATION_DELTA': datetime.timedelta(seconds=300), + 'JWT_ALLOW_REFRESH': True, + 'JWT_REFRESH_EXPIRATION_DELTA': datetime.timedelta(days=7), +} diff --git a/backend/src/baserow/config/urls.py b/backend/src/baserow/config/urls.py index 5e19a2143..f1b168c1b 100644 --- a/backend/src/baserow/config/urls.py +++ b/backend/src/baserow/config/urls.py @@ -2,7 +2,7 @@ from django.urls import path, include from django.conf.urls import url from rest_framework import routers -from rest_framework_jwt.views import obtain_jwt_token +from rest_framework_jwt.views import obtain_jwt_token, refresh_jwt_token router = routers.DefaultRouter() @@ -10,5 +10,6 @@ router = routers.DefaultRouter() urlpatterns = [ url(r'^api/token-auth/', obtain_jwt_token), + url(r'^api/token-refresh/', refresh_jwt_token), path('api/', include(router.urls)), ] diff --git a/web-frontend/config/nuxt.config.base.js b/web-frontend/config/nuxt.config.base.js index 61186e868..ddaa1e6a8 100644 --- a/web-frontend/config/nuxt.config.base.js +++ b/web-frontend/config/nuxt.config.base.js @@ -25,7 +25,7 @@ export default { /* ** Plugins to load before mounting the App */ - plugins: [{ src: '@/plugins/Vuelidate.js' }], + plugins: [{ src: '@/plugins/auth.js' }, { src: '@/plugins/vuelidate.js' }], /* ** Nuxt.js modules @@ -40,5 +40,14 @@ export default { */ axios: { // See https://github.com/nuxt-community/axios-module#options + }, + + 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 } } diff --git a/web-frontend/config/nuxt.config.dev.js b/web-frontend/config/nuxt.config.dev.js index f4e4738dc..ad09b0507 100644 --- a/web-frontend/config/nuxt.config.dev.js +++ b/web-frontend/config/nuxt.config.dev.js @@ -6,7 +6,6 @@ import base from './nuxt.config.base.js' const config = { build: { extend(config, ctx) { - // Run ESLint ad Stylelint on save if (ctx.isDev && ctx.isClient) { config.module.rules.push({ enforce: 'pre', diff --git a/web-frontend/pages/login/index.vue b/web-frontend/pages/login/index.vue index db3c47e46..251333f62 100644 --- a/web-frontend/pages/login/index.vue +++ b/web-frontend/pages/login/index.vue @@ -3,14 +3,25 @@ <h1 class="box-title"> <img src="@/static/img/logo.svg" alt="" /> </h1> + <div v-if="invalid" class="alert alert-error alert-has-icon"> + <div class="alert-icon"> + <i class="fas fa-exclamation"></i> + </div> + <div class="alert-title">Incorrect credentials</div> + <p class="alert-content"> + 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> <div class="control-elements"> <input + ref="email" v-model="credentials.email" :class="{ 'input-error': $v.credentials.email.$error }" - type="text" + type="email" class="input input-large" @blur="$v.credentials.email.$touch()" /> @@ -23,6 +34,7 @@ <label class="control-label">Password</label> <div class="control-elements"> <input + ref="password" v-model="credentials.password" :class="{ 'input-error': $v.credentials.password.$error }" type="password" @@ -55,6 +67,7 @@ </template> <script> +import { mapGetters } from 'vuex' import { required, email } from 'vuelidate/lib/validators' export default { @@ -67,12 +80,14 @@ export default { data() { return { loading: false, + invalid: false, credentials: { email: '', password: '' } } }, + computed: { ...mapGetters({ loggedIn: 'auth/loggedIn' }) }, validations: { credentials: { email: { required, email }, @@ -84,6 +99,23 @@ export default { this.$v.$touch() if (!this.$v.$invalid) { this.loading = true + this.$store + .dispatch('auth/login', { + email: this.credentials.email, + password: this.credentials.password + }) + .then(() => { + console.log('@TODO navigate to main page') + }) + .catch(() => { + this.invalid = true + this.credentials.password = '' + this.$v.$reset() + this.$refs.password.focus() + }) + .then(() => { + this.loading = false + }) } } } diff --git a/web-frontend/plugins/.gitkeep b/web-frontend/plugins/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/web-frontend/plugins/auth.js b/web-frontend/plugins/auth.js new file mode 100644 index 000000000..8577adaa8 --- /dev/null +++ b/web-frontend/plugins/auth.js @@ -0,0 +1,11 @@ +export default function({ store }) { + if (!process.browser) { + return + } + + const user = JSON.parse(localStorage.getItem('user')) + if (user) { + store.commit('auth/SET_USER_DATA', user) + store.dispatch('auth/refresh') + } +} diff --git a/web-frontend/plugins/Vuelidate.js b/web-frontend/plugins/vuelidate.js similarity index 100% rename from web-frontend/plugins/Vuelidate.js rename to web-frontend/plugins/vuelidate.js diff --git a/web-frontend/services/authService.js b/web-frontend/services/authService.js new file mode 100644 index 000000000..b5f6b098f --- /dev/null +++ b/web-frontend/services/authService.js @@ -0,0 +1,15 @@ +import { client } from './client' + +export default { + login(username, password) { + return client.post('/api/token-auth/', { + username: username, + password: password + }) + }, + refresh(token) { + return client.post('/api/token-refresh/', { + token: token + }) + } +} diff --git a/web-frontend/services/client.js b/web-frontend/services/client.js new file mode 100644 index 000000000..2315c1f32 --- /dev/null +++ b/web-frontend/services/client.js @@ -0,0 +1,10 @@ +import axios from 'axios' + +export const client = axios.create({ + baseURL: process.env.baseUrl, + withCredentials: false, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } +}) diff --git a/web-frontend/store/.gitkeep b/web-frontend/store/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/web-frontend/store/auth.js b/web-frontend/store/auth.js new file mode 100644 index 000000000..010765bc6 --- /dev/null +++ b/web-frontend/store/auth.js @@ -0,0 +1,56 @@ +import AuthService from '@/services/authService.js' +import { client } from '@/services/client.js' + +export const state = () => ({ + 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}` + }, + CLEAR_USER_DATA(state) { + state.user = null + localStorage.removeItem('user') + client.defaults.headers.common.pop('Authorization') + } +} + +export const actions = { + login({ commit, dispatch }, { email, password }) { + return AuthService.login(email, password).then(({ data }) => { + commit('SET_USER_DATA', data) + dispatch('startRefreshTimeout') + }) + }, + refresh({ commit, state, dispatch }) { + return AuthService.refresh(state.user.token) + .then(({ data }) => { + commit('SET_USER_DATA', data) + dispatch('startRefreshTimeout') + }) + .catch(() => { + // The token could not be refreshed, this means the token is no longer + // valid and the user not logged in anymore. + commit('CLEAR_USER_DATA') + }) + }, + /** + * Because the token expires within a configurable time, we need to keep + * refreshing the token before that happens. + */ + startRefreshTimeout({ dispatch }) { + clearTimeout(this.refreshTimeout) + this.refreshTimeout = setTimeout(() => { + dispatch('refresh') + }, (process.env.JWTTokenExpire - 2) * 1000) + } +} + +export const getters = { + loggedIn(state) { + return !!state.user + } +}