2024/07/22

JAX-RS Jersey in Tomcat

在 java 開發 RESTful Web service 的方式,除了比較常見的 Spring MVC 以外,還有一個隸屬於 JSR-370 規格的 JAX-RS API,這份規格定義了在 java web container 裡面,應該提供什麼 API 介面輔助開發者開發 Representational State Transfer (REST) web service。Eclipse Jersey 則是實作了 JAX-RS API 的一組 library,以下記錄如何在 tomcat 10 裡面使用 Jersey

pom.xml

以下定義了 Maven POM xml,裡面主要引用了 jakarta.ws.rs-api JAX-RS API,以及三個 Jersey 實作的 libary,另外還用了 log4j2

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.maxkit</groupId>
    <artifactId>testweb</artifactId>
    <packaging>war</packaging>
    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
    </properties>
    <version>1.0</version>
    <name>testweb</name>
    <url>http://maven.apache.org</url>
    <dependencies>
        <!--junit5-->
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>5.10.1</version>
        </dependency>

        <!-- log4j2 + slf4j -->
        <dependency>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-api</artifactId>
            <version>2.22.1</version>
        </dependency>
        <dependency>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-core</artifactId>
            <version>2.22.1</version>
        </dependency>
        <dependency>
            <groupId>org.apache.logging.log4j</groupId>
            <artifactId>log4j-slf4j-impl</artifactId>
            <version>2.22.1</version>
        </dependency>

        <!-- the REST API -->
        <dependency>
            <groupId>jakarta.ws.rs</groupId>
            <artifactId>jakarta.ws.rs-api</artifactId>
            <version>3.1.0</version>
        </dependency>
        <!-- These next two are needed because we are using Tomcat, which is not a full JEE server - it's just a servlet container. -->

        <!-- the Jersey implementation -->
        <dependency>
            <groupId>org.glassfish.jersey.containers</groupId>
            <artifactId>jersey-container-servlet</artifactId>
            <version>3.1.5</version>
        </dependency>

        <!-- also needed for dependency injection -->
        <dependency>
            <groupId>org.glassfish.jersey.inject</groupId>
            <artifactId>jersey-hk2</artifactId>
            <version>3.1.5</version>
        </dependency>

        <!-- support for using Jackson (JSON) with Jersey -->
        <dependency>
            <groupId>org.glassfish.jersey.media</groupId>
            <artifactId>jersey-media-json-jackson</artifactId>
            <version>3.1.5</version>
        </dependency>

    </dependencies>
    <build>
        <finalName>testweb</finalName>
        <pluginManagement>
            <plugins>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-compiler-plugin</artifactId>
                    <version>3.12.1</version>
                    <configuration>
                        <source>${maven.compiler.source}</source>
                        <target>${maven.compiler.target}</target>
                        <encoding>${project.build.sourceEncoding}</encoding>
                    </configuration>
                </plugin>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-resources-plugin</artifactId>
                    <version>3.3.1</version>
                    <configuration>
                        <encoding>${project.build.sourceEncoding}</encoding>
                    </configuration>
                </plugin>
            </plugins>
        </pluginManagement>
    </build>
</project>

application

有兩種方式定義 application,一種是在 web.xml,一種是直接寫在 Java code 裡面,以 annotation 定義

方法1 web.xml

