mirror of
https://github.com/miniflux/v2.git
synced 2025-09-15 18:57:04 +00:00
Add WebAuthn / Passkey integration
This is a rebase of #1618 in which @dave-atx added WebAuthn support. Closes #1618
This commit is contained in:
parent
62188b49f0
commit
62ef8ed57a
42 changed files with 1357 additions and 33 deletions
20
internal/ui/form/webauthn.go
Normal file
20
internal/ui/form/webauthn.go
Normal file
|
@ -0,0 +1,20 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package form // import "miniflux.app/v2/internal/ui/form"
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// WebauthnForm represents a credential rename form in the UI
|
||||
type WebauthnForm struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// NewWebauthnForm returns a new WebnauthnForm.
|
||||
func NewWebauthnForm(r *http.Request) *WebauthnForm {
|
||||
return &WebauthnForm{
|
||||
Name: r.FormValue("name"),
|
||||
}
|
||||
}
|
|
@ -120,7 +120,7 @@ func (m *middleware) handleAppSession(next http.Handler) http.Handler {
|
|||
ctx = context.WithValue(ctx, request.UserThemeContextKey, session.Data.Theme)
|
||||
ctx = context.WithValue(ctx, request.PocketRequestTokenContextKey, session.Data.PocketRequestToken)
|
||||
ctx = context.WithValue(ctx, request.LastForceRefreshContextKey, session.Data.LastForceRefresh)
|
||||
|
||||
ctx = context.WithValue(ctx, request.WebAuthnDataContextKey, session.Data.WebAuthnSessionData)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
@ -159,7 +159,9 @@ func (m *middleware) isPublicRoute(r *http.Request) bool {
|
|||
"sharedEntry",
|
||||
"healthcheck",
|
||||
"offline",
|
||||
"proxy":
|
||||
"proxy",
|
||||
"webauthnLoginBegin",
|
||||
"webauthnLoginFinish":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
|
|
@ -6,6 +6,7 @@ package session // import "miniflux.app/v2/internal/ui/session"
|
|||
import (
|
||||
"time"
|
||||
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/storage"
|
||||
)
|
||||
|
||||
|
@ -72,3 +73,7 @@ func (s *Session) SetTheme(theme string) {
|
|||
func (s *Session) SetPocketRequestToken(requestToken string) {
|
||||
s.store.UpdateAppSessionField(s.sessionID, "pocket_request_token", requestToken)
|
||||
}
|
||||
|
||||
func (s *Session) SetWebAuthnSessionData(sessionData *model.WebAuthnSession) {
|
||||
s.store.UpdateAppSessionObjectField(s.sessionID, "webauthn_session_data", sessionData)
|
||||
}
|
||||
|
|
|
@ -52,6 +52,12 @@ func (h *handler) showSettingsPage(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
creds, err := h.store.WebAuthnCredentialsByUserID(user.ID)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
view.Set("form", settingsForm)
|
||||
view.Set("themes", model.Themes())
|
||||
view.Set("languages", locale.AvailableLanguages())
|
||||
|
@ -62,6 +68,8 @@ func (h *handler) showSettingsPage(w http.ResponseWriter, r *http.Request) {
|
|||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
view.Set("default_home_pages", model.HomePages())
|
||||
view.Set("categories_sorting_options", model.CategoriesSortingOptions())
|
||||
view.Set("countWebAuthnCerts", h.store.CountWebAuthnCredentialsByUserID(user.ID))
|
||||
view.Set("webAuthnCerts", creds)
|
||||
|
||||
html.OK(w, r, view.Render("settings"))
|
||||
}
|
||||
|
|
|
@ -1112,4 +1112,8 @@ audio, video {
|
|||
|
||||
.integration-form details .form-section {
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
|
196
internal/ui/static/js/webauthn.js
Normal file
196
internal/ui/static/js/webauthn.js
Normal file
|
@ -0,0 +1,196 @@
|
|||
function isWebAuthnSupported() {
|
||||
return window.PublicKeyCredential;
|
||||
}
|
||||
|
||||
async function isConditionalLoginSupported() {
|
||||
return isWebAuthnSupported() &&
|
||||
window.PublicKeyCredential.isConditionalMediationAvailable &&
|
||||
window.PublicKeyCredential.isConditionalMediationAvailable();
|
||||
}
|
||||
|
||||
// URLBase64 to ArrayBuffer
|
||||
function bufferDecode(value) {
|
||||
return Uint8Array.from(atob(value.replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
// ArrayBuffer to URLBase64
|
||||
function bufferEncode(value) {
|
||||
return btoa(String.fromCharCode.apply(null, new Uint8Array(value)))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
}
|
||||
|
||||
function getCsrfToken() {
|
||||
let element = document.querySelector("body[data-csrf-token]");
|
||||
if (element !== null) {
|
||||
return element.dataset.csrfToken;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function post(urlKey, username, data) {
|
||||
var url = document.body.dataset[urlKey];
|
||||
if (username) {
|
||||
url += "?username=" + username;
|
||||
}
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Csrf-Token": getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async function get(urlKey, username) {
|
||||
var url = document.body.dataset[urlKey];
|
||||
if (username) {
|
||||
url += "?username=" + username;
|
||||
}
|
||||
return fetch(url);
|
||||
}
|
||||
|
||||
function showError(error) {
|
||||
console.log("webauthn error: " + error);
|
||||
let alert = document.getElementById("webauthn-error");
|
||||
if (alert) {
|
||||
alert.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
async function register() {
|
||||
let beginRegisterURL = "webauthnRegisterBeginUrl";
|
||||
let r = await get(beginRegisterURL);
|
||||
let credOptions = await r.json();
|
||||
credOptions.publicKey.challenge = bufferDecode(credOptions.publicKey.challenge);
|
||||
credOptions.publicKey.user.id = bufferDecode(credOptions.publicKey.user.id);
|
||||
if(Object.hasOwn(credOptions.publicKey, 'excludeCredentials')) {
|
||||
credOptions.publicKey.excludeCredentials.forEach((credential) => credential.id = bufferDecode(credential.id));
|
||||
}
|
||||
let attestation = await navigator.credentials.create(credOptions);
|
||||
let cred = {
|
||||
id: attestation.id,
|
||||
rawId: bufferEncode(attestation.rawId),
|
||||
type: attestation.type,
|
||||
response: {
|
||||
attestationObject: bufferEncode(attestation.response.attestationObject),
|
||||
clientDataJSON: bufferEncode(attestation.response.clientDataJSON),
|
||||
},
|
||||
};
|
||||
let finishRegisterURL = "webauthnRegisterFinishUrl";
|
||||
let response = await post(finishRegisterURL, null, cred);
|
||||
if (!response.ok) {
|
||||
throw new Error("Login failed with HTTP status " + response.status);
|
||||
}
|
||||
console.log("registration successful");
|
||||
|
||||
let jsonData = await response.json();
|
||||
let redirect = jsonData.redirect;
|
||||
window.location.href = redirect;
|
||||
}
|
||||
|
||||
async function login(username, conditional) {
|
||||
let beginLoginURL = "webauthnLoginBeginUrl";
|
||||
let r = await get(beginLoginURL, username);
|
||||
let credOptions = await r.json();
|
||||
credOptions.publicKey.challenge = bufferDecode(credOptions.publicKey.challenge);
|
||||
if(Object.hasOwn(credOptions.publicKey, 'allowCredentials')) {
|
||||
credOptions.publicKey.allowCredentials.forEach((credential) => credential.id = bufferDecode(credential.id));
|
||||
}
|
||||
if (conditional) {
|
||||
credOptions.signal = abortController.signal;
|
||||
credOptions.mediation = "conditional";
|
||||
}
|
||||
|
||||
var assertion;
|
||||
try {
|
||||
assertion = await navigator.credentials.get(credOptions);
|
||||
}
|
||||
catch (err) {
|
||||
// swallow aborted conditional logins
|
||||
if (err instanceof DOMException && err.name == "AbortError") {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!assertion) {
|
||||
return;
|
||||
}
|
||||
|
||||
let assertionResponse = {
|
||||
id: assertion.id,
|
||||
rawId: bufferEncode(assertion.rawId),
|
||||
type: assertion.type,
|
||||
response: {
|
||||
authenticatorData: bufferEncode(assertion.response.authenticatorData),
|
||||
clientDataJSON: bufferEncode(assertion.response.clientDataJSON),
|
||||
signature: bufferEncode(assertion.response.signature),
|
||||
userHandle: bufferEncode(assertion.response.userHandle),
|
||||
},
|
||||
};
|
||||
|
||||
let finishLoginURL = "webauthnLoginFinishUrl";
|
||||
let response = await post(finishLoginURL, username, assertionResponse);
|
||||
if (!response.ok) {
|
||||
throw new Error("Login failed with HTTP status " + response.status);
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
async function conditionalLogin() {
|
||||
if (await isConditionalLoginSupported()) {
|
||||
login("", true);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeCreds(event) {
|
||||
event.preventDefault();
|
||||
let removeCredsURL = "webauthnDeleteAllUrl";
|
||||
await post(removeCredsURL, null, {});
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
let abortController = new AbortController();
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
if (!isWebAuthnSupported()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let registerButton = document.getElementById("webauthn-register");
|
||||
if (registerButton != null) {
|
||||
registerButton.disabled = false;
|
||||
registerButton.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
register().catch((err) => showError(err));
|
||||
});
|
||||
}
|
||||
|
||||
let removeCredsButton = document.getElementById("webauthn-delete");
|
||||
if (removeCredsButton != null) {
|
||||
removeCredsButton.addEventListener("click", removeCreds);
|
||||
}
|
||||
|
||||
let loginButton = document.getElementById("webauthn-login");
|
||||
if (loginButton != null) {
|
||||
loginButton.disabled = false;
|
||||
let usernameField = document.getElementById("form-username");
|
||||
if (usernameField != null) {
|
||||
usernameField.autocomplete += " webauthn";
|
||||
}
|
||||
let passwordField = document.getElementById("form-password");
|
||||
if (passwordField != null) {
|
||||
passwordField.autocomplete += " webauthn";
|
||||
}
|
||||
|
||||
loginButton.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
abortController.abort();
|
||||
login(usernameField.value).catch(err => showError(err));
|
||||
});
|
||||
|
||||
conditionalLogin().catch(err => showError(err));
|
||||
}
|
||||
});
|
|
@ -123,6 +123,9 @@ func GenerateJavascriptBundles() error {
|
|||
"service-worker": {
|
||||
"js/service_worker.js",
|
||||
},
|
||||
"webauthn": {
|
||||
"js/webauthn.js",
|
||||
},
|
||||
}
|
||||
|
||||
var prefixes = map[string]string{
|
||||
|
|
|
@ -151,6 +151,16 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
|
|||
uiRouter.HandleFunc("/logout", handler.logout).Name("logout").Methods(http.MethodGet)
|
||||
uiRouter.Handle("/", middleware.handleAuthProxy(http.HandlerFunc(handler.showLoginPage))).Name("login").Methods(http.MethodGet)
|
||||
|
||||
// WebAuthn flow
|
||||
uiRouter.HandleFunc("/webauthn/register/begin", handler.beginRegistration).Name("webauthnRegisterBegin").Methods(http.MethodGet)
|
||||
uiRouter.HandleFunc("/webauthn/register/finish", handler.finishRegistration).Name("webauthnRegisterFinish").Methods(http.MethodPost)
|
||||
uiRouter.HandleFunc("/webauthn/login/begin", handler.beginLogin).Name("webauthnLoginBegin").Methods(http.MethodGet)
|
||||
uiRouter.HandleFunc("/webauthn/login/finish", handler.finishLogin).Name("webauthnLoginFinish").Methods(http.MethodPost)
|
||||
uiRouter.HandleFunc("/webauthn/deleteall", handler.deleteAllCredentials).Name("webauthnDeleteAll").Methods(http.MethodPost)
|
||||
uiRouter.HandleFunc("/webauthn/{credentialHandle}/delete", handler.deleteCredential).Name("webauthnDelete").Methods(http.MethodPost)
|
||||
uiRouter.HandleFunc("/webauthn/{credentialHandle}/rename", handler.renameCredential).Name("webauthnRename").Methods(http.MethodGet)
|
||||
uiRouter.HandleFunc("/webauthn/{credentialHandle}/save", handler.saveCredential).Name("webauthnSave").Methods(http.MethodPost)
|
||||
|
||||
router.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write([]byte("User-agent: *\nDisallow: /"))
|
||||
|
|
|
@ -6,6 +6,7 @@ package view // import "miniflux.app/v2/internal/ui/view"
|
|||
import (
|
||||
"net/http"
|
||||
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/template"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
|
@ -43,5 +44,7 @@ func New(tpl *template.Engine, r *http.Request, sess *session.Session) *View {
|
|||
b.params["theme_checksum"] = static.StylesheetBundleChecksums[theme]
|
||||
b.params["app_js_checksum"] = static.JavascriptBundleChecksums["app"]
|
||||
b.params["sw_js_checksum"] = static.JavascriptBundleChecksums["service-worker"]
|
||||
b.params["webauthn_js_checksum"] = static.JavascriptBundleChecksums["webauthn"]
|
||||
b.params["webAuthnEnabled"] = config.Opts.WebAuthn()
|
||||
return b
|
||||
}
|
||||
|
|
395
internal/ui/webauthn.go
Normal file
395
internal/ui/webauthn.go
Normal file
|
@ -0,0 +1,395 @@
|
|||
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package ui // import "miniflux.app/v2/internal/ui"
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"miniflux.app/v2/internal/config"
|
||||
"miniflux.app/v2/internal/crypto"
|
||||
"miniflux.app/v2/internal/http/cookie"
|
||||
"miniflux.app/v2/internal/http/request"
|
||||
"miniflux.app/v2/internal/http/response/html"
|
||||
"miniflux.app/v2/internal/http/response/json"
|
||||
"miniflux.app/v2/internal/http/route"
|
||||
"miniflux.app/v2/internal/model"
|
||||
"miniflux.app/v2/internal/ui/form"
|
||||
"miniflux.app/v2/internal/ui/session"
|
||||
"miniflux.app/v2/internal/ui/view"
|
||||
)
|
||||
|
||||
type WebAuthnUser struct {
|
||||
User *model.User
|
||||
AuthnID []byte
|
||||
Credentials []model.WebAuthnCredential
|
||||
}
|
||||
|
||||
func (u WebAuthnUser) WebAuthnID() []byte {
|
||||
return u.AuthnID
|
||||
}
|
||||
|
||||
func (u WebAuthnUser) WebAuthnName() string {
|
||||
return u.User.Username
|
||||
}
|
||||
|
||||
func (u WebAuthnUser) WebAuthnDisplayName() string {
|
||||
return u.User.Username
|
||||
}
|
||||
|
||||
func (u WebAuthnUser) WebAuthnIcon() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (u WebAuthnUser) WebAuthnCredentials() []webauthn.Credential {
|
||||
creds := make([]webauthn.Credential, len(u.Credentials))
|
||||
for i, cred := range u.Credentials {
|
||||
creds[i] = cred.Credential
|
||||
}
|
||||
return creds
|
||||
}
|
||||
|
||||
func newWebAuthn(h *handler) (*webauthn.WebAuthn, error) {
|
||||
url, err := url.Parse(config.Opts.BaseURL())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return webauthn.New(&webauthn.Config{
|
||||
RPDisplayName: "Miniflux",
|
||||
RPID: url.Hostname(),
|
||||
RPOrigin: config.Opts.RootURL(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) beginRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
web, err := newWebAuthn(h)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
uid := request.UserID(r)
|
||||
if uid == 0 {
|
||||
json.Unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
user, err := h.store.UserByID(uid)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
var creds []model.WebAuthnCredential
|
||||
|
||||
creds, err = h.store.WebAuthnCredentialsByUserID(user.ID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
credsDescriptors := make([]protocol.CredentialDescriptor, len(creds))
|
||||
for i, cred := range creds {
|
||||
credsDescriptors[i] = cred.Credential.Descriptor()
|
||||
}
|
||||
|
||||
options, sessionData, err := web.BeginRegistration(
|
||||
WebAuthnUser{
|
||||
user,
|
||||
crypto.GenerateRandomBytes(32),
|
||||
nil,
|
||||
},
|
||||
webauthn.WithExclusions(credsDescriptors),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
s := session.New(h.store, request.SessionID(r))
|
||||
s.SetWebAuthnSessionData(&model.WebAuthnSession{SessionData: sessionData})
|
||||
json.OK(w, r, options)
|
||||
}
|
||||
|
||||
func (h *handler) finishRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
web, err := newWebAuthn(h)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
uid := request.UserID(r)
|
||||
if uid == 0 {
|
||||
json.Unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
user, err := h.store.UserByID(uid)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
sessionData := request.WebAuthnSessionData(r)
|
||||
webAuthnUser := WebAuthnUser{user, sessionData.UserID, nil}
|
||||
cred, err := web.FinishRegistration(webAuthnUser, *sessionData.SessionData, r)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.store.AddWebAuthnCredential(uid, sessionData.UserID, cred)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
handleEncoded := model.WebAuthnCredential{Handle: sessionData.UserID}.HandleEncoded()
|
||||
redirect := route.Path(h.router, "webauthnRename", "credentialHandle", handleEncoded)
|
||||
json.OK(w, r, map[string]string{"redirect": redirect})
|
||||
}
|
||||
|
||||
func (h *handler) beginLogin(w http.ResponseWriter, r *http.Request) {
|
||||
web, err := newWebAuthn(h)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
var user *model.User
|
||||
username := request.QueryStringParam(r, "username", "")
|
||||
if username != "" {
|
||||
user, err = h.store.UserByUsername(username)
|
||||
if err != nil {
|
||||
json.Unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var assertion *protocol.CredentialAssertion
|
||||
var sessionData *webauthn.SessionData
|
||||
if user != nil {
|
||||
creds, err := h.store.WebAuthnCredentialsByUserID(user.ID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
assertion, sessionData, err = web.BeginLogin(WebAuthnUser{user, nil, creds})
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
assertion, sessionData, err = web.BeginDiscoverableLogin()
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s := session.New(h.store, request.SessionID(r))
|
||||
s.SetWebAuthnSessionData(&model.WebAuthnSession{SessionData: sessionData})
|
||||
json.OK(w, r, assertion)
|
||||
}
|
||||
|
||||
func (h *handler) finishLogin(w http.ResponseWriter, r *http.Request) {
|
||||
web, err := newWebAuthn(h)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
parsedResponse, err := protocol.ParseCredentialRequestResponseBody(r.Body)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
sessionData := request.WebAuthnSessionData(r)
|
||||
|
||||
var user *model.User
|
||||
username := request.QueryStringParam(r, "username", "")
|
||||
if username != "" {
|
||||
user, err = h.store.UserByUsername(username)
|
||||
if err != nil {
|
||||
json.Unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var cred *model.WebAuthnCredential
|
||||
if user != nil {
|
||||
creds, err := h.store.WebAuthnCredentialsByUserID(user.ID)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
sessionData.SessionData.UserID = parsedResponse.Response.UserHandle
|
||||
credCredential, err := web.ValidateLogin(WebAuthnUser{user, parsedResponse.Response.UserHandle, creds}, *sessionData.SessionData, parsedResponse)
|
||||
if err != nil {
|
||||
json.Unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
for _, credTest := range creds {
|
||||
if bytes.Equal(credCredential.ID, credTest.Credential.ID) {
|
||||
cred = &credTest
|
||||
}
|
||||
}
|
||||
|
||||
if cred == nil {
|
||||
json.ServerError(w, r, fmt.Errorf("no matching credential for %v", credCredential))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
userByHandle := func(rawID, userHandle []byte) (webauthn.User, error) {
|
||||
var uid int64
|
||||
uid, cred, err = h.store.WebAuthnCredentialByHandle(userHandle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if uid == 0 {
|
||||
return nil, fmt.Errorf("no user found for handle %x", userHandle)
|
||||
}
|
||||
user, err = h.store.UserByID(uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, fmt.Errorf("no user found for handle %x", userHandle)
|
||||
}
|
||||
return WebAuthnUser{user, userHandle, []model.WebAuthnCredential{*cred}}, nil
|
||||
}
|
||||
|
||||
_, err = web.ValidateDiscoverableLogin(userByHandle, *sessionData.SessionData, parsedResponse)
|
||||
if err != nil {
|
||||
json.Unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sessionToken, _, err := h.store.CreateUserSessionFromUsername(user.Username, r.UserAgent(), request.ClientIP(r))
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.store.WebAuthnSaveLogin(cred.Handle)
|
||||
|
||||
slog.Info("User authenticated successfully with webauthn",
|
||||
slog.Bool("authentication_successful", true),
|
||||
slog.String("client_ip", request.ClientIP(r)),
|
||||
slog.String("user_agent", r.UserAgent()),
|
||||
slog.Int64("user_id", user.ID),
|
||||
slog.String("username", user.Username),
|
||||
)
|
||||
h.store.SetLastLogin(user.ID)
|
||||
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
sess.SetLanguage(user.Language)
|
||||
sess.SetTheme(user.Theme)
|
||||
|
||||
http.SetCookie(w, cookie.New(
|
||||
cookie.CookieUserSessionID,
|
||||
sessionToken,
|
||||
config.Opts.HTTPS,
|
||||
config.Opts.BasePath(),
|
||||
))
|
||||
|
||||
json.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) renameCredential(w http.ResponseWriter, r *http.Request) {
|
||||
sess := session.New(h.store, request.SessionID(r))
|
||||
view := view.New(h.tpl, r, sess)
|
||||
|
||||
user, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
credentialHandleEncoded := request.RouteStringParam(r, "credentialHandle")
|
||||
credentialHandle, err := hex.DecodeString(credentialHandleEncoded)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
cred_uid, cred, err := h.store.WebAuthnCredentialByHandle(credentialHandle)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if cred_uid != user.ID {
|
||||
html.Forbidden(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
webauthnForm := form.WebauthnForm{Name: cred.Name}
|
||||
|
||||
view.Set("form", webauthnForm)
|
||||
view.Set("cred", cred)
|
||||
view.Set("menu", "settings")
|
||||
view.Set("user", user)
|
||||
view.Set("countUnread", h.store.CountUnreadEntries(user.ID))
|
||||
view.Set("countErrorFeeds", h.store.CountUserFeedsWithErrors(user.ID))
|
||||
|
||||
html.OK(w, r, view.Render("webauthn_rename"))
|
||||
}
|
||||
|
||||
func (h *handler) saveCredential(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := h.store.UserByID(request.UserID(r))
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
credentialHandleEncoded := request.RouteStringParam(r, "credentialHandle")
|
||||
credentialHandle, err := hex.DecodeString(credentialHandleEncoded)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
newName := r.FormValue("name")
|
||||
err = h.store.WebAuthnUpdateName(credentialHandle, newName)
|
||||
if err != nil {
|
||||
html.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
html.Redirect(w, r, route.Path(h.router, "settings"))
|
||||
}
|
||||
|
||||
func (h *handler) deleteCredential(w http.ResponseWriter, r *http.Request) {
|
||||
uid := request.UserID(r)
|
||||
if uid == 0 {
|
||||
json.Unauthorized(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
credentialHandleEncoded := request.RouteStringParam(r, "credentialHandle")
|
||||
credentialHandle, err := hex.DecodeString(credentialHandleEncoded)
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.store.DeleteCredentialByHandle(uid, []byte(credentialHandle))
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) deleteAllCredentials(w http.ResponseWriter, r *http.Request) {
|
||||
err := h.store.DeleteAllWebAuthnCredentialsByUserID(request.UserID(r))
|
||||
if err != nil {
|
||||
json.ServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
json.NoContent(w, r)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue