mirror of
https://github.com/h44z/wg-portal.git
synced 2025-09-15 07:11:15 +00:00
add webauthn (passkey) support
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
|
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -31,6 +31,8 @@ type SessionData struct {
|
||||
OauthProvider string
|
||||
OauthReturnTo string
|
||||
|
||||
WebAuthnData string
|
||||
|
||||
CsrfToken string
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user