mirror of https://github.com/veypi/OneAuth.git
feat(auth): replace user-level token version with session-based authentication
- Replace global user token version with per-session versioning in JWT claims
- Add session CRUD operations with DB + Redis dual-write caching strategy
- Create/list/revoke individual sessions and batch revoke other sessions
- Update login flow to create sessions with device info and IP extraction
- Update refresh flow to validate and rotate session-level token version
- Update logout to revoke only the current session instead of all tokens
- Add session management UI page with device/browser detection and relative time display
- Add i18n keys for session management in both Chinese and English
- Add sessions route and navigation menu items in both default and icon layouts
master
parent
99c1e0c148
commit
3913640f5b
@ -0,0 +1,326 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta name="description" content="Session Management">
|
||||
<title>{{ $t('session.title') }}</title>
|
||||
<style>
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: var(--spacing-xl);
|
||||
padding-bottom: var(--spacing-xl);
|
||||
box-sizing: border-box;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sessions-container {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
padding: var(--spacing-xl);
|
||||
background: var(--bg-color-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.session-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.session-item:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.session-item.current {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary-light, #f0f7ff);
|
||||
}
|
||||
|
||||
.session-left {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-md);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-color-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.session-item.current .session-icon {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
}
|
||||
|
||||
.session-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-device {
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.session-meta {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
margin-top: 4px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-light);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.session-meta span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.current-badge {
|
||||
font-size: 0.75rem;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
margin-left: var(--spacing-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--spacing-xl);
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 48px;
|
||||
margin-bottom: var(--spacing-md);
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="sessions-container">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">{{ $t('session.title') }}</h2>
|
||||
<v-btn v-if="otherSessions.length > 0" size="sm" variant="outline" color="danger" :click="revokeAllOthers">
|
||||
{{ $t('session.revoke_others') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" style="text-align: center; padding: var(--spacing-xl);">
|
||||
{{ $t('common.processing') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="session-list">
|
||||
<div v-if="sessions.length === 0" class="empty-state">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
<p>{{ $t('session.empty') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-for="s in sessions"
|
||||
class="session-item"
|
||||
:class="{ current: s.is_current }">
|
||||
<div class="session-left">
|
||||
<div class="session-icon">
|
||||
<i class="fas" :class="getDeviceIcon(s.device_info)"></i>
|
||||
</div>
|
||||
<div class="session-info">
|
||||
<div class="session-device">
|
||||
{{ formatDevice(s.device_info) }}
|
||||
<span v-if="s.is_current" class="current-badge">{{ $t('session.current') }}</span>
|
||||
</div>
|
||||
<div class="session-meta">
|
||||
<span><i class="fas fa-map-marker-alt"></i> {{ s.ip || '-' }}</span>
|
||||
<span><i class="fas fa-clock"></i> {{ formatLoginTime(s.created_at) }}</span>
|
||||
<span v-if="s.expires_at"><i class="fas fa-hourglass-half"></i> {{ $t('session.expires') }}: {{ formatExpiry(s.expires_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-actions">
|
||||
<v-btn v-if="!s.is_current" size="sm" variant="outline" color="danger" :click="() => revokeSession(s.id)">
|
||||
{{ $t('session.revoke') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
<script setup>
|
||||
sessions = [];
|
||||
loading = true;
|
||||
|
||||
loadSessions = async () => {
|
||||
loading = true;
|
||||
try {
|
||||
sessions = await $fetch('/api/auth/sessions') || [];
|
||||
} catch (e) {
|
||||
$message.error(e.message);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
revokeSession = async (id) => {
|
||||
try {
|
||||
await $message.confirm($t('session.revoke_confirm'));
|
||||
await $fetch(`/api/auth/sessions/${id}`, { method: 'DELETE' });
|
||||
$message.success($t('session.revoke_success'));
|
||||
loadSessions();
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') $message.error(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
revokeAllOthers = async () => {
|
||||
try {
|
||||
await $message.confirm($t('session.revoke_others_confirm'));
|
||||
await $fetch('/api/auth/sessions', { method: 'DELETE' });
|
||||
$message.success($t('session.revoke_success'));
|
||||
loadSessions();
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') $message.error(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
otherSessions = () => sessions.filter(s => !s.is_current);
|
||||
|
||||
getDeviceIcon = (ua) => {
|
||||
if (!ua) return 'fa-desktop';
|
||||
const lower = ua.toLowerCase();
|
||||
if (lower.indexOf('iphone') > -1 || lower.indexOf('ipad') > -1) return 'fa-mobile-alt';
|
||||
if (lower.indexOf('android') > -1) return 'fa-android';
|
||||
if (lower.indexOf('macintosh') > -1 || lower.indexOf('mac os') > -1) return 'fa-apple';
|
||||
if (lower.indexOf('windows') > -1) return 'fa-windows';
|
||||
if (lower.indexOf('linux') > -1) return 'fa-linux';
|
||||
if (lower.indexOf('chrome') > -1 || lower.indexOf('firefox') > -1 || lower.indexOf('safari') > -1) return 'fa-globe';
|
||||
return 'fa-desktop';
|
||||
};
|
||||
|
||||
formatDevice = (ua) => {
|
||||
if (!ua) return '-';
|
||||
// Extract browser + OS name from UA string
|
||||
let name = '';
|
||||
if (ua.indexOf('iPhone') > -1) {
|
||||
name = 'iPhone';
|
||||
} else if (ua.indexOf('iPad') > -1) {
|
||||
name = 'iPad';
|
||||
} else if (ua.indexOf('Android') > -1) {
|
||||
name = 'Android';
|
||||
} else if (ua.indexOf('Macintosh') > -1 || ua.indexOf('Mac OS') > -1) {
|
||||
name = 'Mac';
|
||||
} else if (ua.indexOf('Windows') > -1) {
|
||||
name = 'Windows';
|
||||
} else if (ua.indexOf('Linux') > -1) {
|
||||
name = 'Linux';
|
||||
} else {
|
||||
name = 'Unknown';
|
||||
}
|
||||
|
||||
// Browser
|
||||
if (ua.indexOf('Edg') > -1) name += ' · Edge';
|
||||
else if (ua.indexOf('Chrome') > -1) name += ' · Chrome';
|
||||
else if (ua.indexOf('Firefox') > -1) name += ' · Firefox';
|
||||
else if (ua.indexOf('Safari') > -1) name += ' · Safari';
|
||||
|
||||
return name;
|
||||
};
|
||||
|
||||
parseISO = (t) => {
|
||||
if (!t) return null;
|
||||
// 兼容 Safari 不支持 +08:00 冒号格式
|
||||
const s = t.replace(/([+-]\d{2}):(\d{2})$/, '$1$2');
|
||||
const d = new Date(s);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
};
|
||||
|
||||
formatLoginTime = (t) => {
|
||||
const d = parseISO(t);
|
||||
if (!d) return '-';
|
||||
const now = new Date();
|
||||
const diff = now - d;
|
||||
if (diff < 0) return formatDate(d);
|
||||
if (diff < 60000) return $t('session.just_now');
|
||||
if (diff < 3600000) return Math.floor(diff / 60000) + ' ' + $t('session.minutes_ago');
|
||||
if (diff < 86400000) return Math.floor(diff / 3600000) + ' ' + $t('session.hours_ago');
|
||||
if (diff < 604800000) return Math.floor(diff / 86400000) + ' ' + $t('session.days_ago');
|
||||
return formatDate(d);
|
||||
};
|
||||
|
||||
formatExpiry = (t) => {
|
||||
const d = parseISO(t);
|
||||
if (!d) return '-';
|
||||
return formatDate(d);
|
||||
};
|
||||
|
||||
formatDate = (d) => {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const h = String(d.getHours()).padStart(2, '0');
|
||||
const min = String(d.getMinutes()).padStart(2, '0');
|
||||
return y + '-' + m + '-' + day + ' ' + h + ':' + min;
|
||||
};
|
||||
</script>
|
||||
<script>
|
||||
$data.loadSessions();
|
||||
</script>
|
||||
|
||||
</html>
|
||||
Loading…
Reference in New Issue