Skip to content

Commit 96c5f8b

Browse files
Thales-Chagasclaude
andcommitted
Desbloqueio por Face ID / digital (biometria)
- WebAuthn (autenticador de plataforma) como tranca local, com PIN de reserva. Tela de bloqueio mostra botao "Entrar com Face ID / digital" + opcao de PIN. Barra lateral permite ativar/desativar (so aparece se o aparelho tiver biometria). App passa a travar na abertura quando a biometria esta ativa. - src/biometria.js: registrar/verificar via navigator.credentials. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d28d395 commit 96c5f8b

2 files changed

Lines changed: 170 additions & 3 deletions

File tree

src/App.jsx

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
Square,
3636
Image as ImageIcon,
3737
Sparkles,
38+
ScanFace,
3839
Printer,
3940
FileSpreadsheet,
4041
} from "lucide-react";
@@ -54,6 +55,11 @@ import {
5455
import logoUrl from "./logo.png";
5556
import emblemaUrl from "./emblema.png";
5657
import { supabase } from "./supabaseClient";
58+
import {
59+
temAutenticadorPlataforma,
60+
registrarBiometria,
61+
verificarBiometria,
62+
} from "./biometria";
5763

5864
// Dados reais importados das planilhas — o arquivo fica só neste computador,
5965
// fora do repositório público. Na versão publicada ele não existe e o app
@@ -2014,7 +2020,7 @@ function CampoLogin({ label, ...props }) {
20142020
);
20152021
}
20162022

