iducationducation
IndexArticlesFormationsProfilOutilsBibliothech
N°014 — 2026
Navigation
01Index02Articles03Formations04Profil05Outils06Bibliothech
N°014 — 2026
iducationducation
IndexArticlesFormationsProfilOutilsBibliothech
N°014 — 2026
Navigation
01Index02Articles03Formations04Profil05Outils06Bibliothech
N°014 — 2026
Formations
TypeScript · Avancé

OpenCaddyHost

Construire un dashboard Nuxt 4 pour piloter Caddy via son Admin API — authentification JWT, reverse proxy CRUD, import/export, et déploiement Docker Compose.

Nuxt 4Vue 3TypeScriptPiniaCaddyDocker
GitHub
01Setup et configuration02L'API Admin de Caddy03Server routes Nuxt 404Authentification JWT05Client Caddy et gestion des proxies06Interface et composants Vue07Déploiement Docker Compose
Chapitre 4·30 min

Authentification JWT

OpenCaddyHost est une application mono-utilisateur : un seul compte admin, défini dans .env. Pas de base de données, pas d'inscription. L'authentification repose sur un JWT signé côté serveur.

Le flux complet

Client → POST /api/auth { username, password }
Serveur → vérifie les credentials → génère JWT → renvoie { token }
Client → stocke le token dans localStorage via Pinia
Client → envoie Authorization: Bearer <token> sur chaque requête protégée
Serveur → vérifie la signature du JWT → autorise ou rejette

Endpoint de login

server/api/auth.post.ts
import jwt from 'jsonwebtoken'
import bcrypt from 'bcrypt'
import { readBody, createError } from 'h3'
 
export default defineEventHandler(async (event) => {
  const config = useRuntimeConfig()
  const body = await readBody(event)
  const { username, password } = body
 
  // Validation basique
  if (!username || !password) {
    throw createError({ statusCode: 400, statusMessage: 'Identifiants manquants' })
  }
 
  // Vérification username
  if (username !== config.adminUsername) {
    throw createError({ statusCode: 401, statusMessage: 'Identifiants invalides' })
  }
 
  // Vérification password
  const isValid = await bcrypt.compare(password, await bcrypt.hash(config.adminPassword, 10))
    || password === config.adminPassword
 
  if (!isValid) {
    throw createError({ statusCode: 401, statusMessage: 'Identifiants invalides' })
  }
 
  // Générer le JWT
  const token = jwt.sign(
    { username, role: 'admin' },
    config.jwtSecret,
    { expiresIn: '24h' }
  )
 
  return { success: true, token, user: { username, role: 'admin' } }
})

Le token expire dans 24h. jwt.sign encode le payload et le signe avec jwtSecret — sans ce secret, impossible de forger un token valide.

Vérification du token

server/api/auth/verify.get.ts
import jwt from 'jsonwebtoken'
 
export default defineEventHandler(async (event) => {
  const config = useRuntimeConfig()
  const user = authenticateToken(event) // throws 401/403 si invalide
 
  return { valid: true, user }
})

Le client appelle cet endpoint au démarrage pour valider que le token stocké est toujours valide.

Pinia auth store

app/stores/auth.ts
import { defineStore } from 'pinia'
 
interface AuthState {
  token: string | null
  user: { username: string; role: string } | null
  isAuthenticated: boolean
}
 
