Implement code word based remote authentication (#1)
Add comprehensive remote authentication system allowing users to log in from one device by authenticating from another trusted device. Features include: - Proof of Work (PoW) protection using PBKDF2-SHA512 to prevent abuse - Simple pairing codes (3 words) protected by dynamic PoW difficulty - Autocomplete pairing code input with error checking - Real-time WebSocket communication between devices Unlike device addition links and reset links with QR codes that only allow adding an authentication method, and that work offline over the duration of several days, this mechanism is strictly online, with 5 minute time limit.
This commit is contained in:
@@ -0,0 +1,535 @@
|
||||
<template>
|
||||
<div class="remote-auth-inline">
|
||||
<!-- Success state -->
|
||||
<div v-if="completed" class="success-section">
|
||||
<p class="success-message">✅ {{ successMessage }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="error-section">
|
||||
<p class="error-message">{{ error }}</p>
|
||||
<button class="btn-primary" @click="retry" style="margin-top: 0.75rem;">Try Again</button>
|
||||
</div>
|
||||
|
||||
<!-- Connecting phase -->
|
||||
<div v-else-if="phase === 'connecting'" class="auth-display">
|
||||
<div class="auth-content">
|
||||
<div class="pairing-code-section">
|
||||
<p class="pairing-label">Enter the code words:</p>
|
||||
<div class="slot-machine" aria-hidden="true">
|
||||
<div class="slot-reel" v-for="(word, index) in animatedWords" :key="index">
|
||||
<div class="slot-word">{{ word }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="site-url">{{ siteUrlDisplay }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="waiting-indicator">
|
||||
<div class="spinner-small"></div>
|
||||
<span>Generating code…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Waiting/Authenticating phase - show codes -->
|
||||
<div v-else class="auth-display">
|
||||
<div class="auth-content">
|
||||
<div v-if="pairingCode" class="pairing-code-section">
|
||||
<p class="pairing-label">Enter the code words:</p>
|
||||
<div class="slot-machine stopped">
|
||||
<div class="slot-reel" v-for="(word, index) in displayCode.split(' ')" :key="index">
|
||||
<div class="slot-word">{{ word }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="site-url">{{ siteUrlDisplay }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="waiting-indicator">
|
||||
<div class="spinner-small"></div>
|
||||
<span>{{ waitingMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import aWebSocket from '@/utils/awaitable-websocket'
|
||||
import { dec as b64dec, enc as b64enc } from '@/utils/base64url'
|
||||
import { getSettings } from '@/utils/settings'
|
||||
import { solvePoW } from '@/utils/pow'
|
||||
import { words } from '@/utils/wordlist'
|
||||
|
||||
const props = defineProps({
|
||||
active: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['authenticated', 'cancelled', 'error', 'register'])
|
||||
|
||||
const pairingCode = ref(null)
|
||||
const completed = ref(false)
|
||||
const error = ref(null)
|
||||
const phase = ref('connecting')
|
||||
const settings = ref(null)
|
||||
const animatedWords = ref(['', '', ''])
|
||||
let ws = null
|
||||
let wordAnimationTimer = null
|
||||
|
||||
const displayCode = computed(() => pairingCode.value ? pairingCode.value.replace(/\./g, ' ') : '')
|
||||
|
||||
const siteUrlDisplay = computed(() => {
|
||||
if (!settings.value) return ''
|
||||
const authSiteUrl = settings.value.auth_site_url || `${location.protocol}//${location.host}/auth/`
|
||||
// Remove the protocol and any trailing slash
|
||||
const withoutProtocol = authSiteUrl.replace(/^https?:\/\//, '')
|
||||
return withoutProtocol.endsWith('/') ? withoutProtocol.slice(0, -1) : withoutProtocol
|
||||
})
|
||||
|
||||
const waitingMessage = computed(() => {
|
||||
return phase.value === 'authenticating'
|
||||
? 'Complete on another device…'
|
||||
: 'Waiting for authentication…'
|
||||
})
|
||||
|
||||
const successMessage = computed(() => 'Authenticated successfully!')
|
||||
|
||||
function getRandomWord() {
|
||||
return words[Math.floor(Math.random() * words.length)]
|
||||
}
|
||||
|
||||
function startWordAnimation() {
|
||||
// Initialize with random words
|
||||
animatedWords.value = [getRandomWord(), getRandomWord(), getRandomWord()]
|
||||
|
||||
let updateCount = 0
|
||||
const maxUpdates = 20 // Number of cycles before stopping
|
||||
|
||||
// Different intervals for each slot to spin independently
|
||||
const intervals = [
|
||||
setInterval(() => {
|
||||
const newWords = [...animatedWords.value]
|
||||
newWords[0] = getRandomWord()
|
||||
animatedWords.value = newWords
|
||||
}, 140),
|
||||
setInterval(() => {
|
||||
const newWords = [...animatedWords.value]
|
||||
newWords[1] = getRandomWord()
|
||||
animatedWords.value = newWords
|
||||
}, 170),
|
||||
setInterval(() => {
|
||||
const newWords = [...animatedWords.value]
|
||||
newWords[2] = getRandomWord()
|
||||
animatedWords.value = newWords
|
||||
}, 200)
|
||||
]
|
||||
|
||||
wordAnimationTimer = intervals
|
||||
|
||||
// Stop all after max updates
|
||||
setTimeout(() => {
|
||||
intervals.forEach(interval => clearInterval(interval))
|
||||
wordAnimationTimer = null
|
||||
}, maxUpdates * 170) // Average interval time
|
||||
}
|
||||
|
||||
function stopWordAnimation() {
|
||||
if (wordAnimationTimer) {
|
||||
if (Array.isArray(wordAnimationTimer)) {
|
||||
wordAnimationTimer.forEach(interval => clearInterval(interval))
|
||||
} else {
|
||||
clearInterval(wordAnimationTimer)
|
||||
}
|
||||
wordAnimationTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function startRemoteAuth() {
|
||||
error.value = null
|
||||
completed.value = false
|
||||
pairingCode.value = null
|
||||
phase.value = 'connecting'
|
||||
|
||||
// Start word animation
|
||||
startWordAnimation()
|
||||
|
||||
try {
|
||||
settings.value = await getSettings()
|
||||
const authHost = settings.value?.auth_host
|
||||
const wsPath = '/auth/ws/remote-auth/request'
|
||||
const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath
|
||||
|
||||
ws = await aWebSocket(wsUrl)
|
||||
|
||||
// PoW challenge
|
||||
const powChallenge = await ws.receive_json()
|
||||
if (powChallenge.pow) {
|
||||
const challenge = b64dec(powChallenge.pow.challenge)
|
||||
const nonces = await solvePoW(challenge, powChallenge.pow.work)
|
||||
ws.send_json({ pow: b64enc(nonces), action: 'login' })
|
||||
}
|
||||
|
||||
// Receive the pairing code
|
||||
const res = await ws.receive_json()
|
||||
|
||||
if (res.status) {
|
||||
throw new Error(res.detail || `Failed to create remote auth request: ${res.status}`)
|
||||
}
|
||||
|
||||
pairingCode.value = res.pairing_code
|
||||
|
||||
// Stop word animation
|
||||
stopWordAnimation()
|
||||
|
||||
phase.value = 'waiting'
|
||||
|
||||
// Wait for authentication
|
||||
while (true) {
|
||||
const msg = await ws.receive_json()
|
||||
|
||||
if (msg.status === 'locked') {
|
||||
// Someone has entered the code and is authenticating
|
||||
phase.value = 'authenticating'
|
||||
} else if (msg.status === 'paired') {
|
||||
// Legacy/compatibility: Device paired, now authenticating
|
||||
phase.value = 'authenticating'
|
||||
} else if (msg.status === 'authenticated') {
|
||||
// Success
|
||||
completed.value = true
|
||||
emit('authenticated', { session_token: msg.session_token })
|
||||
break
|
||||
} else if (msg.status === 'denied') {
|
||||
// Explicitly denied by the authenticating device
|
||||
throw new Error('Access denied')
|
||||
} else if (msg.status === 'completed') {
|
||||
// Registration flow
|
||||
if (msg.reset_token) {
|
||||
completed.value = true
|
||||
emit('register', msg.reset_token)
|
||||
}
|
||||
break
|
||||
} else if (msg.status === 'error' || msg.detail) {
|
||||
throw new Error(msg.detail || 'Remote authentication failed')
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Remote authentication error:', err)
|
||||
const message = err.message || 'Authentication failed'
|
||||
error.value = message
|
||||
emit('error', message)
|
||||
} finally {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function retry() {
|
||||
startRemoteAuth()
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
emit('cancelled')
|
||||
}
|
||||
|
||||
watch(() => props.active, (newVal) => {
|
||||
if (newVal && !pairingCode.value && !error.value && !completed.value) {
|
||||
startRemoteAuth()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.active) {
|
||||
startRemoteAuth()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
stopWordAnimation()
|
||||
})
|
||||
|
||||
defineExpose({ retry, cancel })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.remote-auth-inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loading-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 2rem 1rem;
|
||||
min-height: 180px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-section p {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.auth-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.auth-content {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.loading-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.loading-placeholder p {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.pairing-code-section {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 280px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.pairing-label {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.slot-machine {
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--color-surface-hover, rgba(0, 0, 0, 0.03));
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.slot-reel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 1.8em;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: var(--color-surface, rgba(255, 255, 255, 0.5));
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.slot-machine:not(.stopped) .slot-reel:nth-child(1) {
|
||||
animation: slotSpin 0.14s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.slot-machine:not(.stopped) .slot-reel:nth-child(2) {
|
||||
animation: slotSpin 0.17s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.slot-machine:not(.stopped) .slot-reel:nth-child(3) {
|
||||
animation: slotSpin 0.20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.slot-word {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.slot-machine:not(.stopped) .slot-reel:nth-child(1) .slot-word {
|
||||
animation: wordRoll 0.14s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.slot-machine:not(.stopped) .slot-reel:nth-child(2) .slot-word {
|
||||
animation: wordRoll 0.17s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.slot-machine:not(.stopped) .slot-reel:nth-child(3) .slot-word {
|
||||
animation: wordRoll 0.20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes slotSpin {
|
||||
0% {
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: inset 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
100% {
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wordRoll {
|
||||
0% {
|
||||
transform: translateY(-30%) scale(0.9);
|
||||
opacity: 0.4;
|
||||
filter: blur(1.5px);
|
||||
}
|
||||
25% {
|
||||
transform: translateY(-10%) scale(0.95);
|
||||
opacity: 0.6;
|
||||
filter: blur(1px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
75% {
|
||||
transform: translateY(10%) scale(0.95);
|
||||
opacity: 0.6;
|
||||
filter: blur(1px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(30%) scale(0.9);
|
||||
opacity: 0.4;
|
||||
filter: blur(1.5px);
|
||||
}
|
||||
}
|
||||
|
||||
.site-url {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.waiting-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--color-surface-hover, rgba(0, 0, 0, 0.02));
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.spinner-small {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.success-section {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
min-height: 180px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--color-success, #10b981);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.error-section {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-error, #ef4444);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 640px) {
|
||||
.auth-content {
|
||||
gap: 1.5rem;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pairing-code-section {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.pairing-code {
|
||||
font-size: 1.1rem;
|
||||
padding: 0.75rem 0.875rem;
|
||||
}
|
||||
|
||||
.pairing-code-section {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user