Skip to content

Commit

Permalink
Add passkeys support (closes #39)
Browse files Browse the repository at this point in the history
  • Loading branch information
jlelse committed Sep 29, 2024
1 parent 865a3da commit 82b0f6d
Show file tree
Hide file tree
Showing 15 changed files with 476 additions and 4 deletions.
5 changes: 4 additions & 1 deletion app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -100,12 +101,14 @@ type goBlog struct {
// Regex Redirects
regexRedirects []*regexRedirect
// Sessions
loginSessions, captchaSessions *dbSessionStore
loginSessions, captchaSessions, webauthnSessions *dbSessionStore
// Shutdown
shutdown shutdowner.Shutdowner
// Template strings
ts *ts.TemplateStrings
// Tor
torAddress string
torHostname string
// WebAuthn
webAuthn *webauthn.WebAuthn
}
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 2 additions & 0 deletions docs/storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ posts
posts_fts
queue
reactions
sections
sessions
settings
shortpath
webmentions
```
Expand Down
6 changes: 6 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
13 changes: 10 additions & 3 deletions httpRouters.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
4 changes: 4 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
10 changes: 10 additions & 0 deletions sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions settingsDb.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
Expand Down
3 changes: 3 additions & 0 deletions strings/de.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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."
Expand Down
4 changes: 4 additions & 0 deletions strings/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand Down
136 changes: 136 additions & 0 deletions templates/assets/js/webauthn.js
Original file line number Diff line number Diff line change
@@ -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.');
}
}

})();
Loading

0 comments on commit 82b0f6d

Please sign in to comment.