Last active
July 25, 2018 16:02
-
-
Save fabioanderegg/5080f0415a077344e07decf16a53787d to your computer and use it in GitHub Desktop.
Vue.js JWT authentication with Django REST Framework Simple JWT as backend
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import axios from 'axios' | |
import { Message } from 'element-ui' | |
import jwtDecode from 'jwt-decode' | |
import endsWith from 'lodash.endswith' | |
import config from '../config' | |
import store from '@/store' | |
import router from '@/router' | |
const LOGIN_URL = 'token/' | |
const REFRESH_URL = 'token/refresh/' | |
const http = axios.create({ | |
baseURL: config.API_URL | |
}) | |
export async function login (username, password) { | |
let response = null | |
try { | |
response = await http.post('token/', { | |
username: username, | |
password: password | |
}) | |
} catch (error) { | |
if (error.response.status === 400) { | |
// user/password is wrong | |
return false | |
} else { | |
// some other error (e.g. 500) occurred | |
throw new Error('Login request error') | |
} | |
} | |
await store.dispatch('auth/login', { accessToken: response.data.access, refreshToken: response.data.refresh }) | |
return true | |
} | |
export async function refreshToken () { | |
let response = null | |
try { | |
response = await http.post(REFRESH_URL, { | |
refresh: store.state.auth.refreshToken | |
}) | |
} catch (error) { | |
if (error.response.status === 401) { | |
// refresh token is not valid | |
return false | |
} else { | |
// some other error (e.g. 500) occurred | |
throw new Error('Refresh token request error') | |
} | |
} | |
await store.dispatch('auth/login', { accessToken: response.data.access, refreshToken: response.data.refresh }) | |
return true | |
} | |
export function isJWTValid (token) { | |
if (!token) { | |
return false | |
} | |
let data = null | |
try { | |
data = jwtDecode(token) | |
const tokenDate = new Date(data.exp * 1000) | |
const now = new Date() | |
return tokenDate > now | |
} catch (e) { | |
return false | |
} | |
} | |
const LOGIN_ERROR_MESSAGE = 'login' | |
export function handleAPIError (error) { | |
// do not show error message when error comes from a redirect to login | |
if (error.message && error.message === LOGIN_ERROR_MESSAGE) { | |
return | |
} | |
Message.error('An error has occurred, please try again later') | |
} | |
const tryTokensRefresh = async () => { | |
if (store.getters['auth/refreshTokenValid']) { | |
let success = null | |
try { | |
success = await refreshToken() | |
} catch (e) { | |
// some kind of server error occurred, cancel the request | |
throw new axios.Cancel('Token refresh error') | |
} | |
if (!success) { | |
// refreshing failed (probably because it is expired), user has to login again | |
router.push({name: 'Login'}) | |
throw new axios.Cancel(LOGIN_ERROR_MESSAGE) | |
} | |
} else { | |
// refresh token is expired, user has to login again | |
router.push({ name: 'Login' }) | |
throw new axios.Cancel(LOGIN_ERROR_MESSAGE) | |
} | |
} | |
// interceptor to handle case when access token is no longer valid | |
http.interceptors.request.use(async (config) => { | |
// login/refresh urls do not require valid tokens | |
if (config.url === LOGIN_URL || config.url === REFRESH_URL) { | |
return config | |
} | |
// if access token is no longer valid (expired), try to refresh it with the refresh token | |
if (!store.getters['auth/accessTokenValid']) { | |
await tryTokensRefresh() | |
// if the refresh is successful, the store now has a valid access token and we can use it to do the request | |
// if not, the user will be redirected to the login view | |
} | |
config.headers.Authorization = 'Bearer ' + store.state.auth.accessToken | |
return config | |
}, (error) => { | |
return Promise.reject(error) | |
}) | |
// interceptor to handle case when we considered the access token still valid | |
// but the server disagrees and sends back a 401 error | |
http.interceptors.response.use((config) => { | |
return config | |
}, async (error) => { | |
if (!error.config || | |
endsWith(error.config.url, LOGIN_URL) || | |
endsWith(error.config.url, REFRESH_URL) || | |
!error.response || | |
error.response.status !== 401) { | |
return Promise.reject(error) | |
} | |
// if response code is something else than token invalid (e.g. user was deleted/set inactive), go to login | |
if (error.response.data.code !== 'token_not_valid') { | |
router.push({ name: 'Login' }) | |
throw new axios.Cancel(LOGIN_ERROR_MESSAGE) | |
} | |
await tryTokensRefresh() | |
// if successful, the store now has a valid access token and we can retry to request with it | |
// if not, the user has been redirected to the login view | |
return http.request(error.config) | |
}) | |
export default http |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView | |
urlpatterns = [ | |
path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), | |
path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), | |
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import pytest | |
import factory | |
from django.contrib.auth.models import User | |
from pytest_factoryboy import register | |
from rest_framework.test import APIClient | |
# enable database for all tests | |
@pytest.fixture(autouse=True) | |
def enable_db(db): | |
pass | |
DEFAULT_USER_PASSWORD = 'password' | |
class UserFactory(factory.django.DjangoModelFactory): | |
class Meta: | |
model = User | |
username = 'user' | |
password = factory.PostGenerationMethodCall('set_password', DEFAULT_USER_PASSWORD) | |
register(UserFactory) | |
@pytest.fixture | |
def api_client(user_factory): | |
user = user_factory() | |
client = APIClient() | |
response = client.post('/api/token/', { | |
'username': user.username, | |
'password': DEFAULT_USER_PASSWORD, | |
}, format='json') | |
client.credentials(HTTP_AUTHORIZATION='Bearer ' + response.data['access']) | |
return client | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
vue | |
vuex | |
axios | |
jwt-decode | |
lodash.endswith | |
vuex-persistedstate | |
element-ui |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<template> | |
<div class="login-container"> | |
<div class="login"> | |
<h1 class="heading">Login</h1> | |
<el-alert title="Invalid username or password" :closable="false" type="error" v-if="error" /> | |
<el-form ref="form" :model="form" :rules="rules"> | |
<el-form-item label="Username" prop="username"> | |
<el-input v-model="form.username" @keyup.enter.native="submit"></el-input> | |
</el-form-item> | |
<el-form-item label="Password" prop="password"> | |
<el-input v-model="form.password" type="password" @keyup.enter.native="submit"></el-input> | |
</el-form-item> | |
<el-form-item> | |
<el-button type="primary" @click="submit">Login</el-button> | |
</el-form-item> | |
</el-form> | |
</div> | |
</div> | |
</template> | |
<script> | |
import {createNamespacedHelpers} from 'vuex' | |
import { login, handleAPIError } from '@/api' | |
import router from '@/router' | |
const {mapActions} = createNamespacedHelpers('auth') | |
export default { | |
data () { | |
return { | |
form: { | |
username: '', | |
password: '' | |
}, | |
rules: { | |
username: [{ required: true, message: 'This field is required', trigger: 'change' }], | |
password: [{ required: true, message: 'This field is required', trigger: 'change' }] | |
}, | |
error: false | |
} | |
}, | |
methods: { | |
...mapActions(['login']), | |
async submit () { | |
this.error = false | |
try { | |
await this.$refs.form.validate() | |
} catch (e) { | |
return | |
} | |
let success = null | |
try { | |
success = await login(this.form.username, this.form.password) | |
} catch (e) { | |
handleAPIError(e) | |
} | |
if (success) { | |
router.push({name: 'CustomerList'}) | |
} else { | |
this.error = true | |
} | |
} | |
} | |
} | |
</script> | |
<style lang="sass" scoped> | |
.heading | |
text-align: center | |
margin-top: 30px | |
.login-container | |
display: flex | |
flex-direction: column | |
flex-grow: 1 | |
justify-content: center | |
align-items: center | |
.login | |
width: 300px | |
</style> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Vue from 'vue' | |
import Router from 'vue-router' | |
Vue.use(Router) | |
export default new Router({ | |
mode: 'history', | |
routes: [ | |
{ | |
path: '/login', | |
name: 'Login', | |
component: Login | |
} | |
] | |
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# dependency: djangorestframework-simplejwt | |
from datetime import timedelta | |
REST_FRAMEWORK = { | |
# all api reviews require authentication by default | |
'DEFAULT_PERMISSION_CLASSES': ( | |
'rest_framework.permissions.IsAuthenticated', | |
) | |
} | |
SIMPLE_JWT = { | |
'ACCESS_TOKEN_LIFETIME': timedelta(hours=1), | |
'REFRESH_TOKEN_LIFETIME': timedelta(days=7), | |
# generate a new refresh token when the access token is refreshed | |
# this way a user newer gets logged out if logs in at least every | |
# REFRESH_TOKEN_LIFETIME days | |
'ROTATE_REFRESH_TOKENS': True, | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Vue from 'vue' | |
import Vuex from 'vuex' | |
import createPersistedState from 'vuex-persistedstate' | |
import auth from './store_auth' | |
Vue.use(Vuex) | |
const debug = process.env.NODE_ENV !== 'production' | |
export default new Vuex.Store({ | |
modules: { | |
auth | |
}, | |
plugins: [createPersistedState({ | |
paths: ['auth.accessToken', 'auth.refreshToken'] | |
})], | |
strict: debug | |
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { isJWTValid } from '@/api' | |
export default { | |
namespaced: true, | |
state: { | |
accessToken: null, | |
refreshToken: null | |
}, | |
getters: { | |
accessTokenValid (state) { | |
return isJWTValid(state.accessToken) | |
}, | |
refreshTokenValid (state) { | |
return isJWTValid(state.refreshToken) | |
} | |
}, | |
actions: { | |
login ({ commit }, data) { | |
commit('loginMutation', data) | |
} | |
}, | |
mutations: { | |
loginMutation (state, { accessToken, refreshToken }) { | |
state.accessToken = accessToken | |
state.refreshToken = refreshToken | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<template> | |
<div> | |
<span v-if="accessTokenValid">logged in</span> | |
<span v-else>not logged in</span> | |
</template> | |
<script> | |
import { createNamespacedHelpers } from 'vuex' | |
import HTTP, { handleAPIError } from '@/api' | |
const { mapGetters } = createNamespacedHelpers('auth') | |
export default { | |
computed: { | |
...mapGetters(['accessTokenValid']) | |
}, | |
mounted() { | |
try { | |
response = await HTTP.get(`something`) | |
} catch (e) { | |
// use this function to not show an error message when the user is not logged in and gets redirected to login page | |
handleAPIError(e) | |
return | |
} | |
} | |
</script> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment