Compare commits
9 Commits
d4e5497406
...
3567b7802b
Author | SHA1 | Date | |
---|---|---|---|
![]() |
3567b7802b | ||
![]() |
1c79132e22 | ||
![]() |
99b5187a33 | ||
![]() |
15aebfca2e | ||
![]() |
f9f263171b | ||
![]() |
7665044032 | ||
![]() |
9711453553 | ||
![]() |
58368e2de3 | ||
![]() |
5a92c6a25f |
1
.gitignore
vendored
1
.gitignore
vendored
@ -5,3 +5,4 @@ dist/
|
|||||||
*.lock
|
*.lock
|
||||||
*.db
|
*.db
|
||||||
server-secret.bin
|
server-secret.bin
|
||||||
|
/passkey/frontend-build
|
||||||
|
23
Caddyfile
Normal file
23
Caddyfile
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
(auth) {
|
||||||
|
# Forward /auth to the authentication service
|
||||||
|
redir /auth /auth/ 302
|
||||||
|
@auth path /auth/*
|
||||||
|
handle @auth {
|
||||||
|
reverse_proxy localhost:4401
|
||||||
|
}
|
||||||
|
handle {
|
||||||
|
# Check for authentication
|
||||||
|
forward_auth localhost:4401 {
|
||||||
|
uri /auth/forward-auth
|
||||||
|
copy_headers x-auth-user-id
|
||||||
|
}
|
||||||
|
{block}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
localhost {
|
||||||
|
import auth {
|
||||||
|
# Proxy authenticated requests to the main application
|
||||||
|
reverse_proxy localhost:3000
|
||||||
|
}
|
||||||
|
}
|
9
dev.py
9
dev.py
@ -1,9 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Development server runner
|
|
||||||
"""
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
from passkeyauth.main import main
|
|
||||||
|
|
||||||
main()
|
|
30
frontend/.gitignore
vendored
Normal file
30
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
|
|
||||||
|
/cypress/videos/
|
||||||
|
/cypress/screenshots/
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
29
frontend/README.md
Normal file
29
frontend/README.md
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# frontend
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||||
|
|
||||||
|
## Customize configuration
|
||||||
|
|
||||||
|
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||||
|
|
||||||
|
## Project Setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compile and Hot-Reload for Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compile and Minify for Production
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" href="/src/assets/icon.webp">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Passkey Authentication</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
8
frontend/jsconfig.json
Normal file
8
frontend/jsconfig.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
21
frontend/package.json
Normal file
21
frontend/package.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@simplewebauthn/browser": "^13.1.2",
|
||||||
|
"pinia": "^3.0.3",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"vue": "^3.5.17"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.0",
|
||||||
|
"vite": "^7.0.4"
|
||||||
|
}
|
||||||
|
}
|
46
frontend/src/App.vue
Normal file
46
frontend/src/App.vue
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<StatusMessage />
|
||||||
|
<LoginView v-if="store.currentView === 'login'" />
|
||||||
|
<RegisterView v-if="store.currentView === 'register'" />
|
||||||
|
<ProfileView v-if="store.currentView === 'profile'" />
|
||||||
|
<DeviceLinkView v-if="store.currentView === 'device-link'" />
|
||||||
|
<AddDeviceCredentialView v-if="store.currentView === 'add-device-credential'" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import StatusMessage from '@/components/StatusMessage.vue'
|
||||||
|
import LoginView from '@/components/LoginView.vue'
|
||||||
|
import RegisterView from '@/components/RegisterView.vue'
|
||||||
|
import ProfileView from '@/components/ProfileView.vue'
|
||||||
|
import DeviceLinkView from '@/components/DeviceLinkView.vue'
|
||||||
|
import AddDeviceCredentialView from '@/components/AddDeviceCredentialView.vue'
|
||||||
|
import { getCookie } from './utils/helpers'
|
||||||
|
|
||||||
|
const store = useAuthStore()
|
||||||
|
let isLoggedIn
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (getCookie('auth-token')) {
|
||||||
|
store.currentView = 'add-device-credential'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isLoggedIn = await store.validateStoredToken()
|
||||||
|
if (isLoggedIn) {
|
||||||
|
// User is logged in, load their data and go to profile
|
||||||
|
try {
|
||||||
|
await store.loadUserInfo()
|
||||||
|
store.currentView = 'profile'
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load user info:', error)
|
||||||
|
store.currentView = 'login'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// User is not logged in, show login
|
||||||
|
store.currentView = 'login'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
BIN
frontend/src/assets/icon.webp
Normal file
BIN
frontend/src/assets/icon.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 86 KiB |
@ -326,3 +326,157 @@ button:disabled {
|
|||||||
color: #495057;
|
color: #495057;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Global Status Styles */
|
||||||
|
.global-status {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 10000;
|
||||||
|
min-width: 300px;
|
||||||
|
max-width: 600px;
|
||||||
|
display: none;
|
||||||
|
animation: slideDown 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-status .status {
|
||||||
|
margin: 0;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
border-width: 2px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.info {
|
||||||
|
background: #d1ecf1;
|
||||||
|
color: #0c5460;
|
||||||
|
border-color: #bee5eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.success {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border-color: #c3e6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border-color: #f5c6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
transform: translateX(-50%) translateY(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vue-specific styles */
|
||||||
|
[v-cloak] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dialog overlay and modal styles */
|
||||||
|
.dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-dialog {
|
||||||
|
background: white;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 15px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: none;
|
||||||
|
animation: slideUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-link-section {
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-info {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-display {
|
||||||
|
margin: 15px 0;
|
||||||
|
padding: 10px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-display code {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(50px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive improvements */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.container {
|
||||||
|
margin: 20px;
|
||||||
|
padding: 30px 20px;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-dialog {
|
||||||
|
margin: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-status {
|
||||||
|
left: 20px;
|
||||||
|
right: 20px;
|
||||||
|
transform: none;
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-dates {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
55
frontend/src/components/AddDeviceCredentialView.vue
Normal file
55
frontend/src/components/AddDeviceCredentialView.vue
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<div class="view active">
|
||||||
|
<h1>🔑 Add Device Credential</h1>
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="authStore.isLoading"
|
||||||
|
@click="register"
|
||||||
|
>
|
||||||
|
{{ authStore.isLoading ? 'Registering...' : 'Register Passkey' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { registerWithToken } from '@/utils/passkey'
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { getCookie } from '@/utils/helpers'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const token = ref(null)
|
||||||
|
|
||||||
|
// Check existing session on app load
|
||||||
|
onMounted(() => {
|
||||||
|
// Check for 'auth-token' cookie
|
||||||
|
token.value = getCookie('auth-token')
|
||||||
|
if (!token.value) {
|
||||||
|
authStore.showMessage('No registration token cookie found.', 'error')
|
||||||
|
authStore.currentView = 'login'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Delete the cookie
|
||||||
|
document.cookie = 'auth-token=; Max-Age=0; path=/'
|
||||||
|
})
|
||||||
|
|
||||||
|
function register() {
|
||||||
|
authStore.isLoading = true
|
||||||
|
authStore.showMessage('Starting registration...', 'info')
|
||||||
|
registerWithToken(token.value).finally(() => {
|
||||||
|
authStore.isLoading = false
|
||||||
|
}).then(() => {
|
||||||
|
authStore.showMessage('Passkey registered successfully!', 'success', 2000)
|
||||||
|
authStore.currentView = 'profile'
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('Registration error:', error)
|
||||||
|
if (error.name === "NotAllowedError") {
|
||||||
|
authStore.showMessage('Registration cancelled', 'error')
|
||||||
|
} else {
|
||||||
|
authStore.showMessage(`Registration failed: ${error.message}`, 'error')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
69
frontend/src/components/DeviceLinkView.vue
Normal file
69
frontend/src/components/DeviceLinkView.vue
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<div class="view active">
|
||||||
|
<h1>📱 Add Device</h1>
|
||||||
|
|
||||||
|
<div class="device-link-section">
|
||||||
|
<h2>Device Addition Link</h2>
|
||||||
|
<div class="qr-container">
|
||||||
|
<canvas id="qrCode" class="qr-code"></canvas>
|
||||||
|
<p v-if="deviceLink.url">
|
||||||
|
<a :href="deviceLink.url" id="deviceLinkText" @click="copyLink">
|
||||||
|
{{ deviceLink.url.replace(/^[^:]+:\/\//, '') }}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>Scan and visit the URL on another device.</strong><br>
|
||||||
|
<small>⚠️ Expires in 24 hours and can only be used once.</small>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button @click="authStore.currentView = 'profile'" class="btn-secondary">
|
||||||
|
Back to Profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import QRCode from 'qrcode/lib/browser'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const deviceLink = ref({ url: '', token: '' })
|
||||||
|
|
||||||
|
const copyLink = async (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (deviceLink.value.url) {
|
||||||
|
await navigator.clipboard.writeText(deviceLink.value.url)
|
||||||
|
authStore.showMessage('Link copied to clipboard!')
|
||||||
|
authStore.currentView = 'profile'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/auth/create-device-link', { method: 'POST' })
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.error) throw new Error(result.error)
|
||||||
|
|
||||||
|
deviceLink.value = {
|
||||||
|
url: result.addition_link,
|
||||||
|
token: result.token
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate QR code
|
||||||
|
const qrCodeElement = document.getElementById('qrCode')
|
||||||
|
if (qrCodeElement) {
|
||||||
|
QRCode.toCanvas(qrCodeElement, deviceLink.value.url, error => {
|
||||||
|
if (error) console.error('Failed to generate QR code:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch device link:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
43
frontend/src/components/LoginView.vue
Normal file
43
frontend/src/components/LoginView.vue
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<div class="view active">
|
||||||
|
<h1>🔐 Passkey Login</h1>
|
||||||
|
<form @submit.prevent="handleLogin">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="authStore.isLoading"
|
||||||
|
>
|
||||||
|
{{ authStore.isLoading ? 'Authenticating...' : 'Login with Your Device' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="toggle-link">
|
||||||
|
<a href="#" @click.prevent="authStore.currentView = 'register'">
|
||||||
|
Don't have an account? Register here
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
try {
|
||||||
|
console.log('Login button clicked')
|
||||||
|
authStore.showMessage('Starting authentication...', 'info')
|
||||||
|
await authStore.authenticate()
|
||||||
|
authStore.showMessage('Authentication successful!', 'success', 2000)
|
||||||
|
if (location.pathname.startsWith('/auth/')) {
|
||||||
|
authStore.currentView = 'profile'
|
||||||
|
} else {
|
||||||
|
location.reload()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
authStore.showMessage(`Authentication failed: ${error.message}`, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
185
frontend/src/components/ProfileView.vue
Normal file
185
frontend/src/components/ProfileView.vue
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<div class="view active">
|
||||||
|
<h1>👋 Welcome!</h1>
|
||||||
|
<div v-if="authStore.currentUser" class="user-info">
|
||||||
|
<h3>👤 {{ authStore.currentUser.user_name }}</h3>
|
||||||
|
<span><strong>Visits:</strong></span>
|
||||||
|
<span>{{ authStore.currentUser.visits || 0 }}</span>
|
||||||
|
<span><strong>Registered:</strong></span>
|
||||||
|
<span>{{ formatDate(authStore.currentUser.created_at) }}</span>
|
||||||
|
<span><strong>Last seen:</strong></span>
|
||||||
|
<span>{{ formatDate(authStore.currentUser.last_seen) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Your Passkeys</h2>
|
||||||
|
<div class="credential-list">
|
||||||
|
<div v-if="authStore.isLoading">
|
||||||
|
<p>Loading credentials...</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="userCredentialsData.credentials.length === 0">
|
||||||
|
<p>No passkeys found.</p>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div
|
||||||
|
v-for="credential in userCredentialsData.credentials"
|
||||||
|
:key="credential.credential_id"
|
||||||
|
:class="['credential-item', { 'current-session': credential.is_current_session }]"
|
||||||
|
>
|
||||||
|
<div class="credential-header">
|
||||||
|
<div class="credential-icon">
|
||||||
|
<img
|
||||||
|
v-if="getCredentialAuthIcon(credential)"
|
||||||
|
:src="getCredentialAuthIcon(credential)"
|
||||||
|
:alt="getCredentialAuthName(credential)"
|
||||||
|
class="auth-icon"
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
>
|
||||||
|
<span v-else class="auth-emoji">🔑</span>
|
||||||
|
</div>
|
||||||
|
<div class="credential-info">
|
||||||
|
<h4>{{ getCredentialAuthName(credential) }}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="credential-dates">
|
||||||
|
<span class="date-label">Created:</span>
|
||||||
|
<span class="date-value">{{ formatDate(credential.created_at) }}</span>
|
||||||
|
<span class="date-label">Last used:</span>
|
||||||
|
<span class="date-value">{{ formatDate(credential.last_used) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="credential-actions">
|
||||||
|
<button
|
||||||
|
@click="deleteCredential(credential.credential_id)"
|
||||||
|
class="btn-delete-credential"
|
||||||
|
:disabled="credential.is_current_session"
|
||||||
|
:title="credential.is_current_session ? 'Cannot delete current session credential' : ''"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-group" style="display: flex; gap: 10px;">
|
||||||
|
<button @click="addNewCredential" class="btn-primary">
|
||||||
|
Add New Passkey
|
||||||
|
</button>
|
||||||
|
<button @click="authStore.currentView = 'device-link'" class="btn-primary">
|
||||||
|
Add Another Device
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button @click="logout" class="btn-danger" style="width: 100%;">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { formatDate } from '@/utils/helpers'
|
||||||
|
import { registerCredential } from '@/utils/passkey'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const currentCredentials = ref([])
|
||||||
|
const userCredentialsData = ref({ credentials: [], aaguid_info: {} })
|
||||||
|
const updateInterval = ref(null)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await authStore.loadUserInfo()
|
||||||
|
currentCredentials.value = await authStore.loadCredentials()
|
||||||
|
} catch (error) {
|
||||||
|
authStore.showMessage(`Failed to load user info: ${error.message}`, 'error')
|
||||||
|
authStore.currentView = 'login'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch user credentials from the server
|
||||||
|
try {
|
||||||
|
const response = await fetch('/auth/user-credentials')
|
||||||
|
const result = await response.json()
|
||||||
|
console.log('Fetch Response:', result) // Log the entire response
|
||||||
|
if (result.error) throw new Error(result.error)
|
||||||
|
|
||||||
|
Object.assign(userCredentialsData.value, result) // Store the entire response
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch user credentials:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateInterval.value = setInterval(() => {
|
||||||
|
// Trigger Vue reactivity to update formatDate fields
|
||||||
|
authStore.currentUser = { ...authStore.currentUser }
|
||||||
|
userCredentialsData.value.credentials = [...userCredentialsData.value.credentials]
|
||||||
|
}, 60000) // Update every minute
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (updateInterval.value) {
|
||||||
|
clearInterval(updateInterval.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const getCredentialAuthName = (credential) => {
|
||||||
|
const authInfo = userCredentialsData.value.aaguid_info[credential.aaguid]
|
||||||
|
return authInfo ? authInfo.name : 'Unknown Authenticator'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCredentialAuthIcon = (credential) => {
|
||||||
|
const authInfo = userCredentialsData.value.aaguid_info[credential.aaguid]
|
||||||
|
if (!authInfo) return null
|
||||||
|
|
||||||
|
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
const iconKey = isDarkMode ? 'icon_dark' : 'icon_light'
|
||||||
|
return authInfo[iconKey] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const addNewCredential = async () => {
|
||||||
|
try {
|
||||||
|
authStore.isLoading = true
|
||||||
|
authStore.showMessage('Adding new passkey...', 'info')
|
||||||
|
const result = await registerCredential()
|
||||||
|
currentCredentials.value = await authStore.loadCredentials()
|
||||||
|
authStore.showMessage('New passkey added successfully!', 'success', 3000)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add new passkey:', error)
|
||||||
|
authStore.showMessage(`Failed to add passkey: ${error.message}`, 'error')
|
||||||
|
} finally {
|
||||||
|
authStore.isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteCredential = async (credentialId) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this passkey?')) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authStore.deleteCredential(credentialId)
|
||||||
|
currentCredentials.value = await authStore.loadCredentials()
|
||||||
|
authStore.showMessage('Passkey deleted successfully!', 'success', 3000)
|
||||||
|
} catch (error) {
|
||||||
|
authStore.showMessage(`Failed to delete passkey: ${error.message}`, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await authStore.logout()
|
||||||
|
authStore.currentView = 'login'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.user-info {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.user-info h3 {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
.user-info span {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
</style>
|
57
frontend/src/components/RegisterView.vue
Normal file
57
frontend/src/components/RegisterView.vue
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<div class="view active">
|
||||||
|
<h1>🔐 Create Account</h1>
|
||||||
|
<form @submit.prevent="handleRegister">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="user_name"
|
||||||
|
placeholder="Enter username"
|
||||||
|
required
|
||||||
|
:disabled="authStore.isLoading"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="authStore.isLoading || !user_name.trim()"
|
||||||
|
>
|
||||||
|
{{ authStore.isLoading ? 'Registering...' : 'Register Passkey' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="toggle-link">
|
||||||
|
<a href="#" @click.prevent="authStore.currentView = 'login'">
|
||||||
|
Already have an account? Login here
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const user_name = ref('')
|
||||||
|
|
||||||
|
const handleRegister = async () => {
|
||||||
|
if (!user_name.value.trim()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
authStore.showMessage('Starting registration...', 'info')
|
||||||
|
await authStore.register(user_name.value.trim())
|
||||||
|
authStore.showMessage('Passkey registered successfully!', 'success', 2000)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
authStore.currentView = 'profile'
|
||||||
|
}, 1500)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Registration error:', error)
|
||||||
|
if (error.name === "NotAllowedError") {
|
||||||
|
authStore.showMessage('Registration cancelled', 'error')
|
||||||
|
} else {
|
||||||
|
authStore.showMessage(`Registration failed: ${error.message}`, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
13
frontend/src/components/StatusMessage.vue
Normal file
13
frontend/src/components/StatusMessage.vue
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="authStore.status.show" class="global-status" style="display: block;">
|
||||||
|
<div :class="['status', authStore.status.type]">
|
||||||
|
{{ authStore.status.message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
</script>
|
11
frontend/src/main.js
Normal file
11
frontend/src/main.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import './assets/style.css'
|
||||||
|
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
|
||||||
|
app.mount('#app')
|
129
frontend/src/stores/auth.js
Normal file
129
frontend/src/stores/auth.js
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { registerUser, authenticateUser, registerWithToken } from '@/utils/passkey'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', {
|
||||||
|
state: () => ({
|
||||||
|
// Auth State
|
||||||
|
currentUser: null,
|
||||||
|
isLoading: false,
|
||||||
|
|
||||||
|
// UI State
|
||||||
|
currentView: 'login', // 'login', 'register', 'profile', 'device-link'
|
||||||
|
status: {
|
||||||
|
message: '',
|
||||||
|
type: 'info',
|
||||||
|
show: false
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
showMessage(message, type = 'info', duration = 3000) {
|
||||||
|
this.status = {
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
show: true
|
||||||
|
}
|
||||||
|
if (duration > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.status.show = false
|
||||||
|
}, duration)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async validateStoredToken() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/auth/validate-token')
|
||||||
|
const result = await response.json()
|
||||||
|
return result.status === 'success'
|
||||||
|
} catch (error) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async setSessionCookie(sessionToken) {
|
||||||
|
const response = await fetch('/auth/set-session', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${sessionToken}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error(result.error)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
async register(user_name) {
|
||||||
|
this.isLoading = true
|
||||||
|
try {
|
||||||
|
const result = await registerUser(user_name)
|
||||||
|
|
||||||
|
await this.setSessionCookie(result.session_token)
|
||||||
|
|
||||||
|
this.currentUser = {
|
||||||
|
user_id: result.user_id,
|
||||||
|
user_name: user_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async authenticate() {
|
||||||
|
this.isLoading = true
|
||||||
|
try {
|
||||||
|
const result = await authenticateUser()
|
||||||
|
|
||||||
|
await this.setSessionCookie(result.session_token)
|
||||||
|
await this.loadUserInfo()
|
||||||
|
|
||||||
|
return result
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadUserInfo() {
|
||||||
|
const response = await fetch('/auth/user-info')
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.error) throw new Error(`Server: ${result.error}`)
|
||||||
|
|
||||||
|
this.currentUser = result.user
|
||||||
|
},
|
||||||
|
async loadCredentials() {
|
||||||
|
this.isLoading = true
|
||||||
|
try {
|
||||||
|
const response = await fetch('/auth/user-credentials')
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.error) throw new Error(`Server: ${result.error}`)
|
||||||
|
|
||||||
|
this.currentCredentials = result.credentials
|
||||||
|
this.aaguidInfo = result.aaguid_info || {}
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async deleteCredential(credentialId) {
|
||||||
|
const response = await fetch('/auth/delete-credential', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ credential_id: credentialId })
|
||||||
|
})
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.error) throw new Error(`Server: ${result.error}`)
|
||||||
|
|
||||||
|
await this.loadCredentials()
|
||||||
|
},
|
||||||
|
async logout() {
|
||||||
|
try {
|
||||||
|
await fetch('/auth/logout', {method: 'POST'})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentUser = null
|
||||||
|
this.currentCredentials = []
|
||||||
|
this.aaguidInfo = {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
83
frontend/src/utils/awaitable-websocket.js
Normal file
83
frontend/src/utils/awaitable-websocket.js
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
class AwaitableWebSocket extends WebSocket {
|
||||||
|
#received = []
|
||||||
|
#waiting = []
|
||||||
|
#err = null
|
||||||
|
#opened = false
|
||||||
|
|
||||||
|
constructor(resolve, reject, url, protocols, binaryType) {
|
||||||
|
super(url, protocols)
|
||||||
|
this.binaryType = binaryType || 'blob'
|
||||||
|
this.onopen = () => {
|
||||||
|
this.#opened = true
|
||||||
|
resolve(this)
|
||||||
|
}
|
||||||
|
this.onmessage = e => {
|
||||||
|
if (this.#waiting.length) this.#waiting.shift().resolve(e.data)
|
||||||
|
else this.#received.push(e.data)
|
||||||
|
}
|
||||||
|
this.onclose = e => {
|
||||||
|
if (!this.#opened) {
|
||||||
|
reject(new Error(`WebSocket ${this.url} failed to connect, code ${e.code}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.#err = e.wasClean
|
||||||
|
? new Error(`Websocket ${this.url} closed ${e.code}`)
|
||||||
|
: new Error(`WebSocket ${this.url} closed with error ${e.code}`)
|
||||||
|
this.#waiting.splice(0).forEach(p => p.reject(this.#err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
receive() {
|
||||||
|
// If we have a message already received, return it immediately
|
||||||
|
if (this.#received.length) return Promise.resolve(this.#received.shift())
|
||||||
|
// Wait for incoming messages, if we have an error, reject immediately
|
||||||
|
if (this.#err) return Promise.reject(this.#err)
|
||||||
|
return new Promise((resolve, reject) => this.#waiting.push({ resolve, reject }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async receive_bytes() {
|
||||||
|
const data = await this.receive()
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
console.error("WebSocket received text data, expected a binary message", data)
|
||||||
|
throw new Error("WebSocket received text data, expected a binary message")
|
||||||
|
}
|
||||||
|
return data instanceof Blob ? data.bytes() : new Uint8Array(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async receive_json() {
|
||||||
|
const data = await this.receive()
|
||||||
|
if (typeof data !== 'string') {
|
||||||
|
console.error("WebSocket received binary data, expected JSON string", data)
|
||||||
|
throw new Error("WebSocket received binary data, expected JSON string")
|
||||||
|
}
|
||||||
|
let parsed
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to parse JSON from WebSocket message", data, err)
|
||||||
|
throw new Error("Failed to parse JSON from WebSocket message")
|
||||||
|
}
|
||||||
|
if (parsed.error) {
|
||||||
|
throw new Error(`Server: ${parsed.error}`)
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
send_json(data) {
|
||||||
|
let jsonData
|
||||||
|
try {
|
||||||
|
jsonData = JSON.stringify(data)
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Failed to stringify data for WebSocket: ${err.message}`)
|
||||||
|
}
|
||||||
|
this.send(jsonData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct an async WebSocket with await aWebSocket(url)
|
||||||
|
export default function aWebSocket(url, options = {}) {
|
||||||
|
const { protocols, binaryType } = options
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
new AwaitableWebSocket(resolve, reject, url, protocols, binaryType)
|
||||||
|
})
|
||||||
|
}
|
24
frontend/src/utils/helpers.js
Normal file
24
frontend/src/utils/helpers.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
// Utility functions
|
||||||
|
|
||||||
|
export function formatDate(dateString) {
|
||||||
|
if (!dateString) return 'Never'
|
||||||
|
|
||||||
|
const date = new Date(dateString)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now - date
|
||||||
|
const diffMinutes = Math.floor(diffMs / (1000 * 60))
|
||||||
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
|
if (diffMs < 0 || diffDays > 7) return date.toLocaleDateString()
|
||||||
|
if (diffMinutes === 0) return 'Just now'
|
||||||
|
if (diffMinutes < 60) return diffMinutes === 1 ? 'a minute ago' : `${diffMinutes} minutes ago`
|
||||||
|
if (diffHours < 24) return diffHours === 1 ? 'an hour ago' : `${diffHours} hours ago`
|
||||||
|
return diffDays === 1 ? 'a day ago' : `${diffDays} days ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCookie(name) {
|
||||||
|
const value = `; ${document.cookie}`
|
||||||
|
const parts = value.split(`; ${name}=`)
|
||||||
|
if (parts.length === 2) return parts.pop().split(';').shift()
|
||||||
|
}
|
38
frontend/src/utils/passkey.js
Normal file
38
frontend/src/utils/passkey.js
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
|
||||||
|
import aWebSocket from '@/utils/awaitable-websocket'
|
||||||
|
|
||||||
|
export async function register(url, options) {
|
||||||
|
if (options) url += `?${new URLSearchParams(options).toString()}`
|
||||||
|
const ws = await aWebSocket(url)
|
||||||
|
|
||||||
|
const optionsJSON = await ws.receive_json()
|
||||||
|
const registrationResponse = await startRegistration({ optionsJSON })
|
||||||
|
ws.send_json(registrationResponse)
|
||||||
|
|
||||||
|
const result = await ws.receive_json()
|
||||||
|
ws.close()
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerUser(user_name) {
|
||||||
|
return register('/auth/ws/register_new', { user_name })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerCredential() {
|
||||||
|
return register('/auth/ws/add_credential')
|
||||||
|
}
|
||||||
|
export async function registerWithToken(token) {
|
||||||
|
return register('/auth/ws/add_device_credential', { token })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authenticateUser() {
|
||||||
|
const ws = await aWebSocket('/auth/ws/authenticate')
|
||||||
|
|
||||||
|
const optionsJSON = await ws.receive_json()
|
||||||
|
const authResponse = await startAuthentication({ optionsJSON })
|
||||||
|
ws.send_json(authResponse)
|
||||||
|
|
||||||
|
const result = await ws.receive_json()
|
||||||
|
ws.close()
|
||||||
|
return result
|
||||||
|
}
|
32
frontend/vite.config.js
Normal file
32
frontend/vite.config.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig(({ command, mode }) => ({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
base: command === 'build' ? '/auth/' : '/',
|
||||||
|
server: {
|
||||||
|
port: 4403,
|
||||||
|
proxy: {
|
||||||
|
'/auth/': {
|
||||||
|
target: 'http://localhost:4401',
|
||||||
|
ws: true,
|
||||||
|
changeOrigin: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: '../passkey/frontend-build',
|
||||||
|
emptyOutDir: true,
|
||||||
|
assetsDir: 'assets'
|
||||||
|
}
|
||||||
|
}))
|
326
main.py
326
main.py
@ -1,326 +0,0 @@
|
|||||||
import json
|
|
||||||
import uuid
|
|
||||||
from typing import Dict
|
|
||||||
|
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from webauthn import generate_registration_options, verify_registration_response
|
|
||||||
from webauthn.helpers.cose import COSEAlgorithmIdentifier
|
|
||||||
from webauthn.helpers.structs import (
|
|
||||||
AuthenticatorSelectionCriteria,
|
|
||||||
ResidentKeyRequirement,
|
|
||||||
UserVerificationRequirement,
|
|
||||||
)
|
|
||||||
|
|
||||||
app = FastAPI(title="WebAuthn Registration Server")
|
|
||||||
|
|
||||||
# In-memory storage for challenges (in production, use Redis or similar)
|
|
||||||
active_challenges: Dict[str, str] = {}
|
|
||||||
|
|
||||||
# WebAuthn configuration
|
|
||||||
RP_ID = "localhost"
|
|
||||||
RP_NAME = "WebAuthn Demo"
|
|
||||||
ORIGIN = "http://localhost:8000"
|
|
||||||
|
|
||||||
|
|
||||||
class ConnectionManager:
|
|
||||||
def __init__(self):
|
|
||||||
self.active_connections: Dict[str, WebSocket] = {}
|
|
||||||
|
|
||||||
async def connect(self, websocket: WebSocket, client_id: str):
|
|
||||||
await websocket.accept()
|
|
||||||
self.active_connections[client_id] = websocket
|
|
||||||
|
|
||||||
def disconnect(self, client_id: str):
|
|
||||||
if client_id in self.active_connections:
|
|
||||||
del self.active_connections[client_id]
|
|
||||||
|
|
||||||
async def send_message(self, message: dict, client_id: str):
|
|
||||||
if client_id in self.active_connections:
|
|
||||||
await self.active_connections[client_id].send_text(json.dumps(message))
|
|
||||||
|
|
||||||
|
|
||||||
manager = ConnectionManager()
|
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/ws/{client_id}")
|
|
||||||
async def websocket_endpoint(websocket: WebSocket, client_id: str):
|
|
||||||
await manager.connect(websocket, client_id)
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
data = await websocket.receive_text()
|
|
||||||
message = json.loads(data)
|
|
||||||
|
|
||||||
if message["type"] == "registration_challenge":
|
|
||||||
await handle_registration_challenge(message, client_id)
|
|
||||||
elif message["type"] == "registration_response":
|
|
||||||
await handle_registration_response(message, client_id)
|
|
||||||
else:
|
|
||||||
await manager.send_message(
|
|
||||||
{
|
|
||||||
"type": "error",
|
|
||||||
"message": f"Unknown message type: {message['type']}",
|
|
||||||
},
|
|
||||||
client_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
manager.disconnect(client_id)
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_registration_challenge(message: dict, client_id: str):
|
|
||||||
"""Handle registration challenge request"""
|
|
||||||
try:
|
|
||||||
username = message.get("username", "user@example.com")
|
|
||||||
user_id = str(uuid.uuid4()).encode()
|
|
||||||
|
|
||||||
# Generate registration options with Resident Key support
|
|
||||||
options = generate_registration_options(
|
|
||||||
rp_id=RP_ID,
|
|
||||||
rp_name=RP_NAME,
|
|
||||||
user_id=user_id,
|
|
||||||
user_name=username,
|
|
||||||
user_display_name=username,
|
|
||||||
# Enable Resident Keys (discoverable credentials)
|
|
||||||
authenticator_selection=AuthenticatorSelectionCriteria(
|
|
||||||
resident_key=ResidentKeyRequirement.REQUIRED,
|
|
||||||
user_verification=UserVerificationRequirement.PREFERRED,
|
|
||||||
),
|
|
||||||
# Support common algorithms
|
|
||||||
supported_pub_key_algs=[
|
|
||||||
COSEAlgorithmIdentifier.ECDSA_SHA_256,
|
|
||||||
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Store challenge for this client
|
|
||||||
active_challenges[client_id] = options.challenge
|
|
||||||
|
|
||||||
# Convert options to dict for JSON serialization
|
|
||||||
options_dict = {
|
|
||||||
"challenge": options.challenge,
|
|
||||||
"rp": {
|
|
||||||
"name": options.rp.name,
|
|
||||||
"id": options.rp.id,
|
|
||||||
},
|
|
||||||
"user": {
|
|
||||||
"id": options.user.id,
|
|
||||||
"name": options.user.name,
|
|
||||||
"displayName": options.user.display_name,
|
|
||||||
},
|
|
||||||
"pubKeyCredParams": [
|
|
||||||
{"alg": param.alg, "type": param.type}
|
|
||||||
for param in options.pub_key_cred_params
|
|
||||||
],
|
|
||||||
"timeout": options.timeout,
|
|
||||||
"attestation": options.attestation,
|
|
||||||
"authenticatorSelection": {
|
|
||||||
"residentKey": options.authenticator_selection.resident_key.value,
|
|
||||||
"userVerification": options.authenticator_selection.user_verification.value,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
await manager.send_message(
|
|
||||||
{"type": "registration_challenge_response", "options": options_dict},
|
|
||||||
client_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
await manager.send_message(
|
|
||||||
{"type": "error", "message": f"Failed to generate challenge: {str(e)}"},
|
|
||||||
client_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_registration_response(message: dict, client_id: str):
|
|
||||||
"""Handle registration response verification"""
|
|
||||||
try:
|
|
||||||
# Get the stored challenge
|
|
||||||
if client_id not in active_challenges:
|
|
||||||
await manager.send_message(
|
|
||||||
{"type": "error", "message": "No active challenge found"}, client_id
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
expected_challenge = active_challenges[client_id]
|
|
||||||
credential = message["credential"]
|
|
||||||
|
|
||||||
# Verify the registration response
|
|
||||||
verification = verify_registration_response(
|
|
||||||
credential=credential,
|
|
||||||
expected_challenge=expected_challenge,
|
|
||||||
expected_origin=ORIGIN,
|
|
||||||
expected_rp_id=RP_ID,
|
|
||||||
)
|
|
||||||
|
|
||||||
if verification.verified:
|
|
||||||
# Clean up the challenge
|
|
||||||
del active_challenges[client_id]
|
|
||||||
|
|
||||||
await manager.send_message(
|
|
||||||
{
|
|
||||||
"type": "registration_success",
|
|
||||||
"message": "Registration successful!",
|
|
||||||
"credentialId": verification.credential_id,
|
|
||||||
"credentialPublicKey": verification.credential_public_key,
|
|
||||||
},
|
|
||||||
client_id,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await manager.send_message(
|
|
||||||
{"type": "error", "message": "Registration verification failed"},
|
|
||||||
client_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
await manager.send_message(
|
|
||||||
{"type": "error", "message": f"Registration failed: {str(e)}"}, client_id
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Serve static files
|
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
|
||||||
async def get_index():
|
|
||||||
return HTMLResponse(
|
|
||||||
content="""
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>WebAuthn Registration Demo</title>
|
|
||||||
<script src="/static/simplewebauthn-browser.min.js"></script>
|
|
||||||
<style>
|
|
||||||
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
|
|
||||||
.container { text-align: center; }
|
|
||||||
button { padding: 10px 20px; margin: 10px; font-size: 16px; cursor: pointer; }
|
|
||||||
.success { color: green; }
|
|
||||||
.error { color: red; }
|
|
||||||
.info { color: blue; }
|
|
||||||
#log { text-align: left; background: #f5f5f5; padding: 10px; margin: 20px 0; border-radius: 5px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>WebAuthn Registration Demo</h1>
|
|
||||||
<p>Test WebAuthn registration with Resident Keys support</p>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="username">Username:</label>
|
|
||||||
<input type="text" id="username" value="user@example.com" style="margin: 10px; padding: 5px;">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button id="registerBtn">Register Passkey</button>
|
|
||||||
|
|
||||||
<div id="status"></div>
|
|
||||||
<div id="log"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const { startRegistration } = SimpleWebAuthnBrowser;
|
|
||||||
|
|
||||||
// Generate a unique client ID
|
|
||||||
const clientId = Math.random().toString(36).substring(7);
|
|
||||||
|
|
||||||
// WebSocket connection
|
|
||||||
const ws = new WebSocket(`ws://localhost:8000/ws/${clientId}`);
|
|
||||||
|
|
||||||
const statusDiv = document.getElementById('status');
|
|
||||||
const logDiv = document.getElementById('log');
|
|
||||||
const registerBtn = document.getElementById('registerBtn');
|
|
||||||
const usernameInput = document.getElementById('username');
|
|
||||||
|
|
||||||
function log(message, type = 'info') {
|
|
||||||
const timestamp = new Date().toLocaleTimeString();
|
|
||||||
logDiv.innerHTML += `<div class="${type}">[${timestamp}] ${message}</div>`;
|
|
||||||
logDiv.scrollTop = logDiv.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStatus(message, type = 'info') {
|
|
||||||
statusDiv.innerHTML = `<div class="${type}">${message}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onopen = function() {
|
|
||||||
log('Connected to WebSocket server', 'success');
|
|
||||||
setStatus('Ready for registration', 'success');
|
|
||||||
registerBtn.disabled = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = async function(event) {
|
|
||||||
const message = JSON.parse(event.data);
|
|
||||||
log(`Received: ${message.type}`);
|
|
||||||
|
|
||||||
if (message.type === 'registration_challenge_response') {
|
|
||||||
try {
|
|
||||||
log('Starting WebAuthn registration...');
|
|
||||||
setStatus('Touch your authenticator...', 'info');
|
|
||||||
|
|
||||||
const attResp = await startRegistration(message.options);
|
|
||||||
|
|
||||||
log('WebAuthn registration completed, verifying...');
|
|
||||||
setStatus('Verifying registration...', 'info');
|
|
||||||
|
|
||||||
ws.send(JSON.stringify({
|
|
||||||
type: 'registration_response',
|
|
||||||
credential: attResp
|
|
||||||
}));
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
log(`Registration failed: ${error.message}`, 'error');
|
|
||||||
setStatus('Registration failed', 'error');
|
|
||||||
registerBtn.disabled = false;
|
|
||||||
}
|
|
||||||
} else if (message.type === 'registration_success') {
|
|
||||||
log('Registration verified successfully!', 'success');
|
|
||||||
setStatus('Registration successful! Passkey created.', 'success');
|
|
||||||
registerBtn.disabled = false;
|
|
||||||
} else if (message.type === 'error') {
|
|
||||||
log(`Error: ${message.message}`, 'error');
|
|
||||||
setStatus(`Error: ${message.message}`, 'error');
|
|
||||||
registerBtn.disabled = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = function(error) {
|
|
||||||
log('WebSocket error: ' + error, 'error');
|
|
||||||
setStatus('Connection error', 'error');
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = function() {
|
|
||||||
log('WebSocket connection closed', 'info');
|
|
||||||
setStatus('Disconnected', 'error');
|
|
||||||
registerBtn.disabled = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
registerBtn.addEventListener('click', function() {
|
|
||||||
const username = usernameInput.value.trim();
|
|
||||||
if (!username) {
|
|
||||||
alert('Please enter a username');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
registerBtn.disabled = true;
|
|
||||||
setStatus('Requesting registration challenge...', 'info');
|
|
||||||
log(`Starting registration for: ${username}`);
|
|
||||||
|
|
||||||
ws.send(JSON.stringify({
|
|
||||||
type: 'registration_challenge',
|
|
||||||
username: username
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Disable button until connection is ready
|
|
||||||
registerBtn.disabled = true;
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import uvicorn
|
|
||||||
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
|
3
passkey/__init__.py
Normal file
3
passkey/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from .sansio import Passkey
|
||||||
|
|
||||||
|
__all__ = ["Passkey"]
|
32
passkey/aaguid/__init__.py
Normal file
32
passkey/aaguid/__init__.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"""
|
||||||
|
AAGUID (Authenticator Attestation GUID) management for WebAuthn credentials.
|
||||||
|
|
||||||
|
This module provides functionality to:
|
||||||
|
- Load AAGUID data from JSON file
|
||||||
|
- Look up authenticator information by AAGUID
|
||||||
|
- Return only relevant AAGUID data for user credentials
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from importlib.resources import files
|
||||||
|
|
||||||
|
__ALL__ = ["AAGUID", "filter"]
|
||||||
|
|
||||||
|
# Path to the AAGUID JSON file
|
||||||
|
AAGUID_FILE = files("passkey") / "aaguid" / "combined_aaguid.json"
|
||||||
|
AAGUID: dict[str, dict] = json.loads(AAGUID_FILE.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def filter(aaguids: Iterable[str]) -> dict[str, dict]:
|
||||||
|
"""
|
||||||
|
Get AAGUID information only for the provided set of AAGUIDs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
aaguids: Set of AAGUID strings that the user has credentials for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping AAGUID to authenticator information for only
|
||||||
|
the AAGUIDs that the user has and that we have data for
|
||||||
|
"""
|
||||||
|
return {aaguid: AAGUID[aaguid] for aaguid in aaguids if aaguid in AAGUID}
|
@ -25,7 +25,7 @@ from sqlalchemy.dialects.sqlite import BLOB
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from .passkey import StoredCredential
|
from ..sansio import StoredCredential
|
||||||
|
|
||||||
DB_PATH = "sqlite+aiosqlite:///webauthn.db"
|
DB_PATH = "sqlite+aiosqlite:///webauthn.db"
|
||||||
|
|
@ -10,14 +10,14 @@ This module contains all the HTTP API endpoints for:
|
|||||||
|
|
||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
|
|
||||||
from . import db
|
from .. import aaguid
|
||||||
from .aaguid_manager import get_aaguid_manager
|
from ..db import sql
|
||||||
from .jwt_manager import refresh_session_token, validate_session_token
|
from ..util.jwt import refresh_session_token, validate_session_token
|
||||||
from .session_manager import (
|
from .session_manager import (
|
||||||
clear_session_cookie,
|
clear_session_cookie,
|
||||||
get_current_user,
|
get_current_user,
|
||||||
get_session_token_from_auth_header_or_body,
|
get_session_token_from_bearer,
|
||||||
get_session_token_from_request,
|
get_session_token_from_cookie,
|
||||||
set_session_cookie,
|
set_session_cookie,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -52,21 +52,20 @@ async def get_user_credentials(request: Request) -> dict:
|
|||||||
|
|
||||||
# Get current session credential ID
|
# Get current session credential ID
|
||||||
current_credential_id = None
|
current_credential_id = None
|
||||||
session_token = get_session_token_from_request(request)
|
session_token = get_session_token_from_cookie(request)
|
||||||
if session_token:
|
if session_token:
|
||||||
token_data = validate_session_token(session_token)
|
token_data = validate_session_token(session_token)
|
||||||
if token_data:
|
if token_data:
|
||||||
current_credential_id = token_data.get("credential_id")
|
current_credential_id = token_data.get("credential_id")
|
||||||
|
|
||||||
# Get all credentials for the user
|
# Get all credentials for the user
|
||||||
credential_ids = await db.get_user_credentials(user.user_id)
|
credential_ids = await sql.get_user_credentials(user.user_id)
|
||||||
|
|
||||||
credentials = []
|
credentials = []
|
||||||
user_aaguids = set()
|
user_aaguids = set()
|
||||||
|
|
||||||
for cred_id in credential_ids:
|
for cred_id in credential_ids:
|
||||||
try:
|
stored_cred = await sql.get_credential_by_id(cred_id)
|
||||||
stored_cred = await db.get_credential_by_id(cred_id)
|
|
||||||
|
|
||||||
# Convert AAGUID to string format
|
# Convert AAGUID to string format
|
||||||
aaguid_str = str(stored_cred.aaguid)
|
aaguid_str = str(stored_cred.aaguid)
|
||||||
@ -90,13 +89,9 @@ async def get_user_credentials(request: Request) -> dict:
|
|||||||
"is_current_session": is_current_session,
|
"is_current_session": is_current_session,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except ValueError:
|
|
||||||
# Skip invalid credentials
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Get AAGUID information for only the AAGUIDs that the user has
|
# Get AAGUID information for only the AAGUIDs that the user has
|
||||||
aaguid_manager = get_aaguid_manager()
|
aaguid_info = aaguid.filter(user_aaguids)
|
||||||
aaguid_info = aaguid_manager.get_relevant_aaguids(user_aaguids)
|
|
||||||
|
|
||||||
# Sort credentials by creation date (earliest first, most recently created last)
|
# Sort credentials by creation date (earliest first, most recently created last)
|
||||||
credentials.sort(key=lambda cred: cred["created_at"])
|
credentials.sort(key=lambda cred: cred["created_at"])
|
||||||
@ -113,7 +108,7 @@ async def get_user_credentials(request: Request) -> dict:
|
|||||||
async def refresh_token(request: Request, response: Response) -> dict:
|
async def refresh_token(request: Request, response: Response) -> dict:
|
||||||
"""Refresh the session token."""
|
"""Refresh the session token."""
|
||||||
try:
|
try:
|
||||||
session_token = get_session_token_from_request(request)
|
session_token = get_session_token_from_cookie(request)
|
||||||
if not session_token:
|
if not session_token:
|
||||||
return {"error": "No session token found"}
|
return {"error": "No session token found"}
|
||||||
|
|
||||||
@ -134,7 +129,7 @@ async def refresh_token(request: Request, response: Response) -> dict:
|
|||||||
async def validate_token(request: Request) -> dict:
|
async def validate_token(request: Request) -> dict:
|
||||||
"""Validate a session token and return user info."""
|
"""Validate a session token and return user info."""
|
||||||
try:
|
try:
|
||||||
session_token = get_session_token_from_request(request)
|
session_token = get_session_token_from_cookie(request)
|
||||||
if not session_token:
|
if not session_token:
|
||||||
return {"error": "No session token found"}
|
return {"error": "No session token found"}
|
||||||
|
|
||||||
@ -165,7 +160,7 @@ async def logout(response: Response) -> dict:
|
|||||||
async def set_session(request: Request, response: Response) -> dict:
|
async def set_session(request: Request, response: Response) -> dict:
|
||||||
"""Set session cookie using JWT token from request body or Authorization header."""
|
"""Set session cookie using JWT token from request body or Authorization header."""
|
||||||
try:
|
try:
|
||||||
session_token = await get_session_token_from_auth_header_or_body(request)
|
session_token = await get_session_token_from_bearer(request)
|
||||||
|
|
||||||
if not session_token:
|
if not session_token:
|
||||||
return {"error": "No session token provided"}
|
return {"error": "No session token provided"}
|
||||||
@ -212,26 +207,26 @@ async def delete_credential(request: Request) -> dict:
|
|||||||
|
|
||||||
# First, verify the credential belongs to the current user
|
# First, verify the credential belongs to the current user
|
||||||
try:
|
try:
|
||||||
stored_cred = await db.get_credential_by_id(credential_id_bytes)
|
stored_cred = await sql.get_credential_by_id(credential_id_bytes)
|
||||||
if stored_cred.user_id != user.user_id:
|
if stored_cred.user_id != user.user_id:
|
||||||
return {"error": "Credential not found or access denied"}
|
return {"error": "Credential not found or access denied"}
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return {"error": "Credential not found"}
|
return {"error": "Credential not found"}
|
||||||
|
|
||||||
# Check if this is the current session credential
|
# Check if this is the current session credential
|
||||||
session_token = get_session_token_from_request(request)
|
session_token = get_session_token_from_cookie(request)
|
||||||
if session_token:
|
if session_token:
|
||||||
token_data = validate_session_token(session_token)
|
token_data = validate_session_token(session_token)
|
||||||
if token_data and token_data.get("credential_id") == credential_id_bytes:
|
if token_data and token_data.get("credential_id") == credential_id_bytes:
|
||||||
return {"error": "Cannot delete current session credential"}
|
return {"error": "Cannot delete current session credential"}
|
||||||
|
|
||||||
# Get user's remaining credentials count
|
# Get user's remaining credentials count
|
||||||
remaining_credentials = await db.get_user_credentials(user.user_id)
|
remaining_credentials = await sql.get_user_credentials(user.user_id)
|
||||||
if len(remaining_credentials) <= 1:
|
if len(remaining_credentials) <= 1:
|
||||||
return {"error": "Cannot delete last remaining credential"}
|
return {"error": "Cannot delete last remaining credential"}
|
||||||
|
|
||||||
# Delete the credential
|
# Delete the credential
|
||||||
await db.delete_user_credential(credential_id_bytes)
|
await sql.delete_user_credential(credential_id_bytes)
|
||||||
|
|
||||||
return {"status": "success", "message": "Credential deleted successfully"}
|
return {"status": "success", "message": "Credential deleted successfully"}
|
||||||
|
|
186
passkey/fastapi/main.py
Normal file
186
passkey/fastapi/main.py
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
"""
|
||||||
|
Minimal FastAPI WebAuthn server with WebSocket support for passkey registration and authentication.
|
||||||
|
|
||||||
|
This module provides a simple WebAuthn implementation that:
|
||||||
|
- Uses WebSocket for real-time communication
|
||||||
|
- Supports Resident Keys (discoverable credentials) for passwordless authentication
|
||||||
|
- Maintains challenges locally per connection
|
||||||
|
- Uses async SQLite database for persistent storage of users and credentials
|
||||||
|
- Enables true passwordless authentication where users don't need to enter a user_name
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import (
|
||||||
|
FastAPI,
|
||||||
|
Request,
|
||||||
|
Response,
|
||||||
|
)
|
||||||
|
from fastapi import (
|
||||||
|
Path as FastAPIPath,
|
||||||
|
)
|
||||||
|
from fastapi.responses import (
|
||||||
|
FileResponse,
|
||||||
|
RedirectResponse,
|
||||||
|
)
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from ..db import sql
|
||||||
|
from .api_handlers import (
|
||||||
|
delete_credential,
|
||||||
|
get_user_credentials,
|
||||||
|
get_user_info,
|
||||||
|
logout,
|
||||||
|
refresh_token,
|
||||||
|
set_session,
|
||||||
|
validate_token,
|
||||||
|
)
|
||||||
|
from .reset_handlers import create_device_addition_link, validate_device_addition_token
|
||||||
|
from .ws_handlers import ws_app
|
||||||
|
|
||||||
|
STATIC_DIR = Path(__file__).parent.parent / "frontend-build"
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
await sql.init_database()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Passkey Auth", lifespan=lifespan)
|
||||||
|
|
||||||
|
# Mount the WebSocket subapp
|
||||||
|
app.mount("/auth/ws", ws_app)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/auth/user-info")
|
||||||
|
async def api_get_user_info(request: Request):
|
||||||
|
"""Get user information from session cookie."""
|
||||||
|
return await get_user_info(request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/auth/user-credentials")
|
||||||
|
async def api_get_user_credentials(request: Request):
|
||||||
|
"""Get all credentials for a user using session cookie."""
|
||||||
|
return await get_user_credentials(request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/auth/refresh-token")
|
||||||
|
async def api_refresh_token(request: Request, response: Response):
|
||||||
|
"""Refresh the session token."""
|
||||||
|
return await refresh_token(request, response)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/auth/validate-token")
|
||||||
|
async def api_validate_token(request: Request):
|
||||||
|
"""Validate a session token and return user info."""
|
||||||
|
return await validate_token(request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/auth/forward-auth")
|
||||||
|
async def forward_authentication(request: Request):
|
||||||
|
"""A verification endpoint to use with Caddy forward_auth or Nginx auth_request."""
|
||||||
|
result = await validate_token(request)
|
||||||
|
if result.get("status") != "success":
|
||||||
|
# Serve the index.html of the authentication app if not authenticated
|
||||||
|
return FileResponse(
|
||||||
|
STATIC_DIR / "index.html",
|
||||||
|
status_code=401,
|
||||||
|
headers={"www-authenticate": "PrivateToken"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# If authenticated, return a success response
|
||||||
|
return Response(
|
||||||
|
status_code=204,
|
||||||
|
headers={"x-auth-user-id": result["user_id"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/auth/logout")
|
||||||
|
async def api_logout(response: Response):
|
||||||
|
"""Log out the current user by clearing the session cookie."""
|
||||||
|
return await logout(response)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/auth/set-session")
|
||||||
|
async def api_set_session(request: Request, response: Response):
|
||||||
|
"""Set session cookie using JWT token from request body or Authorization header."""
|
||||||
|
return await set_session(request, response)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/auth/delete-credential")
|
||||||
|
async def api_delete_credential(request: Request):
|
||||||
|
"""Delete a specific credential for the current user."""
|
||||||
|
return await delete_credential(request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/auth/create-device-link")
|
||||||
|
async def api_create_device_link(request: Request):
|
||||||
|
"""Create a device addition link for the authenticated user."""
|
||||||
|
return await create_device_addition_link(request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/auth/validate-device-token")
|
||||||
|
async def api_validate_device_token(request: Request):
|
||||||
|
"""Validate a device addition token."""
|
||||||
|
return await validate_device_addition_token(request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/auth/{passphrase}")
|
||||||
|
async def reset_authentication(
|
||||||
|
passphrase: str = FastAPIPath(pattern=r"^\w+(\.\w+){2,}$"),
|
||||||
|
):
|
||||||
|
response = RedirectResponse(url="/", status_code=303)
|
||||||
|
response.set_cookie(
|
||||||
|
key="auth-token",
|
||||||
|
value=passphrase,
|
||||||
|
httponly=False,
|
||||||
|
secure=True,
|
||||||
|
samesite="strict",
|
||||||
|
max_age=2,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# Serve static files
|
||||||
|
app.mount(
|
||||||
|
"/auth/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="static assets"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/auth")
|
||||||
|
async def redirect_to_index():
|
||||||
|
"""Serve the main authentication app."""
|
||||||
|
return FileResponse(STATIC_DIR / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
# Catch-all route for SPA - serve index.html for all non-API routes
|
||||||
|
@app.get("/{path:path}")
|
||||||
|
async def spa_handler(request: Request, path: str):
|
||||||
|
"""Serve the Vue SPA for all routes (except API and static)"""
|
||||||
|
if "text/html" not in request.headers.get("accept", ""):
|
||||||
|
return Response(content="Not Found", status_code=404)
|
||||||
|
return FileResponse(STATIC_DIR / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Entry point for the application"""
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
"passkey.fastapi.main:app",
|
||||||
|
host="localhost",
|
||||||
|
port=4401,
|
||||||
|
reload=True,
|
||||||
|
log_level="info",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
main()
|
@ -11,8 +11,8 @@ from datetime import datetime, timedelta
|
|||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
|
||||||
from . import db
|
from ..db import sql
|
||||||
from .passphrase import generate
|
from ..util.passphrase import generate
|
||||||
from .session_manager import get_current_user
|
from .session_manager import get_current_user
|
||||||
|
|
||||||
|
|
||||||
@ -25,19 +25,18 @@ async def create_device_addition_link(request: Request) -> dict:
|
|||||||
return {"error": "Authentication required"}
|
return {"error": "Authentication required"}
|
||||||
|
|
||||||
# Generate a human-readable token
|
# Generate a human-readable token
|
||||||
token = generate(n=4, sep="-") # e.g., "able-ocean-forest-dawn"
|
token = generate(n=4, sep=".") # e.g., "able-ocean-forest-dawn"
|
||||||
|
|
||||||
# Create reset token in database
|
# Create reset token in database
|
||||||
await db.create_reset_token(user.user_id, token)
|
await sql.create_reset_token(user.user_id, token)
|
||||||
|
|
||||||
# Generate the device addition link with pretty URL
|
# Generate the device addition link with pretty URL
|
||||||
addition_link = f"http://localhost:8000/reset/{token}"
|
addition_link = f"{request.headers.get('origin', '')}/auth/{token}"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": "Device addition link generated successfully",
|
"message": "Device addition link generated successfully",
|
||||||
"addition_link": addition_link,
|
"addition_link": addition_link,
|
||||||
"token": token,
|
|
||||||
"expires_in_hours": 24,
|
"expires_in_hours": 24,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,7 +54,7 @@ async def validate_device_addition_token(request: Request) -> dict:
|
|||||||
return {"error": "Device addition token is required"}
|
return {"error": "Device addition token is required"}
|
||||||
|
|
||||||
# Get reset token
|
# Get reset token
|
||||||
reset_token = await db.get_reset_token(token)
|
reset_token = await sql.get_reset_token(token)
|
||||||
if not reset_token:
|
if not reset_token:
|
||||||
return {"error": "Invalid or expired device addition token"}
|
return {"error": "Invalid or expired device addition token"}
|
||||||
|
|
||||||
@ -65,7 +64,7 @@ async def validate_device_addition_token(request: Request) -> dict:
|
|||||||
return {"error": "Device addition token has expired"}
|
return {"error": "Device addition token has expired"}
|
||||||
|
|
||||||
# Get user info
|
# Get user info
|
||||||
user = await db.get_user_by_id(reset_token.user_id)
|
user = await sql.get_user_by_id(reset_token.user_id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
@ -83,7 +82,7 @@ async def use_device_addition_token(token: str) -> dict:
|
|||||||
"""Delete a device addition token after successful use."""
|
"""Delete a device addition token after successful use."""
|
||||||
try:
|
try:
|
||||||
# Get reset token first to validate it exists and is not expired
|
# Get reset token first to validate it exists and is not expired
|
||||||
reset_token = await db.get_reset_token(token)
|
reset_token = await sql.get_reset_token(token)
|
||||||
if not reset_token:
|
if not reset_token:
|
||||||
return {"error": "Invalid or expired device addition token"}
|
return {"error": "Invalid or expired device addition token"}
|
||||||
|
|
||||||
@ -93,7 +92,7 @@ async def use_device_addition_token(token: str) -> dict:
|
|||||||
return {"error": "Device addition token has expired"}
|
return {"error": "Device addition token has expired"}
|
||||||
|
|
||||||
# Delete the token (it's now used)
|
# Delete the token (it's now used)
|
||||||
await db.delete_reset_token(token)
|
await sql.delete_reset_token(token)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
@ -7,19 +7,18 @@ This module provides session management functionality including:
|
|||||||
- Session validation and token handling
|
- Session validation and token handling
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
|
|
||||||
from .db import User, get_user_by_id
|
from ..db.sql import User, get_user_by_id
|
||||||
from .jwt_manager import validate_session_token
|
from ..util.jwt import validate_session_token
|
||||||
|
|
||||||
COOKIE_NAME = "session_token"
|
COOKIE_NAME = "auth"
|
||||||
COOKIE_MAX_AGE = 86400 # 24 hours
|
COOKIE_MAX_AGE = 86400 # 24 hours
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(request: Request) -> Optional[User]:
|
async def get_current_user(request: Request) -> User | None:
|
||||||
"""Get the current user from the session cookie."""
|
"""Get the current user from the session cookie."""
|
||||||
session_token = request.cookies.get(COOKIE_NAME)
|
session_token = request.cookies.get(COOKIE_NAME)
|
||||||
if not session_token:
|
if not session_token:
|
||||||
@ -43,7 +42,7 @@ def set_session_cookie(response: Response, session_token: str) -> None:
|
|||||||
value=session_token,
|
value=session_token,
|
||||||
max_age=COOKIE_MAX_AGE,
|
max_age=COOKIE_MAX_AGE,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
secure=False, # Set to True in production with HTTPS
|
secure=True,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -53,36 +52,29 @@ def clear_session_cookie(response: Response) -> None:
|
|||||||
response.delete_cookie(key=COOKIE_NAME)
|
response.delete_cookie(key=COOKIE_NAME)
|
||||||
|
|
||||||
|
|
||||||
def get_session_token_from_request(request: Request) -> Optional[str]:
|
def get_session_token_from_cookie(request: Request) -> str | None:
|
||||||
"""Extract session token from request cookies."""
|
"""Extract session token from request cookies."""
|
||||||
return request.cookies.get(COOKIE_NAME)
|
return request.cookies.get(COOKIE_NAME)
|
||||||
|
|
||||||
|
|
||||||
async def validate_session_from_request(request: Request) -> Optional[dict]:
|
async def validate_session_from_request(request: Request) -> dict | None:
|
||||||
"""Validate session token from request and return token data."""
|
"""Validate session token from request and return token data."""
|
||||||
session_token = get_session_token_from_request(request)
|
session_token = get_session_token_from_cookie(request)
|
||||||
if not session_token:
|
if not session_token:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return validate_session_token(session_token)
|
return validate_session_token(session_token)
|
||||||
|
|
||||||
|
|
||||||
async def get_session_token_from_auth_header_or_body(request: Request) -> Optional[str]:
|
async def get_session_token_from_bearer(request: Request) -> str | None:
|
||||||
"""Extract session token from Authorization header or request body."""
|
"""Extract session token from Authorization header or request body."""
|
||||||
# Try to get token from Authorization header first
|
# Try to get token from Authorization header first
|
||||||
auth_header = request.headers.get("Authorization")
|
auth_header = request.headers.get("Authorization")
|
||||||
if auth_header and auth_header.startswith("Bearer "):
|
if auth_header and auth_header.startswith("Bearer "):
|
||||||
return auth_header[7:] # Remove "Bearer " prefix
|
return auth_header.removeprefix("Bearer ")
|
||||||
|
|
||||||
# Try to get from request body
|
|
||||||
try:
|
|
||||||
body = await request.json()
|
|
||||||
return body.get("session_token")
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def get_user_from_cookie_string(cookie_header: str) -> Optional[UUID]:
|
async def get_user_from_cookie_string(cookie_header: str) -> UUID | None:
|
||||||
"""Parse cookie header and return user ID if valid session exists."""
|
"""Parse cookie header and return user ID if valid session exists."""
|
||||||
if not cookie_header:
|
if not cookie_header:
|
||||||
return None
|
return None
|
219
passkey/fastapi/ws_handlers.py
Normal file
219
passkey/fastapi/ws_handlers.py
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
"""
|
||||||
|
WebSocket handlers for passkey authentication operations.
|
||||||
|
|
||||||
|
This module contains all WebSocket endpoints for:
|
||||||
|
- User registration
|
||||||
|
- Adding credentials to existing users
|
||||||
|
- Device credential addition via token
|
||||||
|
- Authentication
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import uuid7
|
||||||
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
|
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
|
||||||
|
|
||||||
|
from ..db import sql
|
||||||
|
from ..db.sql import User
|
||||||
|
from ..sansio import Passkey
|
||||||
|
from ..util.jwt import create_session_token
|
||||||
|
from .session_manager import get_user_from_cookie_string
|
||||||
|
|
||||||
|
# Create a FastAPI subapp for WebSocket endpoints
|
||||||
|
ws_app = FastAPI()
|
||||||
|
|
||||||
|
# Initialize the passkey instance
|
||||||
|
passkey = Passkey(
|
||||||
|
rp_id="localhost",
|
||||||
|
rp_name="Passkey Auth",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def register_chat(
|
||||||
|
ws: WebSocket,
|
||||||
|
user_id: UUID,
|
||||||
|
user_name: str,
|
||||||
|
credential_ids: list[bytes] | None = None,
|
||||||
|
origin: str | None = None,
|
||||||
|
):
|
||||||
|
"""Generate registration options and send them to the client."""
|
||||||
|
options, challenge = passkey.reg_generate_options(
|
||||||
|
user_id=user_id,
|
||||||
|
user_name=user_name,
|
||||||
|
credential_ids=credential_ids,
|
||||||
|
origin=origin,
|
||||||
|
)
|
||||||
|
await ws.send_json(options)
|
||||||
|
response = await ws.receive_json()
|
||||||
|
return passkey.reg_verify(response, challenge, user_id, origin=origin)
|
||||||
|
|
||||||
|
|
||||||
|
@ws_app.websocket("/register_new")
|
||||||
|
async def websocket_register_new(ws: WebSocket, user_name: str):
|
||||||
|
"""Register a new user and with a new passkey credential."""
|
||||||
|
await ws.accept()
|
||||||
|
origin = ws.headers.get("origin")
|
||||||
|
try:
|
||||||
|
user_id = uuid7.create()
|
||||||
|
|
||||||
|
# WebAuthn registration
|
||||||
|
credential = await register_chat(ws, user_id, user_name, origin=origin)
|
||||||
|
|
||||||
|
# Store the user and credential in the database
|
||||||
|
await sql.create_user_and_credential(
|
||||||
|
User(user_id, user_name, created_at=datetime.now()),
|
||||||
|
credential,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a session token for the new user
|
||||||
|
session_token = create_session_token(user_id, credential.credential_id)
|
||||||
|
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"user_id": str(user_id),
|
||||||
|
"session_token": session_token,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
await ws.send_json({"error": str(e)})
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Internal Server Error")
|
||||||
|
await ws.send_json({"error": "Internal Server Error"})
|
||||||
|
|
||||||
|
|
||||||
|
@ws_app.websocket("/add_credential")
|
||||||
|
async def websocket_register_add(ws: WebSocket):
|
||||||
|
"""Register a new credential for an existing user."""
|
||||||
|
await ws.accept()
|
||||||
|
origin = ws.headers.get("origin")
|
||||||
|
try:
|
||||||
|
# Authenticate user via cookie
|
||||||
|
cookie_header = ws.headers.get("cookie", "")
|
||||||
|
user_id = await get_user_from_cookie_string(cookie_header)
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
await ws.send_json({"error": "Authentication required"})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get user information to get the user_name
|
||||||
|
user = await sql.get_user_by_id(user_id)
|
||||||
|
user_name = user.user_name
|
||||||
|
challenge_ids = await sql.get_user_credentials(user_id)
|
||||||
|
|
||||||
|
# WebAuthn registration
|
||||||
|
credential = await register_chat(
|
||||||
|
ws, user_id, user_name, challenge_ids, origin=origin
|
||||||
|
)
|
||||||
|
# Store the new credential in the database
|
||||||
|
await sql.create_credential_for_user(credential)
|
||||||
|
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"user_id": str(user_id),
|
||||||
|
"credential_id": credential.credential_id.hex(),
|
||||||
|
"message": "New credential added successfully",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
await ws.send_json({"error": str(e)})
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Internal Server Error")
|
||||||
|
await ws.send_json({"error": "Internal Server Error"})
|
||||||
|
|
||||||
|
|
||||||
|
@ws_app.websocket("/add_device_credential")
|
||||||
|
async def websocket_add_device_credential(ws: WebSocket, token: str):
|
||||||
|
"""Add a new credential for an existing user via device addition token."""
|
||||||
|
await ws.accept()
|
||||||
|
origin = ws.headers.get("origin")
|
||||||
|
try:
|
||||||
|
reset_token = await sql.get_reset_token(token)
|
||||||
|
if not reset_token:
|
||||||
|
await ws.send_json({"error": "Invalid or expired device addition token"})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if token is expired (24 hours)
|
||||||
|
expiry_time = reset_token.created_at + timedelta(hours=24)
|
||||||
|
if datetime.now() > expiry_time:
|
||||||
|
await ws.send_json({"error": "Device addition token has expired"})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get user information
|
||||||
|
user = await sql.get_user_by_id(reset_token.user_id)
|
||||||
|
|
||||||
|
# WebAuthn registration
|
||||||
|
# Fetch challenge IDs for the user
|
||||||
|
challenge_ids = await sql.get_user_credentials(reset_token.user_id)
|
||||||
|
|
||||||
|
credential = await register_chat(
|
||||||
|
ws, reset_token.user_id, user.user_name, challenge_ids, origin=origin
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store the new credential in the database
|
||||||
|
await sql.create_credential_for_user(credential)
|
||||||
|
|
||||||
|
# Delete the device addition token (it's now used)
|
||||||
|
await sql.delete_reset_token(token)
|
||||||
|
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"user_id": str(reset_token.user_id),
|
||||||
|
"credential_id": credential.credential_id.hex(),
|
||||||
|
"message": "New credential added successfully via device addition token",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
await ws.send_json({"error": str(e)})
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Internal Server Error")
|
||||||
|
await ws.send_json({"error": "Internal Server Error"})
|
||||||
|
|
||||||
|
|
||||||
|
@ws_app.websocket("/authenticate")
|
||||||
|
async def websocket_authenticate(ws: WebSocket):
|
||||||
|
await ws.accept()
|
||||||
|
origin = ws.headers.get("origin")
|
||||||
|
try:
|
||||||
|
options, challenge = passkey.auth_generate_options()
|
||||||
|
await ws.send_json(options)
|
||||||
|
# Wait for the client to use his authenticator to authenticate
|
||||||
|
credential = passkey.auth_parse(await ws.receive_json())
|
||||||
|
# Fetch from the database by credential ID
|
||||||
|
stored_cred = await sql.get_credential_by_id(credential.raw_id)
|
||||||
|
# Verify the credential matches the stored data
|
||||||
|
passkey.auth_verify(credential, challenge, stored_cred, origin=origin)
|
||||||
|
# Update both credential and user's last_seen timestamp
|
||||||
|
await sql.login_user(stored_cred.user_id, stored_cred)
|
||||||
|
|
||||||
|
# Create a session token for the authenticated user
|
||||||
|
session_token = create_session_token(
|
||||||
|
stored_cred.user_id, stored_cred.credential_id
|
||||||
|
)
|
||||||
|
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"user_id": str(stored_cred.user_id),
|
||||||
|
"session_token": session_token,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except (ValueError, InvalidAuthenticationResponse) as e:
|
||||||
|
logging.exception("ValueError")
|
||||||
|
await ws.send_json({"error": str(e)})
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Internal Server Error")
|
||||||
|
await ws.send_json({"error": "Internal Server Error"})
|
@ -60,7 +60,7 @@ class Passkey:
|
|||||||
self,
|
self,
|
||||||
rp_id: str,
|
rp_id: str,
|
||||||
rp_name: str,
|
rp_name: str,
|
||||||
origin: str,
|
origin: str | None = None,
|
||||||
supported_pub_key_algs: list[COSEAlgorithmIdentifier] | None = None,
|
supported_pub_key_algs: list[COSEAlgorithmIdentifier] | None = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@ -74,7 +74,7 @@ class Passkey:
|
|||||||
"""
|
"""
|
||||||
self.rp_id = rp_id
|
self.rp_id = rp_id
|
||||||
self.rp_name = rp_name
|
self.rp_name = rp_name
|
||||||
self.origin = origin
|
self.origin = origin or f"https://{rp_id}"
|
||||||
self.supported_pub_key_algs = supported_pub_key_algs or [
|
self.supported_pub_key_algs = supported_pub_key_algs or [
|
||||||
COSEAlgorithmIdentifier.EDDSA,
|
COSEAlgorithmIdentifier.EDDSA,
|
||||||
COSEAlgorithmIdentifier.ECDSA_SHA_256,
|
COSEAlgorithmIdentifier.ECDSA_SHA_256,
|
||||||
@ -88,6 +88,7 @@ class Passkey:
|
|||||||
user_id: UUID,
|
user_id: UUID,
|
||||||
user_name: str,
|
user_name: str,
|
||||||
credential_ids: list[bytes] | None = None,
|
credential_ids: list[bytes] | None = None,
|
||||||
|
origin: str | None = None,
|
||||||
**regopts,
|
**regopts,
|
||||||
) -> tuple[dict, bytes]:
|
) -> tuple[dict, bytes]:
|
||||||
"""
|
"""
|
||||||
@ -99,6 +100,7 @@ class Passkey:
|
|||||||
credential_ids: For an already authenticated user, a list of credential IDs
|
credential_ids: For an already authenticated user, a list of credential IDs
|
||||||
associated with the account. This prevents accidentally adding another
|
associated with the account. This prevents accidentally adding another
|
||||||
credential on an authenticator that already has one of the listed IDs.
|
credential on an authenticator that already has one of the listed IDs.
|
||||||
|
origin: The origin URL of the application (e.g. "https://app.example.com"). Must be a subdomain or same as rp_id, with port and scheme but no path included.
|
||||||
regopts: Additional arguments to generate_registration_options.
|
regopts: Additional arguments to generate_registration_options.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@ -126,6 +128,7 @@ class Passkey:
|
|||||||
response_json: dict | str,
|
response_json: dict | str,
|
||||||
expected_challenge: bytes,
|
expected_challenge: bytes,
|
||||||
user_id: UUID,
|
user_id: UUID,
|
||||||
|
origin: str | None = None,
|
||||||
) -> StoredCredential:
|
) -> StoredCredential:
|
||||||
"""
|
"""
|
||||||
Verify registration response.
|
Verify registration response.
|
||||||
@ -141,7 +144,7 @@ class Passkey:
|
|||||||
registration = verify_registration_response(
|
registration = verify_registration_response(
|
||||||
credential=credential,
|
credential=credential,
|
||||||
expected_challenge=expected_challenge,
|
expected_challenge=expected_challenge,
|
||||||
expected_origin=self.origin,
|
expected_origin=origin or self.origin,
|
||||||
expected_rp_id=self.rp_id,
|
expected_rp_id=self.rp_id,
|
||||||
)
|
)
|
||||||
return StoredCredential(
|
return StoredCredential(
|
||||||
@ -193,6 +196,7 @@ class Passkey:
|
|||||||
credential: AuthenticationCredential,
|
credential: AuthenticationCredential,
|
||||||
expected_challenge: bytes,
|
expected_challenge: bytes,
|
||||||
stored_cred: StoredCredential,
|
stored_cred: StoredCredential,
|
||||||
|
origin: str | None = None,
|
||||||
) -> VerifiedAuthentication:
|
) -> VerifiedAuthentication:
|
||||||
"""
|
"""
|
||||||
Verify authentication response against locally stored credential data.
|
Verify authentication response against locally stored credential data.
|
||||||
@ -202,11 +206,12 @@ class Passkey:
|
|||||||
expected_challenge: The earlier generated challenge bytes
|
expected_challenge: The earlier generated challenge bytes
|
||||||
stored_cred: The server stored credential record (modified by this function)
|
stored_cred: The server stored credential record (modified by this function)
|
||||||
"""
|
"""
|
||||||
|
expected_origin = origin or self.origin
|
||||||
# Verify the authentication response
|
# Verify the authentication response
|
||||||
verification = verify_authentication_response(
|
verification = verify_authentication_response(
|
||||||
credential=credential,
|
credential=credential,
|
||||||
expected_challenge=expected_challenge,
|
expected_challenge=expected_challenge,
|
||||||
expected_origin=self.origin,
|
expected_origin=expected_origin,
|
||||||
expected_rp_id=self.rp_id,
|
expected_rp_id=self.rp_id,
|
||||||
credential_public_key=stored_cred.public_key,
|
credential_public_key=stored_cred.public_key,
|
||||||
credential_current_sign_count=stored_cred.sign_count,
|
credential_current_sign_count=stored_cred.sign_count,
|
@ -22,8 +22,8 @@ def load_or_create_secret() -> bytes:
|
|||||||
if SECRET_FILE.exists():
|
if SECRET_FILE.exists():
|
||||||
return SECRET_FILE.read_bytes()
|
return SECRET_FILE.read_bytes()
|
||||||
else:
|
else:
|
||||||
# Generate a new 32-byte secret
|
# Generate a new 16-byte secret
|
||||||
secret = secrets.token_bytes(32)
|
secret = secrets.token_bytes(16)
|
||||||
SECRET_FILE.write_bytes(secret)
|
SECRET_FILE.write_bytes(secret)
|
||||||
return secret
|
return secret
|
||||||
|
|
||||||
@ -47,7 +47,7 @@ class JWTManager:
|
|||||||
Returns:
|
Returns:
|
||||||
JWT token string
|
JWT token string
|
||||||
"""
|
"""
|
||||||
now = datetime.utcnow()
|
now = datetime.now()
|
||||||
payload = {
|
payload = {
|
||||||
"user_id": str(user_id),
|
"user_id": str(user_id),
|
||||||
"credential_id": credential_id.hex(),
|
"credential_id": credential_id.hex(),
|
||||||
@ -105,7 +105,7 @@ class JWTManager:
|
|||||||
|
|
||||||
|
|
||||||
# Global JWT manager instance
|
# Global JWT manager instance
|
||||||
_jwt_manager: Optional[JWTManager] = None
|
_jwt_manager: JWTManager | None = None
|
||||||
|
|
||||||
|
|
||||||
def get_jwt_manager() -> JWTManager:
|
def get_jwt_manager() -> JWTManager:
|
||||||
@ -114,7 +114,7 @@ def get_jwt_manager() -> JWTManager:
|
|||||||
if _jwt_manager is None:
|
if _jwt_manager is None:
|
||||||
secret = load_or_create_secret()
|
secret = load_or_create_secret()
|
||||||
_jwt_manager = JWTManager(secret)
|
_jwt_manager = JWTManager(secret)
|
||||||
return _jwt_manager
|
return _jwt_manager # type: ignore
|
||||||
|
|
||||||
|
|
||||||
def create_session_token(user_id: UUID, credential_id: bytes) -> str:
|
def create_session_token(user_id: UUID, credential_id: bytes) -> str:
|
@ -1 +0,0 @@
|
|||||||
# passkeyauth package
|
|
@ -1,65 +0,0 @@
|
|||||||
"""
|
|
||||||
AAGUID (Authenticator Attestation GUID) management for WebAuthn credentials.
|
|
||||||
|
|
||||||
This module provides functionality to:
|
|
||||||
- Load AAGUID data from JSON file
|
|
||||||
- Look up authenticator information by AAGUID
|
|
||||||
- Return only relevant AAGUID data for user credentials
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
# Path to the AAGUID JSON file
|
|
||||||
AAGUID_FILE = Path(__file__).parent / "combined_aaguid.json"
|
|
||||||
|
|
||||||
|
|
||||||
class AAGUIDManager:
|
|
||||||
"""Manages AAGUID data and lookups."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.aaguid_data: dict[str, dict] = {}
|
|
||||||
self.load_aaguid_data()
|
|
||||||
|
|
||||||
def load_aaguid_data(self) -> None:
|
|
||||||
"""Load AAGUID data from the JSON file."""
|
|
||||||
try:
|
|
||||||
with open(AAGUID_FILE, encoding="utf-8") as f:
|
|
||||||
self.aaguid_data = json.load(f)
|
|
||||||
except (FileNotFoundError, json.JSONDecodeError) as e:
|
|
||||||
print(f"Warning: Could not load AAGUID data: {e}")
|
|
||||||
self.aaguid_data = {}
|
|
||||||
|
|
||||||
def get_authenticator_info(self, aaguid: str) -> Optional[dict]:
|
|
||||||
"""Get authenticator information for a specific AAGUID."""
|
|
||||||
return self.aaguid_data.get(aaguid)
|
|
||||||
|
|
||||||
def get_relevant_aaguids(self, aaguids: set[str]) -> dict[str, dict]:
|
|
||||||
"""
|
|
||||||
Get AAGUID information only for the provided set of AAGUIDs.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
aaguids: Set of AAGUID strings that the user has credentials for
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary mapping AAGUID to authenticator information for only
|
|
||||||
the AAGUIDs that the user has and that we have data for
|
|
||||||
"""
|
|
||||||
relevant = {}
|
|
||||||
for aaguid in aaguids:
|
|
||||||
if aaguid in self.aaguid_data:
|
|
||||||
relevant[aaguid] = self.aaguid_data[aaguid]
|
|
||||||
return relevant
|
|
||||||
|
|
||||||
|
|
||||||
# Global AAGUID manager instance
|
|
||||||
_aaguid_manager: Optional[AAGUIDManager] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_aaguid_manager() -> AAGUIDManager:
|
|
||||||
"""Get the global AAGUID manager instance."""
|
|
||||||
global _aaguid_manager
|
|
||||||
if _aaguid_manager is None:
|
|
||||||
_aaguid_manager = AAGUIDManager()
|
|
||||||
return _aaguid_manager
|
|
@ -1,358 +0,0 @@
|
|||||||
"""
|
|
||||||
Minimal FastAPI WebAuthn server with WebSocket support for passkey registration and authentication.
|
|
||||||
|
|
||||||
This module provides a simple WebAuthn implementation that:
|
|
||||||
- Uses WebSocket for real-time communication
|
|
||||||
- Supports Resident Keys (discoverable credentials) for passwordless authentication
|
|
||||||
- Maintains challenges locally per connection
|
|
||||||
- Uses async SQLite database for persistent storage of users and credentials
|
|
||||||
- Enables true passwordless authentication where users don't need to enter a user_name
|
|
||||||
"""
|
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect
|
|
||||||
from fastapi.responses import FileResponse
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
|
|
||||||
from . import db
|
|
||||||
from .api_handlers import (
|
|
||||||
delete_credential,
|
|
||||||
get_user_credentials,
|
|
||||||
get_user_info,
|
|
||||||
logout,
|
|
||||||
refresh_token,
|
|
||||||
set_session,
|
|
||||||
validate_token,
|
|
||||||
)
|
|
||||||
from .db import User
|
|
||||||
from .jwt_manager import create_session_token
|
|
||||||
from .passkey import Passkey
|
|
||||||
from .reset_handlers import create_device_addition_link, validate_device_addition_token
|
|
||||||
from .session_manager import get_user_from_cookie_string
|
|
||||||
|
|
||||||
STATIC_DIR = Path(__file__).parent.parent / "static"
|
|
||||||
|
|
||||||
|
|
||||||
passkey = Passkey(
|
|
||||||
rp_id="localhost",
|
|
||||||
rp_name="Passkey Auth",
|
|
||||||
origin="http://localhost:8000",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(app: FastAPI):
|
|
||||||
await db.init_database()
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Passkey Auth", lifespan=lifespan)
|
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/ws/new_user_registration")
|
|
||||||
async def websocket_register_new(ws: WebSocket):
|
|
||||||
"""Register a new user and with a new passkey credential."""
|
|
||||||
await ws.accept()
|
|
||||||
try:
|
|
||||||
# Data for the new user account
|
|
||||||
form = await ws.receive_json()
|
|
||||||
user_id = uuid4()
|
|
||||||
user_name = form["user_name"]
|
|
||||||
|
|
||||||
# WebAuthn registration
|
|
||||||
credential = await register_chat(ws, user_id, user_name)
|
|
||||||
|
|
||||||
# Store the user and credential in the database
|
|
||||||
await db.create_user_and_credential(
|
|
||||||
User(user_id, user_name, created_at=datetime.now()),
|
|
||||||
credential,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create a session token for the new user
|
|
||||||
session_token = create_session_token(user_id, credential.credential_id)
|
|
||||||
|
|
||||||
await ws.send_json(
|
|
||||||
{
|
|
||||||
"status": "success",
|
|
||||||
"user_id": str(user_id),
|
|
||||||
"session_token": session_token,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
|
||||||
await ws.send_json({"error": str(e)})
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/ws/add_credential")
|
|
||||||
async def websocket_register_add(ws: WebSocket):
|
|
||||||
"""Register a new credential for an existing user."""
|
|
||||||
await ws.accept()
|
|
||||||
try:
|
|
||||||
# Authenticate user via cookie
|
|
||||||
cookie_header = ws.headers.get("cookie", "")
|
|
||||||
user_id = await get_user_from_cookie_string(cookie_header)
|
|
||||||
|
|
||||||
if not user_id:
|
|
||||||
await ws.send_json({"error": "Authentication required"})
|
|
||||||
return
|
|
||||||
|
|
||||||
# Get user information to get the user_name
|
|
||||||
user = await db.get_user_by_id(user_id)
|
|
||||||
user_name = user.user_name
|
|
||||||
challenge_ids = await db.get_user_credentials(user_id)
|
|
||||||
|
|
||||||
# WebAuthn registration
|
|
||||||
credential = await register_chat(ws, user_id, user_name, challenge_ids)
|
|
||||||
print(f"New credential for user {user_id}: {credential}")
|
|
||||||
# Store the new credential in the database
|
|
||||||
await db.create_credential_for_user(credential)
|
|
||||||
|
|
||||||
await ws.send_json(
|
|
||||||
{
|
|
||||||
"status": "success",
|
|
||||||
"user_id": str(user_id),
|
|
||||||
"credential_id": credential.credential_id.hex(),
|
|
||||||
"message": "New credential added successfully",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
|
||||||
await ws.send_json({"error": str(e)})
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
await ws.send_json({"error": f"Server error: {str(e)}"})
|
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/ws/add_device_credential")
|
|
||||||
async def websocket_add_device_credential(ws: WebSocket):
|
|
||||||
"""Add a new credential for an existing user via device addition token."""
|
|
||||||
await ws.accept()
|
|
||||||
try:
|
|
||||||
# Get device addition token from client
|
|
||||||
message = await ws.receive_json()
|
|
||||||
token = message.get("token")
|
|
||||||
|
|
||||||
if not token:
|
|
||||||
await ws.send_json({"error": "Device addition token is required"})
|
|
||||||
return
|
|
||||||
|
|
||||||
# Validate device addition token
|
|
||||||
reset_token = await db.get_reset_token(token)
|
|
||||||
if not reset_token:
|
|
||||||
await ws.send_json({"error": "Invalid or expired device addition token"})
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if token is expired (24 hours)
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
expiry_time = reset_token.created_at + timedelta(hours=24)
|
|
||||||
if datetime.now() > expiry_time:
|
|
||||||
await ws.send_json({"error": "Device addition token has expired"})
|
|
||||||
return
|
|
||||||
|
|
||||||
# Get user information
|
|
||||||
user = await db.get_user_by_id(reset_token.user_id)
|
|
||||||
challenge_ids = await db.get_user_credentials(reset_token.user_id)
|
|
||||||
|
|
||||||
# WebAuthn registration
|
|
||||||
credential = await register_chat(
|
|
||||||
ws, reset_token.user_id, user.user_name, challenge_ids
|
|
||||||
)
|
|
||||||
|
|
||||||
# Store the new credential in the database
|
|
||||||
await db.create_credential_for_user(credential)
|
|
||||||
|
|
||||||
# Delete the device addition token (it's now used)
|
|
||||||
await db.delete_reset_token(token)
|
|
||||||
|
|
||||||
await ws.send_json(
|
|
||||||
{
|
|
||||||
"status": "success",
|
|
||||||
"user_id": str(reset_token.user_id),
|
|
||||||
"credential_id": credential.credential_id.hex(),
|
|
||||||
"message": "New credential added successfully via device addition token",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
|
||||||
await ws.send_json({"error": str(e)})
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
await ws.send_json({"error": f"Server error: {str(e)}"})
|
|
||||||
|
|
||||||
|
|
||||||
async def register_chat(
|
|
||||||
ws: WebSocket,
|
|
||||||
user_id: UUID,
|
|
||||||
user_name: str,
|
|
||||||
credential_ids: list[bytes] | None = None,
|
|
||||||
):
|
|
||||||
"""Generate registration options and send them to the client."""
|
|
||||||
options, challenge = passkey.reg_generate_options(
|
|
||||||
user_id=user_id,
|
|
||||||
user_name=user_name,
|
|
||||||
credential_ids=credential_ids,
|
|
||||||
)
|
|
||||||
await ws.send_json(options)
|
|
||||||
response = await ws.receive_json()
|
|
||||||
print(response)
|
|
||||||
return passkey.reg_verify(response, challenge, user_id)
|
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/ws/authenticate")
|
|
||||||
async def websocket_authenticate(ws: WebSocket):
|
|
||||||
await ws.accept()
|
|
||||||
try:
|
|
||||||
options, challenge = passkey.auth_generate_options()
|
|
||||||
await ws.send_json(options)
|
|
||||||
# Wait for the client to use his authenticator to authenticate
|
|
||||||
credential = passkey.auth_parse(await ws.receive_json())
|
|
||||||
# Fetch from the database by credential ID
|
|
||||||
stored_cred = await db.get_credential_by_id(credential.raw_id)
|
|
||||||
# Verify the credential matches the stored data
|
|
||||||
passkey.auth_verify(credential, challenge, stored_cred)
|
|
||||||
# Update both credential and user's last_seen timestamp
|
|
||||||
await db.login_user(stored_cred.user_id, stored_cred)
|
|
||||||
|
|
||||||
# Create a session token for the authenticated user
|
|
||||||
session_token = create_session_token(
|
|
||||||
stored_cred.user_id, stored_cred.credential_id
|
|
||||||
)
|
|
||||||
|
|
||||||
await ws.send_json(
|
|
||||||
{
|
|
||||||
"status": "success",
|
|
||||||
"user_id": str(stored_cred.user_id),
|
|
||||||
"session_token": session_token,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
|
||||||
await ws.send_json({"error": str(e)})
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/user-info")
|
|
||||||
async def api_get_user_info(request: Request):
|
|
||||||
"""Get user information from session cookie."""
|
|
||||||
return await get_user_info(request)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/user-credentials")
|
|
||||||
async def api_get_user_credentials(request: Request):
|
|
||||||
"""Get all credentials for a user using session cookie."""
|
|
||||||
return await get_user_credentials(request)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/refresh-token")
|
|
||||||
async def api_refresh_token(request: Request, response: Response):
|
|
||||||
"""Refresh the session token."""
|
|
||||||
return await refresh_token(request, response)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/validate-token")
|
|
||||||
async def api_validate_token(request: Request):
|
|
||||||
"""Validate a session token and return user info."""
|
|
||||||
return await validate_token(request)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/logout")
|
|
||||||
async def api_logout(response: Response):
|
|
||||||
"""Log out the current user by clearing the session cookie."""
|
|
||||||
return await logout(response)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/set-session")
|
|
||||||
async def api_set_session(request: Request, response: Response):
|
|
||||||
"""Set session cookie using JWT token from request body or Authorization header."""
|
|
||||||
return await set_session(request, response)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/delete-credential")
|
|
||||||
async def api_delete_credential(request: Request):
|
|
||||||
"""Delete a specific credential for the current user."""
|
|
||||||
return await delete_credential(request)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/create-device-link")
|
|
||||||
async def api_create_device_link(request: Request):
|
|
||||||
"""Create a device addition link for the authenticated user."""
|
|
||||||
return await create_device_addition_link(request)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/validate-device-token")
|
|
||||||
async def api_validate_device_token(request: Request):
|
|
||||||
"""Validate a device addition token."""
|
|
||||||
return await validate_device_addition_token(request)
|
|
||||||
|
|
||||||
|
|
||||||
# Serve static files
|
|
||||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
|
||||||
async def get_index():
|
|
||||||
"""Redirect to login page"""
|
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
|
|
||||||
return RedirectResponse(url="/auth/login", status_code=302)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth/login")
|
|
||||||
async def get_login_page():
|
|
||||||
"""Serve the login page"""
|
|
||||||
return FileResponse(STATIC_DIR / "login.html")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth/register")
|
|
||||||
async def get_register_page():
|
|
||||||
"""Serve the register page"""
|
|
||||||
return FileResponse(STATIC_DIR / "register.html")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth/dashboard")
|
|
||||||
async def get_dashboard_page():
|
|
||||||
"""Redirect to profile (dashboard is now profile)"""
|
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
|
|
||||||
return RedirectResponse(url="/auth/profile", status_code=302)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth/profile")
|
|
||||||
async def get_profile_page():
|
|
||||||
"""Serve the profile page"""
|
|
||||||
return FileResponse(STATIC_DIR / "profile.html")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth/reset")
|
|
||||||
async def get_reset_page_without_token():
|
|
||||||
"""Serve the reset page without a token"""
|
|
||||||
return FileResponse(STATIC_DIR / "reset.html")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/reset/{token}")
|
|
||||||
async def get_reset_page(token: str):
|
|
||||||
"""Serve the reset page with the token in URL"""
|
|
||||||
return FileResponse(STATIC_DIR / "reset.html")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Entry point for the application"""
|
|
||||||
import uvicorn
|
|
||||||
|
|
||||||
uvicorn.run(
|
|
||||||
"passkeyauth.main:app",
|
|
||||||
host="0.0.0.0",
|
|
||||||
port=8000,
|
|
||||||
reload=True,
|
|
||||||
log_level="info",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
@ -3,15 +3,14 @@ requires = ["hatchling"]
|
|||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "passkeyauth"
|
name = "passkey"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Minimal FastAPI WebAuthn server with WebSocket support"
|
description = "Passkey Authentication for Web Services"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "User", email = "user@example.com"},
|
{name = "Leo Vasanko"},
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi[standard]>=0.104.1",
|
"fastapi[standard]>=0.104.1",
|
||||||
"uvicorn[standard]>=0.24.0",
|
|
||||||
"websockets>=12.0",
|
"websockets>=12.0",
|
||||||
"webauthn>=1.11.1",
|
"webauthn>=1.11.1",
|
||||||
"base64url>=1.0.0",
|
"base64url>=1.0.0",
|
||||||
@ -34,7 +33,11 @@ select = ["E", "F", "I", "N", "W", "UP"]
|
|||||||
ignore = ["E501"] # Line too long
|
ignore = ["E501"] # Line too long
|
||||||
|
|
||||||
[tool.ruff.isort]
|
[tool.ruff.isort]
|
||||||
known-first-party = ["passkeyauth"]
|
known-first-party = ["passkey"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
serve = "passkeyauth.main:main"
|
serve = "passkey.main:main"
|
||||||
|
|
||||||
|
[tool.hatch.build]
|
||||||
|
artifacts = ["passkeyauth/frontend-static"]
|
||||||
|
targets.sdist.hooks.custom.path = "scripts/build-frontend.py"
|
||||||
|
36
scripts/build-frontend.py
Normal file
36
scripts/build-frontend.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# noqa: INP001
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from sys import stderr
|
||||||
|
|
||||||
|
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||||
|
|
||||||
|
|
||||||
|
class CustomBuildHook(BuildHookInterface):
|
||||||
|
def initialize(self, version, build_data):
|
||||||
|
super().initialize(version, build_data)
|
||||||
|
stderr.write(">>> Building Jacloud frontend\n")
|
||||||
|
npm = None
|
||||||
|
bun = shutil.which("bun")
|
||||||
|
if bun is None:
|
||||||
|
npm = shutil.which("npm")
|
||||||
|
if npm is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Bun or NodeJS `npm` is required for building but neither was found"
|
||||||
|
)
|
||||||
|
# npm --prefix doesn't work on Windows, so we chdir instead
|
||||||
|
os.chdir("frontend")
|
||||||
|
try:
|
||||||
|
if npm:
|
||||||
|
stderr.write("### npm install\n")
|
||||||
|
subprocess.run([npm, "install"], check=True) # noqa: S603
|
||||||
|
stderr.write("\n### npm run build\n")
|
||||||
|
subprocess.run([npm, "run", "build"], check=True) # noqa: S603
|
||||||
|
else:
|
||||||
|
stderr.write("### bun install\n")
|
||||||
|
subprocess.run([bun, "install"], check=True) # noqa: S603
|
||||||
|
stderr.write("\n### bun run build\n")
|
||||||
|
subprocess.run([bun, "run", "build"], check=True) # noqa: S603
|
||||||
|
finally:
|
||||||
|
os.chdir("..")
|
534
static/app.js
534
static/app.js
@ -1,534 +0,0 @@
|
|||||||
const { startRegistration, startAuthentication } = SimpleWebAuthnBrowser
|
|
||||||
|
|
||||||
// Global state
|
|
||||||
let currentUser = null
|
|
||||||
let currentCredentials = []
|
|
||||||
let aaguidInfo = {}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Session Management
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
async function validateStoredToken() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/validate-token', {
|
|
||||||
method: 'GET',
|
|
||||||
credentials: 'include'
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
return result.status === 'success'
|
|
||||||
} catch (error) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setSessionCookie(sessionToken) {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/set-session', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${sessionToken}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
credentials: 'include'
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(result.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to set session cookie: ${error.message}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// View Management
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
function showView(viewId) {
|
|
||||||
document.querySelectorAll('.view').forEach(view => view.classList.remove('active'))
|
|
||||||
const targetView = document.getElementById(viewId)
|
|
||||||
if (targetView) {
|
|
||||||
targetView.classList.add('active')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showLoginView() {
|
|
||||||
if (window.location.pathname !== '/auth/login') {
|
|
||||||
window.location.href = '/auth/login'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
showView('loginView')
|
|
||||||
clearStatus('loginStatus')
|
|
||||||
}
|
|
||||||
|
|
||||||
function showRegisterView() {
|
|
||||||
if (window.location.pathname !== '/auth/register') {
|
|
||||||
window.location.href = '/auth/register'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
showView('registerView')
|
|
||||||
clearStatus('registerStatus')
|
|
||||||
}
|
|
||||||
|
|
||||||
function showDeviceAdditionView() {
|
|
||||||
// This function is no longer needed as device addition is now a dialog
|
|
||||||
// Redirect to profile page if someone tries to access the old route
|
|
||||||
if (window.location.pathname === '/auth/add-device') {
|
|
||||||
window.location.href = '/auth/profile'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function showDashboardView() {
|
|
||||||
if (window.location.pathname !== '/auth/profile') {
|
|
||||||
window.location.href = '/auth/profile'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
showView('profileView')
|
|
||||||
clearStatus('profileStatus')
|
|
||||||
|
|
||||||
try {
|
|
||||||
await loadUserInfo()
|
|
||||||
updateUserInfo()
|
|
||||||
await loadCredentials()
|
|
||||||
} catch (error) {
|
|
||||||
showStatus('profileStatus', `Failed to load user info: ${error.message}`, 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Status Management
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
function showStatus(elementId, message, type = 'info') {
|
|
||||||
const statusEl = document.getElementById(elementId)
|
|
||||||
statusEl.innerHTML = `<div class="status ${type}">${message}</div>`
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearStatus(elementId) {
|
|
||||||
document.getElementById(elementId).innerHTML = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Device Addition & QR Code
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
async function copyDeviceLink() {
|
|
||||||
try {
|
|
||||||
if (window.currentDeviceLink) {
|
|
||||||
await navigator.clipboard.writeText(window.currentDeviceLink)
|
|
||||||
|
|
||||||
const copyButton = document.querySelector('.copy-button')
|
|
||||||
const originalText = copyButton.textContent
|
|
||||||
copyButton.textContent = 'Copied!'
|
|
||||||
copyButton.style.background = '#28a745'
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
copyButton.textContent = originalText
|
|
||||||
copyButton.style.background = '#28a745'
|
|
||||||
}, 2000)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to copy link:', error)
|
|
||||||
const linkText = document.getElementById('deviceLinkText')
|
|
||||||
const range = document.createRange()
|
|
||||||
range.selectNode(linkText)
|
|
||||||
window.getSelection().removeAllRanges()
|
|
||||||
window.getSelection().addRange(range)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// WebAuthn Operations
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
async function register(user_name) {
|
|
||||||
const ws = await aWebSocket('/ws/new_user_registration')
|
|
||||||
|
|
||||||
ws.send(JSON.stringify({ user_name }))
|
|
||||||
|
|
||||||
const optionsJSON = JSON.parse(await ws.recv())
|
|
||||||
if (optionsJSON.error) throw new Error(optionsJSON.error)
|
|
||||||
|
|
||||||
const registrationResponse = await startRegistration({ optionsJSON })
|
|
||||||
ws.send(JSON.stringify(registrationResponse))
|
|
||||||
|
|
||||||
const result = JSON.parse(await ws.recv())
|
|
||||||
if (result.error) throw new Error(`Server: ${result.error}`)
|
|
||||||
|
|
||||||
await setSessionCookie(result.session_token)
|
|
||||||
ws.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function authenticate() {
|
|
||||||
const ws = await aWebSocket('/ws/authenticate')
|
|
||||||
|
|
||||||
const optionsJSON = JSON.parse(await ws.recv())
|
|
||||||
if (optionsJSON.error) throw new Error(optionsJSON.error)
|
|
||||||
|
|
||||||
const authenticationResponse = await startAuthentication({ optionsJSON })
|
|
||||||
ws.send(JSON.stringify(authenticationResponse))
|
|
||||||
|
|
||||||
const result = JSON.parse(await ws.recv())
|
|
||||||
if (result.error) throw new Error(`Server: ${result.error}`)
|
|
||||||
|
|
||||||
await setSessionCookie(result.session_token)
|
|
||||||
ws.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addNewCredential() {
|
|
||||||
try {
|
|
||||||
showStatus('dashboardStatus', 'Adding new passkey...', 'info')
|
|
||||||
|
|
||||||
const ws = await aWebSocket('/ws/add_credential')
|
|
||||||
|
|
||||||
const optionsJSON = JSON.parse(await ws.recv())
|
|
||||||
if (optionsJSON.error) throw new Error(optionsJSON.error)
|
|
||||||
|
|
||||||
const registrationResponse = await startRegistration({ optionsJSON })
|
|
||||||
ws.send(JSON.stringify(registrationResponse))
|
|
||||||
|
|
||||||
const result = JSON.parse(await ws.recv())
|
|
||||||
if (result.error) throw new Error(`Server: ${result.error}`)
|
|
||||||
|
|
||||||
ws.close()
|
|
||||||
|
|
||||||
showStatus('dashboardStatus', 'New passkey added successfully!', 'success')
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
loadCredentials()
|
|
||||||
clearStatus('dashboardStatus')
|
|
||||||
}, 2000)
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
showStatus('dashboardStatus', `Failed to add passkey: ${error.message}`, 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// User Data Management
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// User registration
|
|
||||||
async function register(user_name) {
|
|
||||||
try {
|
|
||||||
const ws = await aWebSocket('/ws/new_user_registration')
|
|
||||||
ws.send(JSON.stringify({user_name}))
|
|
||||||
|
|
||||||
// Registration chat
|
|
||||||
const optionsJSON = JSON.parse(await ws.recv())
|
|
||||||
if (optionsJSON.error) throw new Error(optionsJSON.error)
|
|
||||||
|
|
||||||
showStatus('registerStatus', 'Save to your authenticator...', 'info')
|
|
||||||
|
|
||||||
const registrationResponse = await startRegistration({optionsJSON})
|
|
||||||
ws.send(JSON.stringify(registrationResponse))
|
|
||||||
|
|
||||||
const result = JSON.parse(await ws.recv())
|
|
||||||
if (result.error) throw new Error(`Server: ${result.error}`)
|
|
||||||
|
|
||||||
ws.close()
|
|
||||||
|
|
||||||
// Set session cookie using the JWT token
|
|
||||||
await setSessionCookie(result.session_token)
|
|
||||||
|
|
||||||
// Set current user from registration result
|
|
||||||
currentUser = {
|
|
||||||
user_id: result.user_id,
|
|
||||||
user_name: user_name,
|
|
||||||
last_seen: new Date().toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
} catch (error) {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// User authentication
|
|
||||||
async function authenticate() {
|
|
||||||
try {
|
|
||||||
const ws = await aWebSocket('/ws/authenticate')
|
|
||||||
const optionsJSON = JSON.parse(await ws.recv())
|
|
||||||
if (optionsJSON.error) throw new Error(optionsJSON.error)
|
|
||||||
|
|
||||||
showStatus('loginStatus', 'Please touch your authenticator...', 'info')
|
|
||||||
|
|
||||||
const authResponse = await startAuthentication({optionsJSON})
|
|
||||||
await ws.send(JSON.stringify(authResponse))
|
|
||||||
|
|
||||||
const result = JSON.parse(await ws.recv())
|
|
||||||
if (result.error) throw new Error(`Server: ${result.error}`)
|
|
||||||
|
|
||||||
ws.close()
|
|
||||||
|
|
||||||
// Set session cookie using the JWT token
|
|
||||||
await setSessionCookie(result.session_token)
|
|
||||||
|
|
||||||
// Authentication successful, now get user info using HTTP endpoint
|
|
||||||
const userResponse = await fetch('/api/user-info', {
|
|
||||||
method: 'GET',
|
|
||||||
credentials: 'include'
|
|
||||||
})
|
|
||||||
|
|
||||||
const userInfo = await userResponse.json()
|
|
||||||
if (userInfo.error) throw new Error(`Server: ${userInfo.error}`)
|
|
||||||
|
|
||||||
currentUser = userInfo.user
|
|
||||||
|
|
||||||
return result
|
|
||||||
} catch (error) {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load user credentials
|
|
||||||
async function loadCredentials() {
|
|
||||||
try {
|
|
||||||
const statusElement = document.getElementById('profileStatus') ? 'profileStatus' : 'dashboardStatus'
|
|
||||||
showStatus(statusElement, 'Loading credentials...', 'info')
|
|
||||||
|
|
||||||
const response = await fetch('/api/user-credentials', {
|
|
||||||
method: 'GET',
|
|
||||||
credentials: 'include'
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
if (result.error) throw new Error(`Server: ${result.error}`)
|
|
||||||
|
|
||||||
currentCredentials = result.credentials
|
|
||||||
aaguidInfo = result.aaguid_info || {}
|
|
||||||
updateCredentialList()
|
|
||||||
clearStatus(statusElement)
|
|
||||||
} catch (error) {
|
|
||||||
const statusElement = document.getElementById('profileStatus') ? 'profileStatus' : 'dashboardStatus'
|
|
||||||
showStatus(statusElement, `Failed to load credentials: ${error.message}`, 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load user info using HTTP endpoint
|
|
||||||
async function loadUserInfo() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/user-info', {
|
|
||||||
method: 'GET',
|
|
||||||
credentials: 'include'
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
if (result.error) throw new Error(`Server: ${result.error}`)
|
|
||||||
|
|
||||||
currentUser = result.user
|
|
||||||
} catch (error) {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update user info display
|
|
||||||
function updateUserInfo() {
|
|
||||||
const userInfoEl = document.getElementById('userInfo')
|
|
||||||
if (currentUser) {
|
|
||||||
userInfoEl.innerHTML = `
|
|
||||||
<h3>👤 ${currentUser.user_name}</h3>
|
|
||||||
<p><strong>Visits:</strong> ${currentUser.visits || 0}</p>
|
|
||||||
<p><strong>Member since:</strong> ${currentUser.created_at ? formatHumanReadableDate(currentUser.created_at) : 'N/A'}</p>
|
|
||||||
<p><strong>Last seen:</strong> ${currentUser.last_seen ? formatHumanReadableDate(currentUser.last_seen) : 'N/A'}</p>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update credential list display
|
|
||||||
function updateCredentialList() {
|
|
||||||
const credentialListEl = document.getElementById('credentialList')
|
|
||||||
|
|
||||||
if (currentCredentials.length === 0) {
|
|
||||||
credentialListEl.innerHTML = '<p>No passkeys found.</p>'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
credentialListEl.innerHTML = currentCredentials.map(cred => {
|
|
||||||
// Get authenticator information from AAGUID
|
|
||||||
const authInfo = aaguidInfo[cred.aaguid]
|
|
||||||
const authName = authInfo ? authInfo.name : 'Unknown Authenticator'
|
|
||||||
|
|
||||||
// Determine which icon to use based on current theme (you can implement theme detection)
|
|
||||||
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
||||||
const iconKey = isDarkMode ? 'icon_dark' : 'icon_light'
|
|
||||||
const authIcon = authInfo && authInfo[iconKey] ? authInfo[iconKey] : null
|
|
||||||
|
|
||||||
// Check if this is the current session credential
|
|
||||||
const isCurrentSession = cred.is_current_session || false
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="credential-item${isCurrentSession ? ' current-session' : ''}">
|
|
||||||
<div class="credential-header">
|
|
||||||
<div class="credential-icon">
|
|
||||||
${authIcon ? `<img src="${authIcon}" alt="${authName}" class="auth-icon" width="32" height="32">` : '<span class="auth-emoji">🔑</span>'}
|
|
||||||
</div>
|
|
||||||
<div class="credential-info">
|
|
||||||
<h4>${authName}</h4>
|
|
||||||
</div>
|
|
||||||
<div class="credential-dates">
|
|
||||||
<span class="date-label">Created:</span>
|
|
||||||
<span class="date-value">${formatHumanReadableDate(cred.created_at)}</span>
|
|
||||||
<span class="date-label">Last used:</span>
|
|
||||||
<span class="date-value">${formatHumanReadableDate(cred.last_used)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="credential-actions">
|
|
||||||
<button onclick="deleteCredential('${cred.credential_id}')"
|
|
||||||
class="btn-delete-credential"
|
|
||||||
${isCurrentSession ? 'disabled title="Cannot delete current session credential"' : ''}>
|
|
||||||
🗑️
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
}).join('')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to format dates in a human-readable way
|
|
||||||
function formatHumanReadableDate(dateString) {
|
|
||||||
if (!dateString) return 'Never'
|
|
||||||
|
|
||||||
const date = new Date(dateString)
|
|
||||||
const now = new Date()
|
|
||||||
const diffMs = now - date
|
|
||||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
|
||||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
|
||||||
|
|
||||||
if (diffHours < 1) {
|
|
||||||
return 'Just now'
|
|
||||||
} else if (diffHours < 24) {
|
|
||||||
return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`
|
|
||||||
} else if (diffDays <= 7) {
|
|
||||||
return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`
|
|
||||||
} else {
|
|
||||||
// For dates older than 7 days, show just the date without time
|
|
||||||
return date.toLocaleDateString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logout
|
|
||||||
async function logout() {
|
|
||||||
try {
|
|
||||||
await fetch('/api/logout', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include'
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Logout error:', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
currentUser = null
|
|
||||||
currentCredentials = []
|
|
||||||
aaguidInfo = {}
|
|
||||||
window.location.href = '/auth/login'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is already logged in on page load
|
|
||||||
async function checkExistingSession() {
|
|
||||||
const isLoggedIn = await validateStoredToken()
|
|
||||||
const path = window.location.pathname
|
|
||||||
|
|
||||||
// Protected routes that require authentication
|
|
||||||
const protectedRoutes = ['/auth/profile']
|
|
||||||
|
|
||||||
if (isLoggedIn) {
|
|
||||||
// User is logged in
|
|
||||||
if (path === '/auth/login' || path === '/auth/register' || path === '/') {
|
|
||||||
// Redirect to profile if accessing login/register pages while logged in
|
|
||||||
window.location.href = '/auth/profile'
|
|
||||||
} else if (path === '/auth/add-device') {
|
|
||||||
// Redirect old add-device route to profile
|
|
||||||
window.location.href = '/auth/profile'
|
|
||||||
} else if (protectedRoutes.includes(path)) {
|
|
||||||
// Stay on current protected page and load user data
|
|
||||||
if (path === '/auth/profile') {
|
|
||||||
loadUserInfo().then(() => {
|
|
||||||
updateUserInfo()
|
|
||||||
loadCredentials()
|
|
||||||
}).catch(error => {
|
|
||||||
showStatus('profileStatus', `Failed to load user info: ${error.message}`, 'error')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// User is not logged in
|
|
||||||
if (protectedRoutes.includes(path) || path === '/auth/add-device') {
|
|
||||||
// Redirect to login if accessing protected pages without authentication
|
|
||||||
window.location.href = '/auth/login'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize the app based on current page
|
|
||||||
function initializeApp() {
|
|
||||||
checkExistingSession()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Form event handlers
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Check for existing session on page load
|
|
||||||
initializeApp()
|
|
||||||
|
|
||||||
// Registration form
|
|
||||||
const regForm = document.getElementById('registrationForm')
|
|
||||||
if (regForm) {
|
|
||||||
const regSubmitBtn = regForm.querySelector('button[type="submit"]')
|
|
||||||
|
|
||||||
regForm.addEventListener('submit', async (ev) => {
|
|
||||||
ev.preventDefault()
|
|
||||||
regSubmitBtn.disabled = true
|
|
||||||
clearStatus('registerStatus')
|
|
||||||
|
|
||||||
const user_name = (new FormData(regForm)).get('username')
|
|
||||||
|
|
||||||
try {
|
|
||||||
showStatus('registerStatus', 'Starting registration...', 'info')
|
|
||||||
await register(user_name)
|
|
||||||
showStatus('registerStatus', `Registration successful for ${user_name}!`, 'success')
|
|
||||||
|
|
||||||
// Auto-login after successful registration
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.href = '/auth/profile'
|
|
||||||
}, 1500)
|
|
||||||
} catch (err) {
|
|
||||||
showStatus('registerStatus', `Registration failed: ${err.message}`, 'error')
|
|
||||||
} finally {
|
|
||||||
regSubmitBtn.disabled = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authentication form
|
|
||||||
const authForm = document.getElementById('authenticationForm')
|
|
||||||
if (authForm) {
|
|
||||||
const authSubmitBtn = authForm.querySelector('button[type="submit"]')
|
|
||||||
|
|
||||||
authForm.addEventListener('submit', async (ev) => {
|
|
||||||
ev.preventDefault()
|
|
||||||
authSubmitBtn.disabled = true
|
|
||||||
clearStatus('loginStatus')
|
|
||||||
|
|
||||||
try {
|
|
||||||
showStatus('loginStatus', 'Starting authentication...', 'info')
|
|
||||||
await authenticate()
|
|
||||||
showStatus('loginStatus', 'Authentication successful!', 'success')
|
|
||||||
|
|
||||||
// Navigate to profile
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.href = '/auth/profile'
|
|
||||||
}, 1000)
|
|
||||||
} catch (err) {
|
|
||||||
showStatus('loginStatus', `Authentication failed: ${err.message}`, 'error')
|
|
||||||
} finally {
|
|
||||||
authSubmitBtn.disabled = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
@ -1,43 +0,0 @@
|
|||||||
class AwaitableWebSocket extends WebSocket {
|
|
||||||
#received = []
|
|
||||||
#waiting = []
|
|
||||||
#err = null
|
|
||||||
#opened = false
|
|
||||||
|
|
||||||
constructor(resolve, reject, url, protocols) {
|
|
||||||
super(url, protocols)
|
|
||||||
this.onopen = () => {
|
|
||||||
this.#opened = true
|
|
||||||
resolve(this)
|
|
||||||
}
|
|
||||||
this.onmessage = e => {
|
|
||||||
if (this.#waiting.length) this.#waiting.shift().resolve(e.data)
|
|
||||||
else this.#received.push(e.data)
|
|
||||||
}
|
|
||||||
this.onclose = e => {
|
|
||||||
if (!this.#opened) {
|
|
||||||
reject(new Error(`WebSocket ${this.url} failed to connect, code ${e.code}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.#err = e.wasClean
|
|
||||||
? new Error(`Websocket ${this.url} closed ${e.code}`)
|
|
||||||
: new Error(`WebSocket ${this.url} closed with error ${e.code}`)
|
|
||||||
this.#waiting.splice(0).forEach(p => p.reject(this.#err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
recv() {
|
|
||||||
// If we have a message already received, return it immediately
|
|
||||||
if (this.#received.length) return Promise.resolve(this.#received.shift())
|
|
||||||
// Wait for incoming messages, if we have an error, reject immediately
|
|
||||||
if (this.#err) return Promise.reject(this.#err)
|
|
||||||
return new Promise((resolve, reject) => this.#waiting.push({ resolve, reject }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct an async WebSocket with await aWebSocket(url)
|
|
||||||
function aWebSocket(url, protocols) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
new AwaitableWebSocket(resolve, reject, url, protocols)
|
|
||||||
})
|
|
||||||
}
|
|
@ -1,92 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Passkey Authentication</title>
|
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
|
||||||
<script src="/static/simplewebauthn-browser.min.js"></script>
|
|
||||||
<script src="/static/qrcodejs/qrcode.min.js"></script>
|
|
||||||
<script src="/static/awaitable-websocket.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<!-- Login View -->
|
|
||||||
<div id="loginView" class="view active">
|
|
||||||
<h1>🔐 Passkey Login</h1>
|
|
||||||
<div id="loginStatus"></div>
|
|
||||||
<form id="authenticationForm">
|
|
||||||
<button type="submit" class="btn-primary">Login with Your Device</button>
|
|
||||||
</form>
|
|
||||||
<p class="toggle-link" onclick="showRegisterView()">
|
|
||||||
Don't have an account? Register here
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Register View -->
|
|
||||||
<div id="registerView" class="view">
|
|
||||||
<h1>🔐 Create Account</h1>
|
|
||||||
<div id="registerStatus"></div>
|
|
||||||
<form id="registrationForm">
|
|
||||||
<input type="text" name="username" placeholder="Enter username" required>
|
|
||||||
<button type="submit" class="btn-primary">Register Passkey</button>
|
|
||||||
</form>
|
|
||||||
<p class="toggle-link" onclick="showLoginView()">
|
|
||||||
Already have an account? Login here
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Dashboard View -->
|
|
||||||
<div id="dashboardView" class="view">
|
|
||||||
<h1>👋 Welcome!</h1>
|
|
||||||
<div id="userInfo" class="user-info"></div>
|
|
||||||
<div id="dashboardStatus"></div>
|
|
||||||
|
|
||||||
<h2>Your Passkeys</h2>
|
|
||||||
<div id="credentialList" class="credential-list">
|
|
||||||
<p>Loading credentials...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button onclick="addNewCredential()" class="btn-primary">
|
|
||||||
Add New Passkey
|
|
||||||
</button>
|
|
||||||
<button onclick="generateAndShowDeviceLink()" class="btn-secondary">
|
|
||||||
Generate Device Link
|
|
||||||
</button>
|
|
||||||
<button onclick="logout()" class="btn-danger">
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Device Addition View -->
|
|
||||||
<div id="deviceAdditionView" class="view">
|
|
||||||
<h1>📱 Add Device</h1>
|
|
||||||
<div id="deviceAdditionStatus"></div>
|
|
||||||
|
|
||||||
<div id="deviceLinkSection">
|
|
||||||
<h2>Device Addition Link</h2>
|
|
||||||
<div class="token-info">
|
|
||||||
<p><strong>Share this link to add this account to another device:</strong></p>
|
|
||||||
|
|
||||||
<div class="qr-container">
|
|
||||||
<div id="qrCode" class="qr-code"></div>
|
|
||||||
<p><small>Scan this QR code with your other device</small></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="link-container">
|
|
||||||
<p class="link-text" id="deviceLinkText">Loading...</p>
|
|
||||||
<button class="copy-button" onclick="copyDeviceLink()">Copy Link</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p><small>⚠️ This link expires in 24 hours and can only be used once.</small></p>
|
|
||||||
<p><strong>Human-readable code:</strong> <code id="deviceToken"></code></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button onclick="showDashboardView()" class="btn-secondary">
|
|
||||||
Back to Dashboard
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="static/app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,28 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Login - Passkey Authentication</title>
|
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
|
||||||
<script src="/static/simplewebauthn-browser.min.js"></script>
|
|
||||||
<script src="/static/awaitable-websocket.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<!-- Login View -->
|
|
||||||
<div id="loginView" class="view active">
|
|
||||||
<h1>🔐 Passkey Login</h1>
|
|
||||||
<div id="loginStatus"></div>
|
|
||||||
<form id="authenticationForm">
|
|
||||||
<button type="submit" class="btn-primary">Login with Your Device</button>
|
|
||||||
</form>
|
|
||||||
<p class="toggle-link" onclick="window.location.href='/auth/register'">
|
|
||||||
Don't have an account? Register here
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/static/app.js"></script>
|
|
||||||
<script src="/static/util.js"></script>
|
|
||||||
<script src="/static/login.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,33 +0,0 @@
|
|||||||
// Login page specific functionality
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Initialize the app
|
|
||||||
initializeApp()
|
|
||||||
|
|
||||||
// Authentication form handler
|
|
||||||
const authForm = document.getElementById('authenticationForm')
|
|
||||||
if (authForm) {
|
|
||||||
const authSubmitBtn = authForm.querySelector('button[type="submit"]')
|
|
||||||
|
|
||||||
authForm.addEventListener('submit', async (ev) => {
|
|
||||||
ev.preventDefault()
|
|
||||||
authSubmitBtn.disabled = true
|
|
||||||
clearStatus('loginStatus')
|
|
||||||
|
|
||||||
try {
|
|
||||||
showStatus('loginStatus', 'Starting authentication...', 'info')
|
|
||||||
await authenticate()
|
|
||||||
showStatus('loginStatus', 'Authentication successful!', 'success')
|
|
||||||
|
|
||||||
// Navigate to profile
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.href = '/auth/profile'
|
|
||||||
}, 1000)
|
|
||||||
} catch (err) {
|
|
||||||
showStatus('loginStatus', `Authentication failed: ${err.message}`, 'error')
|
|
||||||
} finally {
|
|
||||||
authSubmitBtn.disabled = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
@ -1,36 +0,0 @@
|
|||||||
/* Profile page dialog styles */
|
|
||||||
|
|
||||||
.container.dialog-open {
|
|
||||||
filter: blur(2px);
|
|
||||||
pointer-events: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dialog styling */
|
|
||||||
#deviceLinkDialog {
|
|
||||||
position: fixed;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
z-index: 999;
|
|
||||||
color: black;
|
|
||||||
background: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
|
||||||
padding: 2rem;
|
|
||||||
max-width: 500px;
|
|
||||||
width: 90%;
|
|
||||||
max-height: 90vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#deviceLinkDialog::backdrop {
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Prevent scrolling when dialog is open */
|
|
||||||
body.dialog-open {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
@ -1,64 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Profile - Passkey Authentication</title>
|
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
|
||||||
<link rel="stylesheet" href="/static/profile-dialog.css">
|
|
||||||
<script src="/static/simplewebauthn-browser.min.js"></script>
|
|
||||||
<script src="/static/qrcodejs/qrcode.min.js"></script>
|
|
||||||
<script src="/static/awaitable-websocket.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<!-- Profile View -->
|
|
||||||
<div id="profileView" class="view active">
|
|
||||||
<h1>👋 Welcome!</h1>
|
|
||||||
<div id="userInfo" class="user-info"></div>
|
|
||||||
<div id="profileStatus"></div>
|
|
||||||
|
|
||||||
<h2>Your Passkeys</h2>
|
|
||||||
<div id="credentialList" class="credential-list">
|
|
||||||
<p>Loading credentials...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button onclick="addNewCredential()" class="btn-primary">
|
|
||||||
Add New Passkey
|
|
||||||
</button>
|
|
||||||
<button onclick="openDeviceLinkDialog()" class="btn-secondary">
|
|
||||||
Generate Device Link
|
|
||||||
</button>
|
|
||||||
<button onclick="logout()" class="btn-danger">
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Device Link Dialog -->
|
|
||||||
<dialog id="deviceLinkDialog">
|
|
||||||
<h1>📱 Add Device</h1>
|
|
||||||
<div id="deviceAdditionStatus"></div>
|
|
||||||
|
|
||||||
<div id="deviceLinkSection">
|
|
||||||
<h2>Device Addition Link</h2>
|
|
||||||
<div class="token-info">
|
|
||||||
<div class="qr-container">
|
|
||||||
<div id="qrCode" class="qr-code"></div>
|
|
||||||
<p><a href="#" id="deviceLinkText"></a></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<strong>Scan the above code and visit the URL on another device.</strong><br>
|
|
||||||
<small>⚠️ Expires in 24 hours and can only be used once.</small>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button onclick="closeDeviceLinkDialog()" class="btn-secondary">
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</dialog>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/static/app.js"></script>
|
|
||||||
<script src="/static/profile.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,124 +0,0 @@
|
|||||||
// Profile page specific functionality
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Initialize the app
|
|
||||||
initializeApp()
|
|
||||||
|
|
||||||
// Setup dialog event handlers
|
|
||||||
setupDialogHandlers()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Setup dialog event handlers
|
|
||||||
function setupDialogHandlers() {
|
|
||||||
// Close dialog when clicking outside
|
|
||||||
const dialog = document.getElementById('deviceLinkDialog')
|
|
||||||
if (dialog) {
|
|
||||||
dialog.addEventListener('click', function(e) {
|
|
||||||
if (e.target === this) {
|
|
||||||
closeDeviceLinkDialog()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close dialog when pressing Escape key
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
const dialog = document.getElementById('deviceLinkDialog')
|
|
||||||
if (e.key === 'Escape' && dialog && dialog.open) {
|
|
||||||
closeDeviceLinkDialog()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open device link dialog
|
|
||||||
function openDeviceLinkDialog() {
|
|
||||||
const dialog = document.getElementById('deviceLinkDialog')
|
|
||||||
const container = document.querySelector('.container')
|
|
||||||
const body = document.body
|
|
||||||
|
|
||||||
if (dialog && container && body) {
|
|
||||||
// Add blur and disable effects
|
|
||||||
container.classList.add('dialog-open')
|
|
||||||
body.classList.add('dialog-open')
|
|
||||||
|
|
||||||
dialog.showModal()
|
|
||||||
generateDeviceLink()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close device link dialog
|
|
||||||
function closeDeviceLinkDialog() {
|
|
||||||
const dialog = document.getElementById('deviceLinkDialog')
|
|
||||||
const container = document.querySelector('.container')
|
|
||||||
const body = document.body
|
|
||||||
|
|
||||||
if (dialog && container && body) {
|
|
||||||
// Remove blur and disable effects
|
|
||||||
container.classList.remove('dialog-open')
|
|
||||||
body.classList.remove('dialog-open')
|
|
||||||
|
|
||||||
dialog.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate device link function
|
|
||||||
async function generateDeviceLink() {
|
|
||||||
clearStatus('deviceAdditionStatus')
|
|
||||||
showStatus('deviceAdditionStatus', 'Generating device link...', 'info')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/create-device-link', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include'
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(result.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update UI with the link
|
|
||||||
const deviceLinkText = document.getElementById('deviceLinkText')
|
|
||||||
|
|
||||||
if (deviceLinkText) {
|
|
||||||
deviceLinkText.href = result.addition_link
|
|
||||||
deviceLinkText.textContent = result.addition_link.replace(/^[a-z]+:\/\//i, '')
|
|
||||||
|
|
||||||
// Add click event listener for copying the link
|
|
||||||
deviceLinkText.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault() // Prevent navigation
|
|
||||||
navigator.clipboard.writeText(deviceLinkText.href).then(() => {
|
|
||||||
closeDeviceLinkDialog() // Close the dialog
|
|
||||||
showStatus('deviceAdditionStatus', 'Device registration link copied', 'success') // Display status
|
|
||||||
}).catch(() => {
|
|
||||||
showStatus('deviceAdditionStatus', 'Failed to copy device registration link', 'error')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store link globally for copy function
|
|
||||||
window.currentDeviceLink = result.addition_link
|
|
||||||
|
|
||||||
// Generate QR code
|
|
||||||
const qrCodeEl = document.getElementById('qrCode')
|
|
||||||
if (qrCodeEl && typeof QRCode !== 'undefined') {
|
|
||||||
qrCodeEl.innerHTML = ''
|
|
||||||
new QRCode(qrCodeEl, {
|
|
||||||
text: result.addition_link,
|
|
||||||
width: 200,
|
|
||||||
height: 200,
|
|
||||||
colorDark: '#000000',
|
|
||||||
colorLight: '#ffffff',
|
|
||||||
correctLevel: QRCode.CorrectLevel.M
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
clearStatus('deviceAdditionStatus')
|
|
||||||
} catch (error) {
|
|
||||||
showStatus('deviceAdditionStatus', `Failed to generate device link: ${error.message}`, 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make functions available globally for onclick handlers
|
|
||||||
window.openDeviceLinkDialog = openDeviceLinkDialog
|
|
||||||
window.closeDeviceLinkDialog = closeDeviceLinkDialog
|
|
4
static/qrcodejs/.gitignore
vendored
4
static/qrcodejs/.gitignore
vendored
@ -1,4 +0,0 @@
|
|||||||
.DS_Store
|
|
||||||
|
|
||||||
.idea
|
|
||||||
.project
|
|
@ -1,14 +0,0 @@
|
|||||||
The MIT License (MIT)
|
|
||||||
---------------------
|
|
||||||
Copyright (c) 2012 davidshimjs
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge,
|
|
||||||
to any person obtaining a copy of this software and associated documentation files (the "Software"),
|
|
||||||
to deal in the Software without restriction,
|
|
||||||
including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
|
||||||
and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
|
|
||||||
subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@ -1,46 +0,0 @@
|
|||||||
# QRCode.js
|
|
||||||
QRCode.js is javascript library for making QRCode. QRCode.js supports Cross-browser with HTML5 Canvas and table tag in DOM.
|
|
||||||
QRCode.js has no dependencies.
|
|
||||||
|
|
||||||
## Basic Usages
|
|
||||||
```
|
|
||||||
<div id="qrcode"></div>
|
|
||||||
<script type="text/javascript">
|
|
||||||
new QRCode(document.getElementById("qrcode"), "http://jindo.dev.naver.com/collie");
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
or with some options
|
|
||||||
|
|
||||||
```
|
|
||||||
<div id="qrcode"></div>
|
|
||||||
<script type="text/javascript">
|
|
||||||
var qrcode = new QRCode(document.getElementById("qrcode"), {
|
|
||||||
text: "http://jindo.dev.naver.com/collie",
|
|
||||||
width: 128,
|
|
||||||
height: 128,
|
|
||||||
colorDark : "#000000",
|
|
||||||
colorLight : "#ffffff",
|
|
||||||
correctLevel : QRCode.CorrectLevel.H
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
and you can use some methods
|
|
||||||
|
|
||||||
```
|
|
||||||
qrcode.clear(); // clear the code.
|
|
||||||
qrcode.makeCode("http://naver.com"); // make another code.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Browser Compatibility
|
|
||||||
IE6~10, Chrome, Firefox, Safari, Opera, Mobile Safari, Android, Windows Mobile, ETC.
|
|
||||||
|
|
||||||
## License
|
|
||||||
MIT License
|
|
||||||
|
|
||||||
## Contact
|
|
||||||
twitter @davidshimjs
|
|
||||||
|
|
||||||
[](https://bitdeli.com/free "Bitdeli Badge")
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "qrcode.js",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"homepage": "https://github.com/davidshimjs/qrcodejs",
|
|
||||||
"authors": [
|
|
||||||
"Sangmin Shim", "Sangmin Shim <ssm0123@gmail.com> (http://jaguarjs.com)"
|
|
||||||
],
|
|
||||||
"description": "Cross-browser QRCode generator for javascript",
|
|
||||||
"main": "qrcode.js",
|
|
||||||
"ignore": [
|
|
||||||
"bower_components",
|
|
||||||
"node_modules",
|
|
||||||
"index.html",
|
|
||||||
"index.svg",
|
|
||||||
"jquery.min.js",
|
|
||||||
"qrcode.min.js"
|
|
||||||
]
|
|
||||||
}
|
|
@ -1,47 +0,0 @@
|
|||||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ko" lang="ko">
|
|
||||||
<head>
|
|
||||||
<title>Cross-Browser QRCode generator for Javascript</title>
|
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no" />
|
|
||||||
<script type="text/javascript" src="jquery.min.js"></script>
|
|
||||||
<script type="text/javascript" src="qrcode.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<input id="text" type="text" value="http://jindo.dev.naver.com/collie" style="width:80%" />
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<g id="qrcode"/>
|
|
||||||
</svg>
|
|
||||||
<script type="text/javascript">
|
|
||||||
var qrcode = new QRCode(document.getElementById("qrcode"), {
|
|
||||||
width : 100,
|
|
||||||
height : 100,
|
|
||||||
useSVG: true
|
|
||||||
});
|
|
||||||
|
|
||||||
function makeCode () {
|
|
||||||
var elText = document.getElementById("text");
|
|
||||||
|
|
||||||
if (!elText.value) {
|
|
||||||
alert("Input a text");
|
|
||||||
elText.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
qrcode.makeCode(elText.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
makeCode();
|
|
||||||
|
|
||||||
$("#text").
|
|
||||||
on("blur", function () {
|
|
||||||
makeCode();
|
|
||||||
}).
|
|
||||||
on("keydown", function (e) {
|
|
||||||
if (e.keyCode == 13) {
|
|
||||||
makeCode();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,44 +0,0 @@
|
|||||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ko" lang="ko">
|
|
||||||
<head>
|
|
||||||
<title>Cross-Browser QRCode generator for Javascript</title>
|
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no" />
|
|
||||||
<script type="text/javascript" src="jquery.min.js"></script>
|
|
||||||
<script type="text/javascript" src="qrcode.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<input id="text" type="text" value="http://jindo.dev.naver.com/collie" style="width:80%" /><br />
|
|
||||||
<div id="qrcode" style="width:100px; height:100px; margin-top:15px;"></div>
|
|
||||||
|
|
||||||
<script type="text/javascript">
|
|
||||||
var qrcode = new QRCode(document.getElementById("qrcode"), {
|
|
||||||
width : 100,
|
|
||||||
height : 100
|
|
||||||
});
|
|
||||||
|
|
||||||
function makeCode () {
|
|
||||||
var elText = document.getElementById("text");
|
|
||||||
|
|
||||||
if (!elText.value) {
|
|
||||||
alert("Input a text");
|
|
||||||
elText.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
qrcode.makeCode(elText.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
makeCode();
|
|
||||||
|
|
||||||
$("#text").
|
|
||||||
on("blur", function () {
|
|
||||||
makeCode();
|
|
||||||
}).
|
|
||||||
on("keydown", function (e) {
|
|
||||||
if (e.keyCode == 13) {
|
|
||||||
makeCode();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
@ -1,37 +0,0 @@
|
|||||||
<?xml version="1.0" standalone="yes"?>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="-50 0 200 100">
|
|
||||||
<g id="qrcode"/>
|
|
||||||
<foreignObject x="-50" y="0" width="100" height="100">
|
|
||||||
<body xmlns="http://www.w3.org/1999/xhtml" style="padding:0; margin:0">
|
|
||||||
<div style="padding:inherit; margin:inherit; height:100%">
|
|
||||||
<textarea id="text" style="height:100%; width:100%; position:absolute; margin:inherit; padding:inherit">james</textarea>
|
|
||||||
</div>
|
|
||||||
<script type="application/ecmascript" src="qrcode.js"></script>
|
|
||||||
<script type="application/ecmascript">
|
|
||||||
var elem = document.getElementById("qrcode");
|
|
||||||
var qrcode = new QRCode(elem, {
|
|
||||||
width : 100,
|
|
||||||
height : 100
|
|
||||||
});
|
|
||||||
|
|
||||||
function makeCode () {
|
|
||||||
var elText = document.getElementById("text");
|
|
||||||
|
|
||||||
if (elText.value === "") {
|
|
||||||
//alert("Input a text");
|
|
||||||
//elText.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
qrcode.makeCode(elText.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
makeCode();
|
|
||||||
|
|
||||||
document.getElementById("text").onkeyup = function (e) {
|
|
||||||
makeCode();
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</foreignObject>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.1 KiB |
2
static/qrcodejs/jquery.min.js
vendored
2
static/qrcodejs/jquery.min.js
vendored
File diff suppressed because one or more lines are too long
@ -1,614 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview
|
|
||||||
* - Using the 'QRCode for Javascript library'
|
|
||||||
* - Fixed dataset of 'QRCode for Javascript library' for support full-spec.
|
|
||||||
* - this library has no dependencies.
|
|
||||||
*
|
|
||||||
* @author davidshimjs
|
|
||||||
* @see <a href="http://www.d-project.com/" target="_blank">http://www.d-project.com/</a>
|
|
||||||
* @see <a href="http://jeromeetienne.github.com/jquery-qrcode/" target="_blank">http://jeromeetienne.github.com/jquery-qrcode/</a>
|
|
||||||
*/
|
|
||||||
var QRCode;
|
|
||||||
|
|
||||||
(function () {
|
|
||||||
//---------------------------------------------------------------------
|
|
||||||
// QRCode for JavaScript
|
|
||||||
//
|
|
||||||
// Copyright (c) 2009 Kazuhiko Arase
|
|
||||||
//
|
|
||||||
// URL: http://www.d-project.com/
|
|
||||||
//
|
|
||||||
// Licensed under the MIT license:
|
|
||||||
// http://www.opensource.org/licenses/mit-license.php
|
|
||||||
//
|
|
||||||
// The word "QR Code" is registered trademark of
|
|
||||||
// DENSO WAVE INCORPORATED
|
|
||||||
// http://www.denso-wave.com/qrcode/faqpatent-e.html
|
|
||||||
//
|
|
||||||
//---------------------------------------------------------------------
|
|
||||||
function QR8bitByte(data) {
|
|
||||||
this.mode = QRMode.MODE_8BIT_BYTE;
|
|
||||||
this.data = data;
|
|
||||||
this.parsedData = [];
|
|
||||||
|
|
||||||
// Added to support UTF-8 Characters
|
|
||||||
for (var i = 0, l = this.data.length; i < l; i++) {
|
|
||||||
var byteArray = [];
|
|
||||||
var code = this.data.charCodeAt(i);
|
|
||||||
|
|
||||||
if (code > 0x10000) {
|
|
||||||
byteArray[0] = 0xF0 | ((code & 0x1C0000) >>> 18);
|
|
||||||
byteArray[1] = 0x80 | ((code & 0x3F000) >>> 12);
|
|
||||||
byteArray[2] = 0x80 | ((code & 0xFC0) >>> 6);
|
|
||||||
byteArray[3] = 0x80 | (code & 0x3F);
|
|
||||||
} else if (code > 0x800) {
|
|
||||||
byteArray[0] = 0xE0 | ((code & 0xF000) >>> 12);
|
|
||||||
byteArray[1] = 0x80 | ((code & 0xFC0) >>> 6);
|
|
||||||
byteArray[2] = 0x80 | (code & 0x3F);
|
|
||||||
} else if (code > 0x80) {
|
|
||||||
byteArray[0] = 0xC0 | ((code & 0x7C0) >>> 6);
|
|
||||||
byteArray[1] = 0x80 | (code & 0x3F);
|
|
||||||
} else {
|
|
||||||
byteArray[0] = code;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.parsedData.push(byteArray);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.parsedData = Array.prototype.concat.apply([], this.parsedData);
|
|
||||||
|
|
||||||
if (this.parsedData.length != this.data.length) {
|
|
||||||
this.parsedData.unshift(191);
|
|
||||||
this.parsedData.unshift(187);
|
|
||||||
this.parsedData.unshift(239);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
QR8bitByte.prototype = {
|
|
||||||
getLength: function (buffer) {
|
|
||||||
return this.parsedData.length;
|
|
||||||
},
|
|
||||||
write: function (buffer) {
|
|
||||||
for (var i = 0, l = this.parsedData.length; i < l; i++) {
|
|
||||||
buffer.put(this.parsedData[i], 8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function QRCodeModel(typeNumber, errorCorrectLevel) {
|
|
||||||
this.typeNumber = typeNumber;
|
|
||||||
this.errorCorrectLevel = errorCorrectLevel;
|
|
||||||
this.modules = null;
|
|
||||||
this.moduleCount = 0;
|
|
||||||
this.dataCache = null;
|
|
||||||
this.dataList = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
QRCodeModel.prototype={addData:function(data){var newData=new QR8bitByte(data);this.dataList.push(newData);this.dataCache=null;},isDark:function(row,col){if(row<0||this.moduleCount<=row||col<0||this.moduleCount<=col){throw new Error(row+","+col);}
|
|
||||||
return this.modules[row][col];},getModuleCount:function(){return this.moduleCount;},make:function(){this.makeImpl(false,this.getBestMaskPattern());},makeImpl:function(test,maskPattern){this.moduleCount=this.typeNumber*4+17;this.modules=new Array(this.moduleCount);for(var row=0;row<this.moduleCount;row++){this.modules[row]=new Array(this.moduleCount);for(var col=0;col<this.moduleCount;col++){this.modules[row][col]=null;}}
|
|
||||||
this.setupPositionProbePattern(0,0);this.setupPositionProbePattern(this.moduleCount-7,0);this.setupPositionProbePattern(0,this.moduleCount-7);this.setupPositionAdjustPattern();this.setupTimingPattern();this.setupTypeInfo(test,maskPattern);if(this.typeNumber>=7){this.setupTypeNumber(test);}
|
|
||||||
if(this.dataCache==null){this.dataCache=QRCodeModel.createData(this.typeNumber,this.errorCorrectLevel,this.dataList);}
|
|
||||||
this.mapData(this.dataCache,maskPattern);},setupPositionProbePattern:function(row,col){for(var r=-1;r<=7;r++){if(row+r<=-1||this.moduleCount<=row+r)continue;for(var c=-1;c<=7;c++){if(col+c<=-1||this.moduleCount<=col+c)continue;if((0<=r&&r<=6&&(c==0||c==6))||(0<=c&&c<=6&&(r==0||r==6))||(2<=r&&r<=4&&2<=c&&c<=4)){this.modules[row+r][col+c]=true;}else{this.modules[row+r][col+c]=false;}}}},getBestMaskPattern:function(){var minLostPoint=0;var pattern=0;for(var i=0;i<8;i++){this.makeImpl(true,i);var lostPoint=QRUtil.getLostPoint(this);if(i==0||minLostPoint>lostPoint){minLostPoint=lostPoint;pattern=i;}}
|
|
||||||
return pattern;},createMovieClip:function(target_mc,instance_name,depth){var qr_mc=target_mc.createEmptyMovieClip(instance_name,depth);var cs=1;this.make();for(var row=0;row<this.modules.length;row++){var y=row*cs;for(var col=0;col<this.modules[row].length;col++){var x=col*cs;var dark=this.modules[row][col];if(dark){qr_mc.beginFill(0,100);qr_mc.moveTo(x,y);qr_mc.lineTo(x+cs,y);qr_mc.lineTo(x+cs,y+cs);qr_mc.lineTo(x,y+cs);qr_mc.endFill();}}}
|
|
||||||
return qr_mc;},setupTimingPattern:function(){for(var r=8;r<this.moduleCount-8;r++){if(this.modules[r][6]!=null){continue;}
|
|
||||||
this.modules[r][6]=(r%2==0);}
|
|
||||||
for(var c=8;c<this.moduleCount-8;c++){if(this.modules[6][c]!=null){continue;}
|
|
||||||
this.modules[6][c]=(c%2==0);}},setupPositionAdjustPattern:function(){var pos=QRUtil.getPatternPosition(this.typeNumber);for(var i=0;i<pos.length;i++){for(var j=0;j<pos.length;j++){var row=pos[i];var col=pos[j];if(this.modules[row][col]!=null){continue;}
|
|
||||||
for(var r=-2;r<=2;r++){for(var c=-2;c<=2;c++){if(r==-2||r==2||c==-2||c==2||(r==0&&c==0)){this.modules[row+r][col+c]=true;}else{this.modules[row+r][col+c]=false;}}}}}},setupTypeNumber:function(test){var bits=QRUtil.getBCHTypeNumber(this.typeNumber);for(var i=0;i<18;i++){var mod=(!test&&((bits>>i)&1)==1);this.modules[Math.floor(i/3)][i%3+this.moduleCount-8-3]=mod;}
|
|
||||||
for(var i=0;i<18;i++){var mod=(!test&&((bits>>i)&1)==1);this.modules[i%3+this.moduleCount-8-3][Math.floor(i/3)]=mod;}},setupTypeInfo:function(test,maskPattern){var data=(this.errorCorrectLevel<<3)|maskPattern;var bits=QRUtil.getBCHTypeInfo(data);for(var i=0;i<15;i++){var mod=(!test&&((bits>>i)&1)==1);if(i<6){this.modules[i][8]=mod;}else if(i<8){this.modules[i+1][8]=mod;}else{this.modules[this.moduleCount-15+i][8]=mod;}}
|
|
||||||
for(var i=0;i<15;i++){var mod=(!test&&((bits>>i)&1)==1);if(i<8){this.modules[8][this.moduleCount-i-1]=mod;}else if(i<9){this.modules[8][15-i-1+1]=mod;}else{this.modules[8][15-i-1]=mod;}}
|
|
||||||
this.modules[this.moduleCount-8][8]=(!test);},mapData:function(data,maskPattern){var inc=-1;var row=this.moduleCount-1;var bitIndex=7;var byteIndex=0;for(var col=this.moduleCount-1;col>0;col-=2){if(col==6)col--;while(true){for(var c=0;c<2;c++){if(this.modules[row][col-c]==null){var dark=false;if(byteIndex<data.length){dark=(((data[byteIndex]>>>bitIndex)&1)==1);}
|
|
||||||
var mask=QRUtil.getMask(maskPattern,row,col-c);if(mask){dark=!dark;}
|
|
||||||
this.modules[row][col-c]=dark;bitIndex--;if(bitIndex==-1){byteIndex++;bitIndex=7;}}}
|
|
||||||
row+=inc;if(row<0||this.moduleCount<=row){row-=inc;inc=-inc;break;}}}}};QRCodeModel.PAD0=0xEC;QRCodeModel.PAD1=0x11;QRCodeModel.createData=function(typeNumber,errorCorrectLevel,dataList){var rsBlocks=QRRSBlock.getRSBlocks(typeNumber,errorCorrectLevel);var buffer=new QRBitBuffer();for(var i=0;i<dataList.length;i++){var data=dataList[i];buffer.put(data.mode,4);buffer.put(data.getLength(),QRUtil.getLengthInBits(data.mode,typeNumber));data.write(buffer);}
|
|
||||||
var totalDataCount=0;for(var i=0;i<rsBlocks.length;i++){totalDataCount+=rsBlocks[i].dataCount;}
|
|
||||||
if(buffer.getLengthInBits()>totalDataCount*8){throw new Error("code length overflow. ("
|
|
||||||
+buffer.getLengthInBits()
|
|
||||||
+">"
|
|
||||||
+totalDataCount*8
|
|
||||||
+")");}
|
|
||||||
if(buffer.getLengthInBits()+4<=totalDataCount*8){buffer.put(0,4);}
|
|
||||||
while(buffer.getLengthInBits()%8!=0){buffer.putBit(false);}
|
|
||||||
while(true){if(buffer.getLengthInBits()>=totalDataCount*8){break;}
|
|
||||||
buffer.put(QRCodeModel.PAD0,8);if(buffer.getLengthInBits()>=totalDataCount*8){break;}
|
|
||||||
buffer.put(QRCodeModel.PAD1,8);}
|
|
||||||
return QRCodeModel.createBytes(buffer,rsBlocks);};QRCodeModel.createBytes=function(buffer,rsBlocks){var offset=0;var maxDcCount=0;var maxEcCount=0;var dcdata=new Array(rsBlocks.length);var ecdata=new Array(rsBlocks.length);for(var r=0;r<rsBlocks.length;r++){var dcCount=rsBlocks[r].dataCount;var ecCount=rsBlocks[r].totalCount-dcCount;maxDcCount=Math.max(maxDcCount,dcCount);maxEcCount=Math.max(maxEcCount,ecCount);dcdata[r]=new Array(dcCount);for(var i=0;i<dcdata[r].length;i++){dcdata[r][i]=0xff&buffer.buffer[i+offset];}
|
|
||||||
offset+=dcCount;var rsPoly=QRUtil.getErrorCorrectPolynomial(ecCount);var rawPoly=new QRPolynomial(dcdata[r],rsPoly.getLength()-1);var modPoly=rawPoly.mod(rsPoly);ecdata[r]=new Array(rsPoly.getLength()-1);for(var i=0;i<ecdata[r].length;i++){var modIndex=i+modPoly.getLength()-ecdata[r].length;ecdata[r][i]=(modIndex>=0)?modPoly.get(modIndex):0;}}
|
|
||||||
var totalCodeCount=0;for(var i=0;i<rsBlocks.length;i++){totalCodeCount+=rsBlocks[i].totalCount;}
|
|
||||||
var data=new Array(totalCodeCount);var index=0;for(var i=0;i<maxDcCount;i++){for(var r=0;r<rsBlocks.length;r++){if(i<dcdata[r].length){data[index++]=dcdata[r][i];}}}
|
|
||||||
for(var i=0;i<maxEcCount;i++){for(var r=0;r<rsBlocks.length;r++){if(i<ecdata[r].length){data[index++]=ecdata[r][i];}}}
|
|
||||||
return data;};var QRMode={MODE_NUMBER:1<<0,MODE_ALPHA_NUM:1<<1,MODE_8BIT_BYTE:1<<2,MODE_KANJI:1<<3};var QRErrorCorrectLevel={L:1,M:0,Q:3,H:2};var QRMaskPattern={PATTERN000:0,PATTERN001:1,PATTERN010:2,PATTERN011:3,PATTERN100:4,PATTERN101:5,PATTERN110:6,PATTERN111:7};var QRUtil={PATTERN_POSITION_TABLE:[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],G15:(1<<10)|(1<<8)|(1<<5)|(1<<4)|(1<<2)|(1<<1)|(1<<0),G18:(1<<12)|(1<<11)|(1<<10)|(1<<9)|(1<<8)|(1<<5)|(1<<2)|(1<<0),G15_MASK:(1<<14)|(1<<12)|(1<<10)|(1<<4)|(1<<1),getBCHTypeInfo:function(data){var d=data<<10;while(QRUtil.getBCHDigit(d)-QRUtil.getBCHDigit(QRUtil.G15)>=0){d^=(QRUtil.G15<<(QRUtil.getBCHDigit(d)-QRUtil.getBCHDigit(QRUtil.G15)));}
|
|
||||||
return((data<<10)|d)^QRUtil.G15_MASK;},getBCHTypeNumber:function(data){var d=data<<12;while(QRUtil.getBCHDigit(d)-QRUtil.getBCHDigit(QRUtil.G18)>=0){d^=(QRUtil.G18<<(QRUtil.getBCHDigit(d)-QRUtil.getBCHDigit(QRUtil.G18)));}
|
|
||||||
return(data<<12)|d;},getBCHDigit:function(data){var digit=0;while(data!=0){digit++;data>>>=1;}
|
|
||||||
return digit;},getPatternPosition:function(typeNumber){return QRUtil.PATTERN_POSITION_TABLE[typeNumber-1];},getMask:function(maskPattern,i,j){switch(maskPattern){case QRMaskPattern.PATTERN000:return(i+j)%2==0;case QRMaskPattern.PATTERN001:return i%2==0;case QRMaskPattern.PATTERN010:return j%3==0;case QRMaskPattern.PATTERN011:return(i+j)%3==0;case QRMaskPattern.PATTERN100:return(Math.floor(i/2)+Math.floor(j/3))%2==0;case QRMaskPattern.PATTERN101:return(i*j)%2+(i*j)%3==0;case QRMaskPattern.PATTERN110:return((i*j)%2+(i*j)%3)%2==0;case QRMaskPattern.PATTERN111:return((i*j)%3+(i+j)%2)%2==0;default:throw new Error("bad maskPattern:"+maskPattern);}},getErrorCorrectPolynomial:function(errorCorrectLength){var a=new QRPolynomial([1],0);for(var i=0;i<errorCorrectLength;i++){a=a.multiply(new QRPolynomial([1,QRMath.gexp(i)],0));}
|
|
||||||
return a;},getLengthInBits:function(mode,type){if(1<=type&&type<10){switch(mode){case QRMode.MODE_NUMBER:return 10;case QRMode.MODE_ALPHA_NUM:return 9;case QRMode.MODE_8BIT_BYTE:return 8;case QRMode.MODE_KANJI:return 8;default:throw new Error("mode:"+mode);}}else if(type<27){switch(mode){case QRMode.MODE_NUMBER:return 12;case QRMode.MODE_ALPHA_NUM:return 11;case QRMode.MODE_8BIT_BYTE:return 16;case QRMode.MODE_KANJI:return 10;default:throw new Error("mode:"+mode);}}else if(type<41){switch(mode){case QRMode.MODE_NUMBER:return 14;case QRMode.MODE_ALPHA_NUM:return 13;case QRMode.MODE_8BIT_BYTE:return 16;case QRMode.MODE_KANJI:return 12;default:throw new Error("mode:"+mode);}}else{throw new Error("type:"+type);}},getLostPoint:function(qrCode){var moduleCount=qrCode.getModuleCount();var lostPoint=0;for(var row=0;row<moduleCount;row++){for(var col=0;col<moduleCount;col++){var sameCount=0;var dark=qrCode.isDark(row,col);for(var r=-1;r<=1;r++){if(row+r<0||moduleCount<=row+r){continue;}
|
|
||||||
for(var c=-1;c<=1;c++){if(col+c<0||moduleCount<=col+c){continue;}
|
|
||||||
if(r==0&&c==0){continue;}
|
|
||||||
if(dark==qrCode.isDark(row+r,col+c)){sameCount++;}}}
|
|
||||||
if(sameCount>5){lostPoint+=(3+sameCount-5);}}}
|
|
||||||
for(var row=0;row<moduleCount-1;row++){for(var col=0;col<moduleCount-1;col++){var count=0;if(qrCode.isDark(row,col))count++;if(qrCode.isDark(row+1,col))count++;if(qrCode.isDark(row,col+1))count++;if(qrCode.isDark(row+1,col+1))count++;if(count==0||count==4){lostPoint+=3;}}}
|
|
||||||
for(var row=0;row<moduleCount;row++){for(var col=0;col<moduleCount-6;col++){if(qrCode.isDark(row,col)&&!qrCode.isDark(row,col+1)&&qrCode.isDark(row,col+2)&&qrCode.isDark(row,col+3)&&qrCode.isDark(row,col+4)&&!qrCode.isDark(row,col+5)&&qrCode.isDark(row,col+6)){lostPoint+=40;}}}
|
|
||||||
for(var col=0;col<moduleCount;col++){for(var row=0;row<moduleCount-6;row++){if(qrCode.isDark(row,col)&&!qrCode.isDark(row+1,col)&&qrCode.isDark(row+2,col)&&qrCode.isDark(row+3,col)&&qrCode.isDark(row+4,col)&&!qrCode.isDark(row+5,col)&&qrCode.isDark(row+6,col)){lostPoint+=40;}}}
|
|
||||||
var darkCount=0;for(var col=0;col<moduleCount;col++){for(var row=0;row<moduleCount;row++){if(qrCode.isDark(row,col)){darkCount++;}}}
|
|
||||||
var ratio=Math.abs(100*darkCount/moduleCount/moduleCount-50)/5;lostPoint+=ratio*10;return lostPoint;}};var QRMath={glog:function(n){if(n<1){throw new Error("glog("+n+")");}
|
|
||||||
return QRMath.LOG_TABLE[n];},gexp:function(n){while(n<0){n+=255;}
|
|
||||||
while(n>=256){n-=255;}
|
|
||||||
return QRMath.EXP_TABLE[n];},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)};for(var i=0;i<8;i++){QRMath.EXP_TABLE[i]=1<<i;}
|
|
||||||
for(var i=8;i<256;i++){QRMath.EXP_TABLE[i]=QRMath.EXP_TABLE[i-4]^QRMath.EXP_TABLE[i-5]^QRMath.EXP_TABLE[i-6]^QRMath.EXP_TABLE[i-8];}
|
|
||||||
for(var i=0;i<255;i++){QRMath.LOG_TABLE[QRMath.EXP_TABLE[i]]=i;}
|
|
||||||
function QRPolynomial(num,shift){if(num.length==undefined){throw new Error(num.length+"/"+shift);}
|
|
||||||
var offset=0;while(offset<num.length&&num[offset]==0){offset++;}
|
|
||||||
this.num=new Array(num.length-offset+shift);for(var i=0;i<num.length-offset;i++){this.num[i]=num[i+offset];}}
|
|
||||||
QRPolynomial.prototype={get:function(index){return this.num[index];},getLength:function(){return this.num.length;},multiply:function(e){var num=new Array(this.getLength()+e.getLength()-1);for(var i=0;i<this.getLength();i++){for(var j=0;j<e.getLength();j++){num[i+j]^=QRMath.gexp(QRMath.glog(this.get(i))+QRMath.glog(e.get(j)));}}
|
|
||||||
return new QRPolynomial(num,0);},mod:function(e){if(this.getLength()-e.getLength()<0){return this;}
|
|
||||||
var ratio=QRMath.glog(this.get(0))-QRMath.glog(e.get(0));var num=new Array(this.getLength());for(var i=0;i<this.getLength();i++){num[i]=this.get(i);}
|
|
||||||
for(var i=0;i<e.getLength();i++){num[i]^=QRMath.gexp(QRMath.glog(e.get(i))+ratio);}
|
|
||||||
return new QRPolynomial(num,0).mod(e);}};function QRRSBlock(totalCount,dataCount){this.totalCount=totalCount;this.dataCount=dataCount;}
|
|
||||||
QRRSBlock.RS_BLOCK_TABLE=[[1,26,19],[1,26,16],[1,26,13],[1,26,9],[1,44,34],[1,44,28],[1,44,22],[1,44,16],[1,70,55],[1,70,44],[2,35,17],[2,35,13],[1,100,80],[2,50,32],[2,50,24],[4,25,9],[1,134,108],[2,67,43],[2,33,15,2,34,16],[2,33,11,2,34,12],[2,86,68],[4,43,27],[4,43,19],[4,43,15],[2,98,78],[4,49,31],[2,32,14,4,33,15],[4,39,13,1,40,14],[2,121,97],[2,60,38,2,61,39],[4,40,18,2,41,19],[4,40,14,2,41,15],[2,146,116],[3,58,36,2,59,37],[4,36,16,4,37,17],[4,36,12,4,37,13],[2,86,68,2,87,69],[4,69,43,1,70,44],[6,43,19,2,44,20],[6,43,15,2,44,16],[4,101,81],[1,80,50,4,81,51],[4,50,22,4,51,23],[3,36,12,8,37,13],[2,116,92,2,117,93],[6,58,36,2,59,37],[4,46,20,6,47,21],[7,42,14,4,43,15],[4,133,107],[8,59,37,1,60,38],[8,44,20,4,45,21],[12,33,11,4,34,12],[3,145,115,1,146,116],[4,64,40,5,65,41],[11,36,16,5,37,17],[11,36,12,5,37,13],[5,109,87,1,110,88],[5,65,41,5,66,42],[5,54,24,7,55,25],[11,36,12],[5,122,98,1,123,99],[7,73,45,3,74,46],[15,43,19,2,44,20],[3,45,15,13,46,16],[1,135,107,5,136,108],[10,74,46,1,75,47],[1,50,22,15,51,23],[2,42,14,17,43,15],[5,150,120,1,151,121],[9,69,43,4,70,44],[17,50,22,1,51,23],[2,42,14,19,43,15],[3,141,113,4,142,114],[3,70,44,11,71,45],[17,47,21,4,48,22],[9,39,13,16,40,14],[3,135,107,5,136,108],[3,67,41,13,68,42],[15,54,24,5,55,25],[15,43,15,10,44,16],[4,144,116,4,145,117],[17,68,42],[17,50,22,6,51,23],[19,46,16,6,47,17],[2,139,111,7,140,112],[17,74,46],[7,54,24,16,55,25],[34,37,13],[4,151,121,5,152,122],[4,75,47,14,76,48],[11,54,24,14,55,25],[16,45,15,14,46,16],[6,147,117,4,148,118],[6,73,45,14,74,46],[11,54,24,16,55,25],[30,46,16,2,47,17],[8,132,106,4,133,107],[8,75,47,13,76,48],[7,54,24,22,55,25],[22,45,15,13,46,16],[10,142,114,2,143,115],[19,74,46,4,75,47],[28,50,22,6,51,23],[33,46,16,4,47,17],[8,152,122,4,153,123],[22,73,45,3,74,46],[8,53,23,26,54,24],[12,45,15,28,46,16],[3,147,117,10,148,118],[3,73,45,23,74,46],[4,54,24,31,55,25],[11,45,15,31,46,16],[7,146,116,7,147,117],[21,73,45,7,74,46],[1,53,23,37,54,24],[19,45,15,26,46,16],[5,145,115,10,146,116],[19,75,47,10,76,48],[15,54,24,25,55,25],[23,45,15,25,46,16],[13,145,115,3,146,116],[2,74,46,29,75,47],[42,54,24,1,55,25],[23,45,15,28,46,16],[17,145,115],[10,74,46,23,75,47],[10,54,24,35,55,25],[19,45,15,35,46,16],[17,145,115,1,146,116],[14,74,46,21,75,47],[29,54,24,19,55,25],[11,45,15,46,46,16],[13,145,115,6,146,116],[14,74,46,23,75,47],[44,54,24,7,55,25],[59,46,16,1,47,17],[12,151,121,7,152,122],[12,75,47,26,76,48],[39,54,24,14,55,25],[22,45,15,41,46,16],[6,151,121,14,152,122],[6,75,47,34,76,48],[46,54,24,10,55,25],[2,45,15,64,46,16],[17,152,122,4,153,123],[29,74,46,14,75,47],[49,54,24,10,55,25],[24,45,15,46,46,16],[4,152,122,18,153,123],[13,74,46,32,75,47],[48,54,24,14,55,25],[42,45,15,32,46,16],[20,147,117,4,148,118],[40,75,47,7,76,48],[43,54,24,22,55,25],[10,45,15,67,46,16],[19,148,118,6,149,119],[18,75,47,31,76,48],[34,54,24,34,55,25],[20,45,15,61,46,16]];QRRSBlock.getRSBlocks=function(typeNumber,errorCorrectLevel){var rsBlock=QRRSBlock.getRsBlockTable(typeNumber,errorCorrectLevel);if(rsBlock==undefined){throw new Error("bad rs block @ typeNumber:"+typeNumber+"/errorCorrectLevel:"+errorCorrectLevel);}
|
|
||||||
var length=rsBlock.length/3;var list=[];for(var i=0;i<length;i++){var count=rsBlock[i*3+0];var totalCount=rsBlock[i*3+1];var dataCount=rsBlock[i*3+2];for(var j=0;j<count;j++){list.push(new QRRSBlock(totalCount,dataCount));}}
|
|
||||||
return list;};QRRSBlock.getRsBlockTable=function(typeNumber,errorCorrectLevel){switch(errorCorrectLevel){case QRErrorCorrectLevel.L:return QRRSBlock.RS_BLOCK_TABLE[(typeNumber-1)*4+0];case QRErrorCorrectLevel.M:return QRRSBlock.RS_BLOCK_TABLE[(typeNumber-1)*4+1];case QRErrorCorrectLevel.Q:return QRRSBlock.RS_BLOCK_TABLE[(typeNumber-1)*4+2];case QRErrorCorrectLevel.H:return QRRSBlock.RS_BLOCK_TABLE[(typeNumber-1)*4+3];default:return undefined;}};function QRBitBuffer(){this.buffer=[];this.length=0;}
|
|
||||||
QRBitBuffer.prototype={get:function(index){var bufIndex=Math.floor(index/8);return((this.buffer[bufIndex]>>>(7-index%8))&1)==1;},put:function(num,length){for(var i=0;i<length;i++){this.putBit(((num>>>(length-i-1))&1)==1);}},getLengthInBits:function(){return this.length;},putBit:function(bit){var bufIndex=Math.floor(this.length/8);if(this.buffer.length<=bufIndex){this.buffer.push(0);}
|
|
||||||
if(bit){this.buffer[bufIndex]|=(0x80>>>(this.length%8));}
|
|
||||||
this.length++;}};var QRCodeLimitLength=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]];
|
|
||||||
|
|
||||||
function _isSupportCanvas() {
|
|
||||||
return typeof CanvasRenderingContext2D != "undefined";
|
|
||||||
}
|
|
||||||
|
|
||||||
// android 2.x doesn't support Data-URI spec
|
|
||||||
function _getAndroid() {
|
|
||||||
var android = false;
|
|
||||||
var sAgent = navigator.userAgent;
|
|
||||||
|
|
||||||
if (/android/i.test(sAgent)) { // android
|
|
||||||
android = true;
|
|
||||||
var aMat = sAgent.toString().match(/android ([0-9]\.[0-9])/i);
|
|
||||||
|
|
||||||
if (aMat && aMat[1]) {
|
|
||||||
android = parseFloat(aMat[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return android;
|
|
||||||
}
|
|
||||||
|
|
||||||
var svgDrawer = (function() {
|
|
||||||
|
|
||||||
var Drawing = function (el, htOption) {
|
|
||||||
this._el = el;
|
|
||||||
this._htOption = htOption;
|
|
||||||
};
|
|
||||||
|
|
||||||
Drawing.prototype.draw = function (oQRCode) {
|
|
||||||
var _htOption = this._htOption;
|
|
||||||
var _el = this._el;
|
|
||||||
var nCount = oQRCode.getModuleCount();
|
|
||||||
var nWidth = Math.floor(_htOption.width / nCount);
|
|
||||||
var nHeight = Math.floor(_htOption.height / nCount);
|
|
||||||
|
|
||||||
this.clear();
|
|
||||||
|
|
||||||
function makeSVG(tag, attrs) {
|
|
||||||
var el = document.createElementNS('http://www.w3.org/2000/svg', tag);
|
|
||||||
for (var k in attrs)
|
|
||||||
if (attrs.hasOwnProperty(k)) el.setAttribute(k, attrs[k]);
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
var svg = makeSVG("svg" , {'viewBox': '0 0 ' + String(nCount) + " " + String(nCount), 'width': '100%', 'height': '100%', 'fill': _htOption.colorLight});
|
|
||||||
svg.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:xlink", "http://www.w3.org/1999/xlink");
|
|
||||||
_el.appendChild(svg);
|
|
||||||
|
|
||||||
svg.appendChild(makeSVG("rect", {"fill": _htOption.colorLight, "width": "100%", "height": "100%"}));
|
|
||||||
svg.appendChild(makeSVG("rect", {"fill": _htOption.colorDark, "width": "1", "height": "1", "id": "template"}));
|
|
||||||
|
|
||||||
for (var row = 0; row < nCount; row++) {
|
|
||||||
for (var col = 0; col < nCount; col++) {
|
|
||||||
if (oQRCode.isDark(row, col)) {
|
|
||||||
var child = makeSVG("use", {"x": String(col), "y": String(row)});
|
|
||||||
child.setAttributeNS("http://www.w3.org/1999/xlink", "href", "#template")
|
|
||||||
svg.appendChild(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Drawing.prototype.clear = function () {
|
|
||||||
while (this._el.hasChildNodes())
|
|
||||||
this._el.removeChild(this._el.lastChild);
|
|
||||||
};
|
|
||||||
return Drawing;
|
|
||||||
})();
|
|
||||||
|
|
||||||
var useSVG = document.documentElement.tagName.toLowerCase() === "svg";
|
|
||||||
|
|
||||||
// Drawing in DOM by using Table tag
|
|
||||||
var Drawing = useSVG ? svgDrawer : !_isSupportCanvas() ? (function () {
|
|
||||||
var Drawing = function (el, htOption) {
|
|
||||||
this._el = el;
|
|
||||||
this._htOption = htOption;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draw the QRCode
|
|
||||||
*
|
|
||||||
* @param {QRCode} oQRCode
|
|
||||||
*/
|
|
||||||
Drawing.prototype.draw = function (oQRCode) {
|
|
||||||
var _htOption = this._htOption;
|
|
||||||
var _el = this._el;
|
|
||||||
var nCount = oQRCode.getModuleCount();
|
|
||||||
var nWidth = Math.floor(_htOption.width / nCount);
|
|
||||||
var nHeight = Math.floor(_htOption.height / nCount);
|
|
||||||
var aHTML = ['<table style="border:0;border-collapse:collapse;">'];
|
|
||||||
|
|
||||||
for (var row = 0; row < nCount; row++) {
|
|
||||||
aHTML.push('<tr>');
|
|
||||||
|
|
||||||
for (var col = 0; col < nCount; col++) {
|
|
||||||
aHTML.push('<td style="border:0;border-collapse:collapse;padding:0;margin:0;width:' + nWidth + 'px;height:' + nHeight + 'px;background-color:' + (oQRCode.isDark(row, col) ? _htOption.colorDark : _htOption.colorLight) + ';"></td>');
|
|
||||||
}
|
|
||||||
|
|
||||||
aHTML.push('</tr>');
|
|
||||||
}
|
|
||||||
|
|
||||||
aHTML.push('</table>');
|
|
||||||
_el.innerHTML = aHTML.join('');
|
|
||||||
|
|
||||||
// Fix the margin values as real size.
|
|
||||||
var elTable = _el.childNodes[0];
|
|
||||||
var nLeftMarginTable = (_htOption.width - elTable.offsetWidth) / 2;
|
|
||||||
var nTopMarginTable = (_htOption.height - elTable.offsetHeight) / 2;
|
|
||||||
|
|
||||||
if (nLeftMarginTable > 0 && nTopMarginTable > 0) {
|
|
||||||
elTable.style.margin = nTopMarginTable + "px " + nLeftMarginTable + "px";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the QRCode
|
|
||||||
*/
|
|
||||||
Drawing.prototype.clear = function () {
|
|
||||||
this._el.innerHTML = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
return Drawing;
|
|
||||||
})() : (function () { // Drawing in Canvas
|
|
||||||
function _onMakeImage() {
|
|
||||||
this._elImage.src = this._elCanvas.toDataURL("image/png");
|
|
||||||
this._elImage.style.display = "block";
|
|
||||||
this._elCanvas.style.display = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Android 2.1 bug workaround
|
|
||||||
// http://code.google.com/p/android/issues/detail?id=5141
|
|
||||||
if (this._android && this._android <= 2.1) {
|
|
||||||
var factor = 1 / window.devicePixelRatio;
|
|
||||||
var drawImage = CanvasRenderingContext2D.prototype.drawImage;
|
|
||||||
CanvasRenderingContext2D.prototype.drawImage = function (image, sx, sy, sw, sh, dx, dy, dw, dh) {
|
|
||||||
if (("nodeName" in image) && /img/i.test(image.nodeName)) {
|
|
||||||
for (var i = arguments.length - 1; i >= 1; i--) {
|
|
||||||
arguments[i] = arguments[i] * factor;
|
|
||||||
}
|
|
||||||
} else if (typeof dw == "undefined") {
|
|
||||||
arguments[1] *= factor;
|
|
||||||
arguments[2] *= factor;
|
|
||||||
arguments[3] *= factor;
|
|
||||||
arguments[4] *= factor;
|
|
||||||
}
|
|
||||||
|
|
||||||
drawImage.apply(this, arguments);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check whether the user's browser supports Data URI or not
|
|
||||||
*
|
|
||||||
* @private
|
|
||||||
* @param {Function} fSuccess Occurs if it supports Data URI
|
|
||||||
* @param {Function} fFail Occurs if it doesn't support Data URI
|
|
||||||
*/
|
|
||||||
function _safeSetDataURI(fSuccess, fFail) {
|
|
||||||
var self = this;
|
|
||||||
self._fFail = fFail;
|
|
||||||
self._fSuccess = fSuccess;
|
|
||||||
|
|
||||||
// Check it just once
|
|
||||||
if (self._bSupportDataURI === null) {
|
|
||||||
var el = document.createElement("img");
|
|
||||||
var fOnError = function() {
|
|
||||||
self._bSupportDataURI = false;
|
|
||||||
|
|
||||||
if (self._fFail) {
|
|
||||||
self._fFail.call(self);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
var fOnSuccess = function() {
|
|
||||||
self._bSupportDataURI = true;
|
|
||||||
|
|
||||||
if (self._fSuccess) {
|
|
||||||
self._fSuccess.call(self);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
el.onabort = fOnError;
|
|
||||||
el.onerror = fOnError;
|
|
||||||
el.onload = fOnSuccess;
|
|
||||||
el.src = "data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="; // the Image contains 1px data.
|
|
||||||
return;
|
|
||||||
} else if (self._bSupportDataURI === true && self._fSuccess) {
|
|
||||||
self._fSuccess.call(self);
|
|
||||||
} else if (self._bSupportDataURI === false && self._fFail) {
|
|
||||||
self._fFail.call(self);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drawing QRCode by using canvas
|
|
||||||
*
|
|
||||||
* @constructor
|
|
||||||
* @param {HTMLElement} el
|
|
||||||
* @param {Object} htOption QRCode Options
|
|
||||||
*/
|
|
||||||
var Drawing = function (el, htOption) {
|
|
||||||
this._bIsPainted = false;
|
|
||||||
this._android = _getAndroid();
|
|
||||||
|
|
||||||
this._htOption = htOption;
|
|
||||||
this._elCanvas = document.createElement("canvas");
|
|
||||||
this._elCanvas.width = htOption.width;
|
|
||||||
this._elCanvas.height = htOption.height;
|
|
||||||
el.appendChild(this._elCanvas);
|
|
||||||
this._el = el;
|
|
||||||
this._oContext = this._elCanvas.getContext("2d");
|
|
||||||
this._bIsPainted = false;
|
|
||||||
this._elImage = document.createElement("img");
|
|
||||||
this._elImage.alt = "Scan me!";
|
|
||||||
this._elImage.style.display = "none";
|
|
||||||
this._el.appendChild(this._elImage);
|
|
||||||
this._bSupportDataURI = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draw the QRCode
|
|
||||||
*
|
|
||||||
* @param {QRCode} oQRCode
|
|
||||||
*/
|
|
||||||
Drawing.prototype.draw = function (oQRCode) {
|
|
||||||
var _elImage = this._elImage;
|
|
||||||
var _oContext = this._oContext;
|
|
||||||
var _htOption = this._htOption;
|
|
||||||
|
|
||||||
var nCount = oQRCode.getModuleCount();
|
|
||||||
var nWidth = _htOption.width / nCount;
|
|
||||||
var nHeight = _htOption.height / nCount;
|
|
||||||
var nRoundedWidth = Math.round(nWidth);
|
|
||||||
var nRoundedHeight = Math.round(nHeight);
|
|
||||||
|
|
||||||
_elImage.style.display = "none";
|
|
||||||
this.clear();
|
|
||||||
|
|
||||||
for (var row = 0; row < nCount; row++) {
|
|
||||||
for (var col = 0; col < nCount; col++) {
|
|
||||||
var bIsDark = oQRCode.isDark(row, col);
|
|
||||||
var nLeft = col * nWidth;
|
|
||||||
var nTop = row * nHeight;
|
|
||||||
_oContext.strokeStyle = bIsDark ? _htOption.colorDark : _htOption.colorLight;
|
|
||||||
_oContext.lineWidth = 1;
|
|
||||||
_oContext.fillStyle = bIsDark ? _htOption.colorDark : _htOption.colorLight;
|
|
||||||
_oContext.fillRect(nLeft, nTop, nWidth, nHeight);
|
|
||||||
|
|
||||||
// 안티 앨리어싱 방지 처리
|
|
||||||
_oContext.strokeRect(
|
|
||||||
Math.floor(nLeft) + 0.5,
|
|
||||||
Math.floor(nTop) + 0.5,
|
|
||||||
nRoundedWidth,
|
|
||||||
nRoundedHeight
|
|
||||||
);
|
|
||||||
|
|
||||||
_oContext.strokeRect(
|
|
||||||
Math.ceil(nLeft) - 0.5,
|
|
||||||
Math.ceil(nTop) - 0.5,
|
|
||||||
nRoundedWidth,
|
|
||||||
nRoundedHeight
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this._bIsPainted = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make the image from Canvas if the browser supports Data URI.
|
|
||||||
*/
|
|
||||||
Drawing.prototype.makeImage = function () {
|
|
||||||
if (this._bIsPainted) {
|
|
||||||
_safeSetDataURI.call(this, _onMakeImage);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return whether the QRCode is painted or not
|
|
||||||
*
|
|
||||||
* @return {Boolean}
|
|
||||||
*/
|
|
||||||
Drawing.prototype.isPainted = function () {
|
|
||||||
return this._bIsPainted;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the QRCode
|
|
||||||
*/
|
|
||||||
Drawing.prototype.clear = function () {
|
|
||||||
this._oContext.clearRect(0, 0, this._elCanvas.width, this._elCanvas.height);
|
|
||||||
this._bIsPainted = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @private
|
|
||||||
* @param {Number} nNumber
|
|
||||||
*/
|
|
||||||
Drawing.prototype.round = function (nNumber) {
|
|
||||||
if (!nNumber) {
|
|
||||||
return nNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.floor(nNumber * 1000) / 1000;
|
|
||||||
};
|
|
||||||
|
|
||||||
return Drawing;
|
|
||||||
})();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the type by string length
|
|
||||||
*
|
|
||||||
* @private
|
|
||||||
* @param {String} sText
|
|
||||||
* @param {Number} nCorrectLevel
|
|
||||||
* @return {Number} type
|
|
||||||
*/
|
|
||||||
function _getTypeNumber(sText, nCorrectLevel) {
|
|
||||||
var nType = 1;
|
|
||||||
var length = _getUTF8Length(sText);
|
|
||||||
|
|
||||||
for (var i = 0, len = QRCodeLimitLength.length; i <= len; i++) {
|
|
||||||
var nLimit = 0;
|
|
||||||
|
|
||||||
switch (nCorrectLevel) {
|
|
||||||
case QRErrorCorrectLevel.L :
|
|
||||||
nLimit = QRCodeLimitLength[i][0];
|
|
||||||
break;
|
|
||||||
case QRErrorCorrectLevel.M :
|
|
||||||
nLimit = QRCodeLimitLength[i][1];
|
|
||||||
break;
|
|
||||||
case QRErrorCorrectLevel.Q :
|
|
||||||
nLimit = QRCodeLimitLength[i][2];
|
|
||||||
break;
|
|
||||||
case QRErrorCorrectLevel.H :
|
|
||||||
nLimit = QRCodeLimitLength[i][3];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (length <= nLimit) {
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
nType++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nType > QRCodeLimitLength.length) {
|
|
||||||
throw new Error("Too long data");
|
|
||||||
}
|
|
||||||
|
|
||||||
return nType;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _getUTF8Length(sText) {
|
|
||||||
var replacedText = encodeURI(sText).toString().replace(/\%[0-9a-fA-F]{2}/g, 'a');
|
|
||||||
return replacedText.length + (replacedText.length != sText ? 3 : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @class QRCode
|
|
||||||
* @constructor
|
|
||||||
* @example
|
|
||||||
* new QRCode(document.getElementById("test"), "http://jindo.dev.naver.com/collie");
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* var oQRCode = new QRCode("test", {
|
|
||||||
* text : "http://naver.com",
|
|
||||||
* width : 128,
|
|
||||||
* height : 128
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* oQRCode.clear(); // Clear the QRCode.
|
|
||||||
* oQRCode.makeCode("http://map.naver.com"); // Re-create the QRCode.
|
|
||||||
*
|
|
||||||
* @param {HTMLElement|String} el target element or 'id' attribute of element.
|
|
||||||
* @param {Object|String} vOption
|
|
||||||
* @param {String} vOption.text QRCode link data
|
|
||||||
* @param {Number} [vOption.width=256]
|
|
||||||
* @param {Number} [vOption.height=256]
|
|
||||||
* @param {String} [vOption.colorDark="#000000"]
|
|
||||||
* @param {String} [vOption.colorLight="#ffffff"]
|
|
||||||
* @param {QRCode.CorrectLevel} [vOption.correctLevel=QRCode.CorrectLevel.H] [L|M|Q|H]
|
|
||||||
*/
|
|
||||||
QRCode = function (el, vOption) {
|
|
||||||
this._htOption = {
|
|
||||||
width : 256,
|
|
||||||
height : 256,
|
|
||||||
typeNumber : 4,
|
|
||||||
colorDark : "#000000",
|
|
||||||
colorLight : "#ffffff",
|
|
||||||
correctLevel : QRErrorCorrectLevel.H
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof vOption === 'string') {
|
|
||||||
vOption = {
|
|
||||||
text : vOption
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overwrites options
|
|
||||||
if (vOption) {
|
|
||||||
for (var i in vOption) {
|
|
||||||
this._htOption[i] = vOption[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof el == "string") {
|
|
||||||
el = document.getElementById(el);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._htOption.useSVG) {
|
|
||||||
Drawing = svgDrawer;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._android = _getAndroid();
|
|
||||||
this._el = el;
|
|
||||||
this._oQRCode = null;
|
|
||||||
this._oDrawing = new Drawing(this._el, this._htOption);
|
|
||||||
|
|
||||||
if (this._htOption.text) {
|
|
||||||
this.makeCode(this._htOption.text);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make the QRCode
|
|
||||||
*
|
|
||||||
* @param {String} sText link data
|
|
||||||
*/
|
|
||||||
QRCode.prototype.makeCode = function (sText) {
|
|
||||||
this._oQRCode = new QRCodeModel(_getTypeNumber(sText, this._htOption.correctLevel), this._htOption.correctLevel);
|
|
||||||
this._oQRCode.addData(sText);
|
|
||||||
this._oQRCode.make();
|
|
||||||
this._el.title = sText;
|
|
||||||
this._oDrawing.draw(this._oQRCode);
|
|
||||||
this.makeImage();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make the Image from Canvas element
|
|
||||||
* - It occurs automatically
|
|
||||||
* - Android below 3 doesn't support Data-URI spec.
|
|
||||||
*
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
QRCode.prototype.makeImage = function () {
|
|
||||||
if (typeof this._oDrawing.makeImage == "function" && (!this._android || this._android >= 3)) {
|
|
||||||
this._oDrawing.makeImage();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the QRCode
|
|
||||||
*/
|
|
||||||
QRCode.prototype.clear = function () {
|
|
||||||
this._oDrawing.clear();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @name QRCode.CorrectLevel
|
|
||||||
*/
|
|
||||||
QRCode.CorrectLevel = QRErrorCorrectLevel;
|
|
||||||
})();
|
|
1
static/qrcodejs/qrcode.min.js
vendored
1
static/qrcodejs/qrcode.min.js
vendored
File diff suppressed because one or more lines are too long
@ -1,29 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Register - Passkey Authentication</title>
|
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
|
||||||
<script src="/static/simplewebauthn-browser.min.js"></script>
|
|
||||||
<script src="/static/awaitable-websocket.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<!-- Register View -->
|
|
||||||
<div id="registerView" class="view active">
|
|
||||||
<h1>🔐 Create Account</h1>
|
|
||||||
<div id="registerStatus"></div>
|
|
||||||
<form id="registrationForm">
|
|
||||||
<input type="text" name="username" placeholder="Enter username" required>
|
|
||||||
<button type="submit" class="btn-primary">Register Passkey</button>
|
|
||||||
</form>
|
|
||||||
<p class="toggle-link" onclick="window.location.href='/auth/login'">
|
|
||||||
Already have an account? Login here
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/static/app.js"></script>
|
|
||||||
<script src="/static/util.js"></script>
|
|
||||||
<script src="/static/register.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,35 +0,0 @@
|
|||||||
// Register page specific functionality
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Initialize the app
|
|
||||||
initializeApp()
|
|
||||||
|
|
||||||
// Registration form handler
|
|
||||||
const regForm = document.getElementById('registrationForm')
|
|
||||||
if (regForm) {
|
|
||||||
const regSubmitBtn = regForm.querySelector('button[type="submit"]')
|
|
||||||
|
|
||||||
regForm.addEventListener('submit', async (ev) => {
|
|
||||||
ev.preventDefault()
|
|
||||||
regSubmitBtn.disabled = true
|
|
||||||
clearStatus('registerStatus')
|
|
||||||
|
|
||||||
const user_name = (new FormData(regForm)).get('username')
|
|
||||||
|
|
||||||
try {
|
|
||||||
showStatus('registerStatus', 'Starting registration...', 'info')
|
|
||||||
await register(user_name)
|
|
||||||
showStatus('registerStatus', `Registration successful for ${user_name}!`, 'success')
|
|
||||||
|
|
||||||
// Auto-login after successful registration
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.href = '/auth/profile'
|
|
||||||
}, 1500)
|
|
||||||
} catch (err) {
|
|
||||||
showStatus('registerStatus', `Registration failed: ${err.message}`, 'error')
|
|
||||||
} finally {
|
|
||||||
regSubmitBtn.disabled = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
@ -1,185 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Add Device - Passkey Authentication</title>
|
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
|
||||||
<script src="/static/simplewebauthn-browser.min.js"></script>
|
|
||||||
<script src="/static/qrcodejs/qrcode.min.js"></script>
|
|
||||||
<script src="/static/awaitable-websocket.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<!-- Request Reset View -->
|
|
||||||
<div id="requestView" class="view">
|
|
||||||
<h1>🔓 Add Device</h1>
|
|
||||||
<p>This page is for adding a new device to an existing account. You need a device addition link to proceed.</p>
|
|
||||||
<div id="requestStatus" class="status info" style="display: block;">
|
|
||||||
<strong>How to get a device addition link:</strong><br>
|
|
||||||
1. Log into your account on a device you already have<br>
|
|
||||||
2. Click "Generate Device Link" in your dashboard<br>
|
|
||||||
3. Copy the link or scan the QR code to add this device
|
|
||||||
</div>
|
|
||||||
<p class="toggle-link" onclick="window.location.href='/'">
|
|
||||||
Back to Login
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add Passkey View -->
|
|
||||||
<div id="addPasskeyView" class="view">
|
|
||||||
<h1>🔑 Add New Passkey</h1>
|
|
||||||
<div id="userInfo" class="token-info">
|
|
||||||
<p><strong>Account:</strong> <span id="userName"></span></p>
|
|
||||||
<p><small>You are about to add a new passkey to this account.</small></p>
|
|
||||||
</div>
|
|
||||||
<div id="addPasskeyStatus" class="status"></div>
|
|
||||||
<button id="addPasskeyBtn" class="btn-primary">Add New Passkey</button>
|
|
||||||
<p class="toggle-link" onclick="window.location.href='/'">
|
|
||||||
Back to Login
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Success Complete View -->
|
|
||||||
<div id="completeView" class="view">
|
|
||||||
<h1>🎉 Passkey Added Successfully!</h1>
|
|
||||||
<p>Your new passkey has been added to your account. You can now use it to log in.</p>
|
|
||||||
<button onclick="window.location.href='/'" class="btn-primary">Go to Login</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const { startRegistration } = SimpleWebAuthnBrowser;
|
|
||||||
|
|
||||||
// Global state
|
|
||||||
let currentToken = null;
|
|
||||||
let currentUser = null;
|
|
||||||
|
|
||||||
// View management
|
|
||||||
function showView(viewId) {
|
|
||||||
document.querySelectorAll('.view').forEach(view => {
|
|
||||||
view.classList.remove('active');
|
|
||||||
});
|
|
||||||
document.getElementById(viewId).classList.add('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
function showAddPasskeyView() {
|
|
||||||
showView('addPasskeyView');
|
|
||||||
clearStatus('addPasskeyStatus');
|
|
||||||
}
|
|
||||||
|
|
||||||
function showCompleteView() {
|
|
||||||
showView('completeView');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status management
|
|
||||||
function showStatus(elementId, message, type = 'info') {
|
|
||||||
const statusEl = document.getElementById(elementId);
|
|
||||||
statusEl.textContent = message;
|
|
||||||
statusEl.className = `status ${type}`;
|
|
||||||
statusEl.style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearStatus(elementId) {
|
|
||||||
const statusEl = document.getElementById(elementId);
|
|
||||||
statusEl.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate reset token and show add passkey view
|
|
||||||
async function validateTokenAndShowAddView(token) {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/validate-device-token', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ token })
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(result.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
currentToken = token;
|
|
||||||
currentUser = result;
|
|
||||||
document.getElementById('userName').textContent = result.user_name;
|
|
||||||
showAddPasskeyView();
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
showStatus('addPasskeyStatus', `Error: ${error.message}`, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add new passkey via reset token
|
|
||||||
async function addPasskeyWithToken(token) {
|
|
||||||
try {
|
|
||||||
const ws = await aWebSocket('/ws/add_device_credential');
|
|
||||||
|
|
||||||
// Send token to server
|
|
||||||
ws.send(JSON.stringify({ token }));
|
|
||||||
|
|
||||||
// Get registration options
|
|
||||||
const optionsJSON = JSON.parse(await ws.recv());
|
|
||||||
if (optionsJSON.error) throw new Error(optionsJSON.error);
|
|
||||||
|
|
||||||
showStatus('addPasskeyStatus', 'Save new passkey to your authenticator...', 'info');
|
|
||||||
|
|
||||||
const registrationResponse = await startRegistration({ optionsJSON });
|
|
||||||
ws.send(JSON.stringify(registrationResponse));
|
|
||||||
|
|
||||||
const result = JSON.parse(await ws.recv());
|
|
||||||
if (result.error) throw new Error(`Server: ${result.error}`);
|
|
||||||
|
|
||||||
ws.close();
|
|
||||||
|
|
||||||
showCompleteView();
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
showStatus('addPasskeyStatus', `Failed to add passkey: ${error.message}`, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check URL path for token on page load
|
|
||||||
function checkUrlParams() {
|
|
||||||
const path = window.location.pathname;
|
|
||||||
const pathParts = path.split('/');
|
|
||||||
|
|
||||||
// Check if URL is in format /reset/token
|
|
||||||
if (pathParts.length >= 3 && pathParts[1] === 'reset') {
|
|
||||||
const token = pathParts[2];
|
|
||||||
if (token) {
|
|
||||||
validateTokenAndShowAddView(token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Form event handlers
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Check for token in URL
|
|
||||||
checkUrlParams();
|
|
||||||
|
|
||||||
// Add passkey button
|
|
||||||
const addPasskeyBtn = document.getElementById('addPasskeyBtn');
|
|
||||||
|
|
||||||
addPasskeyBtn.addEventListener('click', async () => {
|
|
||||||
if (!currentToken) {
|
|
||||||
showStatus('addPasskeyStatus', 'No valid device addition token found', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
addPasskeyBtn.disabled = true;
|
|
||||||
clearStatus('addPasskeyStatus');
|
|
||||||
|
|
||||||
try {
|
|
||||||
showStatus('addPasskeyStatus', 'Starting passkey registration...', 'info');
|
|
||||||
await addPasskeyWithToken(currentToken);
|
|
||||||
} catch (err) {
|
|
||||||
showStatus('addPasskeyStatus', `Registration failed: ${err.message}`, 'error');
|
|
||||||
} finally {
|
|
||||||
addPasskeyBtn.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
2
static/simplewebauthn-browser.min.js
vendored
2
static/simplewebauthn-browser.min.js
vendored
File diff suppressed because one or more lines are too long
104
static/util.js
104
static/util.js
@ -1,104 +0,0 @@
|
|||||||
// Shared utility functions for all views
|
|
||||||
|
|
||||||
// Initialize the app based on current page
|
|
||||||
function initializeApp() {
|
|
||||||
checkExistingSession()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show status message
|
|
||||||
function showStatus(elementId, message, type = 'info') {
|
|
||||||
const statusEl = document.getElementById(elementId)
|
|
||||||
if (statusEl) {
|
|
||||||
statusEl.innerHTML = `<div class="status ${type}">${message}</div>`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear status message
|
|
||||||
function clearStatus(elementId) {
|
|
||||||
const statusEl = document.getElementById(elementId)
|
|
||||||
if (statusEl) {
|
|
||||||
statusEl.innerHTML = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is already logged in on page load
|
|
||||||
async function checkExistingSession() {
|
|
||||||
const isLoggedIn = await validateStoredToken()
|
|
||||||
const path = window.location.pathname
|
|
||||||
|
|
||||||
// Protected routes that require authentication
|
|
||||||
const protectedRoutes = ['/auth/profile']
|
|
||||||
|
|
||||||
if (isLoggedIn) {
|
|
||||||
// User is logged in
|
|
||||||
if (path === '/auth/login' || path === '/auth/register' || path === '/') {
|
|
||||||
// Redirect to profile if accessing login/register pages while logged in
|
|
||||||
window.location.href = '/auth/profile'
|
|
||||||
} else if (path === '/auth/add-device') {
|
|
||||||
// Redirect old add-device route to profile
|
|
||||||
window.location.href = '/auth/profile'
|
|
||||||
} else if (protectedRoutes.includes(path)) {
|
|
||||||
// Stay on current protected page and load user data
|
|
||||||
if (path === '/auth/profile') {
|
|
||||||
try {
|
|
||||||
await loadUserInfo()
|
|
||||||
updateUserInfo()
|
|
||||||
await loadCredentials()
|
|
||||||
} catch (error) {
|
|
||||||
showStatus('profileStatus', `Failed to load user info: ${error.message}`, 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// User is not logged in
|
|
||||||
if (protectedRoutes.includes(path) || path === '/auth/add-device') {
|
|
||||||
// Redirect to login if accessing protected pages without authentication
|
|
||||||
window.location.href = '/auth/login'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate stored token
|
|
||||||
async function validateStoredToken() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/validate-token', {
|
|
||||||
method: 'GET',
|
|
||||||
credentials: 'include'
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
return result.status === 'success'
|
|
||||||
} catch (error) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy device link to clipboard
|
|
||||||
async function copyDeviceLink() {
|
|
||||||
try {
|
|
||||||
if (window.currentDeviceLink) {
|
|
||||||
await navigator.clipboard.writeText(window.currentDeviceLink)
|
|
||||||
|
|
||||||
const copyButton = document.querySelector('.copy-button')
|
|
||||||
if (copyButton) {
|
|
||||||
const originalText = copyButton.textContent
|
|
||||||
copyButton.textContent = 'Copied!'
|
|
||||||
copyButton.style.background = '#28a745'
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
copyButton.textContent = originalText
|
|
||||||
copyButton.style.background = '#28a745'
|
|
||||||
}, 2000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to copy link:', error)
|
|
||||||
const linkText = document.getElementById('deviceLinkText')
|
|
||||||
if (linkText) {
|
|
||||||
const range = document.createRange()
|
|
||||||
range.selectNode(linkText)
|
|
||||||
window.getSelection().removeAllRanges()
|
|
||||||
window.getSelection().addRange(range)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
x
Reference in New Issue
Block a user