Pass login page

This commit is contained in:
2025-07-24 18:45:38 +08:00
parent 31ac45026e
commit 5a24c31d43
5 changed files with 364 additions and 4 deletions

View File

@@ -4,6 +4,7 @@
"": {
"name": "@solar-network/pass",
"dependencies": {
"@fingerprintjs/fingerprintjs": "^4.6.2",
"@fontsource-variable/nunito": "^5.2.6",
"@tailwindcss/vite": "^4.1.11",
"aspnet-prerendering": "^3.0.1",
@@ -131,6 +132,8 @@
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.3", "", { "dependencies": { "@eslint/core": "^0.15.1", "levn": "^0.4.1" } }, "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag=="],
"@fingerprintjs/fingerprintjs": ["@fingerprintjs/fingerprintjs@4.6.2", "", { "dependencies": { "tslib": "^2.4.1" } }, "sha512-g8mXuqcFKbgH2CZKwPfVtsUJDHyvcgIABQI7Y0tzWEFXpGxJaXuAuzlifT2oTakjDBLTK4Gaa9/5PERDhqUjtw=="],
"@fontsource-variable/nunito": ["@fontsource-variable/nunito@5.2.6", "", {}, "sha512-dGYTQ0Hl94jjfMraYefrURHGH8fk/vL/1zYAZGofiPJVs6C0OkM8T87Te5Gwrbe6HG/XEMm5lib8AqasTN3ucw=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],

View File

@@ -15,6 +15,7 @@
"format": "prettier --write src/"
},
"dependencies": {
"@fingerprintjs/fingerprintjs": "^4.6.2",
"@fontsource-variable/nunito": "^5.2.6",
"@tailwindcss/vite": "^4.1.11",
"aspnet-prerendering": "^3.0.1",

View File

@@ -17,7 +17,12 @@ const router = createRouter({
path: '/spells/:word',
name: 'spells',
component: () => import('../views/spells.vue'),
}
},
{
path: '/login',
name: 'login',
component: () => import('../views/login.vue'),
},
],
})

View File

