add webauthn (passkey) support

This commit is contained in:
Christoph Haas
2025-05-12 22:53:43 +02:00
parent 6a96925be7
commit 1394be2341
28 changed files with 1603 additions and 33 deletions

View File

@@ -28,12 +28,54 @@ type AuthenticationService interface {
OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, error)
}
type WebAuthnService interface {
Enabled() bool
StartWebAuthnRegistration(ctx context.Context, userId domain.UserIdentifier) (
responseOptions []byte,
sessionData []byte,
err error,
)
FinishWebAuthnRegistration(
ctx context.Context,
userId domain.UserIdentifier,
name string,
sessionDataAsJSON []byte,
r *http.Request,
) ([]domain.UserWebauthnCredential, error)
GetCredentials(
ctx context.Context,
userId domain.UserIdentifier,
) ([]domain.UserWebauthnCredential, error)
RemoveCredential(
ctx context.Context,
userId domain.UserIdentifier,
credentialIdBase64 string,
) ([]domain.UserWebauthnCredential, error)
UpdateCredential(
ctx context.Context,
userId domain.UserIdentifier,
credentialIdBase64 string,
name string,
) ([]domain.UserWebauthnCredential, error)
StartWebAuthnLogin(_ context.Context) (
optionsAsJSON []byte,
sessionDataAsJSON []byte,
err error,
)
FinishWebAuthnLogin(
ctx context.Context,
sessionDataAsJSON []byte,
r *http.Request,
) (*domain.User, error)
}
type AuthEndpoint struct {
cfg *config.Config
authService AuthenticationService
authenticator Authenticator
session Session
validate Validator
webAuthn WebAuthnService
}
func NewAuthEndpoint(
@@ -42,6 +84,7 @@ func NewAuthEndpoint(
session Session,
validator Validator,
authService AuthenticationService,
webAuthn WebAuthnService,
) AuthEndpoint {
return AuthEndpoint{
cfg: cfg,
@@ -49,6 +92,7 @@ func NewAuthEndpoint(
authenticator: authenticator,
session: session,
validate: validator,
webAuthn: webAuthn,
}
}
@@ -65,6 +109,19 @@ func (e AuthEndpoint) RegisterRoutes(g *routegroup.Bundle) {
apiGroup.HandleFunc("GET /login/{provider}/init", e.handleOauthInitiateGet())
apiGroup.HandleFunc("GET /login/{provider}/callback", e.handleOauthCallbackGet())
apiGroup.HandleFunc("POST /webauthn/login/start", e.handleWebAuthnLoginStart())
apiGroup.HandleFunc("POST /webauthn/login/finish", e.handleWebAuthnLoginFinish())
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("GET /webauthn/credentials",
e.handleWebAuthnCredentialsGet())
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("POST /webauthn/register/start",
e.handleWebAuthnRegisterStart())
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("POST /webauthn/register/finish",
e.handleWebAuthnRegisterFinish())
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("DELETE /webauthn/credential/{id}",
e.handleWebAuthnCredentialsDelete())
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("PUT /webauthn/credential/{id}",
e.handleWebAuthnCredentialsPut())
apiGroup.HandleFunc("POST /login", e.handleLoginPost())
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("POST /logout", e.handleLogoutPost())
}
@@ -389,3 +446,237 @@ func (e AuthEndpoint) isValidReturnUrl(returnUrl string) bool {
return true
}
// handleWebAuthnCredentialsGet returns a gorm Handler function.
//
// @ID auth_handleWebAuthnCredentialsGet
// @Tags Authentication
// @Summary Get all available external login providers.
// @Produce json
// @Success 200 {object} []model.WebAuthnCredentialResponse
// @Router /auth/webauthn/credentials [get]
func (e AuthEndpoint) handleWebAuthnCredentialsGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !e.webAuthn.Enabled() {
respond.JSON(w, http.StatusOK, []model.WebAuthnCredentialResponse{})
return
}
currentSession := e.session.GetData(r.Context())
userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier)
credentials, err := e.webAuthn.GetCredentials(r.Context(), userIdentifier)
if err != nil {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
respond.JSON(w, http.StatusOK, model.NewWebAuthnCredentialResponses(credentials))
}
}
// handleWebAuthnCredentialsDelete returns a gorm Handler function.
//
// @ID auth_handleWebAuthnCredentialsDelete
// @Tags Authentication
// @Summary Delete a WebAuthn credential.
// @Param id path string true "Base64 encoded Credential ID"
// @Produce json
// @Success 200 {object} []model.WebAuthnCredentialResponse
// @Router /auth/webauthn/credential/{id} [delete]
func (e AuthEndpoint) handleWebAuthnCredentialsDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !e.webAuthn.Enabled() {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
return
}
currentSession := e.session.GetData(r.Context())
userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier)
credentialId := Base64UrlDecode(request.Path(r, "id"))
credentials, err := e.webAuthn.RemoveCredential(r.Context(), userIdentifier, credentialId)
if err != nil {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
respond.JSON(w, http.StatusOK, model.NewWebAuthnCredentialResponses(credentials))
}
}
// handleWebAuthnCredentialsPut returns a gorm Handler function.
//
// @ID auth_handleWebAuthnCredentialsPut
// @Tags Authentication
// @Summary Update a WebAuthn credential.
// @Param id path string true "Base64 encoded Credential ID"
// @Param request body model.WebAuthnCredentialRequest true "Credential name"
// @Produce json
// @Success 200 {object} []model.WebAuthnCredentialResponse
// @Router /auth/webauthn/credential/{id} [put]
func (e AuthEndpoint) handleWebAuthnCredentialsPut() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !e.webAuthn.Enabled() {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
return
}
currentSession := e.session.GetData(r.Context())
userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier)
credentialId := Base64UrlDecode(request.Path(r, "id"))
var req model.WebAuthnCredentialRequest
if err := request.BodyJson(r, &req); err != nil {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
credentials, err := e.webAuthn.UpdateCredential(r.Context(), userIdentifier, credentialId, req.Name)
if err != nil {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
respond.JSON(w, http.StatusOK, model.NewWebAuthnCredentialResponses(credentials))
}
}
func (e AuthEndpoint) handleWebAuthnRegisterStart() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !e.webAuthn.Enabled() {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
return
}
currentSession := e.session.GetData(r.Context())
userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier)
options, sessionData, err := e.webAuthn.StartWebAuthnRegistration(r.Context(), userIdentifier)
if err != nil {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
currentSession.WebAuthnData = string(sessionData)
e.session.SetData(r.Context(), currentSession)
respond.Data(w, http.StatusOK, "application/json", options)
}
}
// handleWebAuthnRegisterFinish returns a gorm Handler function.
//
// @ID auth_handleWebAuthnRegisterFinish
// @Tags Authentication
// @Summary Finish the WebAuthn registration process.
// @Param credential_name query string false "Credential name" default("")
// @Produce json
// @Success 200 {object} []model.WebAuthnCredentialResponse
// @Router /auth/webauthn/register/finish [post]
func (e AuthEndpoint) handleWebAuthnRegisterFinish() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !e.webAuthn.Enabled() {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
return
}
name := request.QueryDefault(r, "credential_name", "")
currentSession := e.session.GetData(r.Context())
webAuthnSessionData := []byte(currentSession.WebAuthnData)
currentSession.WebAuthnData = "" // clear the session data
e.session.SetData(r.Context(), currentSession)
credentials, err := e.webAuthn.FinishWebAuthnRegistration(
r.Context(),
domain.UserIdentifier(currentSession.UserIdentifier),
name,
webAuthnSessionData,
r)
if err != nil {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
respond.JSON(w, http.StatusOK, model.NewWebAuthnCredentialResponses(credentials))
}
}
func (e AuthEndpoint) handleWebAuthnLoginStart() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !e.webAuthn.Enabled() {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
return
}
currentSession := e.session.GetData(r.Context())
options, sessionData, err := e.webAuthn.StartWebAuthnLogin(r.Context())
if err != nil {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
currentSession.WebAuthnData = string(sessionData)
e.session.SetData(r.Context(), currentSession)
respond.Data(w, http.StatusOK, "application/json", options)
}
}
// handleWebAuthnLoginFinish returns a gorm Handler function.
//
// @ID auth_handleWebAuthnLoginFinish
// @Tags Authentication
// @Summary Finish the WebAuthn login process.
// @Produce json
// @Success 200 {object} model.User
// @Router /auth/webauthn/login/finish [post]
func (e AuthEndpoint) handleWebAuthnLoginFinish() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !e.webAuthn.Enabled() {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: "WebAuthn is not enabled"})
return
}
currentSession := e.session.GetData(r.Context())
webAuthnSessionData := []byte(currentSession.WebAuthnData)
currentSession.WebAuthnData = "" // clear the session data
e.session.SetData(r.Context(), currentSession)
user, err := e.webAuthn.FinishWebAuthnLogin(
r.Context(),
webAuthnSessionData,
r)
if err != nil {
respond.JSON(w, http.StatusBadRequest,
model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
e.setAuthenticatedUser(r, user)
respond.JSON(w, http.StatusOK, model.NewUser(user, false))
}
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/h44z/wg-portal/internal/app/api/core/respond"
"github.com/h44z/wg-portal/internal/app/api/v0/model"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
)
//go:embed frontend_config.js.gotpl
@@ -46,7 +47,7 @@ func (e ConfigEndpoint) RegisterRoutes(g *routegroup.Bundle) {
apiGroup := g.Mount("/config")
apiGroup.HandleFunc("GET /frontend.js", e.handleConfigJsGet())
apiGroup.With(e.authenticator.LoggedIn()).HandleFunc("GET /settings", e.handleSettingsGet())
apiGroup.HandleFunc("GET /settings", e.handleSettingsGet())
}
// handleConfigJsGet returns a gorm Handler function.
@@ -93,11 +94,21 @@ func (e ConfigEndpoint) handleConfigJsGet() http.HandlerFunc {
// @Router /config/settings [get]
func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
respond.JSON(w, http.StatusOK, model.Settings{
MailLinkOnly: e.cfg.Mail.LinkOnly,
PersistentConfigSupported: e.cfg.Advanced.ConfigStoragePath != "",
SelfProvisioning: e.cfg.Core.SelfProvisioningAllowed,
ApiAdminOnly: e.cfg.Advanced.ApiAdminOnly,
})
sessionUser := domain.GetUserInfo(r.Context())
// For anonymous users, we return the settings object with minimal information
if sessionUser.Id == domain.CtxUnknownUserId || sessionUser.Id == "" {
respond.JSON(w, http.StatusOK, model.Settings{
WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled,
})
} else {
respond.JSON(w, http.StatusOK, model.Settings{
MailLinkOnly: e.cfg.Mail.LinkOnly,
PersistentConfigSupported: e.cfg.Advanced.ConfigStoragePath != "",
SelfProvisioning: e.cfg.Core.SelfProvisioningAllowed,
ApiAdminOnly: e.cfg.Advanced.ApiAdminOnly,
WebAuthnEnabled: e.cfg.Auth.WebAuthn.Enabled,
})
}
}
}

