ํŒจ์Šคํ‚ค ๋กœ๊ทธ์ธ์€ ์‚ฌ์šฉ์ž ๊ณ„์ •์— ๋Œ€ํ•œ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•˜์ง€ ์•Š๊ณ  ์‚ฌ์šฉ์ž ๊ณ„์ •์— ๋Œ€ํ•ด ์ง€๋ฌธ์ด๋‚˜ ์–ผ๊ตด ์ธ์‹ ๊ทธ๋ฆฌ๊ณ  ๋ณด์•ˆํ‚ค ๋“ฑ์„ ์‚ฌ์šฉํ•ด์„œ ๋ฏธ๋ฆฌ ์ธ์ฆ ์ •๋ณด๋ฅผ ๋“ฑ๋กํ•ด๋†“๊ณ  ์‚ฌ์šฉ์ž์˜ ์ธ์ฆ ๊ธฐ๊ธฐ๋ฅผ ์‚ฌ์šฉํ•ด์„œ ๋กœ๊ทธ์ธ์„ ์ˆ˜ํ–‰ํ•˜๋„๋ก ์ œ๊ณตํ•˜๋Š” ๊ฑธ ๋งํ•ฉ๋‹ˆ๋‹ค. ํŒจ์Šคํ‚ค๋Š” ๋กœ๊ทธ์ธ ๋ฟ๋งŒ ์•„๋‹ˆ๋ผ ์•„๋งˆ์กด ์›น ์„œ๋น„์Šค์™€ ๊นƒํ—ˆ๋ธŒ์ฒ˜๋Ÿผ 2์ฐจ ์ธ์ฆ์„ ์œ„ํ•ด์„œ๋„ ๋„์ž…๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ๊ธ€์„ ์ž‘์„ฑํ•ด๋ณด๋Š” ์ด์œ ๋Š” ํŒจ์Šคํ‚ค ๋กœ๊ทธ์ธ์— ๋Œ€ํ•œ ํ๋ฆ„์€ ์ƒ๊ฐ๋ณด๋‹ค ๊ฐ„๋‹จํ•œ๋ฐ ํŒจ์Šคํ‚ค์— ๋Œ€ํ•œ ํ‘œ์ค€์—์„œ ์‚ฌ์šฉ๋˜๋Š” ์šฉ์–ด์™€ ๊ตฌํ˜„ ๊ณผ์ •์—์„œ์˜ ์—ฌ๋Ÿฌ๊ฐ€์ง€ ์ด์Šˆ๋“ค์ด ๋งŽ์•„๋ณด์ด๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ์ด์ œ ํŒจ์Šคํ‚ค ๋กœ๊ทธ์ธ์— ๋Œ€ํ•ด์„œ ์ •๋ฆฌํ•ด๋ณด๋„๋ก ํ•˜์ฃ .

ํŒจ์Šคํ‚ค ๋กœ๊ทธ์ธ์—์„œ ๋“ฑ๋ก๊ณผ ์ธ์ฆ์˜ ํ๋ฆ„

