Passkey 및 WebAuthn을 이용한 Node.js의 비밀번호 없는 인증
Lukas Schneider
DevOps Engineer · Leapcell

소개
오늘날 상호 연결된 디지털 세계에서 사용자 인증은 민감한 정보를 보호하는 중요한 장벽입니다. 수십 년 동안 비밀번호는 어디에나 있는 게이트키퍼 역할을 해왔지만, 여전히 상당한 취약점으로 남아 있습니다. 약하고, 재사용되거나, 침해된 비밀번호는 데이터 유출의 주요 원인이며, 사용자 및 개발자에게 상당한 부담을 줍니다. 사용자는 복잡한 자격 증명을 기억하는 데 어려움을 겪는 반면, 개발자는 강력한 비밀번호 정책 및 보안 저장 메커니즘을 구현하기 위해 노력합니다.
WebAuthn(웹 인증)을 기반으로 구축된 Passkey의 등장은 이 오래된 딜레마에 대한 강력한 솔루션을 제공합니다. 강력한 암호화 기술과 장치별 보안 기능을 활용하여 Passkey는 비밀번호 관련 불안이 과거의 일이 되는 미래를 약속합니다. 이 글은 보안을 강화하고 사용자 경험을 개선하며 개발을 간소화하는 이 최첨단 비밀번호 없는 인증 방법을 Node.js 애플리케이션에 통합하는 방법을 자세히 살펴봅니다.
기본 사항 살펴보기
구현 세부 정보에 들어가기 전에 Passkey 및 WebAuthn의 기초가 되는 핵심 개념을 이해하는 것이 중요합니다.
-
WebAuthn(웹 인증 API): 강력하고, 증명되고, 범위가 지정되고, 공개 키 기반 자격 증명을 생성하기 위한 API를 정의하는 W3C 표준입니다. 이를 통해 웹 애플리케이션은 사용자 확인을 위해 인증자(생체 인식 센서, 하드웨어 보안 키 또는 통합 소프트웨어 인증자와 같은)와 상호 작용할 수 있습니다. 비밀번호를 전송하는 대신 WebAuthn은 암호화 키를 교환하여 피싱 및 기타 자격 증명 기반 공격에 본질적으로 저항적입니다.
-
인증자: 인증자는 암호화 키를 생성하고 저장하며 암호화 작업을 수행하는 책임이 있는 장치 또는 소프트웨어 구성 요소입니다. 예로는 노트북의 TPM(Trusted Platform Module), 휴대폰의 보안 격리, 안면 인식 시스템, 지문 스캐너 또는 외부 FIDO2 보안 키가 있습니다.
-
신뢰 당사자(RP): WebAuthn 맥락에서 신뢰 당사자는 사용자를 인증하려는 웹 애플리케이션 또는 서비스입니다. Node.js 백엔드가 신뢰 당사자 서버 역할을 합니다.
-
증명: 이는 인증자가 자격 증명 생성 중에 신뢰 당사자에게 자신의 진위 및 특성에 대한 증거를 제공하는 선택적이지만 강력한 기능입니다. RP가 인증자가 정품이고 특정 보안 표준을 충족하는지 확인하는 데 도움이 됩니다.
-
Passkey: Passkey는 WebAuthn 위에 구축된 사용자 친화적인 추상화입니다. 본질적으로 사용자가 비밀번호 없이 웹사이트 및 애플리케이션에 로그인할 수 있도록 하는 암호화적으로 안전한 자격 증명입니다. Passkey는 일반적으로 사용자의 장치 간에 동기화되어(예: iCloud Keychain, Google 비밀번호 관리자 또는 Microsoft Authenticator를 통해) 널리 액세스 가능하고 편리하게 사용할 수 있습니다. Passkey를 사용할 때 공개/개인 키 쌍을 사용하는 것입니다. 개인 키는 장치를 벗어나지 않으며 공개 키만 서버로 전송됩니다.
근본 원리는 공개 키 암호화를 중심으로 합니다. 등록 중에 사용자의 장치(인증자)는 애플리케이션에 대한 고유한 공개/개인 키 쌍을 생성합니다. 공개 키는 Node.js 서버로 안전하게 전송되어 저장됩니다. 개인 키는 PIN, 지문 또는 얼굴 스캔과 같은 로컬 인증 메커니즘에 의해 보호되는 사용자의 장치에 남아 있습니다. 후속 로그인의 경우 서버는 무작위 논스에 서명하도록 클라이언트에 도전하여 개인 키 소유권을 증명하도록 합니다. 서명이 유효하면 사용자가 인증됩니다.
비밀번호 없는 인증 구축
Node.js 애플리케이션에서 Passkey 기반 인증을 구현하는 과정을 살펴보겠습니다. @simplewebauthn
라이브러리는 복잡한 WebAuthn의 많은 부분을 추상화하므로 이들을 사용하겠습니다.
먼저 필요한 패키지를 설치합니다.
npm install @simplewebauthn/server @simplewebauthn/browser express body-parser cookie-parser
서버 측 구현(Node.js)
Node.js 서버는 등록 도전 과제 생성, 등록 응답 확인, 인증 도전 과제 생성 및 인증 응답 확인을 처리합니다.
// server.js const express = require('express'); const bodyParser = require('body-parser'); const cookieParser = require('cookie-parser'); const { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse, } = require('@simplewebauthn/server'); const app = express(); const port = 3000; app.use(bodyParser.json()); app.use(cookieParser()); // 실제 애플리케이션에서는 여기에 데이터베이스에 저장합니다. const users = new Map(); // 사용자 세부 정보를 저장하는 Map const authenticators = new Map(); // 사용자의 등록된 인증자를 저장하는 Map // 신뢰 당사자(RP) 구성 const rpName = 'My Awesome App'; const rpID = 'localhost'; // 프로덕션에서는 도메인을 사용해야 합니다 (예: 'example.com') const origin = `http://localhost:${port}`; // 사용자 세션 (간단하게 기본 세션 객체를 사용합니다) const sessions = new Map(); // userId -> { challenge: string } // 1. 등록 도전 과제 생성 app.post('/register/start', async (req, res) => { const { username } = req.body; if (!username) { return res.status(400).json({ message: 'Username is required' }); } let user = users.get(username); if (!user) { user = { id: username, // 간단하게 사용자 이름을 ID로 사용 username: username, authenticators: [], // 이 사용자의 등록된 인증자를 저장 }; users.set(username, user); authenticators.set(username, []); // 이 사용자에 대한 인증자 초기화 } try { const options = await generateRegistrationOptions({ rpName, rpID, userID: user.id, userName: user.username, attestationType: 'none', // 대부분의 경우 'none'이 좋습니다. authenticatorSelection: { residentKey: 'required', // 발견 가능한 자격 증명(Passkey)을 생성하도록 인증자에게 지시합니다. userVerification: 'preferred', // 사용자 확인(PIN, 생체 인식)을 선호합니다. }, // 재등록을 방지하기 위해 기존 인증자 제외 excludeCredentials: authenticators.get(username).map((auth) => ({ id: auth.credentialID, type: 'public-key', transports: auth.transports, })), }); // 나중에 확인하기 위해 세션에 도전 과제 저장 sessions.set(user.id, { challenge: options.challenge }); return res.json(options); } catch (error) { console.error('Error generating registration options:', error); return res.status(500).json({ message: 'Failed to start registration' }); } }); // 2. 등록 확인 app.post('/register/finish', async (req, res) => { const { username, attResp } = req.body; if (!username || !attResp) { return res.status(400).json({ message: 'Username and attestation response are required' }); } const user = users.get(username); if (!user) { return res.status(404).json({ message: 'User not found' }); } const session = sessions.get(user.id); if (!session || !session.challenge) { return res.status(400).json({ message: 'No active registration session found' }); } try { const verification = await verifyRegistrationResponse({ response: attResp, expectedChallenge: session.challenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: false, // 항상 생체 인식/PIN이 필요한 경우 true로 설정 }); const { verified, registrationInfo } = verification; if (verified && registrationInfo) { const { credentialPublicKey, credentialID, counter } = registrationInfo; const newAuthenticator = { credentialID: credentialID, credentialPublicKey: credentialPublicKey, counter: counter, // 향후 등록에서 excludeCredentials에 중요합니다. transports: attResp.response.transports, }; const userAuthenticators = authenticators.get(username); userAuthenticators.push(newAuthenticator); authenticators.set(username, userAuthenticators); sessions.delete(user.id); // 도전 과제 지우기 return res.json({ verified: true, message: 'Registration successful!' }); } else { return res.status(400).json({ verified: false, message: 'Registration failed' }); } } catch (error) { console.error('Error verifying registration response:', error); return res.status(500).json({ message: 'Failed to finish registration' }); } }); // 3. 인증 도전 과제 생성 app.post('/login/start', async (req, res) => { const { username } = req.body; // 발견 가능한 Passkey의 경우 생략 가능 let userAuthenticators = []; if (username) { userAuthenticators = authenticators.get(username) || []; } // 사용자 이름이 제공되지 않으면 브라우저에서 프롬프트할 수 있습니다. // 또는 발견 가능한 자격 증명이 사용되는 경우 사용자가 목록에서 선택합니다. try { const options = await generateAuthenticationOptions({ rpID, // 사용자 이름이 제공되면 알려진 자격 증명과 일치시킵니다. allowCredentials: userAuthenticators.map((auth) => ({ id: auth.credentialID, type: 'public-key', transports: auth.transports, })), userVerification: 'preferred', timeout: 60000, }); // 확인을 위해 도전 과제 저장 sessions.set(username || 'anonymous_login', { challenge: options.challenge }); return res.json(options); } catch (error) { console.error('Error generating authentication options:', error); return res.status(500).json({ message: 'Failed to start login' }); } }); // 4. 인증 확인 app.post('/login/finish', async (req, res) => { const { username, authnResp } = req.body; if (!authnResp) { return res.status(400).json({ message: 'Authentication response is required' }); } // 발견 가능한 자격 증명의 경우 인증자가 자격 증명 ID를 다시 보낼 수 있어 // 사용자를 찾는 데 도움이 됩니다. let user; let registeredAuthenticator; // 로그인에 사용된 인증자 찾기 let foundAuth = false; for (const [key, auths] of authenticators.entries()) { registeredAuthenticator = auths.find( (auth) => auth.credentialID.toString('base64url') === authnResp.rawId ); if (registeredAuthenticator) { user = users.get(key); foundAuth = true; break; } } if (!foundAuth || !user) { return res.status(400).json({ verified: false, message: 'Authenticator not found for user' }); } const sessionKey = user.id; // 세션 키로 사용자 ID 사용 const session = sessions.get(sessionKey); if (!session || !session.challenge) { return res.status(400).json({ message: 'No active authentication session found' }); } try { const verification = await verifyAuthenticationResponse({ response: authnResp, expectedChallenge: session.challenge, expectedOrigin: origin, expectedRPID: rpID, authenticator: registeredAuthenticator, requireUserVerification: false, }); const { verified, authenticationInfo } = verification; if (verified) { // 재사용 공격 방지를 위해 인증자의 카운터 업데이트 registeredAuthenticator.counter = authenticationInfo.newAuthenticatorInfo.counter; // 실제 앱에서는 데이터베이스에서 이를 업데이트합니다. sessions.delete(sessionKey); // 도전 과제 지우기 // 사용자 세션 설정 (예: 쿠키 설정) res.cookie('loggedInUser', user.id, { httpOnly: true, secure: false }); // 프로덕션에서는 secure: true 사용 return res.json({ verified: true, message: 'Authentication successful!', userId: user.id }); } else { return res.status(400).json({ verified: false, message: 'Authentication failed' }); } } catch (error) { console.error('Error verifying authentication response:', error); return res.status(500).json({ message: 'Failed to finish login' }); } }); app.get('/dashboard', (req, res) => { const userId = req.cookies.loggedInUser; if (userId) { return res.send(`Welcome to your dashboard, ${userId}!`); } res.status(401).send('Unauthorized'); }); // 클라이언트 측 상호 작용을 위한 기본 HTML 페이지 제공 app.get('/', (req, res) => { res.sendFile(__dirname + '/index.html'); }); app.listen(port, () => { console.log(`Server listening at http://localhost:${port}`); });
클라이언트 측 구현(HTML/JavaScript)
클라이언트 측 코드는 @simplewebauthn/browser
를 사용하여 브라우저의 WebAuthn API와 상호 작용합니다.
<!-- index.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Passkey Demo</title> <style> body { font-family: sans-serif; margin: 20px; } div { margin-bottom: 10px; } input { padding: 8px; margin-right: 5px; } button { padding: 10px 15px; cursor: pointer; } </style> </head> <body> <h1>Passkey Authentication Demo</h1> <div> <h2>Register (Create a Passkey)</h2> <input type="text" id="regUsername" placeholder="Enter username"> <button onclick="startRegistration()">Register</button> <p id="regMessage"></p> </div> <div> <h2>Login (Use your Passkey)</h2> <input type="text" id="loginUsername" placeholder="Enter username (optional for discoverable passkeys)"> <button onclick="startLogin()">Login</button> <p id="loginMessage"></p> </div> <script type="module"> import { startRegistration, startAuthentication } from 'https://unpkg.com/@simplewebauthn/browser@latest/dist/bundle/index.mjs'; const regUsernameInput = document.getElementById('regUsername'); const regMessage = document.getElementById('regMessage'); const loginUsernameInput = document.getElementById('loginUsername'); const loginMessage = document.getElementById('loginMessage'); window.startRegistration = async () => { const username = regUsernameInput.value; if (!username) { regMessage.textContent = 'Please enter a username for registration.'; return; } try { // 1. 서버에서 등록 옵션 가져오기 const resp = await fetch('/register/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }), }); const options = await resp.json(); if (resp.status !== 200) { regMessage.textContent = `Error: ${options.message}`; return; } // 2. 브라우저에 자격 증명 생성 요청 const attestationResponse = await startRegistration(options); // 3. 서버로 자격 증명 다시 전송하여 확인 const verificationResp = await fetch('/register/finish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, attResp: attestationResponse }), }); const result = await verificationResp.json(); if (result.verified) { regMessage.textContent = 'Registration successful! You can now log in.'; regUsernameInput.value = ''; } else { regMessage.textContent = `Registration failed: ${result.message}`; } } catch (error) { console.error('Registration error:', error); regMessage.textContent = `Registration failed: ${error.message}`; } }; window.startLogin = async () => { const username = loginUsernameInput.value; // 발견 가능한 Passkey의 경우 선택 사항 try { // 1. 서버에서 인증 옵션 가져오기 const resp = await fetch('/login/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }), // 제공된 경우 사용자 이름 보내기 }); const options = await resp.json(); if (resp.status !== 200) { loginMessage.textContent = `Error: ${options.message}`; return; } // 2. 브라우저에 자격 증명 사용하여 인증 요청 const assertionResponse = await startAuthentication(options); // 3. 서버로 주장 다시 전송하여 확인 const verificationResp = await fetch('/login/finish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, authnResp: assertionResponse }), }); const result = await verificationResp.json(); if (result.verified) { loginMessage.textContent = `Login successful! Welcome, ${result.userId}!`; loginUsernameInput.value = ''; // 로그인된 상태를 표시하도록 리디렉션하거나 UI 업데이트 window.location.href = '/dashboard'; } else { loginMessage.textContent = `Login failed: ${result.message}`; } } catch (error) { console.error('Login error:', error); loginMessage.textContent = `Login failed: ${error.message}`; } }; </script> </body> </html>
데모 실행
- Node.js 코드를
server.js
로, HTML 코드를index.html
로 같은 디렉토리에 저장합니다. - 터미널에서
node server.js
를 실행합니다. - 웹 브라우저에서
http://localhost:3000
을 엽니다.
이제 새 사용자를 Passkey로 등록(장치 생체 인식 또는 PIN 사용 가능)한 다음 해당 Passkey를 사용하여 로그인하는 과정을 시도할 수 있습니다.
주요 측면 및 고려 사항:
- 발견 가능한 자격 증명(Resident Keys): 등록 중에
residentKey: 'required'
를 설정하면 신뢰 당사자가 사용자 이름 힌트 없이 Passkey를 발견할 수 있는 Passkey를 인증자가 생성하도록 지시합니다. 이를 통해 사용자가 '로그인'을 클릭하고 계정을 선택하기만 하면 되는 진정한 비밀번호 없는 로그인 경험이 가능합니다. - 사용자 확인:
userVerification: 'preferred'
(또는'required'
)는 개인 키가 사용되기 전에 인증자에서 로컬로 사용자 존재 및 동의가 확인되도록 합니다(예: 지문, 안면 인식 또는 PIN을 통해). 이는 강력한 보안 계층을 추가합니다. - 자격 증명 저장: 프로덕션 환경에서는
users
및authenticators
맵이 안전한 데이터베이스로 대체됩니다. 각 인증자의credentialID
,credentialPublicKey
,counter
는 중요하며 안정적으로 저장되어야 합니다. - 카운터 증가: 인증 중에 반환되는
counter
값은 해당 인증자에 대해 이전에 저장된counter
보다 엄격하게 커야 서버에서 확인해야 합니다. 이는 재사용 공격을 방지합니다. - 크로스-장치 동기화: Passkey는 호환되는 비밀번호 관리자(예: Apple Keychain, Google 비밀번호 관리자)를 사용하는 경우 사용자의 장치 간에 자동으로 동기화되어 생태계 전반에 걸쳐 원활한 환경을 제공합니다.
- 보안 모범 사례:
- 프로덕션에서는 모든 WebAuthn 통신에 항상 HTTPS를 사용하세요.
rpID
와origin
이 프로덕션 도메인과 일치하는지 확인하세요.- 서버의 개인 키 및 비밀을 보호하세요.
- 성공적인 인증 후 강력한 세션 관리를 구현하세요.
결론
WebAuthn으로 구동되는 Passkey는 사용자 인증에서 기념비적인 도약을 나타냅니다. 가장 약한 연결인 비밀번호를 제거함으로써 피싱, 자격 증명 스터핑 및 기타 일반적인 공격에 대해 우수한 보안을 제공하는 동시에 사용자에게 훨씬 더 원활하고 비밀번호 없는 환경을 제공합니다. @simplewebauthn
과 같은 라이브러리 덕분에 Node.js 애플리케이션에 Passkey를 통합하는 것이 이제 간소화된 프로세스가 되어 더 안전하고 사용자 친화적인 디지털 미래를 위한 길을 열었습니다. Passkey를 채택하여 강력한 보안을 제공할 뿐만 아니라 사용하기 즐거운 애플리케이션을 구축하십시오.