2017-
function TelaLogin({ modo, nome, foto, onCriar, onDesbloquear, onEsqueci, escuro, onTema }) {
2023+
function TelaLogin({ modo, nome, foto, onCriar, onDesbloquear, onEsqueci, onBiometria, temBio, escuro, onTema }) {
20182024
const [nomeInput, setNomeInput] = useState("");
20192025
const [pin, setPin] = useState("");
20202026
const [pin2, setPin2] = useState("");
@@ -2154,6 +2160,26 @@ function TelaLogin({ modo, nome, foto, onCriar, onDesbloquear, onEsqueci, escuro
21542160
</form>
21552161
) : (
21562162
<form onSubmit={desbloquear} className="space-y-4">
2163+
{temBio && (
2164+
<>
2165+
<button
2166+
type="button"
2167+
onClick={async () => {
2168+
setErro("");
2169+
const ok = await onBiometria();
2170+
if (!ok) setErro("Não reconheci. Você pode entrar com o PIN abaixo.");
2171+
}}
2172+
className="flex w-full items-center justify-center gap-2 rounded-xl bg-emerald-600 py-3 text-sm font-semibold text-white transition hover:bg-emerald-700"
2173+
>
2174+
<ScanFace size={18} /> Entrar com Face ID / digital
2175+
</button>
2176+
<div className="flex items-center gap-3 text-[11px] text-slate-400">
2177+
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-700" />
2178+
ou use o PIN
2179+
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-700" />
2180+
</div>
2181+
</>
2182+
)}
21572183
<CampoLogin
21582184
label="Seu PIN"
21592185
type="password"
@@ -2162,7 +2188,7 @@ function TelaLogin({ modo, nome, foto, onCriar, onDesbloquear, onEsqueci, escuro
21622188
value={pin}
21632189
onChange={(e) => setPin(e.target.value.replace(/\D/g, ""))}
21642190
placeholder="••••"
2165-
autoFocus
2191+
autoFocus={!temBio}
21662192
/>
21672193
{erro && <p className="text-sm text-red-600 dark:text-red-400">{erro}</p>}
21682194
<button
@@ -2229,6 +2255,7 @@ export default function App() {
22292255
}
22302256
});
22312257
const [arquivoFoto, setArquivoFoto] = useState(null);
2258+
const [bioDisponivel, setBioDisponivel] = useState(false);
22322259
const dirtyRef = useRef(false);
22332260
const toastTimer = useRef(null);
22342261
const fileRef = useRef(null);
@@ -2252,7 +2279,7 @@ export default function App() {
22522279
const conf = rawLogin ? JSON.parse(rawLogin) : null;
22532280
if (conf && conf.pinHash) {
22542281
setLogin(conf);
2255-
setAuth(conf.pedirSempre ? "lock" : "open");
2282+
setAuth(conf.pedirSempre || conf.bioCredId ? "lock" : "open");
22562283
} else {
22572284
setAuth("setup");
22582285
}
@@ -2358,6 +2385,44 @@ export default function App() {
23582385
setAuth("lock");
23592386
}
23602387

2388+
// Verifica, ao abrir, se o aparelho tem Face ID / digital
2389+
useEffect(() => {
2390+
temAutenticadorPlataforma().then(setBioDisponivel);
2391+
}, []);
2392+
2393+
async function ativarBiometria() {
2394+
if (!login) return;
2395+
try {
2396+
const credId = await registrarBiometria(login.nome);
2397+
const conf = { ...login, bioCredId: credId };
2398+
setLogin(conf);
2399+
await storageSet(LOGIN_KEY, JSON.stringify(conf));
2400+
showToast("Face ID / digital ativado ✓");
2401+
} catch {
2402+
showToast("Não consegui ativar a biometria neste aparelho.", true);
2403+
}
2404+
}
2405+
2406+
async function desativarBiometria() {
2407+
if (!login) return;
2408+
const conf = { ...login };
2409+
delete conf.bioCredId;
2410+
setLogin(conf);
2411+
await storageSet(LOGIN_KEY, JSON.stringify(conf));
2412+
showToast("Biometria desativada.");
2413+
}
2414+
2415+
async function desbloquearBiometria() {
2416+
if (!login?.bioCredId) return false;
2417+
try {
2418+
await verificarBiometria(login.bioCredId);
2419+
setAuth("open");
2420+
return true;
2421+
} catch {
2422+
return false;
2423+
}
2424+
}
2425+
23612426
async function salvarFoto(dataUrl) {
23622427
if (!login) return;
23632428
const conf = { ...login, foto: dataUrl };
@@ -2436,6 +2501,8 @@ export default function App() {
24362501
onCriar={criarLogin}
24372502
onDesbloquear={desbloquear}
24382503
onEsqueci={esqueciPin}
2504+
onBiometria={desbloquearBiometria}
2505+
temBio={!!login?.bioCredId}
24392506
escuro={escuro}
24402507
onTema={() => setEscuro((e) => !e)}
24412508
/>
@@ -2519,6 +2586,19 @@ export default function App() {
25192586
<LogOut size={16} />
25202587
</button>
25212588
</div>
2589+
{bioDisponivel && (
2590+
<button
2591+
onClick={login?.bioCredId ? desativarBiometria : ativarBiometria}
2592+
className={
2593+
"flex w-full items-center justify-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition " +
2594+
(login?.bioCredId
2595+
? "border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-300"
2596+
: "border-slate-200 text-slate-500 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-400 dark:hover:bg-slate-800")
2597+
}
2598+
>
2599+
<ScanFace size={16} /> {login?.bioCredId ? "Face ID / digital ativo" : "Ativar Face ID / digital"}
2600+
</button>
2601+
)}
25222602
<button
25232603
onClick={exportData}
25242604
className="flex w-full items-center justify-center gap-2 rounded-xl border border-slate-200 px-3 py-2 text-sm font-medium text-slate-500 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-400 dark:hover:bg-slate-800"

src/biometria.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// ============================================================
2+
// Desbloqueio biométrico (Face ID / Touch ID / digital) via WebAuthn
3+
// Uso LOCAL: sem servidor para validar a assinatura, tratamos um
4+
// get() bem-sucedido como desbloqueio (tranca local do aparelho,
5+
// no mesmo nível de confiança do PIN). Exige HTTPS (GitHub Pages).
6+
// Quando o login na nuvem entrar (Etapa B), a validação fica completa.
7+
// ============================================================
8+
9+
function b64urlFromBuf(buf) {
10+
const bytes = new Uint8Array(buf);
11+
let s = "";
12+
for (const b of bytes) s += String.fromCharCode(b);
13+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
14+
}
15+
16+
function bufFromB64url(s) {
17+
s = s.replace(/-/g, "+").replace(/_/g, "/");
18+
const pad = s.length % 4 ? "=".repeat(4 - (s.length % 4)) : "";
19+
const bin = atob(s + pad);
20+
const bytes = new Uint8Array(bin.length);
21+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
22+
return bytes.buffer;
23+
}
24+
25+
export function biometriaSuportada() {
26+
return (
27+
typeof window !== "undefined" &&
28+
!!window.PublicKeyCredential &&
29+
!!(navigator.credentials && navigator.credentials.create)
30+
);
31+
}
32+
33+
// Confere se o aparelho tem um autenticador de plataforma (Face ID / digital)
34+
export async function temAutenticadorPlataforma() {
35+
try {
36+
if (!biometriaSuportada()) return false;
37+
return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
38+
} catch {
39+
return false;
40+
}
41+
}
42+
43+
// Cadastra a biometria e devolve o id da credencial (para guardar)
44+
export async function registrarBiometria(nome) {
45+
const challenge = crypto.getRandomValues(new Uint8Array(32));
46+
const userId = crypto.getRandomValues(new Uint8Array(16));
47+
const cred = await navigator.credentials.create({
48+
publicKey: {
49+
challenge,
50+
rp: { name: "Thayfinance" },
51+
user: {
52+
id: userId,
53+
name: nome || "Thayfinance",
54+
displayName: nome || "Thayfinance",
55+
},
56+
pubKeyCredParams: [
57+
{ type: "public-key", alg: -7 },
58+
{ type: "public-key", alg: -257 },
59+
],
60+
authenticatorSelection: {
61+
authenticatorAttachment: "platform",
62+
userVerification: "required",
63+
residentKey: "preferred",
64+
},
65+
timeout: 60000,
66+
},
67+
});
68+
if (!cred) throw new Error("não registrado");
69+
return b64urlFromBuf(cred.rawId);
70+
}
71+
72+
// Pede a biometria. Se passar (não lançar), considera desbloqueado.
73+
export async function verificarBiometria(credIdB64) {
74+
const challenge = crypto.getRandomValues(new Uint8Array(32));
75+
const allowCredentials = credIdB64
76+
? [{ type: "public-key", id: bufFromB64url(credIdB64) }]
77+
: [];
78+
const assertion = await navigator.credentials.get({
79+
publicKey: {
80+
challenge,
81+
allowCredentials,
82+
userVerification: "required",
83+
timeout: 60000,
84+
},
85+
});
86+
return !!assertion;
87+
}

0 commit comments

Comments
 (0)