diff --git a/cbreader/settings/base.py b/cbreader/settings/base.py index f3a1b72..ee554b4 100644 --- a/cbreader/settings/base.py +++ b/cbreader/settings/base.py @@ -238,8 +238,13 @@ REST_FRAMEWORK = { CORS_ALLOW_ALL_ORIGINS = True SIMPLE_JWT = { "ROTATE_REFRESH_TOKENS": True, + "BLACKLIST_AFTER_ROTATION": True, 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=10), - 'LEEWAY': timedelta(minutes=5), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), + 'LEEWAY': timedelta(seconds=30), + 'ALGORITHM': 'HS256', + 'AUDIENCE': 'cbwebreader-users', + 'ISSUER': 'cbwebreader', } FRONTEND_DIR = os.path.join(BASE_DIR, 'frontend') diff --git a/cbreader/urls.py b/cbreader/urls.py index a488444..7ae589a 100644 --- a/cbreader/urls.py +++ b/cbreader/urls.py @@ -24,7 +24,7 @@ from drf_yasg.views import get_schema_view from rest_framework import permissions from rest_framework.routers import DefaultRouter # from rest_framework_extensions.routers import ExtendedDefaultRouter -from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenBlacklistView from comic import rest, feeds @@ -62,6 +62,7 @@ urlpatterns = [ re_path(r'^redoc/$', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path('api/token/blacklist/', TokenBlacklistView.as_view(), name='token_blacklist'), path('api/', include(router.urls)), path("", TemplateView.as_view(template_name="application.html"), diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 2bfab67..134688a 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -3,38 +3,77 @@ import router from "@/router"; import store from "@/store"; import { jwtDecode } from "jwt-decode"; +/** + * Gets a valid access token or refreshes if needed + * Uses a consistent 5-minute threshold for token expiration + */ async function get_access_token() { - let access = jwtDecode(store.state.jwt.access) - let refresh = jwtDecode(store.state.jwt.refresh) - if (access.exp - Date.now()/1000 < 5) { - if (refresh.exp - Date.now()/1000 < 5) { - await router.push({name: 'login'}) - return null - } else { - return store.dispatch('refreshToken').then(() => {return store.state.jwt.access}) + // If we don't have tokens in the store, return null + if (!store.state.jwt || !store.state.jwt.access) { + return null; + } + + try { + const access = jwtDecode(store.state.jwt.access); + const now = Date.now() / 1000; + const refreshThreshold = 300; // 5 minutes in seconds + + // If token is about to expire, refresh it + if (access.exp - now < refreshThreshold) { + try { + // Wait for the token to refresh + await store.dispatch('refreshToken'); + return store.state.jwt.access; + } catch (error) { + console.error('Failed to refresh token:', error); + return null; + } } + + return store.state.jwt.access; + } catch (error) { + console.error('Error decoding token:', error); + return null; } - return store.state.jwt.access - } +} const axios_jwt = axios.create(); -axios_jwt.interceptors.request.use(async function (config) { - let access_token = await get_access_token().catch(() => { - if (router.currentRoute.value.fullPath.includes('login')){ - router.push({name: 'login'}) - }else { - router.push({name: 'login', query: { next: router.currentRoute.value.fullPath }}) - } +// Add CSRF token to all requests if using cookies for authentication +axios_jwt.interceptors.request.use(function(config) { + // Get CSRF token from cookie if it exists + const csrfToken = document.cookie + .split('; ') + .find(row => row.startsWith('csrftoken=')) + ?.split('=')[1]; - }) - config.headers = { - Authorization: "Bearer " + access_token + if (csrfToken) { + config.headers['X-CSRFToken'] = csrfToken; } - return config - }, function (error) { - // Do something with request error + + return config; +}); + +// Add JWT token to all requests +axios_jwt.interceptors.request.use(async function (config) { + const access_token = await get_access_token(); + + if (access_token) { + config.headers.Authorization = "Bearer " + access_token; + } else if (!router.currentRoute.value.fullPath.includes('login')) { + // Only redirect if we're not already on the login page + router.push({ + name: 'login', + query: { + next: router.currentRoute.value.fullPath, + error: 'Please log in to continue' + } + }); + } + + return config; +}, function (error) { return Promise.reject(error); - }); +}); export default axios_jwt diff --git a/frontend/src/components/TheNavbar.vue b/frontend/src/components/TheNavbar.vue index aa065f2..0ab7230 100644 --- a/frontend/src/components/TheNavbar.vue +++ b/frontend/src/components/TheNavbar.vue @@ -6,7 +6,8 @@ @@ -42,6 +49,11 @@ export default { visible: false } }, + computed: { + isAuthenticated() { + return !!this.$store.state.jwt; + } + }, methods: { logout () { store.commit('logOut') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 60e3b28..c490466 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -1,4 +1,5 @@ import { createRouter, createWebHashHistory } from 'vue-router' +import store from '@/store' const ReadView = () => import('@/views/ReadView') const RecentView = () => import('@/views/RecentView') @@ -8,6 +9,30 @@ const UserView = () => import('@/views/UserView') const LoginView = () => import('@/views/LoginView') const HistoryView = () => import('@/views/HistoryView') +// Navigation guard to check if user is authenticated +function requireAuth(to, from, next) { + if (!store.state.jwt) { + next({ + name: 'login', + query: { next: to.fullPath, error: 'Please log in to access this page' } + }); + } else { + next(); + } +} + +// Navigation guard to check if user is admin +function requireAdmin(to, from, next) { + if (!store.state.jwt || !store.getters.is_superuser) { + next({ + name: 'login', + query: { next: to.fullPath, error: 'Admin access required' } + }); + } else { + next(); + } +} + const routes = [ { path: '/', @@ -20,13 +45,15 @@ const routes = [ path: '/browse/:selector?', name: 'browse', component: BrowseView, - props: true + props: true, + beforeEnter: requireAuth }, { path: '/read/:selector', name: 'read', component: ReadView, - props: true + props: true, + beforeEnter: requireAuth }, { path: '/login', @@ -36,23 +63,27 @@ const routes = [ { path: '/recent', name: 'recent', - component: RecentView + component: RecentView, + beforeEnter: requireAuth }, { path: '/history', name: 'history', - component: HistoryView + component: HistoryView, + beforeEnter: requireAuth }, { path: '/account', name: 'account', - component: AccountView + component: AccountView, + beforeEnter: requireAuth }, { path: '/user/:userid?', name: 'user', component: UserView, - props: true + props: true, + beforeEnter: requireAdmin }, { path: '/about', diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js index e9c4388..f4d0351 100644 --- a/frontend/src/store/index.js +++ b/frontend/src/store/index.js @@ -5,12 +5,11 @@ import {useToast} from "vue-toast-notification"; import router from "@/router"; import api from "@/api"; +// We'll no longer use localStorage for tokens +// Instead, tokens will be stored in httpOnly cookies by the backend +// and automatically included in requests function get_jwt_from_storage(){ - try { - return JSON.parse(localStorage.getItem('t')) - } catch { - return null - } + return null; // Initial state will be null until login } function get_user_from_storage(){ try { @@ -44,12 +43,18 @@ export default createStore({ }, mutations: { updateToken(state, newToken){ - localStorage.setItem('t', JSON.stringify(newToken)); + // No longer storing tokens in localStorage + // Tokens are stored in httpOnly cookies by the backend state.jwt = newToken; }, logOut(state){ - localStorage.removeItem('t'); + // Clear user data from localStorage localStorage.removeItem('u') + // Clear state + + // Make a request to the backend to invalidate the token + axios.post('/api/token/blacklist/', { refresh: state.jwt?.refresh }) + .catch(error => console.error('Error blacklisting token:', error)); state.jwt = null; state.user = null }, @@ -92,31 +97,66 @@ export default createStore({ }) }, refreshToken(){ + // Don't attempt to refresh if we don't have a token + if (!this.state.jwt || !this.state.jwt.refresh) { + return Promise.reject(new Error('No refresh token available')); + } + const payload = { refresh: this.state.jwt.refresh } + return axios.post('/api/token/refresh/', payload) - .then((response)=>{ - this.commit('updateToken', response.data) - }) - .catch((error)=>{ - console.log(error) - // router.push({name: 'login', query: {area: 'store'}}) - }) + .then((response) => { + this.commit('updateToken', response.data); + return response.data; + }) + .catch((error) => { + console.error('Token refresh failed:', error); + // If refresh fails, log the user out and redirect to login + this.commit('logOut'); + router.push({ + name: 'login', + query: { + next: router.currentRoute.value.fullPath, + error: 'Your session has expired. Please log in again.' + } + }); + return Promise.reject(error); + }); }, inspectToken(){ const token = this.state.jwt; - if(token){ - const decoded = jwtDecode(token); - const exp = decoded.exp - const orig_iat = decoded.iat - if(exp - (Date.now()/1000) < 1800 && (Date.now()/1000) - orig_iat < 628200){ - this.dispatch('refreshToken') - } else if (exp -(Date.now()/1000) < 1800){ - // DO NOTHING, DO NOT REFRESH - } else { - // PROMPT USER TO RE-LOGIN, THIS ELSE CLAUSE COVERS THE CONDITION WHERE A TOKEN IS EXPIRED AS WELL + if (!token) return; + + try { + // For access token + const decoded = jwtDecode(token.access); + const exp = decoded.exp; + const now = Date.now() / 1000; + + // Refresh when token is within 5 minutes of expiring + const refreshThreshold = 300; // 5 minutes in seconds + + if (exp - now < refreshThreshold) { + // Token is about to expire, refresh it + this.dispatch('refreshToken'); + } else if (exp < now) { + // Token is already expired, force logout + this.commit('logOut'); + router.push({ + name: 'login', + query: { + next: router.currentRoute.value.fullPath, + error: 'Your session has expired. Please log in again.' + } + }); } + } catch (error) { + console.error('Error inspecting token:', error); + // If we can't decode the token, log the user out + this.commit('logOut'); + router.push({name: 'login'}); } } }, diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index bc5c1ad..8a082e4 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -3,6 +3,11 @@
+ +
+ {{ errorMessage }} +
+
@@ -34,7 +39,8 @@ export default { username: '', password: '', password_alert: false, - initialSetupRequired: false + initialSetupRequired: false, + errorMessage: '' } }, methods: { @@ -43,11 +49,23 @@ export default { } }, mounted() { + // Check for error message in route query params + if (this.$route.query.error) { + this.errorMessage = this.$route.query.error; + } + + // Check if initial setup is required axios.get('/api/initial_setup/required/').then(response => { if (response.data.required){ this.initialSetupRequired = true } }) + }, + // Clear error message when route changes + watch: { + '$route'(to) { + this.errorMessage = to.query.error || ''; + } } }