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

@@ -129,6 +129,152 @@
}
}
},
"/auth/webauthn/credential/{id}": {
"put": {
"produces": [
"application/json"
],
"tags": [
"Authentication"
],
"summary": "Update a WebAuthn credential.",
"operationId": "auth_handleWebAuthnCredentialsPut",
"parameters": [
{
"type": "string",
"description": "Base64 encoded Credential ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Credential name",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/model.WebAuthnCredentialRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/model.WebAuthnCredentialResponse"
}
}
}
}
},
"delete": {
"produces": [
"application/json"
],
"tags": [
"Authentication"
],
"summary": "Delete a WebAuthn credential.",
"operationId": "auth_handleWebAuthnCredentialsDelete",
"parameters": [
{
"type": "string",
"description": "Base64 encoded Credential ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/model.WebAuthnCredentialResponse"
}
}
}
}
}
},
"/auth/webauthn/credentials": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Authentication"
],
"summary": "Get all available external login providers.",
"operationId": "auth_handleWebAuthnCredentialsGet",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/model.WebAuthnCredentialResponse"
}
}
}
}
}
},
"/auth/webauthn/login/finish": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Authentication"
],
"summary": "Finish the WebAuthn login process.",
"operationId": "auth_handleWebAuthnLoginFinish",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/model.User"
}
}
}
}
},
"/auth/webauthn/register/finish": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Authentication"
],
"summary": "Finish the WebAuthn registration process.",
"operationId": "auth_handleWebAuthnRegisterFinish",
"parameters": [
{
"type": "string",
"default": "\"\"",
"description": "Credential name",
"name": "credential_name",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/model.WebAuthnCredentialResponse"
}
}
}
}
}
},
"/auth/{provider}/callback": {
"get": {
"produces": [
@@ -2093,6 +2239,9 @@
},
"SelfProvisioning": {
"type": "boolean"
},
"WebAuthnEnabled": {
"type": "boolean"
}
}
},
@@ -2161,6 +2310,28 @@
"type": "string"
}
}
},
"model.WebAuthnCredentialRequest": {
"type": "object",
"properties": {
"Name": {
"type": "string"
}
}
},
"model.WebAuthnCredentialResponse": {
"type": "object",
"properties": {
"CreatedAt": {
"type": "string"
},
"ID": {
"type": "string"
},
"Name": {
"type": "string"
}
}
}
}
}

View File

@@ -387,6 +387,8 @@ definitions:
type: boolean
SelfProvisioning:
type: boolean
WebAuthnEnabled:
type: boolean
type: object
model.User:
properties:
@@ -433,6 +435,20 @@ definitions:
Source:
type: string
type: object
model.WebAuthnCredentialRequest:
properties:
Name:
type: string
type: object
model.WebAuthnCredentialResponse:
properties:
CreatedAt:
type: string
ID:
type: string
Name:
type: string
type: object
info:
contact:
name: WireGuard Portal Developers
@@ -548,6 +564,102 @@ paths:
summary: Get information about the currently logged-in user.
tags:
- Authentication
/auth/webauthn/credential/{id}:
delete:
operationId: auth_handleWebAuthnCredentialsDelete
parameters:
- description: Base64 encoded Credential ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/model.WebAuthnCredentialResponse'
type: array
summary: Delete a WebAuthn credential.
tags:
- Authentication
put:
operationId: auth_handleWebAuthnCredentialsPut
parameters:
- description: Base64 encoded Credential ID
in: path
name: id
required: true
type: string
- description: Credential name
in: body
name: request
required: true
schema:
$ref: '#/definitions/model.WebAuthnCredentialRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/model.WebAuthnCredentialResponse'
type: array
summary: Update a WebAuthn credential.
tags:
- Authentication
/auth/webauthn/credentials:
get:
operationId: auth_handleWebAuthnCredentialsGet
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/model.WebAuthnCredentialResponse'
type: array
summary: Get all available external login providers.
tags:
- Authentication
/auth/webauthn/login/finish:
post:
operationId: auth_handleWebAuthnLoginFinish
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/model.User'
summary: Finish the WebAuthn login process.
tags:
- Authentication
/auth/webauthn/register/finish:
post:
operationId: auth_handleWebAuthnRegisterFinish
parameters:
- default: '""'
description: Credential name
in: query
name: credential_name
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/model.WebAuthnCredentialResponse'
type: array
summary: Finish the WebAuthn registration process.
tags:
- Authentication
/config/frontend.js:
get:
operationId: config_handleConfigJsGet

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
}