애플리케이션이 받아들이는 모든 비밀번호는 추측당하거나, 다른 사이트에서 재사용되거나, 가짜 로그인 페이지에 입력되거나, 데이터베이스 백업에서 유출될 수 있습니다. Passkey는 이 네 가지 문제를 한 번에 없애줍니다. 사용자는 이미 휴대폰이나 노트북을 잠금 해제하는 지문, 얼굴, PIN으로 로그인하며, 서버에는 훔칠 가치가 있는 비밀이 더 이상 남아 있지 않습니다.
새 로그인 컴포넌트 개요에서 Passkey는 다섯 개의 항목만 차지했고, SAML 글에서는 자체 identity provider를 운영하는 기업을 위한 로그인을 다뤘습니다. 이번 글은 비밀번호 없는 쪽입니다: TsgcWSAPIServer_WebAuthn이 어떻게 Passkey를 등록하고, 사용자 이름 없이 로그인시키고, 브라우저 자동완성 목록에 Passkey를 표시하고, 모든 credential을 자체 데이터베이스에 보관하는지 살펴봅니다.
Passkey란 무엇인가
Passkey는 사용자의 authenticator가 만드는 키 쌍입니다: Windows Hello, iCloud 키체인, Google 비밀번호 관리자, 비밀번호 관리자, 또는 FIDO2 보안 키가 여기에 해당합니다. 개인 키는 authenticator를 절대 벗어나지 않습니다. 서버는 공개 키만 저장하므로, Passkey 테이블이 유출되더라도 아무도 로그인할 수 없습니다. 모든 서명은 사이트의 도메인에 묶여 있어서, 비슷하게 생긴 피싱 도메인은 아무것도 이용할 수 없습니다. 여기서 말하는 피싱 저항성이 바로 이런 의미입니다.
WebAuthn 용어로 Passkey는 검색 가능한 credential이며, resident key라고도 부릅니다. authenticator는 userHandle을 개인 키 옆에 보관하는데, 그래서 사용자는 사용자 이름을 입력하지 않고도 로그인할 수 있고 브라우저는 스스로 사이트의 Passkey 목록을 보여줄 수 있습니다.
검색 가능한 credential 요청하기
WebAuthn은 보안 컨텍스트가 필요하므로, 페이지를 https로 제공하거나 개발 중에는 localhost에서 제공하고, WebAuthnOptions.RelyingParty를 브라우저가 보여주는 호스트 이름으로 설정하세요. 등록 옵션이 요청하는 residentKey는 WebAuthnOptions.DefaultOptions.Registration.DiscoverableCredential에서 나옵니다:
waundcPreferred, 기본값입니다. authenticator는 가능할 때 검색 가능한 credential을 만듭니다.waundcRequired. 검색 가능한 credential만 허용됩니다. Passkey와 사용자 이름 없는 로그인에 사용하세요.waundcDiscouraged. authenticator는 서버 측 credential을 만들어야 하며, 이는 로그인 시 사용자 이름이 필요합니다.
단일 등록 요청은 여전히 discoverable_credential 필드로 기본값을 재정의할 수 있으며, required, preferred, discouraged 중 하나로 설정합니다.
사용자 이름 없이 로그인하기
빈 사용자 이름으로 인증 옵션을 요청하세요. 이 경우 옵션에는 allowCredentials 목록이 없고, 브라우저는 사용자의 relying party를 위해 보유한 Passkey를 보여주며, 사용자는 그중 하나를 선택합니다. 입력할 것이 없으니 잘못 입력할 것도 없습니다.
이번에는 서버가 credential을 선택하지 않았으므로 더 많은 것을 검사합니다. 응답에는 userHandle이 포함되어야 하고, userHandle은 서명한 credential에 속해야 하며, 애플리케이션이 알지 못하는 credential은 거부됩니다.
자동완성 목록의 Passkey
자동완성, 즉 conditional mediation은 저장된 비밀번호 옆의 사용자 이름 필드 제안 목록에 사이트의 Passkey를 넣습니다. 이는 사용자를 부드럽게 전환시키는 방법입니다: 로그인 페이지는 비밀번호를 사용하는 사람에게는 계속 그대로 작동하고, Passkey를 가진 사람은 목록에서 선택합니다.
- 사용자 이름 입력란에
autocomplete="username webauthn"을 추가하세요. - 페이지에서
/sgcWebAuthn.js를 로드하세요. 이 컴포넌트는EndpointsOptions.WebAuthn에 설정된 엔드포인트에서 이 파일을 직접 제공합니다. - 페이지가 로드되면 사용자 이름 없는 옵션을 요청하고
startAuthentication(options, true)를 호출하세요. 사용자가 목록에서 Passkey를 선택하면 promise가 해결됩니다.
자동완성에는 conditional mediation을 지원하는 브라우저가 필요하며, 오늘날 이는 최신 버전의 Chrome, Edge, Safari를 의미합니다. browserSupportsWebAuthnAutofill()은 그 외 브라우저에서 False를 반환하므로, 페이지에 “Passkey로 로그인” 버튼도 함께 두세요.
여러 개의 Passkey, 하나의 userHandle
실제 사용자는 하나 이상의 Passkey를 가지고 있습니다: 노트북에 하나, 휴대폰에 하나, 어쩌면 둘 다 잃어버리는 날을 위한 보안 키까지. 각각을 같은 사용자 이름으로 등록하세요. 그 사용자 이름에 서버가 이미 알고 있는 credential이 있으면, 새 등록은 그 userHandle(user.id)을 재사용하므로, 계정의 모든 Passkey가 하나의 userHandle을 공유하고 모두 같은 사용자로 이어집니다.
서버는 실행되는 동안 등록된 credential과 AddCredential로 추가한 credential을 알고 있습니다. Passkey가 데이터베이스에 저장되어 있다면, 각 계정이 하나의 userHandle을 유지하도록 시작 시점에 추가하세요. 사용자가 실제로 사용자 이름을 입력하면, 인증 옵션은 allowCredentials에 그 사용자의 모든 Passkey를 나열하고, authenticator는 자신이 가진 것을 사용합니다.
Passkey를 자체 데이터베이스에 보관하기
이 컴포넌트는 credential을 영속화하지 않습니다. Passkey는 사용자 테이블 옆에 속하며, 네 가지 이벤트가 둘을 연결합니다:
OnWebAuthnRegistrationSuccessful. 예를 들어aCredentialRecord.AsJSON과 같은 새 credential 레코드를, 그CredentialId및Username과 함께 저장하세요.OnWebAuthnAuthenticationOptionsRequest. 사용자 이름이 있으면 그 사용자의 Passkey를CredentialRecords에 추가하세요. 사용자 이름이 없으면 아무것도 추가하지 마세요.OnWebAuthnAuthenticationGetCredential. 브라우저가 선택한 credential이 이번 ceremony의 목록에 없을 때 발생하며, 이는 사용자 이름 없는 로그인과 자동완성 로그인마다 해당됩니다. credential을 조회하고,aCredentialRecord를 채우고,Found를 설정하세요.OnWebAuthnAuthenticationSuccessful.aAuthentication.Credential.CredentialRecord의 새SignCount와BackupState를 저장한 다음 세션을 만드세요.
Passkey를 저장하고 다시 불러오는 것은 두 개의 짧은 핸들러로 이루어집니다:
uses
sgcWebAuthn_Classes;
// registration: store the whole record, keyed by its credential id
procedure TForm1.WebAuthnWebAuthnRegistrationSuccessful(Sender: TObject;
const aRegistration: TsgcWebAuthn_Registration;
const aCredentialRecord: TsgcWebAuthn_CredentialRecord; var Accept: Boolean);
begin
DBInsertPasskey(aCredentialRecord.CredentialId, aCredentialRecord.Username,
aCredentialRecord.AsJSON);
end;
// usernameless and autofill sign-in: the browser chose the passkey,
// find it by its credential id and hand it back to the server
procedure TForm1.WebAuthnWebAuthnAuthenticationGetCredential(Sender: TObject;
const aCredentialId: string;
const aCredentialRecord: TsgcWebAuthn_CredentialRecord; var Found: Boolean);
var
vJSON: string;
begin
Found := DBFindPasskey(aCredentialId, vJSON);
if Found then
aCredentialRecord.ReadJSON(vJSON);
end;
DBInsertPasskey와 DBFindPasskey는 여러분 자신의 데이터 접근 코드를 나타냅니다. 반환하는 레코드는 요청된 CredentialId를 담고 있어야 하며, ceremony가 사용자 이름으로 시작했다면 동일한 Username도 담고 있어야 합니다. 그렇지 않으면 로그인이 실패합니다. 서버가 authenticator가 보낸 userHandle과 비교하므로, 저장된 레코드에 UserId도 함께 보관하세요. 이벤트는 서버의 연결 스레드에서 실행되므로, 서버의 다른 곳에서 공유 리소스를 보호하는 것과 같은 방식으로 보호하세요.
동기화 또는 기기 바인딩
authenticator 데이터의 플래그는 여러분이 받은 Passkey의 종류를 알려주며, credential 레코드는 이를 BackupEligible(BE 플래그)과 BackupState(BS 플래그)로 보관합니다:
- BackupEligible True, BackupState True. 플랫폼이나 비밀번호 관리자가 백업하고 사용자의 다른 기기에서도 사용할 수 있는 동기화된 Passkey입니다.
- BackupEligible True, BackupState False. 아직 백업되지 않은 다중 기기 Passkey입니다.
- BackupEligible False, BackupState False. 보안 키나 TPM 키처럼 절대 기기를 벗어나지 않는, 기기에 바인딩된 Passkey입니다. 그 기기를 잃어버리면 credential도 잃는 것이므로 사용자에게 두 번째 Passkey 등록을 권하세요.
정책에 따라 이를 다르게 취급할 수 있습니다. 예를 들어 관리자 계정에서는 기기에 바인딩된 보안 키만 허용하는 식입니다. 로그인할 때마다 서버는 BS 플래그가 설정되고 BE 플래그가 설정되지 않은 응답을 거부하며, BE 플래그가 저장된 BackupEligible과 다른 응답도 거부합니다. credential의 eligibility는 절대 바뀌지 않기 때문입니다. 새 BS 플래그는 BackupState로 복사되며, OnWebAuthnAuthenticationSuccessful이 이를 저장할 곳입니다.
서명 카운터와 복제된 authenticator
일부 authenticator는 로그인할 때마다 서명 카운터를 증가시킵니다. 응답의 카운터나 저장된 SignCount가 0이 아니면, 응답의 카운터는 저장된 값보다 커야 합니다. 그렇지 않으면 로그인이 거부되는데, 같은 키로 응답하는 두 개의 authenticator는 정확히 복제된 authenticator처럼 보이기 때문입니다.
이 검사는 매 로그인 후에 카운터를 저장하고, 저장된 값이 오직 앞으로만 나아갈 때만 작동합니다. 동기화된 Passkey는 보통 매번 0을 보고하는데, 이는 그것들에 대해 검사를 꺼버립니다. 카운터를 안정적으로 증가시키지 않는 authenticator가 있다면, 이를 허용하기 위해 WebAuthnOptions.AllowSignCountLessOrEqualStoredValue를 True로 설정하세요. 저장된 값은 그래도 절대 낮아지지 않습니다.
데모 사용해 보기
데모 Demos\26.Authentication\01.Passkeys는 완전한 relying party로, TsgcWSAPIServer_WebAuthn이 연결된 TsgcWebSocketHTTPServer가 https://localhost:5443에서 작은 로그인 페이지를 제공합니다. 위의 네 가지 이벤트를 통해 각 Passkey를 자체 passkeys.json 파일에 저장합니다.
- 데모를 빌드하고 libcrypto-3.dll과 libssl-3.dll을 실행 파일 옆에 두세요. 데모 폴더 안에 있습니다.
- host 127.0.0.1, port 5443, relying party localhost를 그대로 두고 Start를 클릭하세요.
- Open Browser를 클릭하고 자체 서명된 테스트 인증서를 수락하세요.
- 사용자 이름을 입력하고 Register passkey를 클릭하세요. 같은 사용자 이름으로 두 번째 Passkey를 등록하세요. 폼에는 각 Passkey의 종류(동기화 또는 기기 바인딩)와 서명 카운터가 나열됩니다.
- Sign in without user name을 클릭하고 Passkey를 선택하세요. 서버는
OnWebAuthnAuthenticationGetCredential을 통해 이를 찾고, 로그에 사용자가 표시됩니다. - 페이지를 새로고침하고 사용자 이름 필드를 클릭하세요. Passkey가 자동완성 목록에 나타납니다. 하나를 선택해 로그인하세요.
- 애플리케이션을 재시작하세요. Passkey가 passkeys.json에서 다시 로드되며, 이는 저장소가 컴포넌트가 아니라 여러분의 애플리케이션에 속한다는 것을 보여줍니다.
문서
받는 방법
TsgcWSAPIServer_WebAuthn은 Delphi와 C++ Builder용 sgcWebSockets의 Enterprise 및 All-Access 에디션에 포함되어 있으며, 같은 컴포넌트가 sgcWebSockets .NET에도 포함되어 있습니다. 인증만 필요하다면, sgcAuth 패키지에 다른 로그인 컴포넌트와 함께 들어 있습니다. SGC Auth 팔레트에서 찾을 수 있으며, 폼에 올리기 전까지는 기존 애플리케이션에서 아무것도 바뀌지 않습니다.
다음 읽을거리
- Passkey, SAML SSO, LDAP, TOTP 2FA로 구현하는 Delphi 로그인
- Entra ID, Okta, AD FS로 구현하는 Delphi SAML 싱글 사인온
- WebAuthn, 패스키, 그리고 비밀번호의 종말
영상으로 보기
eSeGeCe 채널에 “Passkeys in Delphi: passwordless login with WebAuthn”이라는 짧은 영상이 있습니다, eSeGeCe 채널에서 볼 수 있습니다. IDE 안의 코드, 백업 플래그와 서명 카운터가 알려주는 것, 그리고 https://localhost에서 실행되는 데모를 보여줍니다: 사용자가 두 개의 Passkey를 등록한 다음 사용자 이름을 입력하지 않고 로그인합니다.
질문이나 피드백이 있으신가요, 아니면 로그인 페이지에 Passkey를 추가하는 데 도움이 필요하신가요? 문의하기. 코드를 작성한 사람들에게서 직접 답변을 받으실 수 있습니다.