View File

@@ -31,6 +31,8 @@ type SessionData struct {
OauthProvider string
OauthReturnTo string
WebAuthnData string
CsrfToken string
}

View File

@@ -10,4 +10,5 @@ type Settings struct {
PersistentConfigSupported bool `json:"PersistentConfigSupported"`
SelfProvisioning bool `json:"SelfProvisioning"`
ApiAdminOnly bool `json:"ApiAdminOnly"`
WebAuthnEnabled bool `json:"WebAuthnEnabled"`
}

View File

@@ -1,6 +1,11 @@
package model
import "github.com/h44z/wg-portal/internal/domain"
import (
"slices"
"strings"
"github.com/h44z/wg-portal/internal/domain"
)
type LoginProviderInfo struct {
Identifier string `json:"Identifier" example:"google"`
@@ -39,3 +44,32 @@ type OauthInitiationResponse struct {
RedirectUrl string
State string
}
type WebAuthnCredentialRequest struct {
Name string `json:"Name"`
}
type WebAuthnCredentialResponse struct {
ID string `json:"ID"`
Name string `json:"Name"`
CreatedAt string `json:"CreatedAt"`
}
func NewWebAuthnCredentialResponse(src domain.UserWebauthnCredential) WebAuthnCredentialResponse {
return WebAuthnCredentialResponse{
ID: src.CredentialIdentifier,
Name: src.DisplayName,
CreatedAt: src.CreatedAt.Format("2006-01-02 15:04:05"),
}
}
func NewWebAuthnCredentialResponses(src []domain.UserWebauthnCredential) []WebAuthnCredentialResponse {
credentials := make([]WebAuthnCredentialResponse, len(src))
for i := range src {
credentials[i] = NewWebAuthnCredentialResponse(src[i])
}
// Sort by CreatedAt, newest first
slices.SortFunc(credentials, func(i, j WebAuthnCredentialResponse) int {
return strings.Compare(i.CreatedAt, j.CreatedAt)
})
return credentials
}