ํŒจ์Šคํ‚ค ๋กœ๊ทธ์ธ ์‹œ ๋“ฑ๋ก๊ณผ ์ธ์ฆ ๊ณผ์ •์€ ์ƒ๊ฐ๋ณด๋‹ค ๊ฐ„๋‹จํ•ฉ๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž๋Š” ๋ณธ์ธ์ด ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ์ธ์ฆ ๊ธฐ๊ธฐ์— ๋ฐœ๊ธ‰ํ•œ ํŒจ์Šคํ‚ค ์ •๋ณด๋ฅผ RP ๋„๋ฉ”์ธ์— ๋Œ€ํ•œ ํŒจ์Šคํ‚ค๋ฅผ ๋“ฑ๋กํ•˜๊ณ  ๋กœ๊ทธ์ธ ํ™”๋ฉด์—์„œ ์‹œ์Šคํ…œ์—์„œ ์ œ๊ณตํ•˜๋Š” ํŒจ์Šคํ‚ค ๋กœ๊ทธ์ธ์„ ์‚ฌ์šฉํ•ด ์ธ์ฆ ๊ธฐ๊ธฐ์— ์กด์žฌํ•˜๋Š” ํŒจ์Šคํ‚ค๋ฅผ ์„ ํƒํ•˜์—ฌ ์‚ฌ์šฉ์ž ๊ณ„์ •์— ๋Œ€ํ•œ ์ธ์ฆ์„ ์š”์ฒญํ•˜๊ณ  ์Šน์ธ๋ฐ›์•„ ๋กœ๊ทธ์ธ์„ ์™„๋ฃŒํ•ฉ๋‹ˆ๋‹ค. ํŒจ์Šคํ‚ค๋ฅผ ๋“ฑ๋กํ•˜๊ณ  ์ธ์ฆํ•˜๋Š” ๊ณผ์ •์—์„œ ์„œ๋ฒ„์—์„œ ์ œ๊ณต๋ฐ›์€ ์ฑŒ๋ฆฐ์ง€์™€ ๊ณต๊ฐœํ‚ค์— ๋Œ€ํ•œ ์„œ๋ช… ์ •๋ณด๋ฅผ ์ œ๊ณตํ•จ์œผ๋กœ์จ ํŒจ์Šคํ‚ค๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋กœ๊ทธ์ธ์„ ์ง€์›ํ•˜๋Š” ์„œ๋ฒ„์—์„œ๋Š” ์‚ฌ์šฉ์ž์— ๋Œ€ํ•œ ์‹ ์›์„ ์•ˆ์ „ํ•˜๊ฒŒ ์ฆ๋ช…(Attestation) ๋˜๋Š” ๊ฒ€์ฆ(Assertion)ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํŒจ์Šคํ‚ค ๊ด€๋ จ ํ‘œ์ค€์—์„œ ๋“ฑ๋ก(Registration)๊ณผ ์ธ์ฆ(Authentication)์— ๋Œ€ํ•œ ๊ณผ์ •์€ ๋‹จ์ˆœํ•œ ์ธ์ฆ ๊ณผ์ •์ด ์•„๋‹ˆ๋ผ ํ•˜๋‚˜์˜ ์ค‘์š”ํ•œ ์˜์‹์ด๋ผ๋Š” ๊ด€์ ์œผ๋กœ ์„ธ๋ ˆ๋ชจ๋‹ˆ(Ceremony) ๋ผ๊ณ  ํ‘œํ˜„ํ•ฉ๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ, ํŒจ์Šคํ‚ค์— ๋Œ€ํ•œ ์—ฌ๋Ÿฌ๊ฐ€์ง€ ์ •๋ณด๋ฅผ ์ฐธ๊ณ ํ•  ๋•Œ ์„ธ๋ ˆ๋ชจ๋‹ˆ๋ผ๋Š” ๋‹จ์–ด๊ฐ€ ๋‚˜์˜ค๋ฉด ๊ณผ์ •์ด๋ผ๊ณ  ์ดํ•ดํ•˜์‹œ๋ฉด ๋ฉ๋‹ˆ๋‹ค.

ํŒจ์Šคํ‚ค ๋“ฑ๋ก์„ ์œ„ํ•œ ์„œ๋น„์Šค ์ œ๊ณต์ž

