Moved remote-link links to same location with reset links, avoiding vite routing issue. Realtime lookup of session when three words have been entered.
This commit is contained in:
@@ -20,16 +20,19 @@ import { computed, onMounted, ref } from 'vue'
|
|||||||
import RestrictedAuth from '@/components/RestrictedAuth.vue'
|
import RestrictedAuth from '@/components/RestrictedAuth.vue'
|
||||||
import RemoteAuthComplete from '@/components/RemoteAuthComplete.vue'
|
import RemoteAuthComplete from '@/components/RemoteAuthComplete.vue'
|
||||||
|
|
||||||
// Check if this is a remote auth URL: /remote/{token} or /auth/remote/{token}
|
// Check if this is a remote auth URL: /{token} or /auth/{token}
|
||||||
|
// The token is a 5-word passphrase like "word1.word2.word3.word4.word5"
|
||||||
const remoteAuthToken = ref(null)
|
const remoteAuthToken = ref(null)
|
||||||
|
|
||||||
function extractRemoteToken() {
|
function extractRemoteToken() {
|
||||||
const path = window.location.pathname
|
const path = window.location.pathname
|
||||||
const match = path.match(/\/(?:auth\/)?remote\/([^/]+)$/)
|
// Match /{token} or /auth/{token} where token is a passphrase with dots
|
||||||
|
const match = path.match(/\/(?:auth\/)?([^/]+)$/)
|
||||||
if (match) {
|
if (match) {
|
||||||
const token = match[1]
|
const token = match[1]
|
||||||
// Validate it looks like a passphrase (contains dots)
|
// Validate it looks like a passphrase (contains dots, likely 5 words)
|
||||||
if (token.includes('.')) {
|
const parts = token.split('.')
|
||||||
|
if (parts.length === 5 && parts.every(p => p.length > 0)) {
|
||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,21 +12,44 @@
|
|||||||
v-model="code"
|
v-model="code"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:disabled="loading"
|
:disabled="loading || completed"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
autocapitalize="characters"
|
autocapitalize="characters"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
class="pairing-input"
|
class="pairing-input"
|
||||||
@input="handleInput"
|
@input="handleInput"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Looking up state -->
|
||||||
|
<div v-if="lookingUp" class="lookup-status">
|
||||||
|
<span class="lookup-spinner"></span>
|
||||||
|
<span>Looking up device…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Device info display (shown when 3 words match a request) -->
|
||||||
|
<div v-if="deviceInfo && !error && !completed" class="device-info">
|
||||||
|
<p class="device-info-label">📱 Device requesting login:</p>
|
||||||
|
<div class="device-info-details">
|
||||||
|
<div class="device-detail">
|
||||||
|
<span class="detail-label">Site:</span>
|
||||||
|
<span class="detail-value">{{ deviceInfo.host }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="device-detail">
|
||||||
|
<span class="detail-label">Browser:</span>
|
||||||
|
<span class="detail-value">{{ deviceInfo.user_agent_pretty }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="!isValid || loading"
|
:disabled="loading"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
|
style="margin-top: 0.75rem; width: 100%;"
|
||||||
>
|
>
|
||||||
{{ loading ? 'Connecting…' : 'Connect' }}
|
{{ loading ? 'Authenticating…' : 'Authenticate to Log In Device' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="error" class="error-message">{{ error }}</p>
|
<p v-if="error" class="error-message">{{ error }}</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -38,15 +61,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { startAuthentication } from '@simplewebauthn/browser'
|
import { startAuthentication } from '@simplewebauthn/browser'
|
||||||
import aWebSocket from '@/utils/awaitable-websocket'
|
import aWebSocket from '@/utils/awaitable-websocket'
|
||||||
import { getSettings } from '@/utils/settings'
|
import { getSettings } from '@/utils/settings'
|
||||||
|
import { apiJson } from '@/utils/api'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
title: { type: String, default: 'Help Another Device Sign In' },
|
title: { type: String, default: 'Help Another Device Sign In' },
|
||||||
description: { type: String, default: 'Enter the code shown on the device that needs to sign in.' },
|
description: { type: String, default: 'Enter the code shown on the device that needs to sign in.' },
|
||||||
placeholder: { type: String, default: 'Enter code' }
|
placeholder: { type: String, default: 'Enter three words' }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['completed', 'error', 'cancelled'])
|
const emit = defineEmits(['completed', 'error', 'cancelled'])
|
||||||
@@ -54,25 +78,78 @@ const emit = defineEmits(['completed', 'error', 'cancelled'])
|
|||||||
const inputRef = ref(null)
|
const inputRef = ref(null)
|
||||||
const code = ref('')
|
const code = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const lookingUp = ref(false)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
const completed = ref(false)
|
const completed = ref(false)
|
||||||
const completedMessage = ref('')
|
const completedMessage = ref('')
|
||||||
|
const deviceInfo = ref(null)
|
||||||
let ws = null
|
let ws = null
|
||||||
|
let lookupTimeout = null
|
||||||
|
|
||||||
// Valid if we have 3 words separated by dots or spaces
|
// Check if we have exactly 3 valid words
|
||||||
const isValid = computed(() => {
|
const hasThreeWords = computed(() => {
|
||||||
const trimmed = code.value.trim()
|
const trimmed = code.value.trim()
|
||||||
if (!trimmed) return false
|
if (!trimmed) return false
|
||||||
const words = trimmed.split(/[.\s]+/).filter(w => w.length > 0)
|
const words = trimmed.split(/[.\s]+/).filter(w => w.length > 0)
|
||||||
return words.length >= 3
|
return words.length === 3
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleInput() {
|
// Normalize code to dot-separated lowercase
|
||||||
|
function normalizeCode(input) {
|
||||||
|
return input.trim().toLowerCase().split(/[.\s]+/).filter(w => w).join('.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for code changes and do real-time lookup
|
||||||
|
watch(code, () => {
|
||||||
|
// Clear previous timeout
|
||||||
|
if (lookupTimeout) {
|
||||||
|
clearTimeout(lookupTimeout)
|
||||||
|
lookupTimeout = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset device info and error when code changes
|
||||||
|
deviceInfo.value = null
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
|
// Only lookup when we have 3 words
|
||||||
|
if (hasThreeWords.value) {
|
||||||
|
// Debounce the lookup slightly to avoid too many requests
|
||||||
|
lookupTimeout = setTimeout(() => {
|
||||||
|
lookupDeviceInfo()
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function lookupDeviceInfo() {
|
||||||
|
if (!hasThreeWords.value || loading.value) return
|
||||||
|
|
||||||
|
lookingUp.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const normalizedCode = normalizeCode(code.value)
|
||||||
|
const info = await apiJson(`/auth/api/remote-auth-info?code=${encodeURIComponent(normalizedCode)}`)
|
||||||
|
deviceInfo.value = info
|
||||||
|
} catch (err) {
|
||||||
|
// 404 means no matching request found
|
||||||
|
if (err.status === 404) {
|
||||||
|
error.value = 'No device found with this code. Check the code and try again.'
|
||||||
|
} else {
|
||||||
|
console.error('Lookup error:', err)
|
||||||
|
error.value = err.message || 'Failed to look up device'
|
||||||
|
}
|
||||||
|
deviceInfo.value = null
|
||||||
|
} finally {
|
||||||
|
lookingUp.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput() {
|
||||||
|
// Error is cleared by the watcher
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitCode() {
|
async function submitCode() {
|
||||||
if (!isValid.value || loading.value) return
|
if (!deviceInfo.value || loading.value) return
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
@@ -81,8 +158,7 @@ async function submitCode() {
|
|||||||
const settings = await getSettings()
|
const settings = await getSettings()
|
||||||
const authHost = settings?.auth_host
|
const authHost = settings?.auth_host
|
||||||
|
|
||||||
// Normalize the code: lowercase words joined by dots
|
const normalizedCode = normalizeCode(code.value)
|
||||||
const normalizedCode = code.value.trim().toLowerCase().split(/[.\s]+/).filter(w => w).join('.')
|
|
||||||
|
|
||||||
const wsPath = `/auth/ws/remote-auth/pair/${encodeURIComponent(normalizedCode)}`
|
const wsPath = `/auth/ws/remote-auth/pair/${encodeURIComponent(normalizedCode)}`
|
||||||
const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath
|
const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath
|
||||||
@@ -131,6 +207,8 @@ function reset() {
|
|||||||
error.value = null
|
error.value = null
|
||||||
completed.value = false
|
completed.value = false
|
||||||
completedMessage.value = ''
|
completedMessage.value = ''
|
||||||
|
deviceInfo.value = null
|
||||||
|
lookingUp.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -196,6 +274,63 @@ defineExpose({ reset })
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lookup-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-spinner {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px 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); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-info {
|
||||||
|
padding: 0.875rem;
|
||||||
|
background: var(--color-surface-hover, rgba(0, 0, 0, 0.03));
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-info-label {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-info-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-detail {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
min-width: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
|||||||
+64
-1
@@ -13,6 +13,7 @@ from fastapi import (
|
|||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.security import HTTPBearer
|
from fastapi.security import HTTPBearer
|
||||||
|
|
||||||
|
from paskia import remoteauth
|
||||||
from paskia.authsession import (
|
from paskia.authsession import (
|
||||||
EXPIRES,
|
EXPIRES,
|
||||||
get_reset,
|
get_reset,
|
||||||
@@ -24,7 +25,7 @@ from paskia.fastapi import authz, session, user
|
|||||||
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME
|
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME
|
||||||
from paskia.globals import db
|
from paskia.globals import db
|
||||||
from paskia.globals import passkey as global_passkey
|
from paskia.globals import passkey as global_passkey
|
||||||
from paskia.util import frontend, hostutil, htmlutil, passphrase, userinfo
|
from paskia.util import frontend, hostutil, htmlutil, passphrase, useragent, userinfo
|
||||||
from paskia.util.tokens import session_key
|
from paskia.util.tokens import session_key
|
||||||
|
|
||||||
bearer_auth = HTTPBearer(auto_error=True)
|
bearer_auth = HTTPBearer(auto_error=True)
|
||||||
@@ -205,6 +206,68 @@ async def get_settings():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/token-info")
|
||||||
|
async def api_token_info(token: str):
|
||||||
|
"""Get information about a token (remote auth or reset token).
|
||||||
|
|
||||||
|
This endpoint allows the frontend to determine what type of token it is
|
||||||
|
dealing with and get relevant information for display.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- type: "remote_auth" or "reset"
|
||||||
|
- For remote_auth: host, user_agent_pretty (requesting device info)
|
||||||
|
- For reset: user info (display name, etc.)
|
||||||
|
"""
|
||||||
|
if not passphrase.is_well_formed(token):
|
||||||
|
raise HTTPException(status_code=404, detail="Invalid token")
|
||||||
|
|
||||||
|
# Check if this is a remote auth token
|
||||||
|
if remoteauth.instance is not None:
|
||||||
|
request = await remoteauth.instance.get_request(token)
|
||||||
|
if request is not None:
|
||||||
|
return {
|
||||||
|
"type": "remote_auth",
|
||||||
|
"host": request.host,
|
||||||
|
"user_agent": request.user_agent,
|
||||||
|
"user_agent_pretty": useragent.compact_user_agent(request.user_agent),
|
||||||
|
"ip": request.ip,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if this is a reset token
|
||||||
|
try:
|
||||||
|
reset_token = await get_reset(token)
|
||||||
|
user = await db.instance.get_user_by_uuid(reset_token.user_uuid)
|
||||||
|
return {
|
||||||
|
"type": "reset",
|
||||||
|
"user_name": user.display_name,
|
||||||
|
"token_type": reset_token.token_type,
|
||||||
|
}
|
||||||
|
except (ValueError, Exception):
|
||||||
|
raise HTTPException(status_code=404, detail="Token not found or expired")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/remote-auth-info")
|
||||||
|
async def api_remote_auth_info(code: str):
|
||||||
|
"""Get information about a remote auth request by pairing code (first 3 words).
|
||||||
|
|
||||||
|
This is used for real-time lookup as the user types the pairing code.
|
||||||
|
Returns info about the requesting device without initiating authentication.
|
||||||
|
"""
|
||||||
|
if remoteauth.instance is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Remote auth not available")
|
||||||
|
|
||||||
|
request = await remoteauth.instance.get_request_by_pairing_code(code)
|
||||||
|
if request is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Invalid or expired code")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"host": request.host,
|
||||||
|
"user_agent": request.user_agent,
|
||||||
|
"user_agent_pretty": useragent.compact_user_agent(request.user_agent),
|
||||||
|
"ip": request.ip,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/user-info")
|
@app.post("/user-info")
|
||||||
async def api_user_info(
|
async def api_user_info(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
+19
-13
@@ -123,19 +123,25 @@ async def examples_page():
|
|||||||
|
|
||||||
|
|
||||||
# Note: this catch-all handler must be the last route defined
|
# Note: this catch-all handler must be the last route defined
|
||||||
@app.get("/remote/{token}")
|
@app.get("/{token}")
|
||||||
@app.get("/auth/remote/{token}")
|
@app.get("/auth/{token}")
|
||||||
async def remote_auth_link(token: str):
|
async def token_link(token: str):
|
||||||
"""Serve the restricted app for cross-device login."""
|
"""Serve the appropriate app based on token type.
|
||||||
|
|
||||||
|
This endpoint handles both:
|
||||||
|
- Remote auth tokens (cross-device login): serve restricted app
|
||||||
|
- Reset tokens (password reset / device addition): serve reset app
|
||||||
|
|
||||||
|
The frontend will detect the type by calling /auth/api/token-info.
|
||||||
|
"""
|
||||||
if not passphrase.is_well_formed(token):
|
if not passphrase.is_well_formed(token):
|
||||||
raise HTTPException(status_code=404)
|
raise HTTPException(status_code=404)
|
||||||
return Response(*await frontend.read("/auth/restricted/index.html"))
|
|
||||||
|
# Check if this is a remote auth token first (they're in-memory, fast lookup)
|
||||||
|
if remoteauth.instance is not None:
|
||||||
@app.get("/{reset}")
|
request = await remoteauth.instance.get_request(token)
|
||||||
@app.get("/auth/{reset}")
|
if request is not None:
|
||||||
async def reset_link(reset: str):
|
return Response(*await frontend.read("/auth/restricted/index.html"))
|
||||||
"""Serve the reset app directly with an injected reset token."""
|
|
||||||
if not passphrase.is_well_formed(reset):
|
# Otherwise, serve the reset app (it will validate the token via API)
|
||||||
raise HTTPException(status_code=404)
|
|
||||||
return Response(*await frontend.read("/int/reset/index.html"))
|
return Response(*await frontend.read("/int/reset/index.html"))
|
||||||
|
|||||||
@@ -232,8 +232,8 @@ async def websocket_remote_auth_request(ws: WebSocket):
|
|||||||
user_agent=metadata.get("user_agent") or "",
|
user_agent=metadata.get("user_agent") or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build the URL for the authenticating device
|
# Build the URL for the authenticating device (same endpoint as reset tokens)
|
||||||
url = hostutil.auth_site_base_url() + f"remote/{token}"
|
url = hostutil.auth_site_base_url() + token
|
||||||
|
|
||||||
# Send the token, pairing code, and URL to the client
|
# Send the token, pairing code, and URL to the client
|
||||||
await ws.send_json(
|
await ws.send_json(
|
||||||
|
|||||||
+36
-21
@@ -12,6 +12,9 @@ Alternative flow (initiated from profile/authenticating device):
|
|||||||
3. Device B authenticates, Device A receives the session
|
3. Device B authenticates, Device A receives the session
|
||||||
|
|
||||||
The requests are stored in-memory with short expiration (5 minutes).
|
The requests are stored in-memory with short expiration (5 minutes).
|
||||||
|
The link uses the same /{token} endpoint as reset tokens, but the server
|
||||||
|
distinguishes between them by checking if the token exists in remoteauth first.
|
||||||
|
The first 3 words of the token serve as the pairing code for manual entry.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -26,7 +29,7 @@ from paskia.util import passphrase
|
|||||||
# Remote auth requests expire after this duration
|
# Remote auth requests expire after this duration
|
||||||
REMOTE_AUTH_LIFETIME = timedelta(minutes=5)
|
REMOTE_AUTH_LIFETIME = timedelta(minutes=5)
|
||||||
|
|
||||||
# Number of words for the short pairing code (easier to communicate than alphanumeric)
|
# Number of words for the short pairing code (first 3 words of the token)
|
||||||
PAIRING_CODE_WORDS = 3
|
PAIRING_CODE_WORDS = 3
|
||||||
|
|
||||||
|
|
||||||
@@ -34,8 +37,8 @@ PAIRING_CODE_WORDS = 3
|
|||||||
class RemoteAuthRequest:
|
class RemoteAuthRequest:
|
||||||
"""A pending remote authentication request."""
|
"""A pending remote authentication request."""
|
||||||
|
|
||||||
key: str # The passphrase token
|
key: str # The passphrase token (5 words)
|
||||||
pairing_code: str # Short alphanumeric code for manual entry
|
pairing_code: str # First 3 words of the token for manual entry
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
host: str # The host where the session should be created
|
host: str # The host where the session should be created
|
||||||
ip: str # IP of the requesting device
|
ip: str # IP of the requesting device
|
||||||
@@ -51,7 +54,10 @@ class RemoteAuthRequest:
|
|||||||
|
|
||||||
|
|
||||||
def _generate_pairing_code() -> str:
|
def _generate_pairing_code() -> str:
|
||||||
"""Generate a short, easy-to-communicate pairing code using words."""
|
"""Generate a short, easy-to-communicate pairing code using words.
|
||||||
|
|
||||||
|
DEPRECATED: Now we use the first 3 words of the main token instead.
|
||||||
|
"""
|
||||||
return passphrase.generate(n=PAIRING_CODE_WORDS)
|
return passphrase.generate(n=PAIRING_CODE_WORDS)
|
||||||
|
|
||||||
|
|
||||||
@@ -60,7 +66,7 @@ class RemoteAuthManager:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._requests: dict[str, RemoteAuthRequest] = {} # keyed by passphrase token
|
self._requests: dict[str, RemoteAuthRequest] = {} # keyed by passphrase token
|
||||||
self._by_pairing_code: dict[str, str] = {} # pairing_code -> token
|
self._by_pairing_code: dict[str, str] = {} # pairing_code (first 3 words) -> token
|
||||||
self._cleanup_task: asyncio.Task | None = None
|
self._cleanup_task: asyncio.Task | None = None
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
@@ -116,28 +122,37 @@ class RemoteAuthManager:
|
|||||||
) -> tuple[str, str, datetime]:
|
) -> tuple[str, str, datetime]:
|
||||||
"""Create a new remote auth request.
|
"""Create a new remote auth request.
|
||||||
|
|
||||||
|
The token is a 5-word passphrase. The first 3 words serve as the pairing code.
|
||||||
|
We ensure the pairing code (first 3 words) is unique across concurrent requests
|
||||||
|
by regenerating the token if there's a collision.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(token, pairing_code, expiry) - The passphrase token, short pairing code, and expiration time
|
(token, pairing_code, expiry) - The passphrase token, pairing code (first 3 words), and expiration time
|
||||||
"""
|
"""
|
||||||
token = passphrase.generate()
|
|
||||||
pairing_code = _generate_pairing_code()
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
expiry = now + REMOTE_AUTH_LIFETIME
|
expiry = now + REMOTE_AUTH_LIFETIME
|
||||||
|
|
||||||
request = RemoteAuthRequest(
|
|
||||||
key=token,
|
|
||||||
pairing_code=pairing_code,
|
|
||||||
created_at=now,
|
|
||||||
host=host,
|
|
||||||
ip=ip,
|
|
||||||
user_agent=user_agent,
|
|
||||||
)
|
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
# Ensure pairing code is unique (regenerate if collision)
|
# Generate token with unique 3-word prefix
|
||||||
while pairing_code in self._by_pairing_code:
|
max_attempts = 100
|
||||||
pairing_code = _generate_pairing_code()
|
for _ in range(max_attempts):
|
||||||
request.pairing_code = pairing_code
|
token = passphrase.generate()
|
||||||
|
pairing_code = passphrase.prefix(token, n=PAIRING_CODE_WORDS)
|
||||||
|
if pairing_code not in self._by_pairing_code:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# Extremely unlikely but handle gracefully
|
||||||
|
raise ValueError("Unable to generate unique pairing code")
|
||||||
|
|
||||||
|
request = RemoteAuthRequest(
|
||||||
|
key=token,
|
||||||
|
pairing_code=pairing_code,
|
||||||
|
created_at=now,
|
||||||
|
host=host,
|
||||||
|
ip=ip,
|
||||||
|
user_agent=user_agent,
|
||||||
|
)
|
||||||
|
|
||||||
self._requests[token] = request
|
self._requests[token] = request
|
||||||
self._by_pairing_code[pairing_code] = token
|
self._by_pairing_code[pairing_code] = token
|
||||||
|
|
||||||
|
|||||||
@@ -17,3 +17,8 @@ def is_well_formed(passphrase: str, n=N_WORDS, sep=".") -> bool:
|
|||||||
"""Check if the passphrase is well-formed according to the regex pattern."""
|
"""Check if the passphrase is well-formed according to the regex pattern."""
|
||||||
p = passphrase.split(sep)
|
p = passphrase.split(sep)
|
||||||
return len(p) == n and all(w in wset for w in passphrase.split("."))
|
return len(p) == n and all(w in wset for w in passphrase.split("."))
|
||||||
|
|
||||||
|
|
||||||
|
def prefix(passphrase: str, n=3, sep=".") -> str:
|
||||||
|
"""Extract the first n words from a passphrase."""
|
||||||
|
return sep.join(passphrase.split(sep)[:n])
|
||||||
|
|||||||
Reference in New Issue
Block a user