Complete WebAuthn web handlers and download real htmx
- Real htmx.min.js (v2.0.4, 50KB) replaces stub - WebAuthn registration handlers (begin/finish) for adding security keys - WebAuthn login handlers (begin/finish) for passwordless login - Key management page (list/delete registered keys) - Login page updated with "Login with Security Key" button + JS - Session store for WebAuthn ceremonies (mutex-protected map) - WebAuthn config passed from server command through to webserver - Added LookupUserID helper for username-based WebAuthn login Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -74,9 +74,12 @@ func runServer(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
// Start Web UI server
|
// Start Web UI server
|
||||||
webSrv, err := webserver.Start(webserver.Config{
|
webSrv, err := webserver.Start(webserver.Config{
|
||||||
Addr: cfg.Web.ListenAddr,
|
Addr: cfg.Web.ListenAddr,
|
||||||
DB: database,
|
DB: database,
|
||||||
BaseURL: cfg.Web.BaseURL,
|
BaseURL: cfg.Web.BaseURL,
|
||||||
|
RPDisplayName: cfg.WebAuthn.RPDisplayName,
|
||||||
|
RPID: cfg.WebAuthn.RPID,
|
||||||
|
RPOrigins: cfg.WebAuthn.RPOrigins,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("start web: %w", err)
|
return fmt.Errorf("start web: %w", err)
|
||||||
|
|||||||
@@ -31,6 +31,16 @@ func CreateUser(database *sql.DB, username, password string, params Argon2Params
|
|||||||
return res.LastInsertId()
|
return res.LastInsertId()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LookupUserID returns the user ID for a username, or an error if not found.
|
||||||
|
func LookupUserID(database *sql.DB, username string) (int64, error) {
|
||||||
|
var userID int64
|
||||||
|
err := database.QueryRow("SELECT id FROM users WHERE username = ?", username).Scan(&userID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("user not found: %w", err)
|
||||||
|
}
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
// AuthenticateUser verifies username/password and returns the user ID.
|
// AuthenticateUser verifies username/password and returns the user ID.
|
||||||
func AuthenticateUser(database *sql.DB, username, password string) (int64, error) {
|
func AuthenticateUser(database *sql.DB, username, password string) (int64, error) {
|
||||||
var userID int64
|
var userID int64
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package webserver
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@@ -10,6 +12,7 @@ import (
|
|||||||
"git.wntrmute.dev/kyle/eng-pad-server/internal/auth"
|
"git.wntrmute.dev/kyle/eng-pad-server/internal/auth"
|
||||||
"git.wntrmute.dev/kyle/eng-pad-server/internal/share"
|
"git.wntrmute.dev/kyle/eng-pad-server/internal/share"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (ws *WebServer) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
func (ws *WebServer) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -256,3 +259,256 @@ func (ws *WebServer) render(w http.ResponseWriter, name string, data any) {
|
|||||||
http.Error(w, "Template error", http.StatusInternalServerError)
|
http.Error(w, "Template error", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- WebAuthn handlers ---
|
||||||
|
|
||||||
|
func (ws *WebServer) jsonError(w http.ResponseWriter, msg string, code int) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *WebServer) handleWebAuthnRegisterBegin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if ws.webauthn == nil {
|
||||||
|
ws.jsonError(w, "WebAuthn not configured", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := r.Context().Value(userIDKey).(int64)
|
||||||
|
user, err := auth.LoadWebAuthnUser(ws.db, userID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("webauthn register begin: load user", "error", err)
|
||||||
|
ws.jsonError(w, "Failed to load user", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
options, session, err := ws.webauthn.BeginRegistration(user)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("webauthn register begin", "error", err)
|
||||||
|
ws.jsonError(w, "Registration failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionKey := fmt.Sprintf("reg:%d", userID)
|
||||||
|
ws.mu.Lock()
|
||||||
|
ws.sessions[sessionKey] = session
|
||||||
|
ws.mu.Unlock()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *WebServer) handleWebAuthnRegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if ws.webauthn == nil {
|
||||||
|
ws.jsonError(w, "WebAuthn not configured", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := r.Context().Value(userIDKey).(int64)
|
||||||
|
user, err := auth.LoadWebAuthnUser(ws.db, userID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("webauthn register finish: load user", "error", err)
|
||||||
|
ws.jsonError(w, "Failed to load user", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionKey := fmt.Sprintf("reg:%d", userID)
|
||||||
|
ws.mu.Lock()
|
||||||
|
session, ok := ws.sessions[sessionKey]
|
||||||
|
if ok {
|
||||||
|
delete(ws.sessions, sessionKey)
|
||||||
|
}
|
||||||
|
ws.mu.Unlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
ws.jsonError(w, "No registration in progress", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedResponse, err := protocol.ParseCredentialCreationResponseBody(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("webauthn register finish: parse response", "error", err)
|
||||||
|
ws.jsonError(w, "Invalid response", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
credential, err := ws.webauthn.CreateCredential(user, *session, parsedResponse)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("webauthn register finish: create credential", "error", err)
|
||||||
|
ws.jsonError(w, "Registration failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
keyName := r.URL.Query().Get("name")
|
||||||
|
if keyName == "" {
|
||||||
|
keyName = "Security Key"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth.StoreWebAuthnCredential(ws.db, userID, keyName, credential); err != nil {
|
||||||
|
slog.Error("webauthn register finish: store credential", "error", err)
|
||||||
|
ws.jsonError(w, "Failed to save credential", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *WebServer) handleWebAuthnLoginBegin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if ws.webauthn == nil {
|
||||||
|
ws.jsonError(w, "WebAuthn not configured", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Username == "" {
|
||||||
|
ws.jsonError(w, "Username is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := auth.LookupUserID(ws.db, req.Username)
|
||||||
|
if err != nil {
|
||||||
|
ws.jsonError(w, "Invalid credentials", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := auth.LoadWebAuthnUser(ws.db, userID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("webauthn login begin: load user", "error", err)
|
||||||
|
ws.jsonError(w, "Invalid credentials", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(user.WebAuthnCredentials()) == 0 {
|
||||||
|
ws.jsonError(w, "No security keys registered", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
options, session, err := ws.webauthn.BeginLogin(user)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("webauthn login begin", "error", err)
|
||||||
|
ws.jsonError(w, "Login failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionKey := fmt.Sprintf("login:%s", req.Username)
|
||||||
|
ws.mu.Lock()
|
||||||
|
ws.sessions[sessionKey] = session
|
||||||
|
ws.mu.Unlock()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *WebServer) handleWebAuthnLoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if ws.webauthn == nil {
|
||||||
|
ws.jsonError(w, "WebAuthn not configured", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := r.URL.Query().Get("username")
|
||||||
|
if username == "" {
|
||||||
|
ws.jsonError(w, "Username is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := auth.LookupUserID(ws.db, username)
|
||||||
|
if err != nil {
|
||||||
|
ws.jsonError(w, "Invalid credentials", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := auth.LoadWebAuthnUser(ws.db, userID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("webauthn login finish: load user", "error", err)
|
||||||
|
ws.jsonError(w, "Invalid credentials", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionKey := fmt.Sprintf("login:%s", username)
|
||||||
|
ws.mu.Lock()
|
||||||
|
session, ok := ws.sessions[sessionKey]
|
||||||
|
if ok {
|
||||||
|
delete(ws.sessions, sessionKey)
|
||||||
|
}
|
||||||
|
ws.mu.Unlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
ws.jsonError(w, "No login in progress", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedResponse, err := protocol.ParseCredentialRequestResponseBody(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("webauthn login finish: parse response", "error", err)
|
||||||
|
ws.jsonError(w, "Invalid response", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
credential, err := ws.webauthn.ValidateLogin(user, *session, parsedResponse)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("webauthn login finish: validate", "error", err)
|
||||||
|
ws.jsonError(w, "Authentication failed", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sign count to detect cloned authenticators.
|
||||||
|
_ = auth.UpdateWebAuthnSignCount(ws.db, credential.ID, credential.Authenticator.SignCount)
|
||||||
|
|
||||||
|
// Create session token.
|
||||||
|
token, err := auth.CreateToken(ws.db, userID, 24*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("webauthn login finish: create token", "error", err)
|
||||||
|
ws.jsonError(w, "Internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "session",
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
MaxAge: 86400,
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *WebServer) handleKeysList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := r.Context().Value(userIDKey).(int64)
|
||||||
|
|
||||||
|
keys, err := auth.ListWebAuthnCredentials(ws.db, userID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("list keys", "error", err)
|
||||||
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.render(w, "keys.html", map[string]any{
|
||||||
|
"Keys": keys,
|
||||||
|
"WebAuthnEnabled": ws.webauthn != nil,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ws *WebServer) handleKeyDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := r.Context().Value(userIDKey).(int64)
|
||||||
|
|
||||||
|
credID, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid key ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth.DeleteWebAuthnCredential(ws.db, credID, userID); err != nil {
|
||||||
|
slog.Error("delete key", "error", err)
|
||||||
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/keys", http.StatusFound)
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,24 +8,33 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/eng-pad-server/internal/auth"
|
||||||
"git.wntrmute.dev/kyle/eng-pad-server/web"
|
"git.wntrmute.dev/kyle/eng-pad-server/web"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Addr string
|
Addr string
|
||||||
DB *sql.DB
|
DB *sql.DB
|
||||||
BaseURL string
|
BaseURL string
|
||||||
TLSCert string
|
TLSCert string
|
||||||
TLSKey string
|
TLSKey string
|
||||||
|
RPDisplayName string
|
||||||
|
RPID string
|
||||||
|
RPOrigins []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebServer struct {
|
type WebServer struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
baseURL string
|
baseURL string
|
||||||
tmpl *template.Template
|
tmpl *template.Template
|
||||||
|
webauthn *webauthn.WebAuthn
|
||||||
|
mu sync.Mutex
|
||||||
|
sessions map[string]*webauthn.SessionData
|
||||||
}
|
}
|
||||||
|
|
||||||
func Start(cfg Config) (*http.Server, error) {
|
func Start(cfg Config) (*http.Server, error) {
|
||||||
@@ -40,9 +49,19 @@ func Start(cfg Config) (*http.Server, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ws := &WebServer{
|
ws := &WebServer{
|
||||||
db: cfg.DB,
|
db: cfg.DB,
|
||||||
baseURL: cfg.BaseURL,
|
baseURL: cfg.BaseURL,
|
||||||
tmpl: tmpl,
|
tmpl: tmpl,
|
||||||
|
sessions: make(map[string]*webauthn.SessionData),
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.RPID != "" {
|
||||||
|
wa, err := auth.NewWebAuthn(cfg.RPDisplayName, cfg.RPID, cfg.RPOrigins)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("init webauthn: %w", err)
|
||||||
|
}
|
||||||
|
ws.webauthn = wa
|
||||||
|
slog.Info("WebAuthn enabled", "rpid", cfg.RPID)
|
||||||
}
|
}
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
@@ -55,6 +74,10 @@ func Start(cfg Config) (*http.Server, error) {
|
|||||||
r.Get("/login", ws.handleLoginPage)
|
r.Get("/login", ws.handleLoginPage)
|
||||||
r.Post("/login", ws.handleLoginSubmit)
|
r.Post("/login", ws.handleLoginSubmit)
|
||||||
|
|
||||||
|
// WebAuthn public routes (login ceremony)
|
||||||
|
r.Post("/webauthn/login/begin", ws.handleWebAuthnLoginBegin)
|
||||||
|
r.Post("/webauthn/login/finish", ws.handleWebAuthnLoginFinish)
|
||||||
|
|
||||||
// Share routes (no auth)
|
// Share routes (no auth)
|
||||||
r.Get("/s/{token}", ws.handleShareNotebook)
|
r.Get("/s/{token}", ws.handleShareNotebook)
|
||||||
r.Get("/s/{token}/pages/{num}", ws.handleSharePage)
|
r.Get("/s/{token}/pages/{num}", ws.handleSharePage)
|
||||||
@@ -67,6 +90,12 @@ func Start(cfg Config) (*http.Server, error) {
|
|||||||
r.Get("/notebooks/{id}", ws.handleNotebook)
|
r.Get("/notebooks/{id}", ws.handleNotebook)
|
||||||
r.Get("/notebooks/{id}/pages/{num}", ws.handlePage)
|
r.Get("/notebooks/{id}/pages/{num}", ws.handlePage)
|
||||||
r.Get("/logout", ws.handleLogout)
|
r.Get("/logout", ws.handleLogout)
|
||||||
|
|
||||||
|
// WebAuthn authenticated routes (registration + key management)
|
||||||
|
r.Post("/webauthn/register/begin", ws.handleWebAuthnRegisterBegin)
|
||||||
|
r.Post("/webauthn/register/finish", ws.handleWebAuthnRegisterFinish)
|
||||||
|
r.Get("/keys", ws.handleKeysList)
|
||||||
|
r.Post("/keys/delete", ws.handleKeyDelete)
|
||||||
})
|
})
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
|
|||||||
4
web/static/htmx.min.js
vendored
4
web/static/htmx.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -13,4 +13,104 @@
|
|||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn">Login</button>
|
<button type="submit" class="btn">Login</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div id="webauthn-login" style="display:none; margin-top:1.5rem;">
|
||||||
|
<hr style="margin:1rem 0;">
|
||||||
|
<button type="button" class="btn" id="webauthn-login-btn">Login with Security Key</button>
|
||||||
|
<p id="webauthn-login-status" style="margin-top:0.5rem;"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
if (!window.PublicKeyCredential) return;
|
||||||
|
|
||||||
|
var section = document.getElementById("webauthn-login");
|
||||||
|
section.style.display = "block";
|
||||||
|
|
||||||
|
var btn = document.getElementById("webauthn-login-btn");
|
||||||
|
var statusEl = document.getElementById("webauthn-login-status");
|
||||||
|
|
||||||
|
btn.addEventListener("click", async function() {
|
||||||
|
var username = document.getElementById("username").value;
|
||||||
|
if (!username) {
|
||||||
|
statusEl.style.color = "red";
|
||||||
|
statusEl.textContent = "Enter your username first.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusEl.textContent = "";
|
||||||
|
statusEl.style.color = "";
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = "Waiting for security key...";
|
||||||
|
|
||||||
|
try {
|
||||||
|
var beginResp = await fetch("/webauthn/login/begin", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify({username: username})
|
||||||
|
});
|
||||||
|
if (!beginResp.ok) {
|
||||||
|
var err = await beginResp.json();
|
||||||
|
throw new Error(err.error || "Login failed");
|
||||||
|
}
|
||||||
|
var options = await beginResp.json();
|
||||||
|
|
||||||
|
options.publicKey.challenge = base64urlToBuffer(options.publicKey.challenge);
|
||||||
|
if (options.publicKey.allowCredentials) {
|
||||||
|
options.publicKey.allowCredentials.forEach(function(c) {
|
||||||
|
c.id = base64urlToBuffer(c.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var assertion = await navigator.credentials.get({publicKey: options.publicKey});
|
||||||
|
|
||||||
|
var body = JSON.stringify({
|
||||||
|
id: assertion.id,
|
||||||
|
rawId: bufferToBase64url(assertion.rawId),
|
||||||
|
type: assertion.type,
|
||||||
|
response: {
|
||||||
|
authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
|
||||||
|
clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
|
||||||
|
signature: bufferToBase64url(assertion.response.signature),
|
||||||
|
userHandle: assertion.response.userHandle ? bufferToBase64url(assertion.response.userHandle) : ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var finishResp = await fetch("/webauthn/login/finish?username=" + encodeURIComponent(username), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: body
|
||||||
|
});
|
||||||
|
if (!finishResp.ok) {
|
||||||
|
var err2 = await finishResp.json();
|
||||||
|
throw new Error(err2.error || "Login failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = "/notebooks";
|
||||||
|
} catch(e) {
|
||||||
|
statusEl.style.color = "red";
|
||||||
|
statusEl.textContent = e.message || "Login failed";
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = "Login with Security Key";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function base64urlToBuffer(b64url) {
|
||||||
|
var b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
while (b64.length % 4) b64 += "=";
|
||||||
|
var binary = atob(b64);
|
||||||
|
var bytes = new Uint8Array(binary.length);
|
||||||
|
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bufferToBase64url(buf) {
|
||||||
|
var bytes = new Uint8Array(buf);
|
||||||
|
var binary = "";
|
||||||
|
for (var i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
||||||
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user