ํจ์คํค ๋ก๊ทธ์ธ
ํจ์คํค ๋ก๊ทธ์ธ์ ์ฌ์ฉ์ ๊ณ์ ์ ๋ํ ๋น๋ฐ๋ฒํธ๋ฅผ ์ ๋ ฅํ์ง ์๊ณ ์ฌ์ฉ์ ๊ณ์ ์ ๋ํด ์ง๋ฌธ์ด๋ ์ผ๊ตด ์ธ์ ๊ทธ๋ฆฌ๊ณ ๋ณด์ํค ๋ฑ์ ์ฌ์ฉํด์ ๋ฏธ๋ฆฌ ์ธ์ฆ ์ ๋ณด๋ฅผ ๋ฑ๋กํด๋๊ณ ์ฌ์ฉ์์ ์ธ์ฆ ๊ธฐ๊ธฐ๋ฅผ ์ฌ์ฉํด์ ๋ก๊ทธ์ธ์ ์ํํ๋๋ก ์ ๊ณตํ๋ ๊ฑธ ๋งํฉ๋๋ค. ํจ์คํค๋ ๋ก๊ทธ์ธ ๋ฟ๋ง ์๋๋ผ ์๋ง์กด ์น ์๋น์ค์ ๊นํ๋ธ์ฒ๋ผ 2์ฐจ ์ธ์ฆ์ ์ํด์๋ ๋์ ๋ ์ ์์ต๋๋ค. ์ด ๊ธ์ ์์ฑํด๋ณด๋ ์ด์ ๋ ํจ์คํค ๋ก๊ทธ์ธ์ ๋ํ ํ๋ฆ์ ์๊ฐ๋ณด๋ค ๊ฐ๋จํ๋ฐ ํจ์คํค์ ๋ํ ํ์ค์์ ์ฌ์ฉ๋๋ ์ฉ์ด์ ๊ตฌํ ๊ณผ์ ์์์ ์ฌ๋ฌ๊ฐ์ง ์ด์๋ค์ด ๋ง์๋ณด์ด๊ธฐ ๋๋ฌธ์ ๋๋ค. ์ด์ ํจ์คํค ๋ก๊ทธ์ธ์ ๋ํด์ ์ ๋ฆฌํด๋ณด๋๋ก ํ์ฃ .
ํจ์คํค ๋ก๊ทธ์ธ์์ ๋ฑ๋ก๊ณผ ์ธ์ฆ์ ํ๋ฆ
ํจ์คํค ๋ก๊ทธ์ธ ์ ๋ฑ๋ก๊ณผ ์ธ์ฆ ๊ณผ์ ์ ์๊ฐ๋ณด๋ค ๊ฐ๋จํฉ๋๋ค. ์ฌ์ฉ์๋ ๋ณธ์ธ์ด ์ฌ์ฉํ ์ ์๋ ์ธ์ฆ ๊ธฐ๊ธฐ์ ๋ฐ๊ธํ ํจ์คํค ์ ๋ณด๋ฅผ RP ๋๋ฉ์ธ์ ๋ํ ํจ์คํค๋ฅผ ๋ฑ๋กํ๊ณ ๋ก๊ทธ์ธ ํ๋ฉด์์ ์์คํ ์์ ์ ๊ณตํ๋ ํจ์คํค ๋ก๊ทธ์ธ์ ์ฌ์ฉํด ์ธ์ฆ ๊ธฐ๊ธฐ์ ์กด์ฌํ๋ ํจ์คํค๋ฅผ ์ ํํ์ฌ ์ฌ์ฉ์ ๊ณ์ ์ ๋ํ ์ธ์ฆ์ ์์ฒญํ๊ณ ์น์ธ๋ฐ์ ๋ก๊ทธ์ธ์ ์๋ฃํฉ๋๋ค. ํจ์คํค๋ฅผ ๋ฑ๋กํ๊ณ ์ธ์ฆํ๋ ๊ณผ์ ์์ ์๋ฒ์์ ์ ๊ณต๋ฐ์ ์ฑ๋ฆฐ์ง์ ๊ณต๊ฐํค์ ๋ํ ์๋ช ์ ๋ณด๋ฅผ ์ ๊ณตํจ์ผ๋ก์จ ํจ์คํค๋ฅผ ์ฌ์ฉํ์ฌ ๋ก๊ทธ์ธ์ ์ง์ํ๋ ์๋ฒ์์๋ ์ฌ์ฉ์์ ๋ํ ์ ์์ ์์ ํ๊ฒ ์ฆ๋ช (Attestation) ๋๋ ๊ฒ์ฆ(Assertion)ํ ์ ์์ต๋๋ค.
ํจ์คํค ๊ด๋ จ ํ์ค์์ ๋ฑ๋ก(Registration)๊ณผ ์ธ์ฆ(Authentication)์ ๋ํ ๊ณผ์ ์ ๋จ์ํ ์ธ์ฆ ๊ณผ์ ์ด ์๋๋ผ ํ๋์ ์ค์ํ ์์์ด๋ผ๋ ๊ด์ ์ผ๋ก ์ธ๋ ๋ชจ๋(Ceremony) ๋ผ๊ณ ํํํฉ๋๋ค. ๋ฐ๋ผ์, ํจ์คํค์ ๋ํ ์ฌ๋ฌ๊ฐ์ง ์ ๋ณด๋ฅผ ์ฐธ๊ณ ํ ๋ ์ธ๋ ๋ชจ๋๋ผ๋ ๋จ์ด๊ฐ ๋์ค๋ฉด ๊ณผ์ ์ด๋ผ๊ณ ์ดํดํ์๋ฉด ๋ฉ๋๋ค.
ํจ์คํค ๋ฑ๋ก์ ์ํ ์๋น์ค ์ ๊ณต์
ํจ์คํค๋ฅผ ํตํด ์ ์์ ๊ด๋ฆฌํ๊ณ ๊ฒ์ฆํ๋ ์ฃผ์ฒด์ธ ์๋น์ค ์ ๊ณต์๋ ์ ๋ขฐ๋น์ฌ์(Relyting Party)๋ผ๊ณ ํฉ๋๋ค. RP๋ ๋๋ฉ์ธ์ ์๋ฏธํ๋ฉฐ ํจ์คํค ๋ฑ๋ก๊ณผ ์ธ์ฆ์ ์์ฒญํ ๋๋ง๋ค ์ฑ๋ฆฐ์ง(Challenge)๋ฅผ ๋ฐ์๊ฐ๋๋ก ์๊ตฌํฉ๋๋ค. RP์์ ์๋ตํ๋ ์ฑ๋ฆฐ์ง๋ ๋์๋ก ์ด๋ฃจ์ด์ง ๋ฐ์ดํธ ๋ฐฐ์ด๋ก ์์์๋ ์๋ณ์๋ก CSRF ํ ํฐ๊ณผ ๋น์ทํ ์ฉ๋๋ก ์ฌ์ฉ๋ฉ๋๋ค. ๋ํ, RP์ ๋ํ ์์ด๋๋ ๋๋ฉ์ธ์ผ๋ก ๊ตฌ์ฑ๋๊ธฐ ๋๋ฌธ์ ํด๋ผ์ด์ธํธ๋ก๋ถํฐ ์ ๋ฌ๋๋ ํจ์คํค์ ๋ํ ๊ณต๊ฐํค ์ธ์ฆ ์ ๋ณด๊ฐ ์ฌ๋ฐ๋ฅธ ์ถ์ฒ์ ์์์ ์ฝ๊ฒ ํ์ธํ ์ ์๊ฒ ๋ฉ๋๋ค. ๊ทธ๋ฌ๋ฉด ์๋ฐ ์ ํ๋ฆฌ์ผ์ด์ ์๋ฒ์์ RP๋ฅผ ๊ตฌ์ฑํด๋ณด๋๋ก ํ ๊น์?
dependencies {
implementation 'com.yubico:webauthn-server-core:2.5.2'
implementation 'com.yubico:webauthn-server-attestation:2.5.2'
implementation 'com.yubico:yubico-util:2.5.2'
}
๋จผ์ , ์๋ฐ ์ ํ๋ฆฌ์ผ์ด์ ์์ WebAuthn์ ๋ํ Relyting Party๋ฅผ ๊ตฌ์ฑํ๊ธฐ ์ํด์ java-webauthn-server ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์์กด์ฑ์ ์ถ๊ฐํด์ผํฉ๋๋ค. ๊ทธ๋ฆฌ๊ณ ์๋์ ๊ฐ์ด RelyingPartyIdentity๋ก RP ๋๋ฉ์ธ์ ๋ํ ์ ์์ ์ ์ํ๊ณ RelyingParty๋ฅผ ์ด๊ธฐํํ๋ฉด ๋ฉ๋๋ค. CredentialRepository ๋ ํจ์คํค ๋ฑ๋ก๊ณผ ์ธ์ฆ ๊ณผ์ ์์ ์ด๋ฏธ ๋ฑ๋ก๋ ํจ์คํค ์ ๋ณด๋ฅผ ์ ๊ณตํ๊ธฐ ์ํ ์ฉ๋๋ก ์ฌ์ฉ๋ฉ๋๋ค.
private RelyingParty generateRelyingParty(PasskeyCredentialRepository passkeyCredentialRepository) {
RelyingPartyIdentity rpID = RelyingPartyIdentity.builder()
.id("kdev.ing")
.name("Mambo App")
.build();
return RelyingParty.builder()
.identity(rpID)
.credentialRepository(passkeyCredentialRepository)
.allowOriginSubdomain(true)
.origins(Set.of("https://kdev.ing"))
.build();
}
๐ฅ SecurityError: The RP ID โmamboโ is invalid for this domain
SecurityError: The RP ID "mambo" is invalid for this domain
at identifyAuthenticationError (@simplewebauthn_browser.js?v=db6ca826:278:14)
at startAuthentication (@simplewebauthn_browser.js?v=db6ca826:325:11)
Caused by: DOMException: The relying party ID is not a registrable domain suffix of, nor equal to the current domain.
RP ์๋ฒ์ ๋ํ ์์ด๋๋ WebAuthn ํ์ค์ ๋ฐ๋ผ ๋ฐ๋์ ๋๋ฉ์ธ์ผ๋ก ๊ตฌ์ฑ๋์ด์ผํฉ๋๋ค. ๊ทธ๋ ์ง ์์ ๊ฒฝ์ฐ ์์ ๊ฐ์ด ํด๋ผ์ด์ธํธ ์ค๋ฅ๊ฐ ๋ฐ์ํ ์ ์์ต๋๋ค.
RelyingParty.generateChallenge
RelyingParty์ startRegistartion ๋ฅผ ํตํด ํจ์คํค ๋ฑ๋ก์ ๋ํ ์ฑ๋ฆฐ์ง์ ๊ณต๊ฐํค ์์ฑ ์ต์ ์ ํด๋ผ์ด์ธํธ์๊ฒ ์ ๊ณตํ ์ ์๋๋ฐ ์ด๋ ํฌํจ๋๋ ์ฑ๋ฆฐ์ง๋ RelyingParty์ 32๋ฐ์ดํธ๋ก ๊ตฌ์ฑ๋ ๋์๋ก ์ด๋ฃจ์ด์ง ๋ฐ์ดํธ ๋ฐฐ์ด๋ก ๊ตฌํ๋์ด์์ต๋๋ค.
private static final SecureRandom random = new SecureRandom();
private static ByteArray generateChallenge() {
byte[] bytes = new byte[32];
random.nextBytes(bytes);
return new ByteArray(bytes);
}
ํจ์คํค ๋ฑ๋ก์ ์ํ ์์ฑ ์ต์ ๋ฐ๊ธ
RelyingParty์ startRegistration ๋งค๊ฐ๋ณ์์ StartRegistrationOptions๋ฅผ ์ ๋ฌํจ์ผ๋ก์จ ํจ์คํค ๋ฑ๋ก ์ ์ฌ์ฉ๋ ์ฑ๋ฆฐ์ง์ ์์ฑ ์ต์ ์ธ PublicKeyCredentialCreationOptions๋ฅผ ๋ง๋ค ์ ์์ต๋๋ค. PublicKeyCredentialCreationOptions์ toCredentialsCreateJson ํจ์๋ RP ์๋ฒ์์ ํด๋ผ์ด์ธํธ๋ก ์๋ตํ ๋ ๋ฐ์ดํธ ๋ฐฐ์ด๋ก ์ด๋ฃจ์ด์ ธ์ผํ๋ ํญ๋ชฉ์ ๋ํด Base64URL์ ์ ์ฉํ JSON์ผ๋ก ๊ตฌ์ฑ๋๋๋ก ๊ตฌํ๋์ด์์ผ๋ฏ๋ก ์ฝ๊ฒ ์๋ตํ ์ ์์ต๋๋ค.
private byte[] createUserHandle() throws NoSuchAlgorithmException {
byte[] userHandle = new byte[32];
SecureRandom.getInstanceStrong().nextBytes(userHandle);
return userHandle;
}
@GetMapping("/registration/challenge")
public String startRegistration(HttpSession httpSession) throws JsonProcessingException {
UserIdentity userIdentity = UserIdentity.builder()
.name("mambo")
.displayName("Mambo")
.id(new ByteArray(createUserHandle()))
.build();
PublicKeyCredentialCreationOptions publicKeyCredentialCreationOptions = rp.startRegistration(
StartRegistrationOptions.builder()
.user(userIdentity)
.authenticatorSelection(AuthenticatorSelectionCriteria.builder()
.residentKey(ResidentKeyRequirement.REQUIRED)
.authenticatorAttachment(AuthenticatorAttachment.CROSS_PLATFORM)
.build())
.build());
httpSession.setAttribute("publicKeyCredentialCreationOptions", publicKeyCredentialCreationOptions.toJson());
return publicKeyCredentialCreationOptions.toCredentialsCreateJson();
}
UserIdentity์ id๋ ๊ณต๊ฐํค ์ธ์ฆ ์ ๋ณด์ ํฌํจ๋๋ User Handle๊ณผ ๋์ผํฉ๋๋ค. ํจ์คํค ํ์ค์์๋ ์ฌ์ฉ์ ๊ณ์ ์ ๋ํ ์๋ณ์๋ฅผ ๊ทธ๋๋ก ์ด์ฉํ์ง ์๊ณ ๋ณ๋๋ก ๋์๋ก ์ด๋ฃจ์ด์ง ์๋ณ์๋ฅผ ์ฌ์ฉํ๋๋ก ์๊ตฌํ๊ณ ์์ต๋๋ค. ์ฌ์ฉ์ ๊ณ์ ์ ๋ํด ์ ์ผํ ์๋ณ์๋ก ์์ฑํ๋ ๋ก์ง์ ๊ตฌํํ๋๋ก ํ์ธ์.
@simplewebauthn/browser.startRegistration
ํด๋ผ์ด์ธํธ์์๋ ์๋์ ๊ฐ์ด ํจ์คํค ๋ฑ๋ก์ ์ํด ์ฑ๋ฆฐ์ง๋ฅผ ์์ฒญํ๊ณ ๋ฐ์ ๊ฒฐ๊ณผ๋ฅผ startRegistartion์ ๋งค๊ฐ๋ณ์์ ์ ๋ฌํจ์ผ๋ก์จ ์ฌ์ฉ์์๊ฒ ํจ์คํค ๋ฑ๋ก์ ๋ํ ๋ํ์์๋ฅผ ํ์ํด์ฃผ๊ณ ์ ๋ฌ๋ฐ์ ๊ฒฐ๊ณผ๋ฅผ ์๋ฒ์ ์ ๋ฌํจ์ผ๋ก์จ ํจ์คํค ๋ฑ๋ก์ ์๋ฃํ๋ ๋ก์ง์ ๊ตฌํํ ์ ์์ต๋๋ค. ์๋์ ์ฝ๋๋ ์ด๋ฏธ ๋ฑ๋ก๋ ํจ์คํค์ ๋ํ ์ค๋ฅ์ ๋ํ ์์ธ ์ฒ๋ฆฌ๊ฐ ์์ผ๋ฏ๋ก ์ฌ๋ฌ๊ฐ์ง ์์ธ ์ํฉ์ ํ์ธํ๊ณ ์๋ง๊ฒ ์ฒ๋ฆฌํ๋๋ก ๊ตฌํํด์ผํฉ๋๋ค.
export const registerPasskey = async () => {
const challenge = await axios.get('/api/passkey/registration/challenge')
const registrationResponse = await startRegistration(challenge.data.publicKey)
const response = await axios.post('/api/passkey/registration/verify', registrationResponse)
}
ํจ์คํค ๊ฒ์ฆ ๋ฐ ๋ฑ๋ก
Relying Party์ finishRegistartion ํจ์๋ฅผ ํตํด ํด๋ผ์ด์ธํธ์์ ์ ๋ฌํ ํจ์คํค ์ ๋ณด๊ฐ ์ฌ๋ฐ๋ฅด๊ฒ ์๋ช ๋์ด์๋์ง๋ฅผ ๊ฒ์ฆํ๊ณ ์ ๋ฌ๋ฐ์ ์ ๋ณด์์ ์ผ๋ถ ํญ๋ชฉ์ ํจ์คํค์ ๋ํ ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์ ์ฅํ๋ฉด ๋ฉ๋๋ค.
@PostMapping("/registration/verify")
public boolean finishRegistration(HttpSession httpSession,
@RequestBody String credential) throws RegistrationFailedException, IOException {
String credentialsCreateJson = (String) httpSession.getAttribute("publicKeyCredentialCreationOptions");
httpSession.removeAttribute("publicKeyCredentialCreationOptions");
PublicKeyCredentialCreationOptions publicKeyCredentialCreationOptions =
PublicKeyCredentialCreationOptions.fromJson(credentialsCreateJson);
PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> publicKeyCredential =
PublicKeyCredential.parseRegistrationResponseJson(credential);
RegistrationResult result = relyingParty.finishRegistration(
FinishRegistrationOptions.builder()
.request(publicKeyCredentialCreationOptions)
.response(publicKeyCredential)
.build());
ByteArray aaguid = result.getAaguid();
ByteArray credentialId = result.getKeyId().getId();
ByteArray userHandle = publicKeyCredentialCreationOptions.getUser().getId();
ByteArray publicKey = result.getPublicKeyCose();
String[] transports = publicKeyCredential.getResponse().getTransports().stream().map(AuthenticatorTransport::getId).toArray(String[]::new);
long signatureCounter = publicKeyCredential.getResponse().getAttestation().getAuthenticatorData().getSignatureCounter();
Authenticator authenticator = new Authenticator()
.setCredentialId(credentialId.getBase64Url())
.setUserHandle(userHandle.getBase64Url())
.setAaguid(aaguid.getBase64Url())
.setPublicKey(publicKey.getBase64Url())
.setSignatureCount(signatureCounter)
.setTransports(transports)
.setNickname("")
.setLastAccessTime(null)
.setCreatedAt(OffsetDateTime.now());
return authenticatorRepository.save(authenticator);
}
๊ณ์ ์ค์ ๋ด ํจ์คํค ํ์
ํจ์คํค ๋ฑ๋ก ์ ์๋ฒ์ ์ ์ฅ๋ ์ ๋ณด์๋ ์ฌ์ฉ์ ์นํ์ ์ผ๋ก ํ์ํ ์ ์๋ ์ ๋ณด๊ฐ ํฌํจ๋์ด์์ง ์์ต๋๋ค. ํจ์คํค๋ฅผ ๋ง๋๋ ๋ฐ ์ฌ์ฉํ ์ ๋ณด๋ ๊ณ์ ์ค์ ๋ด ํจ์คํค ์นด๋ ํ์์ ๊ฐ์ ์ ๋ณด๋ค์ ์ฐธ๊ณ ํ์ฌ ํจ์คํค์ ๋ํด ์๋์ ๊ฐ์ ํญ๋ชฉ์ด ํฌํจ๋๋๋ก ์ค๊ณํ๋ ๊ฒ์ด ์ข์ต๋๋ค.
- ํจ์คํค์ ๋ํด ํ์ํ ์ด๋ฆ (Nickname)
- ํจ์คํค๊ฐ ๋ง์ง๋ง์ผ๋ก ์ฌ์ฉ๋ ์๊ฐ (Last Access Time)
- ํจ์คํค ์ธ์ฆ ๊ธฐ๊ธฐ ์ ํ (AAGUID)
ํจ์คํค์ ๋ํด ํ์ํ ์ด๋ฆ์ ์ค์ ํ๋ ๊ฑด ํ์ ์ฌํญ์ ์๋์ง๋ง ๊นํ๋ธ์์์ ํจ์คํค ๊ด๋ฆฌ ์ ์ด๋ฆ์ ์ค์ ํ ์ ์๊ฒ ์ ๊ณตํ๋๋ฐ ์ฌ์ฉ์ ์ค์ค๋ก ๊ตฌ๋ถํ ์ ์๊ฒ ์ง์ํด์ค ์ ์๋ ๊ฒ ๊ฐ์ต๋๋ค. ํจ์คํค ์ธ์ฆ ๊ธฐ๊ธฐ ์ ๋ณด์ ๊ฒฝ์ฐ AttestedCredentialData์ ํฌํจ๋ AAGUID๋ฅผ ํตํด ์ธ์ฆ ๊ธฐ๊ธฐ๋ฅผ ์ ๊ณตํ๋ ์ ์ฒด์ ์๋ณํ ์ ์๋๋ฐ AAGUID๋ ์ผ๋ฐ์ ์ผ๋ก ์ฌ๋์ด ์ธ์งํ ์ ์๋ ๋ฌธ์์ด์ ์๋๋ฏ๋ก AAGUID ์ ์ฅ์์ aaguid.json ์ธ์ฆ ์ฅ์น์ ์ ํ์ ๋ํด์ ์ด๋ฆ์ด๋ ์์ด์ฝ์ ํ์ํ ์ ์๊ธฐ๋ ํฉ๋๋ค.
๋ฑ๋ก๋ ํจ์คํค ์ญ์
์ ํ๋ฉด์ ๊นํ๋ธ์์ ์ฌ์ฉ์ ๊ณ์ ์ ๋ฑ๋กํ๋ ํจ์คํค๋ฅผ ์ญ์ ํ๋ ค๊ณ ํ์๋ ์ ๊ณต๋๋ ๋ฉ์์ง์ ๋๋ค. ํจ์คํค ๊ด๋ฆฌ ์ ๋ฑ๋กํ ํจ์คํค๋ฅผ ์ญ์ ํ๋ฉด ๋๊ฒ ์ง๋ง ๊ณ ๋ คํด์ผํ ๋ถ๋ถ์ด ์์ต๋๋ค. ์๋ฒ์ ๋ฑ๋ก๋ ํจ์คํค๋ฅผ ์ญ์ ํ๋ค๊ณ ํด์ ์ฌ์ฉ์๊ฐ ์ฌ์ฉํ๋ ์ธ์ฆ ๊ธฐ๊ธฐ์ ํจ์คํค ๋ฑ๋ก ์ ๋ณด๋ ์ญ์ ๋์ง ์๋๋ค๋ ๊ฒ์ ๋๋ค. ๋ํ, ์ฌ์ฉ์๊ฐ ์ธ์ฆ ๊ธฐ๊ธฐ์ ํจ์คํค๋ฅผ ์ญ์ ํด๋ฒ๋ฆฌ๋ ๊ฒฝ์ฐ ์ฌ์ฉ์ ๊ณ์ ์ ๋ฑ๋ก๋ ํจ์คํค๋ ๋ฌด์ฉ์ง๋ฌผ์ด ๋๋ฉฐ ๋ถํ์ํ๊ฒ ํจ์คํค ๋ฑ๋ก ์ ๋ณด๋ก ๋จ์์๊ฒ ๋์ด๋ฒ๋ฆฝ๋๋ค.
ํจ์คํค ๋ฑ๋ก์ ์ง์ํ๋ ์ธ์ฆ ๊ธฐ๊ธฐ๋ณ๋ก ์์ธํ ํจ์คํค ์ญ์ ๊ฐ์ด๋๋ฅผ ์ ๊ณตํ ์ ์์ผ๋ฏ๋ก ์ฌ์ฉ์ ๊ณ์ ์์ ํจ์คํค๋ฅผ ์ญ์ ํ๋๋ผ๋ ํจ์คํค ๋ก๊ทธ์ธ ์์๋ ํจ์คํค ์ต์ ์ผ๋ก ํ์๋ ์ ์์์ ์๋ ค์ฃผ๊ณ ์๋ ๊ฒ ๊ฐ์ต๋๋ค. ๋ค์์ ์์ฃผ ์ฌ์ฉ๋ ๋งํ ์ธ์ฆ ๊ธฐ๊ธฐ์์์ ํจ์คํค ์ญ์ ๋ฐฉ๋ฒ์ด๋ฏ๋ก ์ฐธ๊ณ ํด๋ณด์ธ์.
- Google Passsword Manager: ๋ธ๋ผ์ฐ์ URL์ chrome://settings/passkeys ์ ๋ ฅ
- iCloud Keychain: Mac ๋ฐ iCloud ํค์ฒด์ธ์์ ํจ์คํค ๋๋ ์ํธ ์ ๊ฑฐํ๊ธฐ
- Chrome Profile: Chrome์์ ํจ์คํค ๊ด๋ฆฌํ๊ธฐ
- Samsung Pass: Samsung Wallet โ Samsung Pass โ ๋ก๊ทธ์ธ ์ ๋ณด โ ํจ์คํค
ํจ์คํค ์ธ์ฆ ์์ฒญ์ ๋ํ ์ต์ ๋ฐ๊ธ
Relying Party์ startAssertion ํจ์๋ฅผ ํตํด ์ฌ์ฉ์ ๊ณ์ฉก์ ๋ฑ๋ก๋ ํจ์คํค์ ๋ํด ๋ก๊ทธ์ธํ ์ ์๋๋ก ์ฑ๋ฆฐ์ง๋ฅผ ๋ฐ๊ธํ ์ ์์ต๋๋ค. ์ด๋, ๋งค๊ฐ๋ณ์๋ก ์ ๋ฌํ StartAssertionOptions์ ๊ตฌ์ฑํ ๋ ํด๋ผ์ด์ธํธ๋ ์ฌ์ฉ์๊ฐ ์ ๋ ฅํ ์์ด๋๋ฅผ ์์ฒญ ์ ์ ๊ณตํ๋ค๋ฉด ํน์ ์ฌ์ฉ์ ๊ณ์ ์ ๋ํ ๊ณต๊ฐํค๋ง ์กฐํํ์ฌ ํฌํจ์ํฌ ์ ์์ต๋๋ค.
@GetMapping("/authentication/challenge")
public String startAssertion(HttpSession httpSession,
@RequestParam(required = false) String username) throws JsonProcessingException {
StartAssertionOptions.StartAssertionOptionsBuilder builder = StartAssertionOptions.builder()
.timeout(Duration.ofMinutes(3).toMillis());
if (username != null && !username.trim().isEmpty()) {
builder.username(username);
}
AssertionRequest assertionRequest = relyingParty.startAssertion(builder.build());
httpSession.setAttribute("assertionRequest", assertionRequest.toJson());
return assertionRequest.toCredentialsGetJson();
}
ํจ์คํค ๊ฒ์ฆ ๋ฐ ์ธ์ฆ
Relying Party์ finishAssertion ํจ์๋ฅผ ํตํด ํด๋ผ์ด์ธํธ์์ ์ ๋ฌํ ํจ์คํค ์ธ์ฆ ์ ๋ณด๋ฅผ ์ ๋ขฐํ ์ ์๋์ง ํ์ธํ ์ ์์ต๋๋ค. ์ธ์ฆ ์ ๋ณด์ ํฌํจ๋ credentailId์ userHandle์ ํ ๋๋ก ์ฌ์ฉ์ ๊ณ์ ์ ๋ํด ๋ก๊ทธ์ธ์ ์ฒ๋ฆฌํ๋ฉด ๋ฉ๋๋ค.
import axios from 'axios';
import { startAuthentication } from '@simplewebauthn/browser';
export const passkeyLogin = async () => {
const challenge = await axios.get('/api/passkey/authentication/challenge')
const authenticationResponse = await startAuthentication(challenge.data.publicKey)
const response = await axios.post('/api/passkey/authentication/verify', authenticationResponse)
}
@PostMapping("/authentication/verify")
public Object finishAssertion(HttpSession httpSession,
@RequestBody String credential) throws IOException, AssertionFailedException {
String assertionRequestJson = (String) httpSession.getAttribute("assertionRequest");
AssertionRequest assertionRequest = AssertionRequest.fromJson(assertionRequestJson);
PublicKeyCredential<AuthenticatorAssertionResponse, ClientAssertionExtensionOutputs> publicKeyCredential
= PublicKeyCredential.parseAssertionResponseJson(credential);
AssertionResult result = relyingParty.finishAssertion(FinishAssertionOptions.builder()
.request(assertionRequest)
.response(publicKeyCredential)
.build());
if (result.isSuccess()) {
// todo: process authenticate
String username = result.getUsername();
ByteArray credentialId = result.getCredential().getCredentialId();
ByteArray userHandle = result.getCredential().getUserHandle();
return true;
}
return false;
}
์ฌ์ฉ์ ์์ด๋ ํ๋์ ์๋ ์์ฑ UI ํ์
ํจ์คํค ์ธ์ฆ์ ์ํด startAuthentication๋ฅผ ํธ์ถํ ๋ useBrowserAutofill ๋งค๊ฐ๋ณ์๋ฅผ ์ค์ ํ๋ ๊ฒฝ์ฐ mediation ์ต์ ์ด conditional๋ก ์ง์ ๋๋ฉด์ ์ฌ์ฉ์์๊ฒ ๋ฐ๋ก ํจ์คํค ์ ํ์ ์๊ตฌํ๋ ๋ํ์์๋ฅผ ํ์ํ์ง ์๊ณ ๋ธ๋ผ์ฐ์ ๋ง๋ค ๊ตฌํ๋ ๋ฐฉ์์ผ๋ก ์๋ ์์ฑ(์กฐ๊ฑด๋ถ UI)์ด ํ์๋ฉ๋๋ค. ํจ์คํค ์๋ ์์ฑ์ ์ํด ํธ์ถํ๋ค๋ฉด ์ฌ์ฉ์ ๊ณ์ ์ ์ ๋ ฅํ๋ ํ๋์ autocomplete ์์ฑ์ webauthn ์ ํฌํจ์ํค๋ฉด ๋ฉ๋๋ค.
// Login.vue
onMounted(async () => {
await processAutofill()
})
// passkey.js
import {browserSupportsWebAuthnAutofill} from '@simplewebauthn/browser';
export const processAutofill = async () => {
if (await browserSupportsWebAuthnAutofill()) {
const challenge = await axios.get('/api/passkey/authentication/challenge')
const authenticationResponse = await startAuthentication(challenge.data.publicKey, true)
const response = await axios.post('/api/passkey/authentication/verify', authenticationResponse)
}
}
์กฐ๊ฑด๋ถ UI๋ก ์ฌ์ฉ์์๊ฒ ์๋ ์์ฑ์ ์ ๊ณตํ ๋ ํจ์คํค์ ๋ํ ํ๋ก๋ฏธ์ค๊ฐ ๊ณ์ ๋๊ธฐํ๋ ์ํ๋ก ์งํํ๊ณ ์๋๋ฐ์. ๊ธฐ์กด์ ์งํ์ค์ธ ํจ์คํค๋ฅผ ์ทจ์ํ ์ ์๋๋ก SimpleWebAuthn์ startAuthentication์ ๋ด๋ถ์ ์ผ๋ก ๊ตฌํ๋์ด์์ผ๋ฏ๋ก ์ ๊ฒฝ ์ธ ํ์๋ ์์ต๋๋ค.
๋ณธ ๊ธ์ ์์ฑํ๊ธฐ ์ํด์ ์๋์ ๊ฐ์ ์ ๋ณด๋ค์ ์ฐธ๊ณ ํ์ต๋๋ค.