mirror of
https://github.com/h44z/wg-portal.git
synced 2025-06-27 16:57:01 +00:00
302 lines
8.1 KiB
Go
302 lines
8.1 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
|
|
"github.com/go-webauthn/webauthn/protocol"
|
|
"github.com/go-webauthn/webauthn/webauthn"
|
|
|
|
"github.com/h44z/wg-portal/internal/app"
|
|
"github.com/h44z/wg-portal/internal/app/audit"
|
|
"github.com/h44z/wg-portal/internal/config"
|
|
"github.com/h44z/wg-portal/internal/domain"
|
|
)
|
|
|
|
type WebAuthnUserManager interface {
|
|
// GetUser returns a user by its identifier.
|
|
GetUser(context.Context, domain.UserIdentifier) (*domain.User, error)
|
|
// GetUserByWebAuthnCredential returns a user by its WebAuthn ID.
|
|
GetUserByWebAuthnCredential(ctx context.Context, credentialIdBase64 string) (*domain.User, error)
|
|
// UpdateUser updates an existing user in the database.
|
|
UpdateUser(ctx context.Context, user *domain.User) (*domain.User, error)
|
|
}
|
|
|
|
type WebAuthnAuthenticator struct {
|
|
webAuthn *webauthn.WebAuthn
|
|
users WebAuthnUserManager
|
|
bus EventBus
|
|
}
|
|
|
|
func NewWebAuthnAuthenticator(cfg *config.Config, bus EventBus, users WebAuthnUserManager) (
|
|
*WebAuthnAuthenticator,
|
|
error,
|
|
) {
|
|
if !cfg.Auth.WebAuthn.Enabled {
|
|
return nil, nil
|
|
}
|
|
|
|
extUrl, err := url.Parse(cfg.Web.ExternalUrl)
|
|
if err != nil {
|
|
return nil, errors.New("failed to parse external URL - required for WebAuthn RP ID")
|
|
}
|
|
|
|
rpId := extUrl.Hostname()
|
|
if rpId == "" {
|
|
return nil, errors.New("failed to determine Webauthn RPID")
|
|
}
|
|
|
|
// Initialize the WebAuthn authenticator with the provided configuration
|
|
awCfg := &webauthn.Config{
|
|
RPID: rpId,
|
|
RPDisplayName: cfg.Web.SiteTitle,
|
|
RPOrigins: []string{cfg.Web.ExternalUrl},
|
|
}
|
|
|
|
webAuthn, err := webauthn.New(awCfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create Webauthn instance: %w", err)
|
|
}
|
|
|
|
return &WebAuthnAuthenticator{
|
|
webAuthn: webAuthn,
|
|
users: users,
|
|
bus: bus,
|
|
}, nil
|
|
}
|
|
|
|
func (a *WebAuthnAuthenticator) Enabled() bool {
|
|
return a != nil && a.webAuthn != nil
|
|
}
|
|
|
|
func (a *WebAuthnAuthenticator) StartWebAuthnRegistration(ctx context.Context, userId domain.UserIdentifier) (
|
|
optionsAsJSON []byte,
|
|
sessionDataAsJSON []byte,
|
|
err error,
|
|
) {
|
|
user, err := a.users.GetUser(ctx, userId)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to get user: %w", err)
|
|
}
|
|
if user.IsLocked() || user.IsDisabled() {
|
|
return nil, nil, errors.New("user is locked") // adding passkey to locked user is not allowed
|
|
}
|
|
|
|
if user.WebAuthnId == "" {
|
|
user.GenerateWebAuthnId()
|
|
user, err = a.users.UpdateUser(ctx, user)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to store webauthn id to user: %w", err)
|
|
}
|
|
}
|
|
|
|
options, sessionData, err := a.webAuthn.BeginRegistration(user,
|
|
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
|
|
)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to begin WebAuthn registration: %w", err)
|
|
}
|
|
|
|
optionsAsJSON, err = json.Marshal(options)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to marshal webauthn options to JSON: %w", err)
|
|
}
|
|
sessionDataAsJSON, err = json.Marshal(sessionData)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to marshal webauthn session data to JSON: %w", err)
|
|
}
|
|
|
|
return optionsAsJSON, sessionDataAsJSON, nil
|
|
}
|
|
|
|
func (a *WebAuthnAuthenticator) FinishWebAuthnRegistration(
|
|
ctx context.Context,
|
|
userId domain.UserIdentifier,
|
|
name string,
|
|
sessionDataAsJSON []byte,
|
|
r *http.Request,
|
|
) ([]domain.UserWebauthnCredential, error) {
|
|
user, err := a.users.GetUser(ctx, userId)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get user: %w", err)
|
|
}
|
|
if user.IsLocked() || user.IsDisabled() {
|
|
return nil, errors.New("user is locked") // adding passkey to locked user is not allowed
|
|
}
|
|
|
|
var webAuthnData webauthn.SessionData
|
|
err = json.Unmarshal(sessionDataAsJSON, &webAuthnData)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal webauthn session data: %w", err)
|
|
}
|
|
|
|
credential, err := a.webAuthn.FinishRegistration(user, webAuthnData, r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if name == "" {
|
|
name = fmt.Sprintf("Passkey %d", len(user.WebAuthnCredentialList)+1) // fallback name
|
|
}
|
|
|
|
// Add the credential to the user
|
|
err = user.AddCredential(userId, name, *credential)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
user, err = a.users.UpdateUser(ctx, user)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return user.WebAuthnCredentialList, nil
|
|
}
|
|
|
|
func (a *WebAuthnAuthenticator) GetCredentials(
|
|
ctx context.Context,
|
|
userId domain.UserIdentifier,
|
|
) ([]domain.UserWebauthnCredential, error) {
|
|
user, err := a.users.GetUser(ctx, userId)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get user: %w", err)
|
|
}
|
|
|
|
return user.WebAuthnCredentialList, nil
|
|
}
|
|
|
|
func (a *WebAuthnAuthenticator) RemoveCredential(
|
|
ctx context.Context,
|
|
userId domain.UserIdentifier,
|
|
credentialIdBase64 string,
|
|
) ([]domain.UserWebauthnCredential, error) {
|
|
user, err := a.users.GetUser(ctx, userId)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get user: %w", err)
|
|
}
|
|
|
|
user.RemoveCredential(credentialIdBase64)
|
|
user, err = a.users.UpdateUser(ctx, user)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return user.WebAuthnCredentialList, nil
|
|
}
|
|
|
|
func (a *WebAuthnAuthenticator) UpdateCredential(
|
|
ctx context.Context,
|
|
userId domain.UserIdentifier,
|
|
credentialIdBase64 string,
|
|
name string,
|
|
) ([]domain.UserWebauthnCredential, error) {
|
|
user, err := a.users.GetUser(ctx, userId)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get user: %w", err)
|
|
}
|
|
|
|
err = user.UpdateCredential(credentialIdBase64, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
user, err = a.users.UpdateUser(ctx, user)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return user.WebAuthnCredentialList, nil
|
|
}
|
|
|
|
func (a *WebAuthnAuthenticator) StartWebAuthnLogin(_ context.Context) (
|
|
optionsAsJSON []byte,
|
|
sessionDataAsJSON []byte,
|
|
err error,
|
|
) {
|
|
options, sessionData, err := a.webAuthn.BeginDiscoverableLogin()
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to begin WebAuthn login: %w", err)
|
|
}
|
|
|
|
optionsAsJSON, err = json.Marshal(options)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to marshal webauthn options to JSON: %w", err)
|
|
}
|
|
sessionDataAsJSON, err = json.Marshal(sessionData)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to marshal webauthn session data to JSON: %w", err)
|
|
}
|
|
|
|
return optionsAsJSON, sessionDataAsJSON, nil
|
|
}
|
|
|
|
func (a *WebAuthnAuthenticator) FinishWebAuthnLogin(
|
|
ctx context.Context,
|
|
sessionDataAsJSON []byte,
|
|
r *http.Request,
|
|
) (*domain.User, error) {
|
|
|
|
var webAuthnData webauthn.SessionData
|
|
err := json.Unmarshal(sessionDataAsJSON, &webAuthnData)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal webauthn session data: %w", err)
|
|
}
|
|
|
|
// switch to admin context for user lookup
|
|
ctx = domain.SetUserInfo(ctx, domain.SystemAdminContextUserInfo())
|
|
|
|
credential, err := a.webAuthn.FinishDiscoverableLogin(a.findUserForWebAuthnSecretFn(ctx), webAuthnData, r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Find the user by the WebAuthn ID
|
|
user, err := a.users.GetUserByWebAuthnCredential(ctx,
|
|
base64.StdEncoding.EncodeToString(credential.ID))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get user by webauthn credential: %w", err)
|
|
}
|
|
|
|
if user.IsLocked() || user.IsDisabled() {
|
|
a.bus.Publish(app.TopicAuditLoginFailed, domain.AuditEventWrapper[audit.AuthEvent]{
|
|
Ctx: ctx,
|
|
Source: "passkey",
|
|
Event: audit.AuthEvent{
|
|
Username: string(user.Identifier), Error: "User is locked",
|
|
},
|
|
})
|
|
return nil, errors.New("user is locked") // login with passkey is not allowed
|
|
}
|
|
|
|
a.bus.Publish(app.TopicAuthLogin, user.Identifier)
|
|
a.bus.Publish(app.TopicAuditLoginSuccess, domain.AuditEventWrapper[audit.AuthEvent]{
|
|
Ctx: ctx,
|
|
Source: "passkey",
|
|
Event: audit.AuthEvent{
|
|
Username: string(user.Identifier),
|
|
},
|
|
})
|
|
|
|
return user, nil
|
|
}
|
|
|
|
func (a *WebAuthnAuthenticator) findUserForWebAuthnSecretFn(ctx context.Context) func(rawID, userHandle []byte) (
|
|
user webauthn.User,
|
|
err error,
|
|
) {
|
|
return func(rawID, userHandle []byte) (webauthn.User, error) {
|
|
// Find the user by the WebAuthn ID
|
|
user, err := a.users.GetUserByWebAuthnCredential(ctx, base64.StdEncoding.EncodeToString(rawID))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get user by webauthn credential: %w", err)
|
|
}
|
|
|
|
return user, nil
|
|
}
|
|
}
|