security: migrate auth tokens to httpOnly cookies (hybrid bearer token approach)

Backend:
- CookieBearerToken middleware reads httpOnly cookie and injects Authorization
  header before Sanctum validates (prepended to API middleware group)
- SetAuthCookie trait provides cookie creation/expiry helpers with per-app
  cookie names (crewli_admin_token, crewli_app_token, crewli_portal_token)
- LoginController sets token via Set-Cookie, removes it from JSON body
- LogoutController expires the auth cookie on logout
- AuthRefreshController (POST /auth/refresh) rotates tokens with new cookie
- InvitationController accept also sets token via cookie, not JSON body
- All cookies: httpOnly, SameSite=Strict, Secure (in production)

Frontend (all three SPAs):
- Removed all localStorage token storage (apps/app, apps/portal)
- Removed all JS-readable cookie token storage (apps/admin)
- Removed Authorization: Bearer header interceptors from axios
- Auth stores now rely on GET /auth/me to validate httpOnly cookie
- Admin app: new Pinia auth store replaces useCookie-based auth pattern
- withCredentials: true ensures browser sends cookies automatically

Fixes security findings A13-1 (localStorage tokens) and A13-2 (admin
cookie flags). Tokens are now invisible to JavaScript.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 16:06:44 +02:00
parent 836cffa232
commit 513ca519b2
32 changed files with 826 additions and 227 deletions

View File