以下用 org.glassfish.jersey.servlet.ServletContainer 定義了一個 JerseyRestServlet,並對應到 /rest/* 這個 url-pattern。

jersey.config.server.provider.packages 則是這個 application 會使用到的 web service 實作的 java classes 會放在哪一個 java package 裡面

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xmlns="http://java.sun.com/xml/ns/javaee" xmlns:web="http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
         xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
         id="testweb" version="3.0">
    <display-name>testweb</display-name>

    <servlet>
        <servlet-name>JerseyRestServlet</servlet-name>
        <servlet-class>org.glassfish.jersey.servlet.ServletContainer</servlet-class>
        <init-param>
            <param-name>jersey.config.server.provider.packages</param-name>
            <param-value>com.maxkit.testweb.jersey.rest</param-value>
        </init-param>
        <init-param>
            <param-name>jersey.config.server.provider.scanning.recursive</param-name>
            <param-value>true</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>
    <servlet-mapping>
        <servlet-name>JerseyRestServlet</servlet-name>
        <url-pattern>/rest/*</url-pattern>
    </servlet-mapping>

    <security-constraint>
        <web-resource-collection>
            <web-resource-name>Forbidden</web-resource-name>
            <url-pattern>/WEB-INF/*</url-pattern>
        </web-resource-collection>
        <auth-constraint />
    </security-constraint>
</web-app>

方法2 Annotation

透過 @ApplicationPath 這個 annotation,tomcat 在啟動時,會自動掃描,並定義一個新的 /rest2/* url-pattern 的 application。

這個 application 會使用到的 web service 實作的 java classes 會放在哪一個 java package 裡面,是用 packages() 定義

package com.maxkit.testweb.jersey;

import jakarta.ws.rs.ApplicationPath;
import org.glassfish.jersey.server.ResourceConfig;

@ApplicationPath("rest2")
public class Rest2Application extends ResourceConfig {

    public Rest2Application() {
        packages("com.maxkit.testweb.jersey.rest2");
    }
}

web service

JAX-RS 定義了一些 annotation,標註為 web service

  • @Path

    url method 的相對路徑

  • @GET,@PUT,@POST,@DELETE

    使用哪一個 http method

  • @Consumes

    可接受的 request 裡面資料的 MIME type

  • @Produces

    回傳的資料的 MIME type

  • @PathParam,@QueryParam,@HeaderParam,@CookieParam,@MatrixParam,@FormParam

    參數的來源,@PathParam來自於URL的路徑,@QueryParam來自於URL的查詢參數,@HeaderParam來自於HTTP請求的頭信息,@CookieParam來自於HTTP請求的Cookie

@Path("/demo") 的部分,定義了對應的url,以 ping method 為例,要呼叫 ping,可使用這個 url: http://localhost:8080/testweb/rest/demo/ping

getNotification, postNotification 是測試要再回傳的資料中,以 JSON 格式回傳

login 裡面有用到 Cookie,因為 Jersey 的 Cookie 沒有 maxAge 的功能,這邊加上 maxAge,在 response 透過 Response.ResponseBuilder 設定 cookie

package com.maxkit.testweb.jersey.rest;

import com.maxkit.testweb.jersey.JerseyCookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Date;
import java.util.UUID;

//
// Pingable at:
// http://localhost:8080/testweb/rest/demo/ping
//
@Path("/demo")
public class DemoCommand {
    Logger log = LoggerFactory.getLogger(this.getClass().getName());

    @GET
    @Path("/ping")
    public Response ping() {
        log.debug("ping");
        return Response.ok().entity("Service online").build();
    }

    @GET
    @Path("/get/{id}")
    @Produces(MediaType.APPLICATION_JSON)
    public Response getNotification(@PathParam("id") int id) {
        return Response.ok()
                .entity(new DemoPojo(id, "test message"))
                .build();
    }

    @POST
    @Path("/post")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Response postNotification(DemoPojo pojo) {
        log.debug("demopojo={}", pojo);
        if( pojo.getTestdate() == null ) {
            pojo.setTestdate(new Date());
        }
        return Response.status(201).entity(pojo).build();
    }

    @POST
    @Path("/login")
    @Produces(MediaType.APPLICATION_JSON)
    public Response login(@Context HttpServletRequest req, @FormParam("userid") String userid, @FormParam("pd") String pwd) {
        JerseyCookie cookie = null;
        String cookiekey = UUID.randomUUID().toString();

        int validtime = 1 * 86400;
        cookie = new JerseyCookie("randomuuid", cookiekey, "/", null, 0, null, validtime, false, false);

        DemoPojo demo = new DemoPojo();
        demo.setId(12345);
        demo.setMessage("message");
        demo.setTestdate(new Date());
        Response.ResponseBuilder builder = Response.ok(demo);
        return builder.header("Set-Cookie", cookie).build();
    }
}

DemoPojo 是單純的一個 java data class

package com.maxkit.testweb.jersey.rest;

import java.util.Date;

public class DemoPojo {

    private int id;
    private String message;
    private Date testdate;

    public DemoPojo() {
    }

    public DemoPojo(int id, String message) {
        this.id = id;
        this.message = message;
    }

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    public Date getTestdate() {
        return testdate;
    }

    public void setTestdate(Date testdate) {
        this.testdate = testdate;
    }
}

JerseyCookie.java

package com.maxkit.testweb.jersey;

import org.glassfish.jersey.message.internal.HttpHeaderReader;
import org.glassfish.jersey.message.internal.StringBuilderUtils;

import jakarta.ws.rs.core.Cookie;
import jakarta.ws.rs.core.NewCookie;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.TimeZone;
//import com.sun.jersey.core.impl.provider.header.WriterUtil;

public class JerseyCookie extends Cookie {
    public static final int DEFAULT_MAX_AGE = -1;

    private String comment = null;
    private int maxAge = DEFAULT_MAX_AGE;
    private boolean secure = false;
    private Date expires;
    private boolean httponly = false;

    public JerseyCookie(String name, String value) {
        super(name, value);
    }

    public JerseyCookie(String name, String value, String path, String domain, String comment, int maxAge,
            boolean secure) {
        super(name, value, path, domain);
        this.comment = comment;
        this.maxAge = maxAge;
        this.secure = secure;
    }

    public JerseyCookie(String name, String value, String path, String domain, int version, String comment, int maxAge,
            boolean secure) {
        super(name, value, path, domain, version);
        this.comment = comment;
        this.maxAge = maxAge;
        this.secure = secure;
    }

    public JerseyCookie(Cookie cookie) {
        super(cookie == null ? null : cookie.getName(), cookie == null ? null : cookie.getValue(),
                cookie == null ? null : cookie.getPath(), cookie == null ? null : cookie.getDomain(),
                cookie == null ? Cookie.DEFAULT_VERSION : cookie.getVersion());
    }

    public JerseyCookie(String name, String value, String path, String domain, int version, String comment, int maxAge,
            boolean secure, boolean httponly) {
        super(name, value, path, domain, version);
        this.comment = comment;
        this.maxAge = maxAge;
        this.secure = secure;
        if (maxAge > 0) {
            expires = new Date(new Date().getTime() + maxAge * 1000);
        }
        this.httponly = httponly;
    }

    public JerseyCookie(Cookie cookie, String comment, int maxAge, boolean secure, boolean httponly) {
        this(cookie);
        this.comment = comment;
        this.maxAge = maxAge;
        this.secure = secure;
        if (maxAge > 0) {
            expires = new Date(System.currentTimeMillis() + maxAge * 1000);
        }
        this.httponly = httponly;
    }

    public JerseyCookie(Cookie cookie, String comment, int maxAge, boolean secure) {
        this(cookie);
        this.comment = comment;
        this.maxAge = maxAge;
        this.secure = secure;
    }

    public static NewCookie valueOf(String value) throws IllegalArgumentException {
        if (value == null)
            throw new IllegalArgumentException("NewCookie is null");

        return HttpHeaderReader.readNewCookie(value);
        // return delegate.fromString(value);
    }

    public String getComment() {
        return comment;
    }

    public int getMaxAge() {
        return maxAge;
    }

    public boolean isSecure() {
        return secure;
    }

    public Cookie toCookie() {
        return new Cookie(this.getName(), this.getValue(), this.getPath(), this.getDomain(), this.getVersion());
    }

    @Override
    public String toString() {
        StringBuilder b = new StringBuilder();

        b.append(getName()).append('=');
        StringBuilderUtils.appendQuotedIfWhitespace(b, getValue());

        b.append(";").append("Version=").append(getVersion());

        if (getComment() != null) {
            b.append(";Comment=");
            StringBuilderUtils.appendQuotedIfWhitespace(b, getComment());
        }
        if (getDomain() != null) {
            b.append(";Domain=");
            StringBuilderUtils.appendQuotedIfWhitespace(b, getDomain());
        }
        SimpleDateFormat COOKIE_EXPIRES_HEADER_FORMAT = new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss z", Locale.US);
        COOKIE_EXPIRES_HEADER_FORMAT.setTimeZone(TimeZone.getTimeZone("GMT"));
        // String cookieExpire = "expires=" +
        // COOKIE_EXPIRES_HEADER_FORMAT.format(expires);
        if (getExpires() != null) {
            b.append(";Expires=");
            b.append(COOKIE_EXPIRES_HEADER_FORMAT.format(expires));
        }
        if (getPath() != null) {
            b.append(";Path=");
            StringBuilderUtils.appendQuotedIfWhitespace(b, getPath());
        }
        // if (getMaxAge() != -1) {
        // b.append(";Max-Age=");
        // b.append(getMaxAge());
        // }
        if (isSecure())
            b.append(";Secure");
        if (isHttponly())
            b.append(";HTTPOnly");
        return b.toString();
    }

    @Override
    public int hashCode() {
        int hash = super.hashCode();
        hash = 59 * hash + (this.comment != null ? this.comment.hashCode() : 0);
        hash = 59 * hash + this.maxAge;
        hash = 59 * hash + (this.secure ? 1 : 0);
        return hash;
    }


    @Override
    public boolean equals(Object obj) {
        if (obj == null) {
            return false;
        }
        if (getClass() != obj.getClass()) {
            return false;
        }
        final JerseyCookie other = (JerseyCookie) obj;
        if (this.getName() != other.getName() && (this.getName() == null || !this.getName().equals(other.getName()))) {
            return false;
        }
        if (this.getValue() != other.getValue()
                && (this.getValue() == null || !this.getValue().equals(other.getValue()))) {
            return false;
        }
        if (this.getVersion() != other.getVersion()) {
            return false;
        }
        if (this.getPath() != other.getPath() && (this.getPath() == null || !this.getPath().equals(other.getPath()))) {
            return false;
        }
        if (this.getDomain() != other.getDomain()
                && (this.getDomain() == null || !this.getDomain().equals(other.getDomain()))) {
            return false;
        }
        if (this.getExpires() != other.getExpires()
                && (this.getExpires() == null || !this.getExpires().equals(other.getExpires()))) {
            return false;
        }
        if (this.comment != other.comment && (this.comment == null || !this.comment.equals(other.comment))) {
            return false;
        }
        if (this.maxAge != other.maxAge) {
            return false;
        }

        if (this.secure != other.secure) {
            return false;
        }
        if (this.httponly != other.httponly) {
            return false;
        }
        return true;
    }

    public Date getExpires() {
        return expires;
    }

    public boolean isHttponly() {
        return httponly;
    }

}

References

JAX-RS - 維基百科,自由的百科全書

Jakarta REST (JAX-RS) on Tomcat 10 - northCoder

Eclipse Jersey

2024/07/15

CBOR

RFC 8949 CBOR: Concise Binary Object Representation,這是一種輕量的二進位資料格式,可簡單理解為一種二進位的 JSON,設計目的是最小化處理程式碼,最小化訊息位元組大小。另外在 RFC 9052, 9053 CBOR Object Signing and Encryption (COSE) 制定了要如何對 CBOR 資料進行資料簽章與加密的方法。

online test

cbor.me

CBOR/web (plain)

可到這兩個網頁進行線上測試

測試時可以發現,不管是整數,字串,都有對應的編碼。甚至是 JSON 字串,也有對應的 Map (key-value pair) 的編碼方式

CBOR

header 有兩個資訊,前 3 bits 是 Major Type,後 5 個 bits 是 Additional Information

字節(byte)數 1 byte (CBOR Data Item Header) 動態長度 動態長度
結構 Major Type Additional Information Payload Length(可選) Data Payload(可選)
bit 3 Bits 5 Bits 動態長度 動態長度

CBOR 有八種 Major Type

首位元組的 lower 5 bits 在不同的主類型表示長度(除主類型 0 和主類型 1),如果長度指示不足,則依次使用後續位元組。

Major Type Name content
0 unsigned integer -
1 negative integer -
2 byte string N bytes
3 text string N bytes (UTF-8 text)
4 array of data items N data items
5 map of pairs of data items 2N data items (key/value pairs)
6 tag of number N 1 data item
7 simple/float -

比較特別的是 Major Type 6 裡面定義了 date time

0xC0 定義了 text-based date/time,0xC1 定義了 epoch-based date/time

還有 Major Type 7 裡面的

  • False 編碼後 0xF4
  • True 編碼後 0xF5
  • Null 編碼後 0b1110_0000 + 0b0001_01110 (22) = 0xF6

implementation

CBOR — Concise Binary Object Representation | Implementations

這邊列出了不同程式語言的 CBOR 實作 library

這邊用 js 測試

<html>
<script src="https://cdn.jsdelivr.net/npm/cbor-js@0.1.0/cbor.min.js"></script>
<script>

function hex(buffer) {
    var s = '', h = '0123456789ABCDEF';
    (new Uint8Array(buffer)).forEach((v) => { s += h[v >> 4] + h[v & 15]; });
    return s;
}

function test0() {
    console.log("");
    console.log("test0 unsigned integer");
    var initial = 1000;
    var encoded = CBOR.encode(initial);
    var decoded = CBOR.decode(encoded);
    console.log("initial =" + initial);
    console.log("encoded hex=", hex(encoded));
    console.log("decoded =" + decoded);
}

function test1() {
    console.log("");
    console.log("test1 negative integer");
    var initial = -1000;
    var encoded = CBOR.encode(initial);
    var decoded = CBOR.decode(encoded);
    console.log("initial =" + initial);
    console.log("encoded hex=", hex(encoded));
    console.log("decoded =" + decoded);
}

function test2() {
    console.log("");
    console.log("test2 byte string");
    var initial = new Uint8Array([0, 1, 2, 3]);
    var encoded = CBOR.encode(initial);
    var decoded = CBOR.decode(encoded);
    console.log("initial =", initial);
    console.log("encoded hex=", hex(encoded));
    console.log("decoded =", decoded);
}

function test3() {
    console.log("");
    console.log("test3 text string");
    var initial = "text string";
    var encoded = CBOR.encode(initial);
    var decoded = CBOR.decode(encoded);
    console.log("initial =", initial);
    console.log("encoded hex=", hex(encoded));
    console.log("decoded =", decoded);
}

function test4() {
    console.log("");
    console.log("test4 array");
    var initial = [1, "2"];
    var encoded = CBOR.encode(initial);
    var decoded = CBOR.decode(encoded);
    console.log("initial =", initial);
    console.log("encoded hex=", hex(encoded));
    console.log("decoded =", decoded);
}

function test5() {
    console.log("");
    console.log("test5 map");
    var initial = { Hello: "World" };
    var encoded = CBOR.encode(initial);
    var decoded = CBOR.decode(encoded);
    console.log("initial =", initial);
    console.log("encoded hex=", hex(encoded));
    console.log("decoded =", decoded);
}

function test6() {
    console.log("");
    console.log("test6 float");
    var initial = 3.1415;
    var encoded = CBOR.encode(initial);
    var decoded = CBOR.decode(encoded);
    console.log("initial =", initial);
    console.log("encoded hex=", hex(encoded));
    console.log("decoded =", decoded);
}

function test7() {
    console.log("");
    console.log("test7 true");
    var initial = true;
    var encoded = CBOR.encode(initial);
    var decoded = CBOR.decode(encoded);
    console.log("initial =", initial);
    console.log("encoded hex=", hex(encoded));
    console.log("decoded =", decoded);
}

function test8() {
    console.log("");
    console.log("test8 null");
    var initial = null;
    var encoded = CBOR.encode(initial);
    var decoded = CBOR.decode(encoded);
    console.log("initial =", initial);
    console.log("encoded hex=", hex(encoded));
    console.log("decoded =", decoded);
}

test0();
test1();
test2();
test3();
test4();
test5();
test6();
test7();
test8();

</script>
</html>

結果

test0 unsigned integer
initial =1000
encoded hex= 1903E8
decoded =1000

test1 negative integer
initial =-1000
encoded hex= 3903E7
decoded =-1000

test2 byte string
initial = Uint8Array(4) [0, 1, 2, 3, buffer: ArrayBuffer(4), byteLength: 4, byteOffset: 0, length: 4, Symbol(Symbol.toStringTag): 'Uint8Array']
encoded hex= 4400010203
decoded = Uint8Array(4) [0, 1, 2, 3, buffer: ArrayBuffer(5), byteLength: 4, byteOffset: 1, length: 4, Symbol(Symbol.toStringTag): 'Uint8Array']

test3 text string
initial = text string
encoded hex= 6B7465787420737472696E67
decoded = text string

test4 array
initial = (2) [1, '2']
encoded hex= 82016132
decoded = (2) [1, '2']

test5 map
initial = {Hello: 'World'}
encoded hex= A16548656C6C6F65576F726C64
decoded = {Hello: 'World'}

test6 float
initial = 3.1415
encoded hex= FB400921CAC083126F
decoded = 3.1415

test7 true
initial = true
encoded hex= F5
decoded = true

test8 null
initial = null
encoded hex= F6
decoded = null

references

# 物聯網專用資料交換格式 CBOR

cobr.io

CBOR_百度百科

2024/07/01

WebAuthn

WebAuthn 是 Web 與 Authentication 結合的縮寫,也就是 Web Authentication,用在網頁身份認證。這個規格是由 W3C 與 FIDO (Fast IDentity Online) 提出,該正式API 規格,透過瀏覽器的支援,可透過 Public Key Cryptography 機制註冊並認證使用者,取代傳統密碼形式的認證方式。

最傳統的身份認證機制是用密碼,但因為密碼太多了,又因為資安政策需要常常修改密碼,正常人應該已經沒辦法記得很清楚,哪個網站是用哪一個密碼。

WebAuthn 是  FIDO2 framework 的部分,透過 server, browser, authenticator 的搭配,提供一種 passwordless 無密碼的認證方式。

Demo

webauthn.io 是一個單純的網頁 demo。demo 時分兩個步驟,Register 與 Authenticate。

Register 就是註冊帳號,使用者向 browser 要求要註冊一個新帳號,browser 透過 authenticator 的搭配輔助,產生一組 public key,然後完成註冊的步驟。 authenticator 可能是本機的指紋 scanner,也可以是 USB key,或是目前最容易取得的每一個人的手機,並搭配手機的 FaceID 或是指紋 scanner 輔助做身份認證。

註冊完成後,會取得註冊的 id,然後在透過認證 API,進行使用者身份認證。

Javascript demo

註冊

// should generate from server
const challenge = new Uint8Array(32);
window.crypto.getRandomValues(challenge);

const userID = '5d157c55-ea8b-45ca-870a-da877a9c9916';

const utf8EncodeText = new TextEncoder();
const id = utf8EncodeText.encode(userID);

const publicKeyCredentialCreationOptions = {
    challenge,
    rp: {
        name: "Maxkit RP",
        // 這邊只是 demo,沒有放到某個 https 網站測試,如果是網站,就要改成該網站的 domain name
        //id: "maxkit.com.tw",
    },
    user: {
        id,
        name: "test",
        displayName: "Test",
    },
    pubKeyCredParams: [{alg: -7, type: "public-key"}],
    authenticatorSelection: {
        authenticatorAttachment: "cross-platform",
    },
    timeout: 60000,
    attestation: "direct"
};

const credential = await navigator.credentials.create({
    publicKey: publicKeyCredentialCreationOptions
});

把上面這一部分的 js code 直接貼到 chrome console 執行。主要就是透過 navigator.credentials.create 註冊產生 public key

challenge 欄位是由 server 產生,最終會由 authenticator 以私鑰簽章後,在 credential response 裡面傳回來,讓 server 能夠檢查確認 authenticator

rp.id 應該要填網域 domain name,這邊只是本機測試,不填寫

authenticatorAttachment: "cross-platform" 的意思是,可搭配使用外部認證工具,例如在 NB 瀏覽認證網頁時,可使用手機作為認證工具進行認證。

在 NB 提示認證時,會看到這個畫面

這時候,就用自己的手機相機,拍 QR code,手機上會出現以下畫面

確認後,會在手機的密碼部分,儲存一個專屬的密碼。iOS 手機選單在 "設定 -> 密碼"

在 console 列印 credential 的內容

// print credential
console.log(credential)
PublicKeyCredential {rawId: ArrayBuffer(20), response: AuthenticatorAttestationResponse, authenticatorAttachment: 'cross-platform', id: 'ku22CGGrZdYlkr9cCXL4IWtyYLc', type: 'public-key'}

正常狀況應該把 credential 傳送到 server,檢查並 decode 得到使用者的 id, public key。這邊僅是測試 demo,不做處理。

response.attestationObject 裡面有 public key 跟其他 metadata,是用 CBOR Concise Binary Object Representation 編碼後的 binary data。

認證

以下是認證的 demo code,從剛剛的 credential.id 欄位取得 id,通常應該是儲存在 server,從 server 傳到網頁得到這個資料

const credentialIdStr = credential.id;
const credentialId = Uint8Array.from(window.atob(credentialIdStr), c=>c.charCodeAt(0));

const challenge2 = new Uint8Array(32);
window.crypto.getRandomValues(challenge2);

const publicKeyCredentialRequestOptions = {
    challenge: challenge2,
    //rpId: "maxkit.com.tw",
    allowCredentials: [{
        id: credentialId, // from registration
        type: 'public-key',
        transports: ['usb', 'ble', 'nfc', 'hybrid'],
    }],
    timeout: 60000,
}

const assertion = await navigator.credentials.get({
    publicKey: publicKeyCredentialRequestOptions
});

transports 是可以認證的方式

網頁同樣會出現 QR code,等待 authenticator,這邊我們用剛剛註冊的手機掃描

手機掃描時,會出現一個提示

點"使用通行密鑰登入"

繼續透過 FaceID 認證,認證完成,同樣可取得 response

console.log(assertion)

CTAP

Client-to-Authenticator Protocol,這是在瀏覽器打開註冊/認證網頁後,authnticator 跟瀏覽器之間傳輸的協定,也就是 WebAuthn API 跟外部硬體認證機制溝通的規格

References

WebAuthn guide

什么是WebAuthn:在Web上使用Touch ID和Windows Hello登录_webauthn 网站登录-CSDN博客

你可能不知道的WebAuthN(FIDO) | 又LAG隨性筆記

初探 WebAuthn UAF 的無密碼 Passwordless 登入技術 – 要改的地方太多了,那就改天吧

一起來了解 Web Authentication

CredentialsContainer - Web APIs | MDN