La autenticación basada en tokens funciona de la siguiente manera:
Authorization: Bearer <token>
Si el servidor responde 401 Unauthorized, significa que el token no es válido o ha expirado y el usuario debe autenticarse de nuevo.
src/
├── stores/
│ └── auth.js
├── services/
│ └── api.js
├── router/
│ └── index.js
└── views/
└── Login.vue
Separar responsabilidades evita código desordenado y difícil de mantener.
📄 stores/auth.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '@/services/api'
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('token') || null)
const isAuthenticated = computed(() => !!token.value)
async function login(credentials) {
const response = await api.users.login(credentials)
token.value = response.data.token
localStorage.setItem('token', token.value)
}
function logout() {
token.value = null
localStorage.removeItem('token')
}
return {
token,
isAuthenticated,
login,
logout
}
})
Aquí se centraliza el estado de la autenticación. En muchas ocasiones no nos interesará sólo el token sino un objeto user con la información del usuario autenticado que nos devolverá el backend tras autenticarse, per exemple:
const user = ref({
name: null,
token: localStorage.getItem('token'),
role: null,
})
La inicialización del token se hace leyendo el valor de localStorage para mantener la sesión tras recargar la página.
El computed que creamos permite siempre saber si el usuario está autenticado o no y al estar cacheado sólo se ejecutará de nuevo cuando el token cambie.
📄 services/api.js
import axios from 'axios'
import router from '@/router'
import { useAuthStore } from '@/stores/auth'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL
})
Añade automáticamente el token a todas las peticiones.
api.interceptors.request.use((config) => {
const authStore = useAuthStore()
const token = authStore.token
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
Detecta errores 401 y redirige al login.
api.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
const authStore = useAuthStore()
authStore.logout()
router.replace({
path: '/login',
query: { redirect: router.currentRoute.value.fullPath }
})
}
return Promise.reject(error)
}
)
Si el backend devuelve 401:
📄 router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import Login from '@/views/Login.vue'
import DatosView from '@/views/DatosView.vue'
const routes = [
{
path: '/login',
component: Login
},
{
path: '/datos',
component: DatosView,
meta: { requiresAuth: true }
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next({
path: '/login',
query: { redirect: to.fullPath }
})
} else {
next()
}
})
export default router
Además de simplemente proteger una ruta podemos indicar para qué roles se permite el acceso:
...
{ path: '/datos', component: DatosView, meta: { requiresAuth: true, roles: ['admin','vendor'] } },
...
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next({
path: '/login',
query: { redirect: to.fullPath }
})
} else if (to.meta.roles?.length) {
const role = auth.role
if (!role || !to.meta.roles.includes(role)) next('/forbidden')
} else {
next()
}
})
📄 Login.vue
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const email = ref('')
const password = ref('')
const error = ref(null)
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
async function handleLogin() {
try {
await authStore.login({
email: email.value,
password: password.value
})
const redirectPath = route.query.redirect || '/'
router.push(redirectPath)
} catch (err) {
error.value = 'Credenciales incorrectas'
}
}
</script>
<template>
<form @submit.prevent="handleLogin">
<input v-model="email" placeholder="Email" />
<input v-model="password" type="password" placeholder="Password" />
<button>Login</button>
<p v-if="error"></p>
</form>
</template>
Un JWT incluye un campo exp que indica la fecha de expiración.
No comprobar nada manualmente.
import jwt_decode from 'jwt-decode'
function isTokenExpired(token) {
const decoded = jwt_decode(token)
return decoded.exp * 1000 < Date.now()
}
Si está expirado:
logout()
En vez de obligar al usuario a loguearse cuando el token expira:
Cuando el access token expira:
Login → accessToken + refreshToken
Peticiones → accessToken
Si 401 → intentar refresh
Si refresh OK → repetir petición
Si refresh falla → logout
let isRefreshing = false
api.interceptors.response.use(
response => response,
async error => {
const authStore = useAuthStore()
if (error.response?.status === 401 && !isRefreshing) {
isRefreshing = true
try {
const response = await axios.post('/auth/refresh', {
refreshToken: localStorage.getItem('refreshToken')
})
authStore.token = response.data.token
localStorage.setItem('token', response.data.token)
error.config.headers.Authorization = `Bearer ${response.data.token}`
return api(error.config)
} catch (err) {
authStore.logout()
router.push('/login')
} finally {
isRefreshing = false
}
}
return Promise.reject(error)
}
)
| Elemento | Función |
|---|---|
| Pinia | Estado global de autenticación |
| localStorage | Persistencia tras recarga |
| Axios request interceptor | Añade token automáticamente |
| Axios response interceptor | Detecta 401 |
| Router guard | Protege rutas |
| Refresh token | Renovación automática |
Una autenticación bien diseñada:
Estructura limpia, profesional y mantenible.