export const useAuthStore = defineStore('auth', {
  state: (): AuthState => ({
    token: null,
    user: null,
    isAuthenticated: false,
  }),
 
  actions: {
    async login(username: string, password: string) {
      const data = await $fetch('/api/auth', {
        method: 'POST',
        body: { username, password },
      })
 
      this.token = data.token
      this.user = data.user
      this.isAuthenticated = true
 
      // Persister dans localStorage
      localStorage.setItem('auth_token', data.token)
      localStorage.setItem('auth_user', JSON.stringify(data.user))
 
      // Planifier la déconnexion auto avant expiration
      this.scheduleTokenCleanup()
    },
 
    logout() {
      this.token = null
      this.user = null
      this.isAuthenticated = false
      localStorage.removeItem('auth_token')
      localStorage.removeItem('auth_user')
    },
 
    initAuth() {
      const token = localStorage.getItem('auth_token')
      const user = localStorage.getItem('auth_user')
 
      if (!token || !user) return
 
      // Vérifier l'expiration sans appel serveur
      if (this.isTokenExpired(token)) {
        this.logout()
        return
      }
 
      this.token = token
      this.user = JSON.parse(user)
      this.isAuthenticated = true
      this.scheduleTokenCleanup()
    },
 
    isTokenExpired(token: string): boolean {
      try {
        const payload = JSON.parse(atob(token.split('.')[1]))
        return payload.exp * 1000 < Date.now()
      } catch {
        return true
      }
    },
 
    scheduleTokenCleanup() {
      if (!this.token) return
      try {
        const payload = JSON.parse(atob(this.token.split('.')[1]))
        const msUntilExpiry = payload.exp * 1000 - Date.now() - 60_000 // 1 min avant
        if (msUntilExpiry > 0) {
          setTimeout(() => this.logout(), msUntilExpiry)
        }
      } catch { /* token malformé */ }
    },
 
    getAuthHeaders(): Record<string, string> {
      if (!this.token || this.isTokenExpired(this.token)) {
        this.logout()
        return {}
      }
      return { Authorization: `Bearer ${this.token}` }
    },
  },
})

scheduleTokenCleanup calcule le temps restant avant expiration et programme un setTimeout pour déconnecter l'utilisateur proprement — pas de session expirée silencieuse.

Composable useAuth

app/composables/useAuth.ts
export function useAuth() {
  const authStore = useAuthStore()
  const router = useRouter()
 
  async function login(username: string, password: string) {
    await authStore.login(username, password)
    await router.push('/')
  }
 
  function logout() {
    authStore.logout()
    router.push('/login')
  }
 
  // Vérification périodique toutes les 5 minutes
  function startAuthCheck() {
    setInterval(async () => {
      if (!authStore.isAuthenticated) return
      try {
        await $fetch('/api/auth/verify', {
          headers: authStore.getAuthHeaders(),
        })
      } catch {
        logout()
      }
    }, 5 * 60 * 1000)
  }
 
  return {
    isAuthenticated: computed(() => authStore.isAuthenticated),
    user: computed(() => authStore.user),
    login,
    logout,
    startAuthCheck,
    getAuthHeaders: () => authStore.getAuthHeaders(),
  }
}

Page de login

app/pages/login.vue
<script setup lang="ts">
definePageMeta({ layout: false }) // Pas de sidebar sur la page login
 
const { login } = useAuth()
const form = reactive({ username: '', password: '' })
const error = ref('')
const loading = ref(false)
 
async function submit() {
  loading.value = true
  error.value = ''
  try {
    await login(form.username, form.password)
  } catch (e: any) {
    error.value = e.data?.statusMessage || 'Identifiants invalides'
  } finally {
    loading.value = false
  }
}
</script>
 
<template>
  <div class="min-h-screen flex items-center justify-center bg-gray-900">
    <form @submit.prevent="submit" class="bg-gray-800 p-8 rounded-xl w-full max-w-sm space-y-4">
      <h1 class="text-2xl font-bold text-white">OpenCaddyHost</h1>
 
      <p v-if="error" class="text-red-400 text-sm">{{ error }}</p>
 
      <input v-model="form.username" type="text" placeholder="Identifiant" class="input" required />
      <input v-model="form.password" type="password" placeholder="Mot de passe" class="input" required />
 
      <button type="submit" :disabled="loading" class="btn-primary w-full">
        {{ loading ? 'Connexion...' : 'Se connecter' }}
      </button>
    </form>
  </div>
</template>

Middleware de protection des routes

app/middleware/auth.global.ts
export default defineNuxtRouteMiddleware((to) => {
  if (to.path === '/login') return
 
  const authStore = useAuthStore()
  authStore.initAuth() // restaure depuis localStorage
 
  if (!authStore.isAuthenticated) {
    return navigateTo('/login')
  }
})

Le suffixe .global.ts applique ce middleware sur toutes les routes sans le déclarer page par page.

Précédent
Server routes Nuxt 4
Suivant
Client Caddy et gestion des proxies

Développeur fullstack passionné. J'apprends en construisant et je documente tout — front, back, outils. Le code s'apprend mieux en public.

Naviguer

IndexTous les articlesFormationsProfilOutilsBibliothech

Ailleurs

GitHub RSS

Newsletter

Les articles, libs et découvertes. Une fois par semaine, pas plus.

© 2026 William LoreeConçu & codé à la main