@@ -0,0 +1,342 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { NCard, NSpace, NInput, NButton, NSpin, NAlert, NProgress } from 'naive-ui'
import { useRouter } from 'vue-router'
import FingerprintJS from '@fingerprintjs/fingerprintjs'
// State management
const stage = ref<'find-account' | 'select-factor' | 'enter-code' | 'token-exchange'>(
'find-account',
)
const isLoading = ref(false)
const error = ref<string | null>(null)
// Stage 1: Find Account
const accountIdentifier = ref('')
const deviceId = ref('')
// Stage 2 & 3: Challenge
const challenge = ref<any>(null)
const factors = ref<any[]>([])
const selectedFactorId = ref<string | null>(null)
const password = ref('') // Used for password or verification code
const router = useRouter()
// Generate deviceId based on browser fingerprint
onMounted(async () => {
const fp = await FingerprintJS.load()
const result = await fp.get()
deviceId.value = result.visitorId
localStorage.setItem('deviceId', deviceId.value)
})
const selectedFactor = computed(() => {
if (!selectedFactorId.value) return null
return factors.value.find((f) => f.id === selectedFactorId.value)
})
async function handleFindAccount() {
if (!accountIdentifier.value) {
error.value = 'Please enter your email or username.'
return
}
isLoading.value = true
error.value = null
try {
const response = await fetch('/api/auth/challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
platform: 1,
account: accountIdentifier.value,
device_id: deviceId.value,
}),
})
if (!response.ok) {
const message = await response.text()
throw new Error(message || 'Account not found.')
}
challenge.value = await response.json()
await getFactors()
stage.value = 'select-factor'
} catch (e: any) {
error.value = e.message
} finally {
isLoading.value = false
}
}
async function getFactors() {
isLoading.value = true
error.value = null
try {
const response = await fetch(`/api/auth/challenge/${challenge.value.id}/factors`)
if (!response.ok) {
throw new Error('Could not fetch authentication factors.')
}
const availableFactors = await response.json()
factors.value = availableFactors.filter(
(f: any) => !challenge.value.blacklist_factors.includes(f.id),
)
if (factors.value.length > 0) {
selectedFactorId.value = null // Let user choose
} else if (challenge.value.step_remain > 0) {
error.value =
'No more available authentication factors, but authentication is not complete. Please contact support.'
}
} catch (e: any) {
error.value = e.message
} finally {
isLoading.value = false
}
}
async function requestVerificationCode(hint: string | null) {
if (!selectedFactorId.value) return
const isResend = stage.value === 'enter-code'
if (isResend) isLoading.value = true
error.value = null
try {
const response = await fetch(
`/api/auth/challenge/${challenge.value.id}/factors/${selectedFactorId.value}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(hint),
},
)
if (!response.ok) {
const message = await response.text()
throw new Error(message || 'Failed to send code.')
}
} catch (e: any) {
error.value = e.message
throw e // Rethrow to be handled by caller
} finally {
if (isResend) isLoading.value = false
}
}
async function handleFactorSelected() {
if (!selectedFactor.value) {
error.value = 'Please select an authentication method.'
return
}
// For password or TOTP, just move to the next step
if (selectedFactor.value.type === 0 || selectedFactor.value.type === 2) {
stage.value = 'enter-code'
return
}
// For email, send the code first
if (selectedFactor.value.type === 1) {
isLoading.value = true
error.value = null
try {
await requestVerificationCode(selectedFactor.value.contact)
stage.value = 'enter-code'
} catch {
// Error is already set by requestVerificationCode
} finally {
isLoading.value = false
}
}
}
async function handleVerifyFactor() {
if (!selectedFactorId.value || !password.value) {
error.value = 'Please enter your password/code.'
return
}
isLoading.value = true
error.value = null
try {
const response = await fetch(`/api/auth/challenge/${challenge.value.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
factor_id: selectedFactorId.value,
password: password.value,
}),
})
if (!response.ok) {
const message = await response.text()
throw new Error(message || 'Verification failed.')
}
challenge.value = await response.json()
password.value = ''
if (challenge.value.step_remain === 0) {
stage.value = 'token-exchange'
await exchangeToken()
} else {
await getFactors()
stage.value = 'select-factor' // MFA step
}
} catch (e: any) {
error.value = e.message
} finally {
isLoading.value = false
}
}
async function exchangeToken() {
isLoading.value = true
error.value = null
try {
const response = await fetch('/api/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
code: challenge.value.id,
}),
})
if (!response.ok) {
const message = await response.text()
throw new Error(message || 'Token exchange failed.')
}
const { token } = await response.json()
localStorage.setItem('authToken', token)
await router.push('/')
} catch (e: any) {
error.value = e.message
stage.value = 'select-factor' // Go back if token exchange fails
} finally {
isLoading.value = false
}
}
function getFactorName(factorType: number) {
switch (factorType) {
case 0:
return 'Password'
case 1:
return 'Email'
case 2:
return 'Authenticator App'
default:
return 'Unknown Factor'
}
}
</script>
<template>
<div class="flex items-center justify-center h-full">
<n-card class="w-full max-w-md" title="Login">
<n-spin :show="isLoading">
<n-space vertical>
<!-- Stage 1: Find Account -->
<div v-if="stage === 'find-account'">
<p>Welcome back!</p>
<p class="mb-4">Login with your Solarpass.</p>
<p class="mb-4">Enter your account identifier to continue.</p>
<n-input
v-model:value="accountIdentifier"
placeholder="Email or Username"
size="large"
@keydown.enter="handleFindAccount"
class="mb-4"
/>
<n-button type="primary" block class="mt-4" size="large" @click="handleFindAccount">
Continue
</n-button>
</div>
<!-- Stage 2: Select Factor -->
<div v-if="stage === 'select-factor' && challenge">
<div class="flex items-center mb-4 gap-3">
<span class="flex-shrink-1">Completeness</span>
<n-progress
type="line"
:percentage="(1 - challenge.step_remain / challenge.step_total) * 100"
indicator-placement="inside"
class="flex-1"
/>
</div>
<div class="flex flex-col gap-3">
<n-card
v-for="factor in factors"
:key="factor.id"
size="small"
hoverable
class="cursor-pointer"
@click="
() => {
selectedFactorId = factor.id
handleFactorSelected()
}
"
:title="getFactorName(factor.type)"
></n-card>
</div>
<p class="text-center text-xs opacity-75 mt-3">Select a method to authenticate</p>
</div>
<!-- Stage 3: Enter Code -->
<div v-if="stage === 'enter-code' && selectedFactor">
<h3 class="mb-3">
Enter the {{ selectedFactor.type === 0 ? 'password' : 'verification code' }} to
continue.
</h3>
<p v-if="selectedFactor.type === 1">
A code has been sent to {{ selectedFactor.contact }}.
</p>
<p v-if="selectedFactor.type === 2">Enter the code from your authenticator app.</p>
<n-input
v-model:value="password"
type="password"
show-password-on="click"
:placeholder="selectedFactor.type === 0 ? 'Password' : 'Code'"
size="large"
class="mb-4"
@keydown.enter="handleVerifyFactor"
/>
<n-space justify="end">
<n-button
v-if="selectedFactor.type === 1"
text
@click="requestVerificationCode(selectedFactor.contact)"
>
Resend Code
</n-button>
</n-space>
<n-button type="primary" block class="mt-4" size="large" @click="handleVerifyFactor">
Verify
</n-button>
</div>
<!-- Stage 4: Token Exchange -->
<div v-if="stage === 'token-exchange'">
<h3 class="mb-4">Finalizing Login</h3>
<n-spin />
</div>
<n-alert
v-if="error"
title="Error"
type="error"
closable
@after-hide="error = null"
class="mt-2"
>
{{ error }}
</n-alert>
</n-space>
</n-spin>
</n-card>
</div>
</template>

View File

@@ -97,6 +97,7 @@
<AutoGen>True</AutoGen>
<DependentUpon>SharedResource.resx</DependentUpon>
</Compile>
<Compile Remove="Client\**" />
</ItemGroup>
<ItemGroup>
@@ -132,6 +133,7 @@
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>SharedResource.Designer.cs</LastGenOutput>
</EmbeddedResource>
<EmbeddedResource Remove="Client\**" />
</ItemGroup>
<ItemGroup>
@@ -154,12 +156,19 @@
<_ContentIncludedByDefault Remove="Pages\Shared\_Layout.cshtml" />
<_ContentIncludedByDefault Remove="Pages\Checkpoint\CheckpointPage.cshtml" />
<_ContentIncludedByDefault Remove="Pages\Spell\MagicSpellPage.cshtml" />
<_ContentIncludedByDefault Remove="Client\.prettierrc.json" />
<_ContentIncludedByDefault Remove="Client\package.json" />
<_ContentIncludedByDefault Remove="Client\tsconfig.app.json" />
<_ContentIncludedByDefault Remove="Client\tsconfig.json" />
<_ContentIncludedByDefault Remove="Client\tsconfig.node.json" />
</ItemGroup>
<ItemGroup>
<Folder Include="Client\public\" />
<Folder Include="Client\src\components\" />
<Folder Include="Client\src\stores\" />
<Content Remove="Client\**" />
</ItemGroup>
<ItemGroup>
<None Remove="Client\**" />
</ItemGroup>
</Project>