ํŒจ์Šคํ‚ค๋ฅผ ํ†ตํ•ด ์‹ ์›์„ ๊ด€๋ฆฌํ•˜๊ณ  ๊ฒ€์ฆํ•˜๋Š” ์ฃผ์ฒด์ธ ์„œ๋น„์Šค ์ œ๊ณต์ž๋Š” ์‹ ๋ขฐ๋‹น์‚ฌ์ž(Relyting Party)๋ผ๊ณ  ํ•ฉ๋‹ˆ๋‹ค. RP๋Š” ๋„๋ฉ”์ธ์„ ์˜๋ฏธํ•˜๋ฉฐ ํŒจ์Šคํ‚ค ๋“ฑ๋ก๊ณผ ์ธ์ฆ์„ ์š”์ฒญํ• ๋•Œ๋งˆ๋‹ค ์ฑŒ๋ฆฐ์ง€(Challenge)๋ฅผ ๋ฐ›์•„๊ฐ€๋„๋ก ์š”๊ตฌํ•ฉ๋‹ˆ๋‹ค. RP์—์„œ ์‘๋‹ตํ•˜๋Š” ์ฑŒ๋ฆฐ์ง€๋Š” ๋‚œ์ˆ˜๋กœ ์ด๋ฃจ์–ด์ง„ ๋ฐ”์ดํŠธ ๋ฐฐ์—ด๋กœ ์•Œ์ˆ˜์—†๋Š” ์‹๋ณ„์ž๋กœ CSRF ํ† ํฐ๊ณผ ๋น„์Šทํ•œ ์šฉ๋„๋กœ ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค. ๋˜ํ•œ, RP์— ๋Œ€ํ•œ ์•„์ด๋””๋Š” ๋„๋ฉ”์ธ์œผ๋กœ ๊ตฌ์„ฑ๋˜๊ธฐ ๋•Œ๋ฌธ์— ํด๋ผ์ด์–ธํŠธ๋กœ๋ถ€ํ„ฐ ์ „๋‹ฌ๋˜๋Š” ํŒจ์Šคํ‚ค์— ๋Œ€ํ•œ ๊ณต๊ฐœํ‚ค ์ธ์ฆ ์ •๋ณด๊ฐ€ ์˜ฌ๋ฐ”๋ฅธ ์ถœ์ฒ˜์— ์žˆ์Œ์„ ์‰ฝ๊ฒŒ ํ™•์ธํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. ๊ทธ๋Ÿฌ๋ฉด ์ž๋ฐ” ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„œ๋ฒ„์—์„œ RP๋ฅผ ๊ตฌ์„ฑํ•ด๋ณด๋„๋ก ํ• ๊นŒ์š”?

build.gradle
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๋ฐ”์ดํŠธ๋กœ ๊ตฌ์„ฑ๋œ ๋‚œ์ˆ˜๋กœ ์ด๋ฃจ์–ด์ง„ ๋ฐ”์ดํŠธ ๋ฐฐ์—ด๋กœ ๊ตฌํ˜„๋˜์–ด์žˆ์Šต๋‹ˆ๋‹ค.

RelyingParty.java
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 ์ธ์ฆ ์žฅ์น˜์˜ ์œ ํ˜•์— ๋Œ€ํ•ด์„œ ์ด๋ฆ„์ด๋‚˜ ์•„์ด์ฝ˜์„ ํ‘œ์‹œํ•  ์ˆ˜ ์žˆ๊ธฐ๋„ ํ•ฉ๋‹ˆ๋‹ค.

๋“ฑ๋ก๋œ ํŒจ์Šคํ‚ค ์‚ญ์ œ

