Identify sessions by session key - a hash that can be safely shared.
This commit is contained in:
@@ -57,11 +57,11 @@ async function handleDelete(credential) {
|
||||
}
|
||||
|
||||
async function handleTerminateSession(session) {
|
||||
const sessionId = session?.id
|
||||
if (!sessionId) return
|
||||
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
|
||||
const sessionKey = session?.key
|
||||
if (!sessionKey) return
|
||||
terminatingSessions.value = { ...terminatingSessions.value, [sessionKey]: true }
|
||||
try {
|
||||
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/sessions/${sessionId}`, { method: 'DELETE' })
|
||||
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/sessions/${sessionKey}`, { method: 'DELETE' })
|
||||
if (data.status === 'ok') {
|
||||
if (data.current_session_terminated) {
|
||||
sessionStorage.clear()
|
||||
@@ -78,7 +78,7 @@ async function handleTerminateSession(session) {
|
||||
authStore.showMessage(err.message || 'Failed to terminate session', 'error')
|
||||
} finally {
|
||||
const next = { ...terminatingSessions.value }
|
||||
delete next[sessionId]
|
||||
delete next[sessionKey]
|
||||
terminatingSessions.value = next
|
||||
}
|
||||
}
|
||||
@@ -232,7 +232,7 @@ defineExpose({ focusFirstElement })
|
||||
</section>
|
||||
<SessionList
|
||||
ref="sessionListRef"
|
||||
:sessions="userDetail.sessions || []"
|
||||
:sessions="userDetail.sessions || {}"
|
||||
:terminating-sessions="terminatingSessions"
|
||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||
:navigation-disabled="hasActiveModal"
|
||||
|
||||
@@ -341,22 +341,22 @@ const handleDelete = async (credential) => {
|
||||
|
||||
const rpName = computed(() => authStore.settings?.rp_name || 'this service')
|
||||
const paskiaVersion = computed(() => authStore.settings?.version || '')
|
||||
const sessions = computed(() => authStore.userInfo?.sessions || [])
|
||||
const sessions = computed(() => authStore.userInfo?.sessions || {})
|
||||
const currentSessionHost = computed(() => {
|
||||
const currentSession = sessions.value.find(session => session.is_current)
|
||||
const currentSession = Object.values(sessions.value).find(session => session.is_current)
|
||||
return currentSession?.host || 'this host'
|
||||
})
|
||||
const terminatingSessions = ref({})
|
||||
|
||||
const terminateSession = async (session) => {
|
||||
const sessionId = session?.id
|
||||
if (!sessionId) return
|
||||
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
|
||||
try { await authStore.terminateSession(sessionId) }
|
||||
const sessionKey = session?.key
|
||||
if (!sessionKey) return
|
||||
terminatingSessions.value = { ...terminatingSessions.value, [sessionKey]: true }
|
||||
try { await authStore.terminateSession(sessionKey) }
|
||||
catch (error) { authStore.showMessage(error.message || 'Failed to terminate session', 'error', 5000) }
|
||||
finally {
|
||||
const next = { ...terminatingSessions.value }
|
||||
delete next[sessionId]
|
||||
delete next[sessionKey]
|
||||
terminatingSessions.value = next
|
||||
}
|
||||
}
|
||||
@@ -368,12 +368,12 @@ const isAdmin = computed(() => {
|
||||
const perms = authStore.ctx?.permissions
|
||||
return perms?.includes('auth:admin') || perms?.includes('auth:org:admin')
|
||||
})
|
||||
const hasMultipleSessions = computed(() => sessions.value.length > 1)
|
||||
const hasMultipleSessions = computed(() => Object.keys(sessions.value).length > 1)
|
||||
const credentials = computed(() => authStore.userInfo?.credentials ? Object.values(authStore.userInfo.credentials) : [])
|
||||
const useWideLayout = computed(() => {
|
||||
// Check if any single site has more than 8 sessions
|
||||
const groups = {}
|
||||
for (const session of sessions.value) {
|
||||
for (const session of Object.values(sessions.value)) {
|
||||
const host = session.host || ''
|
||||
if (!groups[host]) groups[host] = []
|
||||
groups[host].push(session)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<div>
|
||||
<template v-if="Array.isArray(sessions) && sessions.length">
|
||||
<template v-if="sessionsArray.length">
|
||||
<div v-for="(group, key) in groupedSessions" :key="key" class="session-group" tabindex="0" @keydown="handleGroupKeydown($event, key)">
|
||||
<span :class="['session-group-host', { 'is-current-site': group.isCurrentSite }]">
|
||||
<span class="session-group-icon">{{ group.isOIDC ? '🪪' : '🌐' }}</span>
|
||||
@@ -17,10 +17,10 @@
|
||||
<div class="session-list">
|
||||
<div
|
||||
v-for="session in group.sessions"
|
||||
:key="session.id"
|
||||
:key="session.key"
|
||||
:class="['session-item', {
|
||||
'is-current': session.is_current && !hoveredIp && !hoveredCredentialUuid,
|
||||
'is-hovered': hoveredSession?.id === session.id,
|
||||
'is-hovered': hoveredSession?.key === session.key,
|
||||
'is-linked-credential': hoveredCredentialUuid === session.credential
|
||||
}]"
|
||||
tabindex="-1"
|
||||
@@ -34,14 +34,14 @@
|
||||
<h4 class="item-title">{{ session.user_agent || '—' }}</h4>
|
||||
<div class="item-actions">
|
||||
<span v-if="session.is_current && !hoveredIp && !hoveredCredentialUuid" class="badge badge-current">Current</span>
|
||||
<span v-else-if="hoveredSession?.id === session.id" class="badge badge-current">Selected</span>
|
||||
<span v-else-if="hoveredSession?.key === session.key" class="badge badge-current">Selected</span>
|
||||
<span v-else-if="hoveredCredentialUuid === session.credential" class="badge badge-current">Linked</span>
|
||||
<span v-else-if="!hoveredCredentialUuid && isSameHost(session.ip)" class="badge">Same IP</span>
|
||||
<button
|
||||
@click="$emit('terminate', session)"
|
||||
class="btn-card-delete"
|
||||
:disabled="isTerminating(session.id)"
|
||||
:title="isTerminating(session.id) ? 'Terminating...' : 'Terminate session'"
|
||||
:disabled="isTerminating(session.key)"
|
||||
:title="isTerminating(session.key) ? 'Terminating...' : 'Terminate session'"
|
||||
tabindex="-1"
|
||||
>❌</button>
|
||||
</div>
|
||||
@@ -70,7 +70,7 @@ import { hostIP } from '@/utils/helpers'
|
||||
import { navigateGrid, handleDeleteKey, handleEscape, getDirection } from '@/utils/keynav'
|
||||
|
||||
const props = defineProps({
|
||||
sessions: { type: Array, default: () => [] },
|
||||
sessions: { type: Object, default: () => ({}) },
|
||||
emptyMessage: { type: String, default: 'You currently have no other active sessions.' },
|
||||
sectionDescription: { type: String, default: "Review where you're signed in and end any sessions you no longer recognize." },
|
||||
terminatingSessions: { type: Object, default: () => ({}) },
|
||||
@@ -108,7 +108,7 @@ const handleCardClick = (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
const isTerminating = (sessionId) => !!props.terminatingSessions[sessionId]
|
||||
const isTerminating = (sessionKey) => !!props.terminatingSessions[sessionKey]
|
||||
|
||||
const handleGroupKeydown = (event, host) => {
|
||||
const group = event.currentTarget
|
||||
@@ -151,7 +151,7 @@ const handleGroupKeydown = (event, host) => {
|
||||
const handleItemKeydown = (event, session) => {
|
||||
// Handle delete (always allowed even with modal)
|
||||
handleDeleteKey(event, () => {
|
||||
if (!isTerminating(session.id)) emit('terminate', session)
|
||||
if (!isTerminating(session.key)) emit('terminate', session)
|
||||
})
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
@@ -210,9 +210,14 @@ const copyIp = async (ip) => {
|
||||
|
||||
const displayIp = ip => hostIP(ip) ?? ip
|
||||
|
||||
// Convert sessions dict to array with key attached
|
||||
const sessionsArray = computed(() =>
|
||||
Object.entries(props.sessions || {}).map(([key, session]) => ({ ...session, key }))
|
||||
)
|
||||
|
||||
const currentHostIP = computed(() => {
|
||||
if (hoveredIp.value) return hostIP(hoveredIp.value)
|
||||
const current = props.sessions.find(s => s.is_current)
|
||||
const current = sessionsArray.value.find(s => s.is_current)
|
||||
return current ? hostIP(current.ip) : null
|
||||
})
|
||||
|
||||
@@ -220,15 +225,15 @@ const isSameHost = ip => currentHostIP.value && hostIP(ip) === currentHostIP.val
|
||||
|
||||
const groupedSessions = computed(() => {
|
||||
const groups = {}
|
||||
for (const session of props.sessions) {
|
||||
const key = session.client || session.host || ''
|
||||
if (!groups[key]) {
|
||||
groups[key] = { sessions: [], isCurrentSite: false, isOIDC: !!session.client, displayName: session.client_name || key }
|
||||
for (const session of sessionsArray.value) {
|
||||
const groupKey = session.client || session.host || ''
|
||||
if (!groups[groupKey]) {
|
||||
groups[groupKey] = { sessions: [], isCurrentSite: false, isOIDC: !!session.client, displayName: session.client_name || groupKey }
|
||||
}
|
||||
groups[key].sessions.push(session)
|
||||
if (session.is_current_host) groups[key].isCurrentSite = true
|
||||
groups[groupKey].sessions.push(session)
|
||||
if (session.is_current_host) groups[groupKey].isCurrentSite = true
|
||||
}
|
||||
for (const key in groups) groups[key].sessions.sort((a, b) => new Date(b.last_renewed) - new Date(a.last_renewed))
|
||||
for (const groupKey in groups) groups[groupKey].sessions.sort((a, b) => new Date(b.last_renewed) - new Date(a.last_renewed))
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
|
||||
const sorted = Object.entries(groups).sort(([, a], [, b]) => {
|
||||
if (a.isOIDC !== b.isOIDC) return a.isOIDC ? 1 : -1
|
||||
|
||||
@@ -104,9 +104,9 @@ export const useAuthStore = defineStore('auth', {
|
||||
await apiJson(`/auth/api/user/credential/${uuid}`, { method: 'DELETE' })
|
||||
await this.loadUserInfo()
|
||||
},
|
||||
async terminateSession(sessionId) {
|
||||
async terminateSession(sessionKey) {
|
||||
try {
|
||||
const payload = await apiJson(`/auth/api/user/session/${sessionId}`, { method: 'DELETE' })
|
||||
const payload = await apiJson(`/auth/api/user/session/${sessionKey}`, { method: 'DELETE' })
|
||||
if (payload?.current_session_terminated) {
|
||||
sessionStorage.clear()
|
||||
location.reload()
|
||||
|
||||
@@ -12,6 +12,7 @@ from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
import uuid7
|
||||
import base64url
|
||||
|
||||
from paskia.config import SESSION_LIFETIME
|
||||
from paskia.db.jsonl import (
|
||||
@@ -588,7 +589,7 @@ def login(
|
||||
session = Session.create(
|
||||
user=user_uuid,
|
||||
credential=credential_uuid,
|
||||
key=hash_secret("cookie", token),
|
||||
key=base64url.enc(hash_secret("cookie", token)),
|
||||
host=host,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
@@ -656,7 +657,7 @@ def create_credential_session(
|
||||
|
||||
# Generate token and derive key
|
||||
token = secrets.token_urlsafe(12)
|
||||
key = hash_secret("cookie", token)
|
||||
key = base64url.enc(hash_secret("cookie", token))
|
||||
|
||||
session = Session.create(
|
||||
user=user_uuid,
|
||||
|
||||
@@ -7,6 +7,7 @@ from uuid import UUID
|
||||
|
||||
import msgspec
|
||||
import uuid7
|
||||
import base64url
|
||||
|
||||
from paskia import db
|
||||
from paskia.util import hostutil
|
||||
@@ -383,7 +384,7 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
|
||||
def __post_init__(self):
|
||||
if not hasattr(self, "key"):
|
||||
self.key: bytes = b""
|
||||
self.key: str = ""
|
||||
|
||||
@property
|
||||
def user(self) -> User:
|
||||
@@ -423,7 +424,7 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
cls,
|
||||
user: UUID | User,
|
||||
credential: UUID | Credential,
|
||||
key: bytes,
|
||||
key: str,
|
||||
host: str,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
@@ -433,7 +434,7 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
"""Create a new Session with the provided key.
|
||||
|
||||
Args:
|
||||
key: The hashed session key (derived from secret via hash_secret)
|
||||
key: The base64url-encoded hashed session key (derived from secret via hash_secret then base64url.enc)
|
||||
|
||||
Returns:
|
||||
Session object with key set
|
||||
@@ -625,7 +626,7 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
roles: dict[UUID, Role] = {}
|
||||
users: dict[UUID, User] = {}
|
||||
credentials: dict[UUID, Credential] = {}
|
||||
sessions: dict[bytes, Session] = {}
|
||||
sessions: dict[str, Session] = {}
|
||||
reset_tokens: dict[bytes, ResetToken] = {}
|
||||
# OIDC provider data
|
||||
oidc: OIDC = msgspec.field(default_factory=lambda: OIDC())
|
||||
@@ -669,7 +670,7 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
SessionContext if valid, None if session not found, expired, or host mismatch
|
||||
"""
|
||||
|
||||
key = hash_secret("cookie", session_secret)
|
||||
key = base64url.enc(hash_secret("cookie", session_secret))
|
||||
try:
|
||||
s = self.sessions[key]
|
||||
except KeyError:
|
||||
|
||||
@@ -564,15 +564,15 @@ async def admin_get_user_detail(
|
||||
)
|
||||
normalized_host = hostutil.normalize_host(request.headers.get("host"))
|
||||
|
||||
sessions = [
|
||||
ApiUserSession.from_db(
|
||||
sessions = {
|
||||
s.key: ApiUserSession.from_db(
|
||||
s,
|
||||
current_key=auth,
|
||||
current_key=ctx.session.key,
|
||||
normalized_host=normalized_host,
|
||||
expires_delta=EXPIRES,
|
||||
)
|
||||
for s in user.sessions
|
||||
]
|
||||
}
|
||||
|
||||
return MsgspecResponse(
|
||||
ApiUserDetail(
|
||||
@@ -716,10 +716,7 @@ async def admin_delete_user_session(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
|
||||
try:
|
||||
session_key = base64url.dec(session_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid session ID format")
|
||||
session_key = session_id
|
||||
|
||||
target_session = db.data().sessions.get(session_key)
|
||||
if not target_session or target_session.user_uuid != user_uuid:
|
||||
|
||||
@@ -223,8 +223,7 @@ async def api_user_info(
|
||||
return MsgspecResponse(
|
||||
await userinfo.build_user_info(
|
||||
user_uuid=ctx.user.uuid,
|
||||
auth=auth,
|
||||
session_record=ctx.session,
|
||||
session_key=ctx.session.key,
|
||||
request_host=request.headers.get("host"),
|
||||
ctx=ctx,
|
||||
)
|
||||
|
||||
@@ -41,7 +41,7 @@ def _oidc_session_by_token(
|
||||
token: str, client_uuid: UUID | None = None
|
||||
) -> Session | None:
|
||||
"""Look up an OIDC session by token (refresh token value)."""
|
||||
key = hash_secret("oidc", token)
|
||||
key = base64url.enc(hash_secret("oidc", token))
|
||||
s = db.data().sessions.get(key)
|
||||
if not s or s.client_uuid is None:
|
||||
return None
|
||||
@@ -57,7 +57,7 @@ def _oidc_session_by_sid(sid: bytes, client_uuid: UUID | None = None) -> Session
|
||||
continue
|
||||
if client_uuid is not None and s.client_uuid != client_uuid:
|
||||
continue
|
||||
if hash_secret("oidc", s.key) == sid:
|
||||
if base64url.dec(s.key) == sid:
|
||||
return s
|
||||
return None
|
||||
|
||||
@@ -235,7 +235,7 @@ async def _handle_authorization_code(
|
||||
)
|
||||
|
||||
# Derive sid from session key
|
||||
sid = base64url.enc(hash_secret("oidc", session.key))
|
||||
sid = session.key
|
||||
|
||||
return _build_token_response(
|
||||
request,
|
||||
@@ -312,7 +312,7 @@ async def _handle_refresh_token(
|
||||
_logger.info("OIDC session refreshed: %s", session.key)
|
||||
|
||||
# Base64url encode session's derived sid for JWT claim
|
||||
sid_str = base64url.enc(hash_secret("oidc", session.key))
|
||||
sid_str = session.key
|
||||
|
||||
return _build_token_response(
|
||||
request,
|
||||
|
||||
@@ -158,10 +158,7 @@ async def api_delete_session(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
)
|
||||
|
||||
try:
|
||||
session_key = base64url.dec(session_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid session ID format")
|
||||
session_key = session_id
|
||||
|
||||
target_session = db.data().sessions.get(session_key)
|
||||
if not target_session or target_session.user_uuid != ctx.user.uuid:
|
||||
|
||||
@@ -122,7 +122,7 @@ class ApiUserDetail(msgspec.Struct, kw_only=True):
|
||||
user: ApiUser
|
||||
credentials: dict[UUID, Credential]
|
||||
aaguid_info: dict[str, ApiAaguidInfo]
|
||||
sessions: list[ApiUserSession]
|
||||
sessions: dict[bytes, ApiUserSession]
|
||||
permissions: dict[UUID, ApiPermission] = {}
|
||||
org: ApiOrg | None = None
|
||||
role: ApiRole | None = None
|
||||
|
||||
@@ -37,8 +37,7 @@ def build_session_context(ctx: SessionContext) -> ApiSessionContext:
|
||||
async def build_user_info(
|
||||
*,
|
||||
user_uuid,
|
||||
auth: str,
|
||||
session_record,
|
||||
session_key: str,
|
||||
request_host: str | None,
|
||||
ctx: SessionContext | None = None,
|
||||
) -> ApiUserDetail:
|
||||
@@ -46,15 +45,15 @@ async def build_user_info(
|
||||
user = db.data().users[user_uuid]
|
||||
normalized_host = hostutil.normalize_host(request_host)
|
||||
|
||||
sessions = [
|
||||
ApiUserSession.from_db(
|
||||
sessions = {
|
||||
s.key: ApiUserSession.from_db(
|
||||
s,
|
||||
current_key=auth,
|
||||
current_key=session_key,
|
||||
normalized_host=normalized_host,
|
||||
expires_delta=EXPIRES,
|
||||
)
|
||||
for s in user.sessions
|
||||
]
|
||||
}
|
||||
|
||||
return ApiUserDetail(
|
||||
user=ApiUser.from_db(user),
|
||||
|
||||
Reference in New Issue
Block a user