Core Authentication
MStore Dashboard menggunakan JWT authentication dengan pendekatan keamanan tinggi: access token disimpan di memory dan refresh token sebagai HttpOnly cookie.
Security Architecture
Token Storage Strategy
Token Type Storage Security Reason Access Token Memory (Pinia store) Tidak accessible oleh XSS Refresh Token HttpOnly Cookie Tidak accessible oleh JavaScript
Access token tidak pernah disimpan di localStorage atau sessionStorage untuk mencegah serangan XSS.
Authentication Flow
Login Flow
Token Refresh Flow
Implementation
Auth Store
// features/01_core/store/auth.ts
import { defineStore } from 'pinia'
import { jwtDecode } from 'jwt-decode'
import type { User , JwtClaims } from '../types'
export const useAuthStore = defineStore ( 'auth' , () => {
// State
const accessToken = ref < string | null >( null )
const user = ref < User | null >( null )
const isAuthenticated = ref ( false )
const isLoading = ref ( false )
// Getters
const claims = computed < JwtClaims | null >(() => {
if ( ! accessToken . value ) return null
try {
return jwtDecode < JwtClaims >( accessToken . value )
} catch {
return null
}
})
const isTokenExpired = computed (() => {
if ( ! claims . value ?. exp ) return true
return Date . now () >= claims . value . exp * 1000
})
const hasRole = ( role : string ) => {
return claims . value ?. roles ?. includes ( role ) ?? false
}
const hasPermission = ( permission : string ) => {
return claims . value ?. permissions ?. includes ( permission ) ?? false
}
// Actions
const setToken = ( token : string ) => {
accessToken . value = token
isAuthenticated . value = true
}
const setUser = ( userData : User ) => {
user . value = userData
}
const logout = async () => {
try {
await $fetch ( '/api/v1/auth/logout' , {
method: 'POST' ,
credentials: 'include' ,
})
} catch ( e ) {
// Ignore logout errors
} finally {
accessToken . value = null
user . value = null
isAuthenticated . value = false
navigateTo ( '/login' )
}
}
return {
// State
accessToken ,
user ,
isAuthenticated ,
isLoading ,
// Getters
claims ,
isTokenExpired ,
hasRole ,
hasPermission ,
// Actions
setToken ,
setUser ,
logout ,
}
}, {
// Only persist user info, NOT token
persist: {
pick: [ 'user' , 'isAuthenticated' ],
},
})
JWT Claims Interface
// features/01_core/types/auth.types.ts
export interface JwtClaims {
sub : string // User ID
email : string // User email
name : string // User name
roles : string [] // User roles (e.g., ['admin', 'cashier'])
permissions : string [] // Permissions (e.g., ['inventory.read', 'inventory.write'])
merchantId ?: string // Merchant ID (for multi-tenant)
branchId ?: string // Branch ID
iat : number // Issued at
exp : number // Expiration time
}
export interface User {
id : string
email : string
name : string
avatar ?: string
roles : string []
merchantId ?: string
branchId ?: string
}
export interface LoginRequest {
email : string
password : string
}
export interface LoginResponse {
access_token : string
user : User
}
Auth Composable
// features/01_core/composables/useAuth.ts
import { useAuthStore } from '../store/auth'
import type { LoginRequest } from '../types'
export const useAuth = () => {
const store = useAuthStore ()
const router = useRouter ()
const login = async ( credentials : LoginRequest ) => {
store . isLoading = true
try {
const response = await $fetch < LoginResponse >( '/api/v1/auth/login' , {
method: 'POST' ,
body: credentials ,
credentials: 'include' , // Important: Include cookies
})
store . setToken ( response . access_token )
store . setUser ( response . user )
// Redirect to intended page or dashboard
const redirectUrl = useRoute (). query . redirect as string
router . push ( redirectUrl || '/' )
return response
} catch ( error ) {
throw error
} finally {
store . isLoading = false
}
}
const logout = async () => {
await store . logout ()
}
const refreshToken = async () => {
try {
const response = await $fetch <{ access_token : string }>( '/api/v1/auth/refresh-token' , {
method: 'POST' ,
credentials: 'include' ,
})
store . setToken ( response . access_token )
return response . access_token
} catch ( error ) {
store . logout ()
throw error
}
}
return {
// State
user: computed (() => store . user ),
isAuthenticated: computed (() => store . isAuthenticated ),
isLoading: computed (() => store . isLoading ),
// Methods
login ,
logout ,
refreshToken ,
hasRole: store . hasRole ,
hasPermission: store . hasPermission ,
}
}
Auth Middleware
// middleware/auth.global.ts
export default defineNuxtRouteMiddleware ( async ( to ) => {
const authStore = useAuthStore ()
// Public routes that don't require auth
const publicRoutes = [ '/login' , '/register' , '/forgot-password' ]
if ( publicRoutes . includes ( to . path )) {
// If already authenticated, redirect to dashboard
if ( authStore . isAuthenticated ) {
return navigateTo ( '/' )
}
return
}
// Protected routes - check authentication
if ( ! authStore . isAuthenticated ) {
return navigateTo ({
path: '/login' ,
query: { redirect: to . fullPath },
})
}
// Check if token is expired and try to refresh
if ( authStore . isTokenExpired ) {
try {
const { refreshToken } = useAuth ()
await refreshToken ()
} catch ( error ) {
return navigateTo ( '/login' )
}
}
} )
Login Page Implementation
<!-- app/pages/login.vue -->
< script setup lang = "ts" >
import { useAuth } from '~/features/01_core'
definePageMeta ({
layout: 'auth' ,
})
const { login , isLoading } = useAuth ()
const form = reactive ({
email: '' ,
password: '' ,
})
const error = ref < string | null >( null )
const handleSubmit = async () => {
error . value = null
try {
await login ({
email: form . email ,
password: form . password ,
})
} catch ( e ) {
if ( e instanceof Error ) {
error . value = e . message
} else {
error . value = 'Login failed. Please try again.'
}
}
}
</ script >
< template >
< div class = "min-h-screen flex items-center justify-center" >
< UCard class = "w-full max-w-md" >
< template # header >
< h1 class = "text-2xl font-bold" > Login </ h1 >
</ template >
< form @ submit . prevent = " handleSubmit " class = "space-y-4" >
< UAlert v-if = " error " color = "red" : title = " error " />
< UFormGroup label = "Email" >
< UInput
v-model = " form . email "
type = "email"
placeholder = "Enter your email"
required
/>
</ UFormGroup >
< UFormGroup label = "Password" >
< UInput
v-model = " form . password "
type = "password"
placeholder = "Enter your password"
required
/>
</ UFormGroup >
< UButton
type = "submit"
color = "primary"
block
: loading = " isLoading "
>
Login
</ UButton >
</ form >
</ UCard >
</ div >
</ template >
Role-Based Access Control
Check Role in Component
< script setup lang = "ts" >
const { hasRole , hasPermission } = useAuth ()
</ script >
< template >
< div >
<!-- Show only for admin -->
< UButton v-if = " hasRole ( 'admin' ) " >
Admin Settings
</ UButton >
<!-- Show only if has permission -->
< UButton v-if = " hasPermission ( 'inventory.write' ) " >
Add Product
</ UButton >
</ div >
</ template >
Route-Level Permission
// middleware/admin.ts
export default defineNuxtRouteMiddleware (() => {
const { hasRole } = useAuth ()
if ( ! hasRole ( 'admin' )) {
return navigateTo ( '/unauthorized' )
}
} )
<!-- app/pages/admin/index.vue -->
< script setup lang = "ts" >
definePageMeta ({
middleware: [ 'admin' ],
})
</ script >
Security Best Practices
NEVER simpan access token di localStorage/sessionStorage
Access token hanya di memory (Pinia store)
Refresh token sebagai HttpOnly cookie
Clear tokens on logout
Semua komunikasi harus via HTTPS
Set Secure flag pada cookies
Use SameSite=Strict atau Lax
Access token: short-lived (15-30 minutes)
Refresh token: longer-lived (7-30 days)
Auto-refresh sebelum expiration
Force re-login jika refresh token expired
Sanitize user input
Use Vue’s built-in XSS protection
Content Security Policy (CSP) headers
Avoid v-html with user content
Next Steps