SHA256
add basic passkey template
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
module OwOrganizer
|
||||
|
||||
go 1.21.7
|
||||
|
||||
require (
|
||||
github.com/go-webauthn/webauthn v0.10.2
|
||||
github.com/google/uuid v1.6.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/fxamacker/cbor/v2 v2.6.0 // indirect
|
||||
github.com/go-webauthn/x v0.1.9 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
||||
github.com/google/go-tpm v0.9.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/crypto v0.21.0 // indirect
|
||||
golang.org/x/sys v0.18.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fxamacker/cbor/v2 v2.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE=
|
||||
github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
|
||||
github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA=
|
||||
github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||
github.com/go-webauthn/webauthn v0.10.1 h1:+RFKj4yHPy282teiiy5sqTYPfRilzBpJyedrz9KsNFE=
|
||||
github.com/go-webauthn/webauthn v0.10.1/go.mod h1:a7BwAtrSMkeuJXtIKz433Av99nAv01pdfzB0a9xkDnI=
|
||||
github.com/go-webauthn/webauthn v0.10.2 h1:OG7B+DyuTytrEPFmTX503K77fqs3HDK/0Iv+z8UYbq4=
|
||||
github.com/go-webauthn/webauthn v0.10.2/go.mod h1:Gd1IDsGAybuvK1NkwUTLbGmeksxuRJjVN2PE/xsPxHs=
|
||||
github.com/go-webauthn/x v0.1.8 h1:f1C6k1AyUlDvnIzWSW+G9rN9nbp1hhLXZagUtyxZ8nc=
|
||||
github.com/go-webauthn/x v0.1.8/go.mod h1:i8UNlGVt3oy6oAFcP4SZB1djZLx/4pbekCbWowjTaJg=
|
||||
github.com/go-webauthn/x v0.1.9 h1:v1oeLmoaa+gPOaZqUdDentu6Rl7HkSSsmOT6gxEQHhE=
|
||||
github.com/go-webauthn/x v0.1.9/go.mod h1:pJNMlIMP1SU7cN8HNlKJpLEnFHCygLCvaLZ8a1xeoQA=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk=
|
||||
github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47QWKfU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,339 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
var (
|
||||
webAuthn *webauthn.WebAuthn
|
||||
err error
|
||||
|
||||
datastore PasskeyStore
|
||||
//sessions SessionStore
|
||||
l Logger
|
||||
)
|
||||
|
||||
type Logger interface {
|
||||
Printf(format string, v ...interface{})
|
||||
}
|
||||
|
||||
type PasskeyUser interface {
|
||||
webauthn.User
|
||||
AddCredential(*webauthn.Credential)
|
||||
UpdateCredential(*webauthn.Credential)
|
||||
}
|
||||
|
||||
type PasskeyStore interface {
|
||||
GetOrCreateUser(userName string) PasskeyUser
|
||||
SaveUser(PasskeyUser)
|
||||
GenSessionID() (string, error)
|
||||
GetSession(token string) (webauthn.SessionData, bool)
|
||||
SaveSession(token string, data webauthn.SessionData)
|
||||
DeleteSession(token string)
|
||||
}
|
||||
|
||||
func main() {
|
||||
l = log.Default()
|
||||
|
||||
proto := getEnv("PROTO", "http")
|
||||
host := getEnv("HOST", "localhost")
|
||||
port := getEnv("PORT", ":8080")
|
||||
origin := fmt.Sprintf("%s://%s%s", proto, host, port)
|
||||
|
||||
l.Printf("[INFO] make webauthn config")
|
||||
wconfig := &webauthn.Config{
|
||||
RPDisplayName: "Go Webauthn", // Display Name for your site
|
||||
RPID: host, // Generally the FQDN for your site
|
||||
RPOrigins: []string{origin}, // The origin URLs allowed for WebAuthn
|
||||
}
|
||||
|
||||
l.Printf("[INFO] create webauthn")
|
||||
if webAuthn, err = webauthn.New(wconfig); err != nil {
|
||||
fmt.Printf("[FATA] %s", err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
l.Printf("[INFO] create datastore")
|
||||
datastore = NewInMem(l)
|
||||
|
||||
l.Printf("[INFO] register routes")
|
||||
// Serve the web files
|
||||
http.Handle("/", http.FileServer(http.Dir("./web")))
|
||||
|
||||
// Add auth the routes
|
||||
http.HandleFunc("/api/passkey/registerStart", BeginRegistration)
|
||||
http.HandleFunc("/api/passkey/registerFinish", FinishRegistration)
|
||||
http.HandleFunc("/api/passkey/loginStart", BeginLogin)
|
||||
http.HandleFunc("/api/passkey/loginFinish", FinishLogin)
|
||||
|
||||
http.Handle("/private", LoggedInMiddleware(http.HandlerFunc(PrivatePage)))
|
||||
|
||||
// Start the server
|
||||
l.Printf("[INFO] start server at %s", origin)
|
||||
if err := http.ListenAndServe(port, nil); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
func BeginRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
l.Printf("[INFO] begin registration ----------------------\\")
|
||||
|
||||
// TODO: i don't like this, but it's a quick solution
|
||||
// can we actually do not use the username at all?
|
||||
username, err := getUsername(r)
|
||||
if err != nil {
|
||||
l.Printf("[ERRO] can't get user name: %s", err.Error())
|
||||
|
||||
panic(err) // FIXME: handle error
|
||||
}
|
||||
|
||||
user := datastore.GetOrCreateUser(username) // Find or create the new user
|
||||
|
||||
options, session, err := webAuthn.BeginRegistration(user)
|
||||
if err != nil {
|
||||
msg := fmt.Sprintf("can't begin registration: %s", err.Error())
|
||||
l.Printf("[ERRO] %s", msg)
|
||||
JSONResponse(w, msg, http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Make a session key and store the sessionData values
|
||||
t, err := datastore.GenSessionID()
|
||||
if err != nil {
|
||||
l.Printf("[ERRO] can't generate session id: %s", err.Error())
|
||||
|
||||
panic(err) // FIXME: handle error
|
||||
}
|
||||
|
||||
datastore.SaveSession(t, *session)
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "sid",
|
||||
Value: t,
|
||||
Path: "api/passkey/registerStart",
|
||||
MaxAge: 3600,
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode, // TODO: SameSiteStrictMode maybe?
|
||||
})
|
||||
|
||||
JSONResponse(w, options, http.StatusOK) // return the options generated with the session key
|
||||
// options.publicKey contain our registration options
|
||||
}
|
||||
|
||||
func FinishRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
// Get the session key from cookie
|
||||
sid, err := r.Cookie("sid")
|
||||
if err != nil {
|
||||
l.Printf("[ERRO] can't get session id: %s", err.Error())
|
||||
|
||||
panic(err) // FIXME: handle error
|
||||
}
|
||||
|
||||
// Get the session data stored from the function above
|
||||
session, _ := datastore.GetSession(sid.Value) // FIXME: cover invalid session
|
||||
|
||||
// In out example username == userID, but in real world it should be different
|
||||
user := datastore.GetOrCreateUser(string(session.UserID)) // Get the user
|
||||
|
||||
credential, err := webAuthn.FinishRegistration(user, session, r)
|
||||
if err != nil {
|
||||
msg := fmt.Sprintf("can't finish registration: %s", err.Error())
|
||||
l.Printf("[ERRO] %s", msg)
|
||||
// clean up sid cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "sid",
|
||||
Value: "",
|
||||
})
|
||||
JSONResponse(w, msg, http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// If creation was successful, store the credential object
|
||||
user.AddCredential(credential)
|
||||
datastore.SaveUser(user)
|
||||
// Delete the session data
|
||||
datastore.DeleteSession(sid.Value)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "sid",
|
||||
Value: "",
|
||||
})
|
||||
|
||||
l.Printf("[INFO] finish registration ----------------------/")
|
||||
JSONResponse(w, "Registration Success", http.StatusOK) // Handle next steps
|
||||
}
|
||||
|
||||
func BeginLogin(w http.ResponseWriter, r *http.Request) {
|
||||
l.Printf("[INFO] begin login ----------------------\\")
|
||||
|
||||
username, err := getUsername(r)
|
||||
if err != nil {
|
||||
l.Printf("[ERRO]can't get user name: %s", err.Error())
|
||||
panic(err)
|
||||
}
|
||||
|
||||
user := datastore.GetOrCreateUser(username) // Find the user
|
||||
|
||||
options, session, err := webAuthn.BeginLogin(user)
|
||||
if err != nil {
|
||||
msg := fmt.Sprintf("can't begin login: %s", err.Error())
|
||||
l.Printf("[ERRO] %s", msg)
|
||||
JSONResponse(w, msg, http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Make a session key and store the sessionData values
|
||||
t, err := datastore.GenSessionID()
|
||||
if err != nil {
|
||||
l.Printf("[ERRO] can't generate session id: %s", err.Error())
|
||||
|
||||
panic(err) // TODO: handle error
|
||||
}
|
||||
datastore.SaveSession(t, *session)
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "sid",
|
||||
Value: t,
|
||||
Path: "api/passkey/loginStart",
|
||||
MaxAge: 3600,
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode, // TODO: SameSiteStrictMode maybe?
|
||||
})
|
||||
|
||||
JSONResponse(w, options, http.StatusOK) // return the options generated with the session key
|
||||
// options.publicKey contain our registration options
|
||||
}
|
||||
|
||||
func FinishLogin(w http.ResponseWriter, r *http.Request) {
|
||||
// Get the session key from cookie
|
||||
sid, err := r.Cookie("sid")
|
||||
if err != nil {
|
||||
l.Printf("[ERRO] can't get session id: %s", err.Error())
|
||||
|
||||
panic(err) // FIXME: handle error
|
||||
}
|
||||
// Get the session data stored from the function above
|
||||
session, _ := datastore.GetSession(sid.Value) // FIXME: cover invalid session
|
||||
|
||||
// In out example username == userID, but in real world it should be different
|
||||
user := datastore.GetOrCreateUser(string(session.UserID)) // Get the user
|
||||
|
||||
credential, err := webAuthn.FinishLogin(user, session, r)
|
||||
if err != nil {
|
||||
l.Printf("[ERRO] can't finish login: %s", err.Error())
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Handle credential.Authenticator.CloneWarning
|
||||
if credential.Authenticator.CloneWarning {
|
||||
l.Printf("[WARN] can't finish login: %s", "CloneWarning")
|
||||
}
|
||||
|
||||
// If login was successful, update the credential object
|
||||
user.UpdateCredential(credential)
|
||||
datastore.SaveUser(user)
|
||||
|
||||
// Delete the login session data
|
||||
datastore.DeleteSession(sid.Value)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "sid",
|
||||
Value: "",
|
||||
})
|
||||
|
||||
// Add the new session cookie
|
||||
t, err := datastore.GenSessionID()
|
||||
if err != nil {
|
||||
l.Printf("[ERRO] can't generate session id: %s", err.Error())
|
||||
|
||||
panic(err) // TODO: handle error
|
||||
}
|
||||
|
||||
datastore.SaveSession(t, webauthn.SessionData{
|
||||
Expires: time.Now().Add(time.Hour),
|
||||
})
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "sid",
|
||||
Value: t,
|
||||
Path: "/",
|
||||
MaxAge: 3600,
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode, // TODO: SameSiteStrictMode maybe?
|
||||
})
|
||||
|
||||
l.Printf("[INFO] finish login ----------------------/")
|
||||
JSONResponse(w, "Login Success", http.StatusOK)
|
||||
}
|
||||
|
||||
func PrivatePage(w http.ResponseWriter, r *http.Request) {
|
||||
// just show "Hello, World!" for now
|
||||
_, _ = w.Write([]byte("Hello, World!"))
|
||||
}
|
||||
|
||||
// JSONResponse is a helper function to send json response
|
||||
func JSONResponse(w http.ResponseWriter, data interface{}, status int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
// getUsername is a helper function to extract the username from json request
|
||||
func getUsername(r *http.Request) (string, error) {
|
||||
type Username struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
var u Username
|
||||
if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return u.Username, nil
|
||||
}
|
||||
|
||||
// getEnv is a helper function to get the environment variable
|
||||
func getEnv(key, def string) string {
|
||||
if value, exists := os.LookupEnv(key); exists {
|
||||
return value
|
||||
}
|
||||
|
||||
return def
|
||||
}
|
||||
|
||||
func LoggedInMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: url to redirect to should be passed as a parameter
|
||||
|
||||
sid, err := r.Cookie("sid")
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
session, ok := datastore.GetSession(sid.Value)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if session.Expires.Before(time.Now()) {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import "github.com/go-webauthn/webauthn/webauthn"
|
||||
|
||||
type User struct {
|
||||
ID []byte
|
||||
DisplayName string
|
||||
Name string
|
||||
|
||||
creds []webauthn.Credential
|
||||
}
|
||||
|
||||
func (o *User) WebAuthnID() []byte {
|
||||
return o.ID
|
||||
}
|
||||
|
||||
func (o *User) WebAuthnName() string {
|
||||
return o.Name
|
||||
}
|
||||
|
||||
func (o *User) WebAuthnDisplayName() string {
|
||||
return o.DisplayName
|
||||
}
|
||||
|
||||
func (o *User) WebAuthnIcon() string {
|
||||
return "https://pics.com/avatar.png"
|
||||
}
|
||||
|
||||
func (o *User) WebAuthnCredentials() []webauthn.Credential {
|
||||
return o.creds
|
||||
}
|
||||
|
||||
func (o *User) AddCredential(credential *webauthn.Credential) {
|
||||
o.creds = append(o.creds, *credential)
|
||||
}
|
||||
|
||||
func (o *User) UpdateCredential(credential *webauthn.Credential) {
|
||||
for i, c := range o.creds {
|
||||
if string(c.ID) == string(credential.ID) {
|
||||
o.creds[i] = *credential
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
type InMem struct {
|
||||
// TODO: it would be nice to have a mutex here
|
||||
// TODO: use pointers to avoid copying
|
||||
users map[string]PasskeyUser
|
||||
sessions map[string]webauthn.SessionData
|
||||
|
||||
log Logger
|
||||
}
|
||||
|
||||
func (i *InMem) GenSessionID() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return base64.URLEncoding.EncodeToString(b), nil
|
||||
|
||||
}
|
||||
|
||||
func NewInMem(log Logger) *InMem {
|
||||
return &InMem{
|
||||
users: make(map[string]PasskeyUser),
|
||||
sessions: make(map[string]webauthn.SessionData),
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (i *InMem) GetSession(token string) (webauthn.SessionData, bool) {
|
||||
i.log.Printf("[DEBUG] GetSession: %v", i.sessions[token])
|
||||
val, ok := i.sessions[token]
|
||||
|
||||
return val, ok
|
||||
}
|
||||
|
||||
func (i *InMem) SaveSession(token string, data webauthn.SessionData) {
|
||||
i.log.Printf("[DEBUG] SaveSession: %s - %v", token, data)
|
||||
i.sessions[token] = data
|
||||
}
|
||||
|
||||
func (i *InMem) DeleteSession(token string) {
|
||||
i.log.Printf("[DEBUG] DeleteSession: %v", token)
|
||||
delete(i.sessions, token)
|
||||
}
|
||||
|
||||
func (i *InMem) GetOrCreateUser(userName string) PasskeyUser {
|
||||
i.log.Printf("[DEBUG] GetOrCreateUser: %v", userName)
|
||||
if _, ok := i.users[userName]; !ok {
|
||||
i.log.Printf("[DEBUG] GetOrCreateUser: creating new user: %v", userName)
|
||||
i.users[userName] = &User{
|
||||
ID: []byte(userName),
|
||||
DisplayName: userName,
|
||||
Name: userName,
|
||||
}
|
||||
}
|
||||
|
||||
return i.users[userName]
|
||||
}
|
||||
|
||||
func (i *InMem) SaveUser(user PasskeyUser) {
|
||||
i.log.Printf("[DEBUG] SaveUser: %v", user.WebAuthnName())
|
||||
i.log.Printf("[DEBUG] SaveUser: %v", user)
|
||||
i.users[user.WebAuthnName()] = user
|
||||
}
|
||||
Vendored
+306
@@ -0,0 +1,306 @@
|
||||
/* [@simplewebauthn/browser@10.0.0] */
|
||||
!function(e, t) {
|
||||
"object" == typeof exports && "undefined" != typeof module ? t(exports)
|
||||
: "function" == typeof define && define.amd
|
||||
? define([ "exports" ], t)
|
||||
: t((e = "undefined" != typeof globalThis ? globalThis : e || self)
|
||||
.SimpleWebAuthnBrowser = {})
|
||||
}(this, (function(e) {
|
||||
"use strict";
|
||||
function t(e) {
|
||||
const t = new Uint8Array(e);
|
||||
let r = "";
|
||||
for (const e of t)
|
||||
r += String.fromCharCode(e);
|
||||
return btoa(r).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||
}
|
||||
function r(e) {
|
||||
const t = e.replace(/-/g, "+").replace(/_/g, "/"),
|
||||
r = (4 - t.length % 4) % 4, n = t.padEnd(t.length + r, "="),
|
||||
o = atob(n), i = new ArrayBuffer(o.length), a = new Uint8Array(i);
|
||||
for (let e = 0; e < o.length; e++)
|
||||
a[e] = o.charCodeAt(e);
|
||||
return i
|
||||
}
|
||||
function n() {
|
||||
return void 0 !== window?.PublicKeyCredential &&
|
||||
"function" == typeof window.PublicKeyCredential
|
||||
}
|
||||
function o(e) {
|
||||
const {id : t} = e;
|
||||
return { ...e, id: r(t), transports: e.transports }
|
||||
}
|
||||
function i(e) {
|
||||
return "localhost" === e ||
|
||||
/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i.test(e)
|
||||
}
|
||||
class a extends Error {
|
||||
constructor({message : e, code: t, cause: r, name: n}) {
|
||||
super(e, {cause : r}), this.name = n ?? r.name, this.code = t
|
||||
}
|
||||
}
|
||||
const s = new class {
|
||||
createNewAbortSignal() {
|
||||
if (this.controller) {
|
||||
const e =
|
||||
new Error("Cancelling existing WebAuthn API call for new one");
|
||||
e.name = "AbortError", this.controller.abort(e)
|
||||
}
|
||||
const e = new AbortController;
|
||||
return this.controller = e, e.signal
|
||||
}
|
||||
cancelCeremony() {
|
||||
if (this.controller) {
|
||||
const e = new Error("Manually cancelling existing WebAuthn API call");
|
||||
e.name = "AbortError", this.controller.abort(e),
|
||||
this.controller = void 0
|
||||
}
|
||||
}
|
||||
}
|
||||
, c = [ "cross-platform", "platform" ];
|
||||
function l(e) {
|
||||
if (e && !(c.indexOf(e) < 0))
|
||||
return e
|
||||
}
|
||||
function u(e, t) {
|
||||
console.warn(
|
||||
`The browser extension that intercepted this WebAuthn API call incorrectly implemented ${
|
||||
e}. You should report this error to them.\n`,
|
||||
t)
|
||||
}
|
||||
function d() {
|
||||
if (!n())
|
||||
return new Promise((e => e(!1)));
|
||||
const e = window.PublicKeyCredential;
|
||||
return void 0 === e.isConditionalMediationAvailable
|
||||
? new Promise((e => e(!1)))
|
||||
: e.isConditionalMediationAvailable()
|
||||
}
|
||||
e.WebAuthnAbortService = s, e.WebAuthnError = a,
|
||||
e.base64URLStringToBuffer = r, e.browserSupportsWebAuthn = n,
|
||||
e.browserSupportsWebAuthnAutofill = d, e.bufferToBase64URLString = t,
|
||||
e.platformAuthenticatorIsAvailable = function() {
|
||||
return n() ? PublicKeyCredential
|
||||
.isUserVerifyingPlatformAuthenticatorAvailable()
|
||||
: new Promise((e => e(!1)))
|
||||
}, e.startAuthentication = async function(e, c = !1) {
|
||||
if (!n())
|
||||
throw new Error("WebAuthn is not supported in this browser");
|
||||
let u;
|
||||
0 !== e.allowCredentials?.length && (u = e.allowCredentials?.map(o));
|
||||
const h = {...e, challenge : r(e.challenge), allowCredentials : u},
|
||||
f = {};
|
||||
if (c) {
|
||||
if (!await d())
|
||||
throw Error("Browser does not support WebAuthn autofill");
|
||||
if (document.querySelectorAll("input[autocomplete$='webauthn']")
|
||||
.length < 1)
|
||||
throw Error(
|
||||
'No <input> with "webauthn" as the only or last value in its `autocomplete` attribute was detected');
|
||||
f.mediation = "conditional", h.allowCredentials = []
|
||||
}
|
||||
let p;
|
||||
f.publicKey = h, f.signal = s.createNewAbortSignal();
|
||||
try {
|
||||
p = await navigator.credentials.get(f)
|
||||
} catch (e) {
|
||||
throw function({error : e, options : t}) {
|
||||
const {publicKey : r} = t;
|
||||
if (!r)
|
||||
throw Error("options was missing required publicKey property");
|
||||
if ("AbortError" === e.name) {
|
||||
if (t.signal instanceof AbortSignal)
|
||||
return new a({
|
||||
message : "Authentication ceremony was sent an abort signal",
|
||||
code : "ERROR_CEREMONY_ABORTED",
|
||||
cause : e
|
||||
})
|
||||
} else {
|
||||
if ("NotAllowedError" === e.name)
|
||||
return new a({
|
||||
message : e.message,
|
||||
code : "ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY",
|
||||
cause : e
|
||||
});
|
||||
if ("SecurityError" === e.name) {
|
||||
const t = window.location.hostname;
|
||||
if (!i(t))
|
||||
return new a({
|
||||
message : `${window.location.hostname} is an invalid domain`,
|
||||
code : "ERROR_INVALID_DOMAIN",
|
||||
cause : e
|
||||
});
|
||||
if (r.rpId !== t)
|
||||
return new a({
|
||||
message : `The RP ID "${r.rpId}" is invalid for this domain`,
|
||||
code : "ERROR_INVALID_RP_ID",
|
||||
cause : e
|
||||
})
|
||||
} else if ("UnknownError" === e.name)
|
||||
return new a({
|
||||
message :
|
||||
"The authenticator was unable to process the specified options, or could not create a new assertion signature",
|
||||
code : "ERROR_AUTHENTICATOR_GENERAL_ERROR",
|
||||
cause : e
|
||||
})
|
||||
}
|
||||
return e
|
||||
}({error : e, options : f})
|
||||
}
|
||||
if (!p)
|
||||
throw new Error("Authentication was not completed");
|
||||
const {id : R, rawId : w, response : E, type : g} = p;
|
||||
let A;
|
||||
return E.userHandle && (A = t(E.userHandle)), {
|
||||
id: R, rawId: t(w), response: {
|
||||
authenticatorData: t(E.authenticatorData),
|
||||
clientDataJSON: t(E.clientDataJSON),
|
||||
signature: t(E.signature),
|
||||
userHandle: A
|
||||
},
|
||||
type: g, clientExtensionResults: p.getClientExtensionResults(),
|
||||
authenticatorAttachment: l(p.authenticatorAttachment)
|
||||
}
|
||||
}, e.startRegistration = async function(e) {
|
||||
if (!n())
|
||||
throw new Error("WebAuthn is not supported in this browser");
|
||||
const c = {
|
||||
publicKey : {
|
||||
...e,
|
||||
challenge : r(e.challenge),
|
||||
user : {...e.user, id : r(e.user.id)},
|
||||
excludeCredentials : e.excludeCredentials?.map(o)
|
||||
}
|
||||
};
|
||||
let d;
|
||||
c.signal = s.createNewAbortSignal();
|
||||
try {
|
||||
d = await navigator.credentials.create(c)
|
||||
} catch (e) {
|
||||
throw function({error : e, options : t}) {
|
||||
const {publicKey : r} = t;
|
||||
if (!r)
|
||||
throw Error("options was missing required publicKey property");
|
||||
if ("AbortError" === e.name) {
|
||||
if (t.signal instanceof AbortSignal)
|
||||
return new a({
|
||||
message : "Registration ceremony was sent an abort signal",
|
||||
code : "ERROR_CEREMONY_ABORTED",
|
||||
cause : e
|
||||
})
|
||||
} else if ("ConstraintError" === e.name) {
|
||||
if (!0 === r.authenticatorSelection?.requireResidentKey)
|
||||
return new a({
|
||||
message :
|
||||
"Discoverable credentials were required but no available authenticator supported it",
|
||||
code :
|
||||
"ERROR_AUTHENTICATOR_MISSING_DISCOVERABLE_CREDENTIAL_SUPPORT",
|
||||
cause : e
|
||||
});
|
||||
if ("required" === r.authenticatorSelection?.userVerification)
|
||||
return new a({
|
||||
message :
|
||||
"User verification was required but no available authenticator supported it",
|
||||
code : "ERROR_AUTHENTICATOR_MISSING_USER_VERIFICATION_SUPPORT",
|
||||
cause : e
|
||||
})
|
||||
} else {
|
||||
if ("InvalidStateError" === e.name)
|
||||
return new a({
|
||||
message : "The authenticator was previously registered",
|
||||
code : "ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED",
|
||||
cause : e
|
||||
});
|
||||
if ("NotAllowedError" === e.name)
|
||||
return new a({
|
||||
message : e.message,
|
||||
code : "ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY",
|
||||
cause : e
|
||||
});
|
||||
if ("NotSupportedError" === e.name)
|
||||
return 0 === r.pubKeyCredParams
|
||||
.filter((e => "public-key" === e.type))
|
||||
.length
|
||||
? new a({
|
||||
message :
|
||||
'No entry in pubKeyCredParams was of type "public-key"',
|
||||
code : "ERROR_MALFORMED_PUBKEYCREDPARAMS",
|
||||
cause : e
|
||||
})
|
||||
: new a({
|
||||
message :
|
||||
"No available authenticator supported any of the specified pubKeyCredParams algorithms",
|
||||
code :
|
||||
"ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG",
|
||||
cause : e
|
||||
});
|
||||
if ("SecurityError" === e.name) {
|
||||
const t = window.location.hostname;
|
||||
if (!i(t))
|
||||
return new a({
|
||||
message : `${window.location.hostname} is an invalid domain`,
|
||||
code : "ERROR_INVALID_DOMAIN",
|
||||
cause : e
|
||||
});
|
||||
if (r.rp.id !== t)
|
||||
return new a({
|
||||
message : `The RP ID "${r.rp.id}" is invalid for this domain`,
|
||||
code : "ERROR_INVALID_RP_ID",
|
||||
cause : e
|
||||
})
|
||||
} else if ("TypeError" === e.name) {
|
||||
if (r.user.id.byteLength < 1 || r.user.id.byteLength > 64)
|
||||
return new a({
|
||||
message : "User ID was not between 1 and 64 characters",
|
||||
code : "ERROR_INVALID_USER_ID_LENGTH",
|
||||
cause : e
|
||||
})
|
||||
} else if ("UnknownError" === e.name)
|
||||
return new a({
|
||||
message :
|
||||
"The authenticator was unable to process the specified options, or could not create a new credential",
|
||||
code : "ERROR_AUTHENTICATOR_GENERAL_ERROR",
|
||||
cause : e
|
||||
})
|
||||
}
|
||||
return e
|
||||
}({error : e, options : c})
|
||||
}
|
||||
if (!d)
|
||||
throw new Error("Registration was not completed");
|
||||
const {id : h, rawId : f, response : p, type : R} = d;
|
||||
let w, E, g, A;
|
||||
if ("function" == typeof p.getTransports && (w = p.getTransports()),
|
||||
"function" == typeof p.getPublicKeyAlgorithm)
|
||||
try {
|
||||
E = p.getPublicKeyAlgorithm()
|
||||
} catch (e) {
|
||||
u("getPublicKeyAlgorithm()", e)
|
||||
}
|
||||
if ("function" == typeof p.getPublicKey)
|
||||
try {
|
||||
const e = p.getPublicKey();
|
||||
null !== e && (g = t(e))
|
||||
} catch (e) {
|
||||
u("getPublicKey()", e)
|
||||
}
|
||||
if ("function" == typeof p.getAuthenticatorData)
|
||||
try {
|
||||
A = t(p.getAuthenticatorData())
|
||||
} catch (e) {
|
||||
u("getAuthenticatorData()", e)
|
||||
}
|
||||
return {
|
||||
id: h, rawId: t(f), response: {
|
||||
attestationObject: t(p.attestationObject),
|
||||
clientDataJSON: t(p.clientDataJSON),
|
||||
transports: w,
|
||||
publicKeyAlgorithm: E,
|
||||
publicKey: g,
|
||||
authenticatorData: A
|
||||
},
|
||||
type: R, clientExtensionResults: d.getClientExtensionResults(),
|
||||
authenticatorAttachment: l(d.authenticatorAttachment)
|
||||
}
|
||||
}, Object.defineProperty(e, "__esModule", {value : !0})
|
||||
}));
|
||||
@@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Passkey</title>
|
||||
<link href="bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container d-flex justify-content-center align-items-center vh-100">
|
||||
<div class="bg-light p-5 rounded w-50">
|
||||
<h1 class="mb-4 text-center">🔑 Passkey</h1>
|
||||
<div class="text-center" id="message"></div>
|
||||
<div class="mb-3">
|
||||
<input type="text" class="form-control" id="username" placeholder="username">
|
||||
</div>
|
||||
<div class="d-grid gap-2">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<button class="btn btn-primary w-100" id="registerButton">Register</button>
|
||||
</div>
|
||||
<div class="col">
|
||||
<button class="btn btn-primary w-100" id="loginButton">Login</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/private">PRIVATE</a>
|
||||
</div>
|
||||
|
||||
<script src="index.es5.umd.min.js"></script>
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,97 @@
|
||||
document.getElementById('registerButton').addEventListener('click', register);
|
||||
document.getElementById('loginButton').addEventListener('click', login);
|
||||
|
||||
|
||||
function showMessage(message, isError = false) {
|
||||
const messageElement = document.getElementById('message');
|
||||
messageElement.textContent = message;
|
||||
messageElement.style.color = isError ? 'red' : 'green';
|
||||
}
|
||||
|
||||
async function register() {
|
||||
// Retrieve the username from the input field
|
||||
const username = document.getElementById('username').value;
|
||||
|
||||
try {
|
||||
// Get registration options from your server. Here, we also receive the challenge.
|
||||
const response = await fetch('/api/passkey/registerStart', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({username: username})
|
||||
});
|
||||
|
||||
// Check if the registration options are ok.
|
||||
if (!response.ok) {
|
||||
const msg = await response.json();
|
||||
throw new Error('User already exists or failed to get registration options from server: ' + msg);
|
||||
}
|
||||
|
||||
// Convert the registration options to JSON.
|
||||
const options = await response.json();
|
||||
|
||||
// This triggers the browser to display the passkey / WebAuthn modal (e.g. Face ID, Touch ID, Windows Hello).
|
||||
// A new attestation is created. This also means a new public-private-key pair is created.
|
||||
const attestationResponse = await SimpleWebAuthnBrowser.startRegistration(options.publicKey);
|
||||
|
||||
// Send attestationResponse back to server for verification and storage.
|
||||
const verificationResponse = await fetch('/api/passkey/registerFinish', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(attestationResponse)
|
||||
});
|
||||
|
||||
|
||||
const msg = await verificationResponse.json();
|
||||
if (verificationResponse.ok) {
|
||||
showMessage(msg, false);
|
||||
} else {
|
||||
showMessage(msg, true);
|
||||
}
|
||||
} catch
|
||||
(error) {
|
||||
showMessage('Error: ' + error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function login() {
|
||||
// Retrieve the username from the input field
|
||||
const username = document.getElementById('username').value;
|
||||
|
||||
try {
|
||||
// Get login options from your server. Here, we also receive the challenge.
|
||||
const response = await fetch('/api/passkey/loginStart', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({username: username})
|
||||
});
|
||||
// Check if the login options are ok.
|
||||
if (!response.ok) {
|
||||
const msg = await response.json();
|
||||
throw new Error('Failed to get login options from server: ' + msg);
|
||||
}
|
||||
// Convert the login options to JSON.
|
||||
const options = await response.json();
|
||||
|
||||
// This triggers the browser to display the passkey / WebAuthn modal (e.g. Face ID, Touch ID, Windows Hello).
|
||||
// A new assertionResponse is created. This also means that the challenge has been signed.
|
||||
const assertionResponse = await SimpleWebAuthnBrowser.startAuthentication(options.publicKey);
|
||||
|
||||
// Send assertionResponse back to server for verification.
|
||||
const verificationResponse = await fetch('/api/passkey/loginFinish', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(assertionResponse)
|
||||
});
|
||||
|
||||
const msg = await verificationResponse.json();
|
||||
if (verificationResponse.ok) {
|
||||
showMessage(msg, false);
|
||||
} else {
|
||||
showMessage(msg, true);
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('Error: ' + error.message, true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user