mirror of
https://github.com/ajurna/cbwebreader.git
synced 2025-12-06 06:17:17 +00:00
Add authentication and session management improvements
Some checks failed
Build and push image / deploy (push) Has been cancelled
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:
@@ -238,8 +238,13 @@ REST_FRAMEWORK = {
|
|||||||
CORS_ALLOW_ALL_ORIGINS = True
|
CORS_ALLOW_ALL_ORIGINS = True
|
||||||
SIMPLE_JWT = {
|
SIMPLE_JWT = {
|
||||||
"ROTATE_REFRESH_TOKENS": True,
|
"ROTATE_REFRESH_TOKENS": True,
|
||||||
|
"BLACKLIST_AFTER_ROTATION": True,
|
||||||
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=10),
|
'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')
|
FRONTEND_DIR = os.path.join(BASE_DIR, 'frontend')
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from drf_yasg.views import get_schema_view
|
|||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
# from rest_framework_extensions.routers import ExtendedDefaultRouter
|
# 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
|
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'),
|
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/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||||
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
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('api/', include(router.urls)),
|
||||||
path("",
|
path("",
|
||||||
TemplateView.as_view(template_name="application.html"),
|
TemplateView.as_view(template_name="application.html"),
|
||||||
|
|||||||
@@ -3,38 +3,77 @@ import router from "@/router";
|
|||||||
import store from "@/store";
|
import store from "@/store";
|
||||||
import { jwtDecode } from "jwt-decode";
|
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() {
|
async function get_access_token() {
|
||||||
let access = jwtDecode(store.state.jwt.access)
|
// If we don't have tokens in the store, return null
|
||||||
let refresh = jwtDecode(store.state.jwt.refresh)
|
if (!store.state.jwt || !store.state.jwt.access) {
|
||||||
if (access.exp - Date.now()/1000 < 5) {
|
return null;
|
||||||
if (refresh.exp - Date.now()/1000 < 5) {
|
}
|
||||||
await router.push({name: 'login'})
|
|
||||||
return null
|
try {
|
||||||
} else {
|
const access = jwtDecode(store.state.jwt.access);
|
||||||
return store.dispatch('refreshToken').then(() => {return 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();
|
const axios_jwt = axios.create();
|
||||||
|
|
||||||
axios_jwt.interceptors.request.use(async function (config) {
|
// Add CSRF token to all requests if using cookies for authentication
|
||||||
let access_token = await get_access_token().catch(() => {
|
axios_jwt.interceptors.request.use(function(config) {
|
||||||
if (router.currentRoute.value.fullPath.includes('login')){
|
// Get CSRF token from cookie if it exists
|
||||||
router.push({name: 'login'})
|
const csrfToken = document.cookie
|
||||||
}else {
|
.split('; ')
|
||||||
router.push({name: 'login', query: { next: router.currentRoute.value.fullPath }})
|
.find(row => row.startsWith('csrftoken='))
|
||||||
|
?.split('=')[1];
|
||||||
|
|
||||||
|
if (csrfToken) {
|
||||||
|
config.headers['X-CSRFToken'] = csrfToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
})
|
return config;
|
||||||
config.headers = {
|
});
|
||||||
Authorization: "Bearer " + access_token
|
|
||||||
|
// 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
|
export default axios_jwt
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
<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">
|
<li class="nav-item">
|
||||||
<router-link :to="{name: 'browse'}" class="nav-link" >Browse</router-link>
|
<router-link :to="{name: 'browse'}" class="nav-link" >Browse</router-link>
|
||||||
</li>
|
</li>
|
||||||
@@ -26,6 +27,12 @@
|
|||||||
<a class="nav-link" @click="logout">Log Out</a>
|
<a class="nav-link" @click="logout">Log Out</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -42,6 +49,11 @@ export default {
|
|||||||
visible: false
|
visible: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
isAuthenticated() {
|
||||||
|
return !!this.$store.state.jwt;
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
logout () {
|
logout () {
|
||||||
store.commit('logOut')
|
store.commit('logOut')
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
import store from '@/store'
|
||||||
|
|
||||||
const ReadView = () => import('@/views/ReadView')
|
const ReadView = () => import('@/views/ReadView')
|
||||||
const RecentView = () => import('@/views/RecentView')
|
const RecentView = () => import('@/views/RecentView')
|
||||||
@@ -8,6 +9,30 @@ const UserView = () => import('@/views/UserView')
|
|||||||
const LoginView = () => import('@/views/LoginView')
|
const LoginView = () => import('@/views/LoginView')
|
||||||
const HistoryView = () => import('@/views/HistoryView')
|
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 = [
|
const routes = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -20,13 +45,15 @@ const routes = [
|
|||||||
path: '/browse/:selector?',
|
path: '/browse/:selector?',
|
||||||
name: 'browse',
|
name: 'browse',
|
||||||
component: BrowseView,
|
component: BrowseView,
|
||||||
props: true
|
props: true,
|
||||||
|
beforeEnter: requireAuth
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/read/:selector',
|
path: '/read/:selector',
|
||||||
name: 'read',
|
name: 'read',
|
||||||
component: ReadView,
|
component: ReadView,
|
||||||
props: true
|
props: true,
|
||||||
|
beforeEnter: requireAuth
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
@@ -36,23 +63,27 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: '/recent',
|
path: '/recent',
|
||||||
name: 'recent',
|
name: 'recent',
|
||||||
component: RecentView
|
component: RecentView,
|
||||||
|
beforeEnter: requireAuth
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/history',
|
path: '/history',
|
||||||
name: 'history',
|
name: 'history',
|
||||||
component: HistoryView
|
component: HistoryView,
|
||||||
|
beforeEnter: requireAuth
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/account',
|
path: '/account',
|
||||||
name: 'account',
|
name: 'account',
|
||||||
component: AccountView
|
component: AccountView,
|
||||||
|
beforeEnter: requireAuth
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/user/:userid?',
|
path: '/user/:userid?',
|
||||||
name: 'user',
|
name: 'user',
|
||||||
component: UserView,
|
component: UserView,
|
||||||
props: true
|
props: true,
|
||||||
|
beforeEnter: requireAdmin
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/about',
|
path: '/about',
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ import {useToast} from "vue-toast-notification";
|
|||||||
import router from "@/router";
|
import router from "@/router";
|
||||||
import api from "@/api";
|
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(){
|
function get_jwt_from_storage(){
|
||||||
try {
|
return null; // Initial state will be null until login
|
||||||
return JSON.parse(localStorage.getItem('t'))
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function get_user_from_storage(){
|
function get_user_from_storage(){
|
||||||
try {
|
try {
|
||||||
@@ -44,12 +43,18 @@ export default createStore({
|
|||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
updateToken(state, newToken){
|
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;
|
state.jwt = newToken;
|
||||||
},
|
},
|
||||||
logOut(state){
|
logOut(state){
|
||||||
localStorage.removeItem('t');
|
// Clear user data from localStorage
|
||||||
localStorage.removeItem('u')
|
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.jwt = null;
|
||||||
state.user = null
|
state.user = null
|
||||||
},
|
},
|
||||||
@@ -92,31 +97,66 @@ export default createStore({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
refreshToken(){
|
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 = {
|
const payload = {
|
||||||
refresh: this.state.jwt.refresh
|
refresh: this.state.jwt.refresh
|
||||||
}
|
}
|
||||||
|
|
||||||
return axios.post('/api/token/refresh/', payload)
|
return axios.post('/api/token/refresh/', payload)
|
||||||
.then((response)=>{
|
.then((response) => {
|
||||||
this.commit('updateToken', response.data)
|
this.commit('updateToken', response.data);
|
||||||
})
|
return response.data;
|
||||||
.catch((error)=>{
|
|
||||||
console.log(error)
|
|
||||||
// router.push({name: 'login', query: {area: 'store'}})
|
|
||||||
})
|
})
|
||||||
|
.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(){
|
inspectToken(){
|
||||||
const token = this.state.jwt;
|
const token = this.state.jwt;
|
||||||
if(token){
|
if (!token) return;
|
||||||
const decoded = jwtDecode(token);
|
|
||||||
const exp = decoded.exp
|
try {
|
||||||
const orig_iat = decoded.iat
|
// For access token
|
||||||
if(exp - (Date.now()/1000) < 1800 && (Date.now()/1000) - orig_iat < 628200){
|
const decoded = jwtDecode(token.access);
|
||||||
this.dispatch('refreshToken')
|
const exp = decoded.exp;
|
||||||
} else if (exp -(Date.now()/1000) < 1800){
|
const now = Date.now() / 1000;
|
||||||
// DO NOTHING, DO NOT REFRESH
|
|
||||||
} else {
|
// Refresh when token is within 5 minutes of expiring
|
||||||
// PROMPT USER TO RE-LOGIN, THIS ELSE CLAUSE COVERS THE CONDITION WHERE A TOKEN IS EXPIRED AS WELL
|
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'});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
<div class="row" v-if="!initialSetupRequired">
|
<div class="row" v-if="!initialSetupRequired">
|
||||||
<div class="col col-lg-4" />
|
<div class="col col-lg-4" />
|
||||||
<div class="col col-lg-4" id="login-col">
|
<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">
|
<form @submit="login" v-on:submit.prevent="onSubmit">
|
||||||
<label class="form-label" for="username">Username</label>
|
<label class="form-label" for="username">Username</label>
|
||||||
<input id="username" placeholder="username" aria-describedby="loginFormControlInputHelpInline" class="form-control" type="text" v-model="username" />
|
<input id="username" placeholder="username" aria-describedby="loginFormControlInputHelpInline" class="form-control" type="text" v-model="username" />
|
||||||
@@ -34,7 +39,8 @@ export default {
|
|||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
password_alert: false,
|
password_alert: false,
|
||||||
initialSetupRequired: false
|
initialSetupRequired: false,
|
||||||
|
errorMessage: ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -43,11 +49,23 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
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 => {
|
axios.get('/api/initial_setup/required/').then(response => {
|
||||||
if (response.data.required){
|
if (response.data.required){
|
||||||
this.initialSetupRequired = true
|
this.initialSetupRequired = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
// Clear error message when route changes
|
||||||
|
watch: {
|
||||||
|
'$route'(to) {
|
||||||
|
this.errorMessage = to.query.error || '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user