diff --git a/app.go b/app.go index ceab19e..be75485 100644 --- a/app.go +++ b/app.go @@ -11,6 +11,7 @@ import ( ct "github.com/elnormous/contenttype" apc "github.com/go-ap/client" "github.com/go-fed/httpsig" + "github.com/go-webauthn/webauthn/webauthn" "github.com/kaorimatz/go-opml" rotatelogs "github.com/lestrrat-go/file-rotatelogs" geojson "github.com/paulmach/go.geojson" @@ -100,7 +101,7 @@ type goBlog struct { // Regex Redirects regexRedirects []*regexRedirect // Sessions - loginSessions, captchaSessions *dbSessionStore + loginSessions, captchaSessions, webauthnSessions *dbSessionStore // Shutdown shutdown shutdowner.Shutdowner // Template strings @@ -108,4 +109,6 @@ type goBlog struct { // Tor torAddress string torHostname string + // WebAuthn + webAuthn *webauthn.WebAuthn } diff --git a/docs/index.md b/docs/index.md index 927feb4..c826329 100644 --- a/docs/index.md +++ b/docs/index.md @@ -49,6 +49,7 @@ Here's an (incomplete) list of features: - Short URLs with option for a separate short domain - Command to check for broken links - Command to export all posts to Markdown files +- Authentication security with TOTP 2FA and passkeys ## More information about GoBlog: diff --git a/docs/storage.md b/docs/storage.md index 25436c4..6481696 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -22,7 +22,9 @@ posts posts_fts queue reactions +sections sessions +settings shortpath webmentions ``` diff --git a/go.mod b/go.mod index 56dbcbf..994d486 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 github.com/go-chi/chi/v5 v5.1.0 github.com/go-fed/httpsig v1.1.0 + github.com/go-webauthn/webauthn v0.11.2 github.com/google/uuid v1.6.0 github.com/gorilla/handlers v1.5.2 github.com/gorilla/sessions v1.4.0 @@ -81,7 +82,11 @@ require ( github.com/dlclark/regexp2 v1.11.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-ap/errors v0.0.0-20240910140019-1e9d33cc1568 // indirect + github.com/go-webauthn/x v0.1.14 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect + github.com/google/go-tpm v0.9.1 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -115,6 +120,7 @@ require ( github.com/tidwall/sjson v1.2.5 // indirect github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92 // indirect github.com/valyala/fastjson v1.6.4 // indirect + github.com/x448/float16 v0.8.4 // indirect go.mau.fi/util v0.8.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect diff --git a/go.sum b/go.sum index 05268d6..fbbc697 100644 --- a/go.sum +++ b/go.sum @@ -65,6 +65,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/go-ap/activitypub v0.0.0-20240910141749-b4b8c8aa484c h1:82lzmsy5Nr6JA6HcLRVxGfbdSoWfW45C6jnY3zFS7Ks= github.com/go-ap/activitypub v0.0.0-20240910141749-b4b8c8aa484c/go.mod h1:rpIPGre4qtTgSpVT0zz3hycAMuLtUt7BNngVNpyXhL8= github.com/go-ap/client v0.0.0-20240910141951-13a4f3c4fd53 h1:wHUTCltRHg+Uz24+Ym1Bz5AE/0mnIKjzbdcPU9MKH1Y= @@ -81,10 +83,18 @@ github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpv github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc= +github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0= +github.com/go-webauthn/x v0.1.14 h1:1wrB8jzXAofojJPAaRxnZhRgagvLGnLjhCAwg3kTpT0= +github.com/go-webauthn/x v0.1.14/go.mod h1:UuVvFZ8/NbOnkDz3y1NaxtUN87pmtpC1PQ+/5BBQRdc= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +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-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM= +github.com/google/go-tpm v0.9.1/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -273,6 +283,8 @@ github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLr github.com/vcraescu/go-paginator/v2 v2.0.0 h1:m9If0wF7pSjYfocrJZcyWNiWn7OfIeLFVQLbiDvHf3k= github.com/vcraescu/go-paginator/v2 v2.0.0/go.mod h1:qsrC8+/YgRL0LfurxeY3gCAtsN7oOthkIbmBdqpMX9U= github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0/go.mod h1:IXCdmsXIht47RaVFLEdVnh1t+pgYtTAhQGj73kz+2DM= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xhit/go-simple-mail/v2 v2.16.0 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICLkIkNVccA= github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98= github.com/yuin/goldmark v1.3.7/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= diff --git a/httpRouters.go b/httpRouters.go index 38bc6dc..50d804f 100644 --- a/httpRouters.go +++ b/httpRouters.go @@ -10,9 +10,15 @@ import ( // Login func (a *goBlog) loginRouter(r chi.Router) { - r.Use(a.authMiddleware) - r.Get("/login", serveLogin) - r.Get("/logout", a.serveLogout) + r.Group(func(r chi.Router) { + r.Use(a.authMiddleware) + r.Get("/login", serveLogin) + r.Get("/logout", a.serveLogout) + r.Post("/webauthn/registration/begin", a.beginWebAuthnRegistration) + r.Post("/webauthn/registration/finish", a.finishWebAuthnRegistration) + }) + r.Post("/webauthn/login/begin", a.beginWebAuthnLogin) + r.Post("/webauthn/login/finish", a.finishWebAuthnLogin) } // Micropub @@ -487,5 +493,6 @@ func (a *goBlog) blogSettingsRouter(_ *configBlog) func(r chi.Router) { r.Post(settingsUpdateUserPath, a.settingsUpdateUser) r.Post(settingsUpdateProfileImagePath, a.serveUpdateProfileImage) r.Post(settingsDeleteProfileImagePath, a.serveDeleteProfileImage) + r.Post(settingsDeletePasskeyPath, a.settingsDeletePasskey) } } diff --git a/main.go b/main.go index bf64985..6aab312 100644 --- a/main.go +++ b/main.go @@ -214,6 +214,10 @@ func (app *goBlog) initComponents() { app.logErrAndQuit("Failed to init ActivityPub", "err", err) return } + if err = app.initWebAuthn(); err != nil { + app.logErrAndQuit("Failed to init WebAuthn", "err", err) + return + } app.initWebmention() app.initTelegram() app.initBlogStats() diff --git a/sessions.go b/sessions.go index f987af8..fc4d23c 100644 --- a/sessions.go +++ b/sessions.go @@ -51,6 +51,16 @@ func (a *goBlog) initSessions() { }, db: a.db, } + a.webauthnSessions = &dbSessionStore{ + options: &sessions.Options{ + Secure: a.useSecureCookies(), + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: int((10 * time.Minute).Seconds()), + Path: "/", // Cookie for all pages + }, + db: a.db, + } } type dbSessionStore struct { diff --git a/settingsDb.go b/settingsDb.go index 020194c..fb42d0b 100644 --- a/settingsDb.go +++ b/settingsDb.go @@ -62,6 +62,14 @@ func (a *goBlog) saveSettingValue(name, value string) error { return err } +func (a *goBlog) deleteSettingValue(name string) error { + _, err := a.db.Exec( + "delete from settings where name = @name", + sql.Named("name", name), + ) + return err +} + func (a *goBlog) saveBooleanSettingValue(name string, value bool) error { return a.saveSettingValue(name, lo.If(value, "1").Else("0")) } diff --git a/strings/de.yaml b/strings/de.yaml index 6a417ae..28c93f6 100644 --- a/strings/de.yaml +++ b/strings/de.yaml @@ -21,6 +21,7 @@ delete: "Löschen" deleteall: "Alle löschen" deletedposts: "Gelöschte Posts" deletedpostsdesc: "Gelöschte Posts, die nach 7 Tagen endgültig gelöscht werden." +deletepasskey: "Passkey löschen" docomment: "Kommentieren" download: "Herunterladen" drafts: "Entwürfe" @@ -50,6 +51,7 @@ location: "Standort" locationfailed: "Abfragen des Standorts fehlgeschlagen" locationget: "Standort abfragen" locationnotsupported: "Die Standort-API wird von diesem Browser nicht unterstützt" +loginpasskey: "Mit Passkey anmelden" mediafiles: "Medien-Dateien" message: "Nachricht" messagesent: "Nachricht gesendet" @@ -67,6 +69,7 @@ privateposts: "Private Posts" privatepostsdesc: "Veröffentlichte Posts mit der Sichtbarkeit `private`, die nur eingeloggt sichtbar sind." profileimage: "Profilbild" publishedon: "Veröffentlicht am" +registerupdatepasskey: "Passkey registrieren oder aktualisieren" replyto: "Antwort an" scheduledposts: "Geplante Posts" scheduledpostsdesc: "Beiträge mit dem Status `scheduled`, die veröffentlicht werden, wenn das `published`-Datum erreicht ist." diff --git a/strings/default.yaml b/strings/default.yaml index 20a84b6..eef0b19 100644 --- a/strings/default.yaml +++ b/strings/default.yaml @@ -27,6 +27,7 @@ delete: "Delete" deleteall: "Delete all" deletedposts: "Deleted posts" deletedpostsdesc: "Deleted posts that will be permanently deleted after 7 days." +deletepasskey: "Delete Passkey" docomment: "Comment" download: "Download" drafts: "Drafts" @@ -59,6 +60,7 @@ locationfailed: "Failed to request the location" locationget: "Request location" locationnotsupported: "The location API is not supported by this browser" login: "Login" +loginpasskey: "Login with Passkey" logout: "Logout" mediafiles: "Media files" message: "Message" @@ -71,6 +73,7 @@ nolocations: "No posts with locations" noposts: "There are no posts here." notifications: "Notifications" oldcontent: "⚠️ This entry is already over one year old. It may no longer be up to date. Opinions may have changed." +passkey: "Passkey" password: "Password" pinned: "Pinned" posts: "Posts" @@ -80,6 +83,7 @@ privateposts: "Private posts" privatepostsdesc: "Published posts with visibility `private` that are visible only when logged in." profileimage: "Profile image" publishedon: "Published on" +registerupdatepasskey: "Register or update Passkey" replyto: "Reply to" reverify: "Reverify" scheduledposts: "Scheduled posts" diff --git a/templates/assets/js/webauthn.js b/templates/assets/js/webauthn.js new file mode 100644 index 0000000..a8922bb --- /dev/null +++ b/templates/assets/js/webauthn.js @@ -0,0 +1,136 @@ +(function () { + + function isWebAuthnSupported() { + return ( + !!(navigator.credentials && + typeof navigator.credentials.create === 'function' && + typeof navigator.credentials.get === 'function' && + window.PublicKeyCredential) + ); + } + + function stringToArray(value) { + return Uint8Array.from(value, c => c.charCodeAt(0)); + } + + function decodeBuffer(value) { + return stringToArray(atob(value.replace(/-/g, '+').replace(/_/g, '/'))); + } + + function encodeBuffer(value) { + try { + return btoa(String.fromCharCode.apply(null, new Uint8Array(value))) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + } catch (error) { + console.error('Error encoding buffer:', error); + throw new Error('Failed to encode buffer.'); + } + } + + async function register() { + try { + const response = await fetch('/webauthn/registration/begin', { method: 'POST' }); + if (!response.ok) { + const msg = await response.text(); + throw new Error('Failed to get registration options from server: ' + msg); + } + const options = await response.json(); + options.publicKey.challenge = decodeBuffer(options.publicKey.challenge); + options.publicKey.user.id = stringToArray(options.publicKey.user.id); + + const attestation = await navigator.credentials.create(options); + + const verificationResponse = await fetch('/webauthn/registration/finish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: attestation.id, + rawId: encodeBuffer(attestation.rawId), + type: attestation.type, + response: { + attestationObject: encodeBuffer(attestation.response.attestationObject), + clientDataJSON: encodeBuffer(attestation.response.clientDataJSON), + } + }) + }); + + if (verificationResponse.ok) { + window.location.reload(); + } else { + const msg = await verificationResponse.text(); + throw new Error('Registration failed: ' + msg); + } + } catch (error) { + console.error('Registration error:', error); + alert("An error occurred during WebAuthn registration: " + error.message); + } + } + + async function login() { + try { + const response = await fetch('/webauthn/login/begin', { method: 'POST' }); + if (!response.ok) { + const msg = await response.text(); + throw new Error('Failed to get login options from server: ' + msg); + } + const options = await response.json(); + options.publicKey.challenge = decodeBuffer(options.publicKey.challenge); + + if (options.publicKey.allowCredentials) { + options.publicKey.allowCredentials.forEach(credential => { + credential.id = decodeBuffer(credential.id); + }); + } + + const assertion = await navigator.credentials.get(options); + + const verificationResponse = await fetch('/webauthn/login/finish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: assertion.id, + rawId: encodeBuffer(assertion.rawId), + type: assertion.type, + response: { + authenticatorData: encodeBuffer(assertion.response.authenticatorData), + clientDataJSON: encodeBuffer(assertion.response.clientDataJSON), + signature: encodeBuffer(assertion.response.signature), + userHandle: encodeBuffer(assertion.response.userHandle) + } + }) + }); + + if (verificationResponse.ok) { + window.location.reload(); + } else { + const msg = await verificationResponse.text(); + throw new Error('Login failed: ' + msg); + } + } catch (error) { + console.error('Login error:', error); + alert("An error occurred during WebAuthn login: " + error.message); + } + } + + const registerBtn = document.querySelector('#registerwebauthn'); + const loginBtn = document.querySelector('#loginwebauthn'); + + if (registerBtn) { + if (isWebAuthnSupported()) { + registerBtn.classList.remove('hide'); + registerBtn.addEventListener('click', register); + } else { + console.warn('WebAuthn is not supported in this browser.'); + } + } + + if (loginBtn) { + if (isWebAuthnSupported()) { + loginBtn.classList.remove('hide'); + loginBtn.addEventListener('click', login); + } else { + console.warn('WebAuthn is not supported in this browser.'); + } + } + +})(); \ No newline at end of file diff --git a/ui.go b/ui.go index a9ef628..a44a6b6 100644 --- a/ui.go +++ b/ui.go @@ -228,8 +228,23 @@ func (a *goBlog) renderLogin(hb *htmlbuilder.HtmlBuilder, rd *renderData) { // Submit hb.WriteElementOpen("input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "login")) hb.WriteElementClose("form") + // WebAuthn login + hasPasskey := a.hasWebAuthnCredential() + if hasPasskey { + hb.WriteElementOpen("form", "class", "fw p") + hb.WriteElementOpen( + "input", "id", "loginwebauthn", "type", "button", "class", "hide", + "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "loginpasskey"), + ) + hb.WriteElementClose("form") + } // Author (required for some IndieWeb apps) a.renderAuthor(hb) + // Scripts + if hasPasskey { + hb.WriteElementOpen("script", "src", a.assetFileName("js/webauthn.js"), "defer", "") + hb.WriteElementClose("script") + } hb.WriteElementClose("main") }, ) @@ -1610,6 +1625,8 @@ func (a *goBlog) renderSettings(hb *htmlbuilder.HtmlBuilder, rd *renderData) { hb.WriteElementClose("script") hb.WriteElementOpen("script", "src", a.assetFileName("js/formconfirm.js"), "defer", "") hb.WriteElementClose("script") + hb.WriteElementOpen("script", "src", a.assetFileName("js/webauthn.js"), "defer", "") + hb.WriteElementClose("script") hb.WriteElementClose("main") }, diff --git a/uiComponents.go b/uiComponents.go index bd05028..98ca0b8 100644 --- a/uiComponents.go +++ b/uiComponents.go @@ -754,6 +754,26 @@ func (a *goBlog) renderUserSettings(hb *htmlbuilder.HtmlBuilder, rd *renderData, "formaction", rd.Blog.getRelativePath(settingsPath+settingsDeleteProfileImagePath), ) hb.WriteElementClose("form") + + hb.WriteElementOpen("h3") + hb.WriteEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "passkey")) + hb.WriteElementClose("h3") + + hb.WriteElementOpen("form", "class", "fw p") + hb.WriteElementOpen( + "input", "id", "registerwebauthn", "type", "button", "class", "hide", + "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "registerupdatepasskey"), + ) + hb.WriteElementClose("form") + + if a.hasWebAuthnCredential() { + hb.WriteElementOpen("form", "class", "fw p", "method", "post") + hb.WriteElementOpen( + "input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "deletepasskey"), + "formaction", rd.Blog.getRelativePath(settingsPath+settingsDeletePasskeyPath), + ) + hb.WriteElementClose("form") + } } func (a *goBlog) renderFooter(origHb *htmlbuilder.HtmlBuilder, rd *renderData) { diff --git a/webAuthn.go b/webAuthn.go new file mode 100644 index 0000000..98b2d28 --- /dev/null +++ b/webAuthn.go @@ -0,0 +1,239 @@ +package main + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/go-webauthn/webauthn/webauthn" +) + +const ( + webauthnCredSettingsKey = "webauthncred" + webauthnIdSettingsKey = "webauthnid" + settingsDeletePasskeyPath = "/deletepasskey" +) + +func (a *goBlog) initWebAuthn() error { + wconfig := &webauthn.Config{ + RPDisplayName: "GoBlog", + RPID: a.cfg.Server.publicHostname, + RPOrigins: []string{a.getFullAddress("/")}, + EncodeUserIDAsString: true, + Timeouts: webauthn.TimeoutsConfig{ + Login: webauthn.TimeoutConfig{ + Enforce: true, + Timeout: 5 * time.Minute, + TimeoutUVD: 5 * time.Minute, + }, + Registration: webauthn.TimeoutConfig{ + Enforce: true, + Timeout: 5 * time.Minute, + TimeoutUVD: 5 * time.Minute, + }, + }, + } + webAuthn, err := webauthn.New(wconfig) + if err != nil { + return err + } + a.webAuthn = webAuthn + return nil +} + +func (a *goBlog) beginWebAuthnRegistration(w http.ResponseWriter, r *http.Request) { + options, session, err := a.webAuthn.BeginRegistration(a.getWebAuthnUser()) + if err != nil { + a.debug("failed to begin webauthn registration", "err", err) + a.serveError(w, r, "", http.StatusBadRequest) + return + } + ses, err := a.webauthnSessions.New(r, "wa") + if err != nil { + a.debug("failed to create new webauthn registration session", "err", err) + a.serveError(w, r, "", http.StatusInternalServerError) + return + } + sessionJsonBytes, err := json.Marshal(session) + if err != nil { + a.debug("failed to marshal webauthn session to json", "err", err) + a.serveError(w, r, "", http.StatusInternalServerError) + return + } + ses.Values["session"] = string(sessionJsonBytes) + _ = ses.Save(r, w) + w.WriteHeader(http.StatusOK) + a.respondWithMinifiedJson(w, options) +} + +func (a *goBlog) finishWebAuthnRegistration(w http.ResponseWriter, r *http.Request) { + ses, err := a.webauthnSessions.Get(r, "wa") + if err != nil { + a.debug("failed to get webauthn session", "err", err) + a.serveError(w, r, "", http.StatusBadRequest) + return + } + sessionJson, ok := ses.Values["session"] + if !ok || sessionJson == "" { + a.serveError(w, r, "", http.StatusBadRequest) + return + } + var session webauthn.SessionData + if err := json.Unmarshal([]byte(sessionJson.(string)), &session); err != nil { + a.debug("failed to unmarshal webauthn session data", "err", err) + a.serveError(w, r, "", http.StatusBadRequest) + return + } + user := a.getWebAuthnUser() + credential, err := a.webAuthn.FinishRegistration(user, session, r) + if err != nil { + a.debug("failed to finish webauthn registration", "err", err) + a.serveError(w, r, "", http.StatusBadRequest) + return + } + if err := a.saveWebAuthnCredential(credential); err != nil { + a.error("failed to save webauthn credentials", "err", err) + a.serveError(w, r, "", http.StatusInternalServerError) + return + } + a.webauthnSessions.Delete(r, w, ses) + w.WriteHeader(http.StatusOK) +} + +func (a *goBlog) beginWebAuthnLogin(w http.ResponseWriter, r *http.Request) { + options, session, err := a.webAuthn.BeginLogin(a.getWebAuthnUser()) + if err != nil { + a.debug("failed to begin webauthn login", "err", err) + a.serveError(w, r, "", http.StatusBadRequest) + return + } + ses, err := a.webauthnSessions.New(r, "wa") + if err != nil { + a.debug("failed to create new webauthn login session", "err", err) + a.serveError(w, r, "", http.StatusInternalServerError) + return + } + sessionJsonBytes, err := json.Marshal(session) + if err != nil { + a.debug("failed to marshal webauthn session to json", "err", err) + a.serveError(w, r, "", http.StatusInternalServerError) + return + } + ses.Values["session"] = string(sessionJsonBytes) + _ = ses.Save(r, w) + w.WriteHeader(http.StatusOK) + a.respondWithMinifiedJson(w, options) +} + +func (a *goBlog) finishWebAuthnLogin(w http.ResponseWriter, r *http.Request) { + ses, err := a.webauthnSessions.Get(r, "wa") + if err != nil { + a.debug("failed to get webauthn session", "err", err) + a.serveError(w, r, "", http.StatusBadRequest) + return + } + sessionJson, ok := ses.Values["session"] + if !ok || sessionJson == "" { + a.serveError(w, r, "", http.StatusBadRequest) + return + } + var session webauthn.SessionData + if err := json.Unmarshal([]byte(sessionJson.(string)), &session); err != nil { + a.debug("failed to unmarshal webauthn session data", "err", err) + a.serveError(w, r, "", http.StatusBadRequest) + return + } + user := a.getWebAuthnUser() + credential, err := a.webAuthn.FinishLogin(user, session, r) + if err != nil { + a.debug("failed to finish webauthn login", "err", err) + a.serveError(w, r, "", http.StatusBadRequest) + return + } + if err := a.saveWebAuthnCredential(credential); err != nil { + a.debug("failed to update webauthn credentials", "err", err) + a.serveError(w, r, "", http.StatusInternalServerError) + return + } + a.webauthnSessions.Delete(r, w, ses) + // Also set login cookie + loginSes, err := a.loginSessions.Get(r, "l") + if err != nil { + a.serveError(w, r, "", http.StatusInternalServerError) + return + } + loginSes.Values["login"] = true + _ = a.loginSessions.Save(r, w, loginSes) + // Write header, login successful + w.WriteHeader(http.StatusOK) +} + +func (a *goBlog) settingsDeletePasskey(w http.ResponseWriter, r *http.Request) { + if err := a.deleteSettingValue(webauthnCredSettingsKey); err != nil { + a.debug("failed to delete webauthn cred", "err", err) + a.serveError(w, r, "failed to delete webauthn credential", http.StatusInternalServerError) + return + } + http.Redirect(w, r, ".", http.StatusFound) +} + +type webAuthnUser struct { + a *goBlog +} + +func (a *goBlog) getWebAuthnUser() *webAuthnUser { + return &webAuthnUser{a: a} +} + +func (u *webAuthnUser) WebAuthnID() []byte { + id, _ := u.a.getSettingValue(webauthnIdSettingsKey) + if id == "" { + id = randomString(32) + if err := u.a.saveSettingValue(webauthnIdSettingsKey, id); err != nil { + u.a.error("failed to save webauthnid settings value", "err", err) + } + } + return []byte(id) +} + +func (u *webAuthnUser) WebAuthnName() string { + return u.a.cfg.User.Name +} + +func (u *webAuthnUser) WebAuthnDisplayName() string { + return u.a.cfg.User.Name +} + +func (u *webAuthnUser) WebAuthnCredentials() []webauthn.Credential { + cred, err := u.a.getWebAuthnCredential() + if err != nil { + u.a.error("failed to read webauthn credentials from db", "err", err) + return nil + } + return []webauthn.Credential{*cred} +} + +func (a *goBlog) hasWebAuthnCredential() bool { + val, err := a.getSettingValue(webauthnCredSettingsKey) + return err == nil && val != "" +} + +func (a *goBlog) getWebAuthnCredential() (*webauthn.Credential, error) { + jsonStr, err := a.getSettingValue(webauthnCredSettingsKey) + if err != nil { + return nil, err + } + var cred webauthn.Credential + if err := json.Unmarshal([]byte(jsonStr), &cred); err != nil { + return nil, err + } + return &cred, nil +} + +func (a *goBlog) saveWebAuthnCredential(cred *webauthn.Credential) error { + credBytes, err := json.Marshal(cred) + if err != nil { + return err + } + return a.saveSettingValue(webauthnCredSettingsKey, string(credBytes)) +}