feat: add login authentication and certificate expiry notifications

- JWT-based login with ADMIN_USER/ADMIN_PASSWORD env config
- Login page with auth guard for Vue frontend
- Feishu bot webhook notification for expiring certificates
- SMTP email notification for expiring certificates
- Daily 8:00 AM cron for expiry checks
- Startup health check notification
This commit is contained in:
2026-07-23 16:25:02 +08:00
parent 1bb895fd67
commit 2d70a15307
17 changed files with 2813 additions and 66 deletions
+1753
View File
File diff suppressed because it is too large Load Diff
+92 -38
View File
@@ -1,49 +1,83 @@
<template>
<div id="app">
<el-container style="min-height: 100vh">
<el-header class="app-header">
<div class="header-left">
<el-icon :size="24"><Link /></el-icon>
<span class="header-title">AutoSSL 证书管理</span>
</div>
<div class="header-right">
<el-tag type="success" size="small">运行中</el-tag>
</div>
</el-header>
<el-container>
<el-aside width="200px">
<el-menu
:default-active="route.path"
router
background-color="#001529"
text-color="#ffffffb3"
active-text-color="#fff"
>
<el-menu-item index="/">
<el-icon><HomeFilled /></el-icon>
<span>仪表盘</span>
</el-menu-item>
<el-menu-item index="/certificates">
<el-icon><Document /></el-icon>
<span>证书列表</span>
</el-menu-item>
<el-menu-item index="/create">
<el-icon><Plus /></el-icon>
<span>申请证书</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-main>
<router-view />
</el-main>
<template v-if="route.path === '/login'">
<router-view />
</template>
<template v-else>
<el-container style="min-height: 100vh">
<el-header class="app-header">
<div class="header-left">
<el-icon :size="24"><Link /></el-icon>
<span class="header-title">AutoSSL 证书管理</span>
</div>
<div class="header-right">
<el-tag type="success" size="small" style="margin-right: 12px">运行中</el-tag>
<el-dropdown trigger="click" @command="handleCommand">
<span class="user-dropdown">
<el-icon><UserFilled /></el-icon>
{{ username }}
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">
<el-icon><SwitchButton /></el-icon> 退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-container>
<el-aside width="200px">
<el-menu
:default-active="route.path"
router
background-color="#001529"
text-color="#ffffffb3"
active-text-color="#fff"
>
<el-menu-item index="/">
<el-icon><HomeFilled /></el-icon>
<span>仪表盘</span>
</el-menu-item>
<el-menu-item index="/certificates">
<el-icon><Document /></el-icon>
<span>证书列表</span>
</el-menu-item>
<el-menu-item index="/create">
<el-icon><Plus /></el-icon>
<span>申请证书</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-main>
<router-view />
</el-main>
</el-container>
</el-container>
</el-container>
</template>
</div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowDown, SwitchButton } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const username = ref(localStorage.getItem('username') || 'admin')
const handleCommand = (cmd: string) => {
if (cmd === 'logout') {
localStorage.removeItem('token')
localStorage.removeItem('username')
localStorage.removeItem('token_expires_at')
router.push('/login')
}
}
</script>
<style>
@@ -81,6 +115,26 @@ body {
letter-spacing: 1px;
}
.header-right {
display: flex;
align-items: center;
}
.user-dropdown {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 4px 12px;
border-radius: 4px;
transition: background 0.2s;
font-size: 14px;
}
.user-dropdown:hover {
background: rgba(255, 255, 255, 0.15);
}
.el-aside {
background-color: #001529;
min-height: calc(100vh - 60px);
+61
View File
@@ -5,6 +5,35 @@ const api = axios.create({
timeout: 300000,
})
// Request interceptor: attach JWT token to all requests
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
// Response interceptor: handle 401 unauthorized (redirect to login)
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('username')
localStorage.removeItem('token_expires_at')
// Only redirect if not already on login page
if (window.location.pathname !== '/login') {
window.location.href = '/login'
}
}
return Promise.reject(error)
}
)
export interface Certificate {
id: number
domain: string
@@ -42,6 +71,38 @@ export interface CreateCertRequest {
renew_days?: number
}
export interface LoginResponse {
token: string
username: string
expires_at: number
}
// Auth API
export const authApi = {
login: (username: string, password: string) =>
api.post<LoginResponse>('/login', { username, password }),
health: () => api.get('/health'),
}
// Check if user is logged in
export const isLoggedIn = (): boolean => {
const token = localStorage.getItem('token')
if (!token) return false
const expiresAt = localStorage.getItem('token_expires_at')
if (expiresAt) {
const now = Math.floor(Date.now() / 1000)
if (now >= Number(expiresAt)) {
localStorage.removeItem('token')
localStorage.removeItem('username')
localStorage.removeItem('token_expires_at')
return false
}
}
return true
}
// Certificate API
export const certApi = {
list: () => api.get<Certificate[]>('/certificates'),
get: (id: number) => api.get<Certificate>(`/certificates/${id}`),
+46 -3
View File
@@ -2,14 +2,57 @@ import { createRouter, createWebHistory } from 'vue-router'
import Dashboard from '../views/Dashboard.vue'
import CertList from '../views/CertList.vue'
import CertCreate from '../views/CertCreate.vue'
import Login from '../views/Login.vue'
import { isLoggedIn } from '../api'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'Dashboard', component: Dashboard },
{ path: '/certificates', name: 'CertList', component: CertList },
{ path: '/create', name: 'CertCreate', component: CertCreate },
{
path: '/login',
name: 'Login',
component: Login,
meta: { requiresAuth: false },
},
{
path: '/',
name: 'Dashboard',
component: Dashboard,
meta: { requiresAuth: true },
},
{
path: '/certificates',
name: 'CertList',
component: CertList,
meta: { requiresAuth: true },
},
{
path: '/create',
name: 'CertCreate',
component: CertCreate,
meta: { requiresAuth: true },
},
],
})
// Navigation guard
router.beforeEach((to, _, next) => {
const requiresAuth = to.meta.requiresAuth !== false
if (requiresAuth) {
if (isLoggedIn()) {
next()
} else {
next({ name: 'Login', query: { redirect: to.fullPath } })
}
} else {
// If already logged in and going to login, redirect to dashboard
if (to.name === 'Login' && isLoggedIn()) {
next({ name: 'Dashboard' })
} else {
next()
}
}
})
export default router
+145
View File
@@ -0,0 +1,145 @@
<template>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<el-icon :size="32" color="#1890ff"><Link /></el-icon>
<h2>AutoSSL 证书管理</h2>
<p class="login-desc">请登录以继续</p>
</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="0"
@keyup.enter="handleLogin"
>
<el-form-item prop="username">
<el-input
v-model="form.username"
placeholder="用户名"
size="large"
:prefix-icon="User"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="密码"
size="large"
show-password
:prefix-icon="Lock"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
style="width: 100%"
:loading="loading"
@click="handleLogin"
>
</el-button>
</el-form-item>
</el-form>
<div class="login-footer">
<p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { User, Lock } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { authApi } from '../api'
const router = useRouter()
const formRef = ref()
const loading = ref(false)
const errorMsg = ref('')
const form = reactive({
username: '',
password: '',
})
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}
const handleLogin = async () => {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
loading.value = true
errorMsg.value = ''
try {
const res = await authApi.login(form.username, form.password)
const { token, username, expires_at } = res.data
localStorage.setItem('token', token)
localStorage.setItem('username', username)
localStorage.setItem('token_expires_at', String(expires_at))
ElMessage.success(`欢迎回来, ${username}!`)
router.push('/')
} catch (e: any) {
errorMsg.value = e.response?.data?.error || '登录失败,请检查用户名和密码'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background-size: cover;
}
.login-card {
width: 400px;
background: white;
border-radius: 12px;
padding: 40px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-header h2 {
margin-top: 12px;
font-size: 24px;
color: #1a1a2e;
}
.login-desc {
margin-top: 8px;
color: #999;
font-size: 14px;
}
.login-footer {
text-align: center;
margin-top: 8px;
}
.error-msg {
color: #f56c6c;
font-size: 13px;
}
:deep(.el-input__prefix) {
font-size: 18px;
}
</style>