package main import ( "database/sql" "encoding/json" "fmt" "log" "net/http" "os" "strings" "time" "github.com/go-webauthn/webauthn/webauthn" "github.com/joho/godotenv" "github.com/go-sql-driver/mysql" ) var ( webAuthn *webauthn.WebAuthn err error datastore PasskeyStore //sessions SessionStore l = log.Default() ) 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() { godotenv.Load() proto := getEnv("PROTO", "http") host := getEnv("HOST", "localhost") port := ":" + getEnv("PORT", "8080") origin := fmt.Sprintf("%s://%s%s", proto, host, port) cfg := mysql.NewConfig() cfg.User = os.Getenv("DB_USER") cfg.Passwd = os.Getenv("DB_PASS") cfg.Net = "tcp" cfg.Addr = "127.0.0.1:3306" dbName := os.Getenv("DB_NAME") l.Printf("[INFO] make webauthn config") wconfig := &webauthn.Config{ RPDisplayName: "OwOrganizer", // 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 { l.Panicf(err.Error()) os.Exit(1) } db, err := sql.Open("mysql", cfg.FormatDSN()) if err != nil { l.Panicf(err.Error()) } defer db.Close() db.SetConnMaxLifetime(time.Minute * 3) db.SetMaxOpenConns(10) db.SetMaxIdleConns(10) _, err = db.Exec(fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", dbName)) if err != nil { log.Panicf("Failed to verify/create database: %v", err) } _, err = db.Exec(fmt.Sprintf("USE %s", dbName)) if err != nil { log.Panicf("Failed to switch database: %v", err) } query := "CREATE TABLE IF NOT EXISTS users (" + "username VARCHAR(64) NOT NULL," + "created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP," + // "last_used_at TIMESTAMP NULL," + "rpid VARCHAR(64) NOT NULL," + // -- Relying Party ID" + "kid VARBINARY(64) NOT NULL," + // -- Credential.ID" + "aaguid CHAR(36) NULL," + // -- Authenticator.AAGUID" + "public_key BLOB NOT NULL," + // -- Credential.PublicKey (encrypt at rest)" + "attestation_type VARCHAR(32) NOT NULL," + // -- CredentialAttestation.AttestationType" + "attestation_format VARCHAR(32) NOT NULL," + // -- CredentialAttestation.AttestationFormat" + "attestation BLOB NULL DEFAULT NULL" + // -- CredentialAttestation serialized as Message Pack or JSON (encrypt at rest)" + ")" _, err = db.Exec(query) if err != nil { log.Panicf("Failed to verify/create table: %v", err) } l.Printf("[INFO] create datastore") datastore = NewInMem(l, db) l.Printf("[INFO] register routes") // Serve the web files http.Handle("/", http.FileServer(http.Dir("./web"))) // Add auth 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("[ERROR] 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("[ERROR] %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("[ERROR] 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("[ERROR] 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 our example username == userID, but in real world it should be different user := datastore.GetOrCreateUser(string(session.UserID)) // Get the user var username = strings.Trim(user.WebAuthnName(), " \r\n\t") if len(username) < 2 { l.Printf("[ERROR] SaveUser: Username too short") msg := fmt.Sprintf("Error: Can't finish registration: Username too short") http.SetCookie(w, &http.Cookie{ Name: "sid", Value: "", }) l.Printf(msg) JSONResponse(w, msg, http.StatusBadRequest) return } credential, err := webAuthn.FinishRegistration(user, session, r) if err != nil { msg := fmt.Sprintf("can't finish registration: %s", err.Error()) l.Printf("[ERROR] %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("[ERROR]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("[ERROR] %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("[ERROR] 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("[ERROR] 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("[ERROR] 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("[ERROR] 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) }) }