@@ -1,4 +1,4 @@
import { useCookie } from '@core/composable/useCookie'
import { useAuthStore } from '@/stores/useAuthStore'
import { computed } from 'vue'
export interface AuthOrganisationSummary {
@@ -17,12 +17,12 @@ export interface AuthUserCookie {
}
/**
* First organisation from the session cookie (set at login). Super-admins still need an organisation context for nested event routes.
* First organisation from the auth store (set at login). Super-admins still need an organisation context for nested event routes.
*/
export function useCurrentOrganisationId() {
const userData = useCookie<AuthUserCookie | null>('userData')
const authStore = useAuthStore()
const organisationId = computed(() => userData.value?.organisations?.[0]?.id ?? null)
const organisationId = computed(() => authStore.user?.organisations?.[0]?.id ?? null)
return { organisationId }
}

View File

@@ -1,37 +1,21 @@
<script setup lang="ts">
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
import { useAuthStore } from '@/stores/useAuthStore'
const router = useRouter()
const ability = useAbility()
const authStore = useAuthStore()
// TODO: Get type from backend
const userData = useCookie<any>('userData')
const userData = computed(() => authStore.user)
const logout = async () => {
try {
// Call API logout endpoint
await $api('/auth/logout', { method: 'POST' })
}
catch (err) {
// Continue with logout even if API call fails
console.error('Logout API error:', err)
}
// Remove "accessToken" from cookie
useCookie('accessToken').value = null
// Remove "userData" from cookie
userData.value = null
// Redirect to login page
await router.push('/login')
// We had to remove abilities in then block because if we don't nav menu items mutation is visible while redirecting user to login page
// Remove "userAbilities" from cookie
useCookie('userAbilityRules').value = null
await authStore.logout()
// Reset ability to initial ability
ability.update([])
// Redirect to login page
await router.push('/login')
}
const userProfileList = [

View File

@@ -1,9 +1,9 @@
import axios from 'axios'
import { parse } from 'cookie-es'
import type { AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios'
const apiClient: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_URL,
withCredentials: true,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
@@ -11,21 +11,8 @@ const apiClient: AxiosInstance = axios.create({
timeout: 30000,
})
function getAccessToken(): string | null {
if (typeof document === 'undefined') return null
const cookies = parse(document.cookie)
const token = cookies.accessToken
return token ?? null
}
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = getAccessToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
if (import.meta.env.DEV) {
console.log(`🚀 ${config.method?.toUpperCase()} ${config.url}`, config.data)
}
@@ -52,13 +39,13 @@ apiClient.interceptors.response.use(
}
if (error.response?.status === 401) {
document.cookie = 'accessToken=; path=/; max-age=0'
document.cookie = 'userData=; path=/; max-age=0'
document.cookie = 'userAbilityRules=; path=/; max-age=0'
const publicPaths = ['/login', '/forgot-password', '/reset-password', '/verify-email-change']
if (!publicPaths.some(p => window.location.pathname.startsWith(p))) {
window.location.href = '/login'
}
// Lazy import to avoid circular dependency
import('@/stores/useAuthStore').then(({ useAuthStore }) => {
const authStore = useAuthStore()
if (authStore.isInitialized) {
authStore.handleUnauthorized()
}
})
}
return Promise.reject(error)

View File

@@ -12,14 +12,13 @@ import authV2MaskLight from '@images/pages/misc-mask-light.png'
import { VNodeRenderer } from '@layouts/components/VNodeRenderer'
import { themeConfig } from '@themeConfig'
import { getUserAbilityRules } from '@/utils/auth-ability'
import type { Rule } from '@/plugins/casl/ability'
import { useAuthStore } from '@/stores/useAuthStore'
import type { AuthUserCookie } from '@/composables/useOrganisationContext'
interface LoginApiPayload {
success: boolean
data: {
user: AuthUserCookie & Record<string, unknown>
token: string
}
message?: string
}
@@ -43,6 +42,7 @@ const router = useRouter()
const passwordResetDone = computed(() => route.query.reset === '1')
const ability = useAbility()
const authStore = useAuthStore()
const errors = ref<Record<string, string | undefined>>({
email: undefined,
@@ -80,20 +80,16 @@ const login = async () => {
},
})
// Handle our API response format: { success, data: { user, token }, message }
// Token is set automatically via httpOnly Set-Cookie header
const { data } = res
const userData = data.user
const accessToken = data.token
const roles = Array.isArray(userData.roles) ? userData.roles : []
const userAbilityRules = getUserAbilityRules(roles)
useCookie<Rule[]>('userAbilityRules').value = userAbilityRules
authStore.setUser(userData, roles)
ability.update(userAbilityRules)
useCookie<AuthUserCookie>('userData').value = userData
useCookie<string>('accessToken').value = accessToken
// Redirect to `to` query if exist or redirect to index route
await nextTick()
const rawTo = route.query.to ? String(route.query.to) : ''

View File

@@ -5,14 +5,13 @@ import AuthProvider from '@/views/pages/authentication/AuthProvider.vue'
import { VNodeRenderer } from '@layouts/components/VNodeRenderer'
import { themeConfig } from '@themeConfig'
import { getUserAbilityRules } from '@/utils/auth-ability'
import type { Rule } from '@/plugins/casl/ability'
import { useAuthStore } from '@/stores/useAuthStore'
import type { AuthUserCookie } from '@/composables/useOrganisationContext'
interface RegisterApiPayload {
success: boolean
data: {
user: AuthUserCookie & Record<string, unknown>
token: string
}
message?: string
}
@@ -84,18 +83,16 @@ const register = async () => {
},
})
// Handle our API response format
// Token is set automatically via httpOnly Set-Cookie header
const { data } = res
const userData = data.user
const accessToken = data.token
const roles = Array.isArray(userData.roles) ? userData.roles : []
const userAbilityRules = getUserAbilityRules(roles)
useCookie<Rule[]>('userAbilityRules').value = userAbilityRules
const authStore = useAuthStore()
authStore.setUser(userData, roles)
ability.update(userAbilityRules)
useCookie<AuthUserCookie>('userData').value = userData
useCookie<string>('accessToken').value = accessToken
await nextTick(() => {
router.replace('/')

View File

@@ -2,6 +2,7 @@
import { VNodeRenderer } from '@layouts/components/VNodeRenderer'
import { themeConfig } from '@themeConfig'
import { apiClient } from '@/lib/axios'
import { useAuthStore } from '@/stores/useAuthStore'
definePage({
meta: {
@@ -26,10 +27,9 @@ onMounted(async () => {
try {
await apiClient.post('/verify-email-change', { token })
success.value = true
// Clear auth cookies — email changed, force re-login
document.cookie = 'accessToken=; path=/; max-age=0'
document.cookie = 'userData=; path=/; max-age=0'
document.cookie = 'userAbilityRules=; path=/; max-age=0'
// Clear auth state — email changed, force re-login
const authStore = useAuthStore()
authStore.clearState()
}
catch (error: unknown) {
const ax = error as { response?: { data?: { errors?: Record<string, string[]>; message?: string } } }

View File

@@ -1,4 +1,5 @@
import type { RouteRecordRaw } from 'vue-router/auto'
import { useAuthStore } from '@/stores/useAuthStore'
const emailRouteComponent = () => import('@/pages/apps/email/index.vue')
@@ -10,23 +11,14 @@ export const redirects: RouteRecordRaw[] = [
path: '/',
name: 'index',
redirect: to => {
const userData = useCookie<Record<string, unknown> | null | undefined>('userData')
const accessToken = useCookie<string | null | undefined>('accessToken')
const isLoggedIn = !!(userData.value && accessToken.value)
const authStore = useAuthStore()
if (!isLoggedIn)
if (!authStore.isAuthenticated)
return { name: 'login', query: to.query }
// Laravel API + Spatie: `roles` is string[] (e.g. super_admin, org_admin)
const roles = Array.isArray(userData.value?.roles)
? (userData.value!.roles as string[])
const roles = Array.isArray(authStore.user?.roles)
? authStore.user!.roles
: []
const legacyRole = userData.value?.role as string | undefined
if (legacyRole === 'admin')
return { name: 'dashboards-crm' }
if (legacyRole === 'client')
return { name: 'access-control' }
const isOrgUser = roles.some(r =>
['super_admin', 'org_admin', 'org_member', 'org_readonly'].includes(r),

View File

@@ -1,13 +1,24 @@
import type { RouteNamedMap, _RouterTyped } from "unplugin-vue-router";
import { canNavigate } from "@layouts/plugins/casl";
import { useAuthStore } from "@/stores/useAuthStore";
export const setupGuards = (
router: _RouterTyped<RouteNamedMap & { [key: string]: any }>
) => {
// 👉 router.beforeEach
// Docs: https://router.vuejs.org/guide/advanced/navigation-guards.html#global-before-guards
router.beforeEach((to, from) => {
// Debug logging
router.beforeEach(async (to, from) => {
const authStore = useAuthStore();
// Wait for initialization to complete (only blocks on first navigation)
if (!authStore.isInitialized) {
await authStore.initialize();
// Update CASL ability after initialization
if (authStore.isAuthenticated && authStore.abilityRules.length > 0) {
const ability = useAbility();
ability.update(authStore.abilityRules);
}
}
if (import.meta.env.DEV) {
console.log("🔒 Router Guard:", {
to: to.path,
@@ -33,27 +44,12 @@ export const setupGuards = (
return;
}
/**
* Check if user is logged in by checking if token & user data exists in local storage
* Feel free to update this logic to suit your needs
*/
const userData = useCookie("userData").value;
const accessToken = useCookie("accessToken").value;
const isLoggedIn = !!(userData && accessToken);
const isLoggedIn = authStore.isAuthenticated;
if (import.meta.env.DEV) {
const userDataObj = userData as Record<string, any> | null;
console.log("🔐 Auth Check:", {
isLoggedIn,
hasUserData: !!userData,
hasAccessToken: !!accessToken,
userData: userDataObj
? {
id: userDataObj.id,
email: userDataObj.email,
role: userDataObj.role,
}
: null,
hasUser: !!authStore.user,
});
}

View File

@@ -2,11 +2,10 @@ import type { App } from 'vue'
import { createMongoAbility } from '@casl/ability'
import { abilitiesPlugin } from '@casl/vue'
import type { Rule } from './ability'
export default function (app: App) {
const userAbilityRules = useCookie<Rule[]>('userAbilityRules')
const initialAbility = createMongoAbility(userAbilityRules.value ?? [])
// Initial ability is empty — gets populated after auth initialization
const initialAbility = createMongoAbility([])
app.use(abilitiesPlugin, initialAbility, {
useGlobalProperties: true,

View File

@@ -0,0 +1,112 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { apiClient } from '@/lib/axios'
import { getUserAbilityRules } from '@/utils/auth-ability'
import type { Rule } from '@/plugins/casl/ability'
import type { AuthUserCookie } from '@/composables/useOrganisationContext'
interface MeResponse {
id: string
first_name: string
last_name: string
full_name: string
email: string
timezone: string
locale: string
avatar: string | null
organisations: Array<{
id: string
name: string
slug: string
role: string
}>
app_roles: string[]
permissions: string[]
}
export const useAuthStore = defineStore('auth', () => {
const user = ref<AuthUserCookie | null>(null)
const abilityRules = ref<Rule[]>([])
const isInitialized = ref(false)
const isAuthenticated = computed(() => !!user.value)
function setUser(userData: AuthUserCookie, roles: string[]) {
user.value = userData
abilityRules.value = getUserAbilityRules(roles)
}
function clearState() {
user.value = null
abilityRules.value = []
}
function handleUnauthorized() {
clearState()
isInitialized.value = false
if (typeof window !== 'undefined') {
const publicPaths = ['/login', '/forgot-password', '/reset-password', '/verify-email-change']
if (!publicPaths.some(p => window.location.pathname.startsWith(p))) {
window.location.href = '/login'
}
}
}
async function logout() {
try {
await apiClient.post('/auth/logout')
}
catch {
// Continue with logout even if API call fails
}
clearState()
}
let initializePromise: Promise<void> | null = null
function initialize(): Promise<void> {
if (isInitialized.value) return Promise.resolve()
if (!initializePromise) {
initializePromise = doInitialize()
}
return initializePromise
}
async function doInitialize(): Promise<void> {
try {
const { data } = await apiClient.get<{ success: boolean; data: MeResponse }>('/auth/me')
const me = data.data
const roles = me.app_roles ?? []
setUser(
{
id: me.id,
name: me.full_name,
email: me.email,
roles,
organisations: me.organisations,
},
roles,
)
}
catch {
clearState()
}
finally {
isInitialized.value = true
}
}
return {
user,
abilityRules,
isAuthenticated,
isInitialized,
setUser,
clearState,
logout,
handleUnauthorized,
initialize,
}
})