์œ„ ํ™”๋ฉด์€ ๊นƒํ—ˆ๋ธŒ์—์„œ ์‚ฌ์šฉ์ž ๊ณ„์ •์— ๋“ฑ๋กํ–ˆ๋˜ ํŒจ์Šคํ‚ค๋ฅผ ์‚ญ์ œํ•˜๋ ค๊ณ  ํ–ˆ์„๋•Œ ์ œ๊ณต๋˜๋Š” ๋ฉ”์‹œ์ง€์ž…๋‹ˆ๋‹ค. ํŒจ์Šคํ‚ค ๊ด€๋ฆฌ ์‹œ ๋“ฑ๋กํ•œ ํŒจ์Šคํ‚ค๋ฅผ ์‚ญ์ œํ•˜๋ฉด ๋˜๊ฒ ์ง€๋งŒ ๊ณ ๋ คํ•ด์•ผํ•  ๋ถ€๋ถ„์ด ์žˆ์Šต๋‹ˆ๋‹ค. ์„œ๋ฒ„์— ๋“ฑ๋ก๋œ ํŒจ์Šคํ‚ค๋ฅผ ์‚ญ์ œํ•œ๋‹ค๊ณ ํ•ด์„œ ์‚ฌ์šฉ์ž๊ฐ€ ์‚ฌ์šฉํ–ˆ๋˜ ์ธ์ฆ ๊ธฐ๊ธฐ์˜ ํŒจ์Šคํ‚ค ๋“ฑ๋ก ์ •๋ณด๋Š” ์‚ญ์ œ๋˜์ง€ ์•Š๋Š”๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. ๋˜ํ•œ, ์‚ฌ์šฉ์ž๊ฐ€ ์ธ์ฆ ๊ธฐ๊ธฐ์˜ ํŒจ์Šคํ‚ค๋ฅผ ์‚ญ์ œํ•ด๋ฒ„๋ฆฌ๋Š” ๊ฒฝ์šฐ ์‚ฌ์šฉ์ž ๊ณ„์ •์— ๋“ฑ๋ก๋œ ํŒจ์Šคํ‚ค๋Š” ๋ฌด์šฉ์ง€๋ฌผ์ด ๋˜๋ฉฐ ๋ถˆํ•„์š”ํ•˜๊ฒŒ ํŒจ์Šคํ‚ค ๋“ฑ๋ก ์ •๋ณด๋กœ ๋‚จ์•„์žˆ๊ฒŒ ๋˜์–ด๋ฒ„๋ฆฝ๋‹ˆ๋‹ค.

ํŒจ์Šคํ‚ค ๋“ฑ๋ก์„ ์ง€์›ํ•˜๋Š” ์ธ์ฆ ๊ธฐ๊ธฐ๋ณ„๋กœ ์ž์„ธํ•œ ํŒจ์Šคํ‚ค ์‚ญ์ œ ๊ฐ€์ด๋“œ๋ฅผ ์ œ๊ณตํ•  ์ˆœ ์—†์œผ๋ฏ€๋กœ ์‚ฌ์šฉ์ž ๊ณ„์ •์—์„œ ํŒจ์Šคํ‚ค๋ฅผ ์‚ญ์ œํ•˜๋”๋ผ๋„ ํŒจ์Šคํ‚ค ๋กœ๊ทธ์ธ ์‹œ์—๋Š” ํŒจ์Šคํ‚ค ์˜ต์…˜์œผ๋กœ ํ‘œ์‹œ๋  ์ˆ˜ ์žˆ์Œ์„ ์•Œ๋ ค์ฃผ๊ณ  ์žˆ๋Š” ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค. ๋‹ค์Œ์€ ์ž์ฃผ ์‚ฌ์šฉ๋ ๋งŒํ•œ ์ธ์ฆ ๊ธฐ๊ธฐ์—์„œ์˜ ํŒจ์Šคํ‚ค ์‚ญ์ œ ๋ฐฉ๋ฒ•์ด๋ฏ€๋กœ ์ฐธ๊ณ ํ•ด๋ณด์„ธ์š”.

ํŒจ์Šคํ‚ค ์ธ์ฆ ์š”์ฒญ์— ๋Œ€ํ•œ ์˜ต์…˜ ๋ฐœ๊ธ‰

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์€ ๋‚ด๋ถ€์ ์œผ๋กœ ๊ตฌํ˜„๋˜์–ด์žˆ์œผ๋ฏ€๋กœ ์‹ ๊ฒฝ ์“ธ ํ•„์š”๋Š” ์—†์Šต๋‹ˆ๋‹ค.


๋ณธ ๊ธ€์„ ์ž‘์„ฑํ•˜๊ธฐ ์œ„ํ•ด์„œ ์•„๋ž˜์™€ ๊ฐ™์€ ์ •๋ณด๋“ค์„ ์ฐธ๊ณ ํ–ˆ์Šต๋‹ˆ๋‹ค.