Add authentication and session management improvements
Some checks failed
Build and push image / deploy (push) Has been cancelled

Introduce navigation guards for authentication and admin access within routes. Replace localStorage usage with secure token storage via httpOnly cookies, and add token blacklisting upon logout. Enhance token refresh mechanism and session expiration handling to improve security and user experience.
This commit is contained in:
2025-05-21 22:53:29 +01:00
parent dd5817419b
commit e5086ec653
7 changed files with 204 additions and 58 deletions

View File

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

View File

@@ -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"),

View File

@@ -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
return store.state.jwt.access;
} catch (error) {
console.error('Error decoding token:', error);
return null;
}
}
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];
if (csrfToken) {
config.headers['X-CSRFToken'] = csrfToken;
}
})
config.headers = {
Authorization: "Bearer " + access_token
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) {
// Do something with request error
return Promise.reject(error);
});
}
return config;
}, function (error) {
return Promise.reject(error);
});
export default axios_jwt

View File

@@ -6,7 +6,8 @@
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<!-- Show these links only when user is authenticated -->
<ul class="navbar-nav me-auto mb-2 mb-lg-0" v-if="isAuthenticated">
<li class="nav-item">
<router-link :to="{name: 'browse'}" class="nav-link" >Browse</router-link>
</li>
@@ -26,6 +27,12 @@
<a class="nav-link" @click="logout">Log Out</a>
</li>
</ul>
<!-- Show login link when user is not authenticated -->
<ul class="navbar-nav me-auto mb-2 mb-lg-0" v-else>
<li class="nav-item">
<router-link :to="{name: 'login'}" class="nav-link">Log In</router-link>
</li>
</ul>
</div>
</div>
</nav>
@@ -42,6 +49,11 @@ export default {
visible: false
}
},
computed: {
isAuthenticated() {
return !!this.$store.state.jwt;
}
},
methods: {
logout () {
store.commit('logOut')

View File

@@ -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',

View File

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

View File

@@ -3,6 +3,11 @@
<div class="row" v-if="!initialSetupRequired">
<div class="col col-lg-4" />
<div class="col col-lg-4" id="login-col">
<!-- Display error message if present -->
<div class="alert alert-danger" v-if="errorMessage">
{{ errorMessage }}
</div>
<form @submit="login" v-on:submit.prevent="onSubmit">
<label class="form-label" for="username">Username</label>
<input id="username" placeholder="username" aria-describedby="loginFormControlInputHelpInline" class="form-control" type="text" v-model="username" />
@@ -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 || '';
}
}
}
</script>