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:
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) : ''
|
||||
|
||||
@@ -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('/')
|
||||
|
||||
@@ -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 } } }
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
112
apps/admin/src/stores/useAuthStore.ts
Normal file
112
apps/admin/src/stores/useAuthStore.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user