From 32ef6048fbdbadacf1fe81128eb14f8a79145646 Mon Sep 17 00:00:00 2001 From: h44z Date: Thu, 10 Sep 2026 22:04:20 +0200 Subject: [PATCH] feat: add link-based configuration emails (#725) (#754) * feat: add link-based configuration emails (#725) * fix tests --- .run/wg-portal.run.xml | 4 +- docs/documentation/usage/mail-templates.md | 4 +- frontend/src/App.vue | 20 +++- frontend/src/helpers/encoding.js | 7 ++ frontend/src/helpers/fetch-wrapper.js | 6 +- frontend/src/lang/translations/de.json | 11 ++ frontend/src/lang/translations/en.json | 11 ++ frontend/src/router/index.js | 50 ++++++-- frontend/src/stores/auth.js | 74 +++++++++--- frontend/src/views/LoginView.vue | 22 +++- frontend/src/views/PeerConfigDownloadView.vue | 112 ++++++++++++++++++ internal/app/api/v0/handlers/encoding.go | 18 --- .../v0/handlers/endpoint_authentication.go | 10 +- .../endpoint_authentication_basepath_test.go | 55 +++++++++ .../api/v0/handlers/endpoint_interfaces.go | 16 +-- .../app/api/v0/handlers/endpoint_peers.go | 20 ++-- .../app/api/v0/handlers/endpoint_users.go | 18 +-- .../app/api/v0/handlers/web_authentication.go | 2 +- internal/app/mail/manager.go | 20 +++- internal/app/mail/manager_test.go | 103 ++++++++++++++++ .../app/mail/tpl_files/mail_with_link.gohtml | 44 +++---- .../app/mail/tpl_files/mail_with_link.gotpl | 8 +- internal/domain/encoding.go | 29 +++++ 23 files changed, 550 insertions(+), 114 deletions(-) create mode 100644 frontend/src/views/PeerConfigDownloadView.vue delete mode 100644 internal/app/api/v0/handlers/encoding.go create mode 100644 internal/app/mail/manager_test.go create mode 100644 internal/domain/encoding.go diff --git a/.run/wg-portal.run.xml b/.run/wg-portal.run.xml index 7d3669e9..1533be93 100644 --- a/.run/wg-portal.run.xml +++ b/.run/wg-portal.run.xml @@ -3,8 +3,10 @@ - + + + diff --git a/docs/documentation/usage/mail-templates.md b/docs/documentation/usage/mail-templates.md index b61150eb..d7405c73 100644 --- a/docs/documentation/usage/mail-templates.md +++ b/docs/documentation/usage/mail-templates.md @@ -40,7 +40,9 @@ templates and receive the following data fields, depending on the email type: - `PortalName` (string) - site title/company name - `User` (*domain.User) - the recipient user (may be partially populated when sending to a peer email) - Link email (`mail_with_link.*`): - - `Link` (string) - the download link + - `Link` (string) - a deep link to the WireGuard Portal web UI that starts the configuration download. + Recipients who are not logged in are redirected to the login page first; the download starts automatically + once they have authenticated successfully. - Attachment email (`mail_with_attachment.*`): - `ConfigFileName` (string) - filename of the attached WireGuard config - `QrcodePngName` (string) - CID content-id of the embedded QR code image diff --git a/frontend/src/App.vue b/frontend/src/App.vue index e135faeb..d4c7fe5e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,5 +1,6 @@ + + diff --git a/internal/app/api/v0/handlers/encoding.go b/internal/app/api/v0/handlers/encoding.go deleted file mode 100644 index 28e40b7b..00000000 --- a/internal/app/api/v0/handlers/encoding.go +++ /dev/null @@ -1,18 +0,0 @@ -package handlers - -import ( - "encoding/base64" - "strings" -) - -// Base64UrlDecode decodes a base64 url encoded string. -// In comparison to the standard base64 encoding, the url encoding uses - instead of + and _ instead of / -// as well as . instead of =. -func Base64UrlDecode(in string) string { - in = strings.ReplaceAll(in, "-", "=") - in = strings.ReplaceAll(in, "_", "/") - in = strings.ReplaceAll(in, ".", "+") - - output, _ := base64.StdEncoding.DecodeString(in) - return string(output) -} diff --git a/internal/app/api/v0/handlers/endpoint_authentication.go b/internal/app/api/v0/handlers/endpoint_authentication.go index 6ffbb961..aaa9a63b 100644 --- a/internal/app/api/v0/handlers/endpoint_authentication.go +++ b/internal/app/api/v0/handlers/endpoint_authentication.go @@ -383,12 +383,6 @@ func (e AuthEndpoint) setAuthenticatedUser(r *http.Request, user *domain.User, o // @Router /auth/login [post] func (e AuthEndpoint) handleLoginPost() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - currentSession := e.session.GetData(r.Context()) - if currentSession.LoggedIn { - respond.JSON(w, http.StatusOK, model.Error{Code: http.StatusOK, Message: "already logged in"}) - return - } - var loginData struct { Username string `json:"username" binding:"required,min=2"` Password string `json:"password" binding:"required,min=4"` @@ -570,7 +564,7 @@ func (e AuthEndpoint) handleWebAuthnCredentialsDelete() http.HandlerFunc { userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier) - credentialId := Base64UrlDecode(request.Path(r, "id")) + credentialId := domain.Base64UrlDecode(request.Path(r, "id")) credentials, err := e.webAuthn.RemoveCredential(r.Context(), userIdentifier, credentialId) if err != nil { @@ -605,7 +599,7 @@ func (e AuthEndpoint) handleWebAuthnCredentialsPut() http.HandlerFunc { userIdentifier := domain.UserIdentifier(currentSession.UserIdentifier) - credentialId := Base64UrlDecode(request.Path(r, "id")) + credentialId := domain.Base64UrlDecode(request.Path(r, "id")) var req model.WebAuthnCredentialRequest if err := request.BodyJson(r, &req); err != nil { respond.JSON(w, http.StatusBadRequest, diff --git a/internal/app/api/v0/handlers/endpoint_authentication_basepath_test.go b/internal/app/api/v0/handlers/endpoint_authentication_basepath_test.go index d5c773bc..3510d1f8 100644 --- a/internal/app/api/v0/handlers/endpoint_authentication_basepath_test.go +++ b/internal/app/api/v0/handlers/endpoint_authentication_basepath_test.go @@ -2,11 +2,14 @@ package handlers import ( "context" + "errors" "net/http" "net/http/httptest" + "strings" "testing" "github.com/h44z/wg-portal/internal/config" + "github.com/h44z/wg-portal/internal/domain" ) type testSession struct { @@ -115,3 +118,55 @@ func TestAuthEndpointFrontendUrlUsesBasePathAppMount(t *testing.T) { t.Fatalf("expected frontend URL %q, got %q", want, got) } } + +type dummyAuthService struct { + loginErr error + user *domain.User +} + +func (d dummyAuthService) GetExternalLoginProviders(_ context.Context) []domain.LoginProviderInfo { + return nil +} +func (d dummyAuthService) PlainLogin(_ context.Context, username, password string) (*domain.User, error) { + if d.loginErr != nil { + return nil, d.loginErr + } + return d.user, nil +} +func (d dummyAuthService) OauthLoginStep1(_ context.Context, _ string) (string, string, string, string, error) { + return "", "", "", "", nil +} +func (d dummyAuthService) OauthLoginStep2(_ context.Context, _, _, _, _ string) (*domain.User, string, error) { + return nil, "", nil +} +func (d dummyAuthService) OauthProviderLogoutUrl(_, _, _ string) (string, bool) { + return "", false +} + +type dummyValidator struct{} + +func (d dummyValidator) Struct(_ any) error { + return nil +} + +func TestAuthEndpointHandleLoginPostRejectsInvalidCredentialsEvenIfSessionDirty(t *testing.T) { + session := &testSession{data: SessionData{ + LoggedIn: true, + UserIdentifier: "previous-user", + }} + ep := AuthEndpoint{ + session: session, + authService: dummyAuthService{loginErr: errors.New("auth failed")}, + validate: dummyValidator{}, + } + + req := httptest.NewRequest(http.MethodPost, "/api/v0/auth/login", strings.NewReader(`{"username":"admin","password":"wrongpassword"}`)) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + + ep.handleLoginPost().ServeHTTP(res, req) + + if res.Code != http.StatusUnauthorized { + t.Fatalf("expected status %d (Unauthorized), got %d", http.StatusUnauthorized, res.Code) + } +} diff --git a/internal/app/api/v0/handlers/endpoint_interfaces.go b/internal/app/api/v0/handlers/endpoint_interfaces.go index 18069ea6..044e3732 100644 --- a/internal/app/api/v0/handlers/endpoint_interfaces.go +++ b/internal/app/api/v0/handlers/endpoint_interfaces.go @@ -138,7 +138,7 @@ func (e InterfaceEndpoint) handleAllGet() http.HandlerFunc { // @Router /interface/get/{id} [get] func (e InterfaceEndpoint) handleSingleGet() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id := Base64UrlDecode(request.Path(r, "id")) + id := domain.Base64UrlDecode(request.Path(r, "id")) if id == "" { respond.JSON(w, http.StatusBadRequest, model.Error{ Code: http.StatusInternalServerError, Message: "missing id parameter", @@ -170,7 +170,7 @@ func (e InterfaceEndpoint) handleSingleGet() http.HandlerFunc { // @Router /interface/config/{id} [get] func (e InterfaceEndpoint) handleConfigGet() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id := Base64UrlDecode(request.Path(r, "id")) + id := domain.Base64UrlDecode(request.Path(r, "id")) if id == "" { respond.JSON(w, http.StatusBadRequest, model.Error{ Code: http.StatusInternalServerError, Message: "missing id parameter", @@ -212,7 +212,7 @@ func (e InterfaceEndpoint) handleConfigGet() http.HandlerFunc { // @Router /interface/{id} [put] func (e InterfaceEndpoint) handleUpdatePut() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id := Base64UrlDecode(request.Path(r, "id")) + id := domain.Base64UrlDecode(request.Path(r, "id")) if id == "" { respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing interface id"}) @@ -293,7 +293,7 @@ func (e InterfaceEndpoint) handleCreatePost() http.HandlerFunc { // @Router /interface/peers/{id} [get] func (e InterfaceEndpoint) handlePeersGet() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id := Base64UrlDecode(request.Path(r, "id")) + id := domain.Base64UrlDecode(request.Path(r, "id")) if id == "" { respond.JSON(w, http.StatusBadRequest, model.Error{ Code: http.StatusInternalServerError, Message: "missing id parameter", @@ -326,7 +326,7 @@ func (e InterfaceEndpoint) handlePeersGet() http.HandlerFunc { // @Router /interface/{id} [delete] func (e InterfaceEndpoint) handleDelete() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id := Base64UrlDecode(request.Path(r, "id")) + id := domain.Base64UrlDecode(request.Path(r, "id")) if id == "" { respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing interface id"}) @@ -358,7 +358,7 @@ func (e InterfaceEndpoint) handleDelete() http.HandlerFunc { // @Router /interface/{id}/save-config [post] func (e InterfaceEndpoint) handleSaveConfigPost() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id := Base64UrlDecode(request.Path(r, "id")) + id := domain.Base64UrlDecode(request.Path(r, "id")) if id == "" { respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing interface id"}) @@ -391,7 +391,7 @@ func (e InterfaceEndpoint) handleSaveConfigPost() http.HandlerFunc { // @Router /interface/{id}/apply-peer-defaults [post] func (e InterfaceEndpoint) handleApplyPeerDefaultsPost() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id := Base64UrlDecode(request.Path(r, "id")) + id := domain.Base64UrlDecode(request.Path(r, "id")) if id == "" { respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing interface id"}) @@ -438,7 +438,7 @@ func (e InterfaceEndpoint) handleApplyPeerDefaultsPost() http.HandlerFunc { // @Router /interface/{id}/create-default-peers [post] func (e InterfaceEndpoint) handleCreateDefaultPeersPost() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id := Base64UrlDecode(request.Path(r, "id")) + id := domain.Base64UrlDecode(request.Path(r, "id")) if id == "" { respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing interface id"}) diff --git a/internal/app/api/v0/handlers/endpoint_peers.go b/internal/app/api/v0/handlers/endpoint_peers.go index 0972e9d6..2e4b7444 100644 --- a/internal/app/api/v0/handlers/endpoint_peers.go +++ b/internal/app/api/v0/handlers/endpoint_peers.go @@ -107,7 +107,7 @@ func (e PeerEndpoint) RegisterRoutes(g *routegroup.Bundle) { // @Router /peer/iface/{iface}/all [get] func (e PeerEndpoint) handleAllGet() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - interfaceId := Base64UrlDecode(request.Path(r, "iface")) + interfaceId := domain.Base64UrlDecode(request.Path(r, "iface")) if interfaceId == "" { respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing iface parameter"}) @@ -138,7 +138,7 @@ func (e PeerEndpoint) handleAllGet() http.HandlerFunc { // @Router /peer/{id} [get] func (e PeerEndpoint) handleSingleGet() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - peerId := Base64UrlDecode(request.Path(r, "id")) + peerId := domain.Base64UrlDecode(request.Path(r, "id")) if peerId == "" { respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing id parameter"}) @@ -169,7 +169,7 @@ func (e PeerEndpoint) handleSingleGet() http.HandlerFunc { // @Router /peer/iface/{iface}/prepare [get] func (e PeerEndpoint) handlePrepareGet() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - interfaceId := Base64UrlDecode(request.Path(r, "iface")) + interfaceId := domain.Base64UrlDecode(request.Path(r, "iface")) if interfaceId == "" { respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing iface parameter"}) @@ -201,7 +201,7 @@ func (e PeerEndpoint) handlePrepareGet() http.HandlerFunc { // @Router /peer/iface/{iface}/new [post] func (e PeerEndpoint) handleCreatePost() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - interfaceId := Base64UrlDecode(request.Path(r, "iface")) + interfaceId := domain.Base64UrlDecode(request.Path(r, "iface")) if interfaceId == "" { respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing iface parameter"}) @@ -249,7 +249,7 @@ func (e PeerEndpoint) handleCreatePost() http.HandlerFunc { // @Router /peer/iface/{iface}/multiplenew [post] func (e PeerEndpoint) handleCreateMultiplePost() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - interfaceId := Base64UrlDecode(request.Path(r, "iface")) + interfaceId := domain.Base64UrlDecode(request.Path(r, "iface")) if interfaceId == "" { respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing iface parameter"}) @@ -292,7 +292,7 @@ func (e PeerEndpoint) handleCreateMultiplePost() http.HandlerFunc { // @Router /peer/{id} [put] func (e PeerEndpoint) handleUpdatePut() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - peerId := Base64UrlDecode(request.Path(r, "id")) + peerId := domain.Base64UrlDecode(request.Path(r, "id")) if peerId == "" { respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing id parameter"}) @@ -339,7 +339,7 @@ func (e PeerEndpoint) handleUpdatePut() http.HandlerFunc { // @Router /peer/{id} [delete] func (e PeerEndpoint) handleDelete() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id := Base64UrlDecode(request.Path(r, "id")) + id := domain.Base64UrlDecode(request.Path(r, "id")) if id == "" { respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing peer id"}) return @@ -370,7 +370,7 @@ func (e PeerEndpoint) handleDelete() http.HandlerFunc { // @Router /peer/config/{id} [get] func (e PeerEndpoint) handleConfigGet() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id := Base64UrlDecode(request.Path(r, "id")) + id := domain.Base64UrlDecode(request.Path(r, "id")) if id == "" { respond.JSON(w, http.StatusBadRequest, model.Error{ Code: http.StatusInternalServerError, Message: "missing id parameter", @@ -415,7 +415,7 @@ func (e PeerEndpoint) handleConfigGet() http.HandlerFunc { // @Router /peer/config-qr/{id} [get] func (e PeerEndpoint) handleQrCodeGet() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id := Base64UrlDecode(request.Path(r, "id")) + id := domain.Base64UrlDecode(request.Path(r, "id")) if id == "" { respond.JSON(w, http.StatusBadRequest, model.Error{ Code: http.StatusInternalServerError, Message: "missing id parameter", @@ -504,7 +504,7 @@ func (e PeerEndpoint) handleEmailPost() http.HandlerFunc { // @Router /peer/iface/{iface}/stats [get] func (e PeerEndpoint) handleStatsGet() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - interfaceId := Base64UrlDecode(request.Path(r, "iface")) + interfaceId := domain.Base64UrlDecode(request.Path(r, "iface")) if interfaceId == "" { respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing iface parameter"}) diff --git a/internal/app/api/v0/handlers/endpoint_users.go b/internal/app/api/v0/handlers/endpoint_users.go index ae771e9f..5aeb40b3 100644 --- a/internal/app/api/v0/handlers/endpoint_users.go +++ b/internal/app/api/v0/handlers/endpoint_users.go @@ -125,7 +125,7 @@ func (e UserEndpoint) handleAllGet() http.HandlerFunc { // @Router /user/{id} [get] func (e UserEndpoint) handleSingleGet() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id := Base64UrlDecode(request.Path(r, "id")) + id := domain.Base64UrlDecode(request.Path(r, "id")) if id == "" { respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing user id"}) return @@ -156,7 +156,7 @@ func (e UserEndpoint) handleSingleGet() http.HandlerFunc { // @Router /user/{id} [put] func (e UserEndpoint) handleUpdatePut() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id := Base64UrlDecode(request.Path(r, "id")) + id := domain.Base64UrlDecode(request.Path(r, "id")) if id == "" { respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing user id"}) return @@ -236,7 +236,7 @@ func (e UserEndpoint) handleCreatePost() http.HandlerFunc { // @Router /user/{id}/peers [get] func (e UserEndpoint) handlePeersGet() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userId := Base64UrlDecode(request.Path(r, "id")) + userId := domain.Base64UrlDecode(request.Path(r, "id")) if userId == "" { respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"}) @@ -267,7 +267,7 @@ func (e UserEndpoint) handlePeersGet() http.HandlerFunc { // @Router /user/{id}/stats [get] func (e UserEndpoint) handleStatsGet() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userId := Base64UrlDecode(request.Path(r, "id")) + userId := domain.Base64UrlDecode(request.Path(r, "id")) if userId == "" { respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"}) @@ -298,7 +298,7 @@ func (e UserEndpoint) handleStatsGet() http.HandlerFunc { // @Router /user/{id}/interfaces [get] func (e UserEndpoint) handleInterfacesGet() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userId := Base64UrlDecode(request.Path(r, "id")) + userId := domain.Base64UrlDecode(request.Path(r, "id")) if userId == "" { respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"}) @@ -329,7 +329,7 @@ func (e UserEndpoint) handleInterfacesGet() http.HandlerFunc { // @Router /user/{id} [delete] func (e UserEndpoint) handleDelete() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - id := Base64UrlDecode(request.Path(r, "id")) + id := domain.Base64UrlDecode(request.Path(r, "id")) if id == "" { respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: "missing user id"}) return @@ -358,7 +358,7 @@ func (e UserEndpoint) handleDelete() http.HandlerFunc { // @Router /user/{id}/api/enable [post] func (e UserEndpoint) handleApiEnablePost() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userId := Base64UrlDecode(request.Path(r, "id")) + userId := domain.Base64UrlDecode(request.Path(r, "id")) if userId == "" { respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"}) @@ -388,7 +388,7 @@ func (e UserEndpoint) handleApiEnablePost() http.HandlerFunc { // @Router /user/{id}/api/disable [post] func (e UserEndpoint) handleApiDisablePost() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userId := Base64UrlDecode(request.Path(r, "id")) + userId := domain.Base64UrlDecode(request.Path(r, "id")) if userId == "" { respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"}) @@ -418,7 +418,7 @@ func (e UserEndpoint) handleApiDisablePost() http.HandlerFunc { // @Router /user/{id}/change-password [post] func (e UserEndpoint) handleChangePasswordPost() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - userId := Base64UrlDecode(request.Path(r, "id")) + userId := domain.Base64UrlDecode(request.Path(r, "id")) if userId == "" { respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusInternalServerError, Message: "missing id parameter"}) diff --git a/internal/app/api/v0/handlers/web_authentication.go b/internal/app/api/v0/handlers/web_authentication.go index f214739c..f847cb87 100644 --- a/internal/app/api/v0/handlers/web_authentication.go +++ b/internal/app/api/v0/handlers/web_authentication.go @@ -110,7 +110,7 @@ func (h AuthenticationHandler) UserIdMatch(idParameter string) func(next http.Ha } sessionUserId := domain.UserIdentifier(session.UserIdentifier) - requestUserId := domain.UserIdentifier(Base64UrlDecode(request.Path(r, idParameter))) + requestUserId := domain.UserIdentifier(domain.Base64UrlDecode(request.Path(r, idParameter))) if sessionUserId != requestUserId { // Abort the request with the appropriate error code diff --git a/internal/app/mail/manager.go b/internal/app/mail/manager.go index 5a2e5435..ff4b0b41 100644 --- a/internal/app/mail/manager.go +++ b/internal/app/mail/manager.go @@ -6,6 +6,7 @@ import ( "io" "log/slog" "net/mail" + "net/url" "github.com/h44z/wg-portal/internal/config" "github.com/h44z/wg-portal/internal/domain" @@ -135,7 +136,8 @@ func (m Manager) sendPeerEmail( mailOptions domain.MailOptions ) if linkOnly { - txtMail, htmlMail, err = m.tplHandler.GetConfigMail(user, "deep link TBD") + configDownloadLink := m.getPeerConfigDownloadLink(peer.Identifier, style) + txtMail, htmlMail, err = m.tplHandler.GetConfigMail(user, configDownloadLink) if err != nil { return fmt.Errorf("failed to get mail body: %w", err) } @@ -182,6 +184,22 @@ func (m Manager) sendPeerEmail( return nil } +// getPeerConfigDownloadLink builds an absolute link that points to the peer configuration download +// page of the WireGuard Portal web frontend. The link is used in link-only emails. +// +// The link is a "deep link" into the single-page application (hash based routing). When the recipient +// opens the link while not being authenticated, the frontend redirects them to the login page first and +// only starts the configuration download after a successful authentication. +func (m Manager) getPeerConfigDownloadLink(peerId domain.PeerIdentifier, style string) string { + encodedId := domain.Base64UrlEncode(string(peerId)) + link := fmt.Sprintf("%s%s/app/#/peer/config/%s", + m.cfg.Web.ExternalUrl, m.cfg.Web.BasePath, encodedId) + if style != "" { + link += "?style=" + url.QueryEscape(style) + } + return link +} + func (m Manager) resolveEmail(ctx context.Context, peer *domain.Peer) (string, domain.User) { user, err := m.users.GetUser(ctx, peer.UserIdentifier) if err != nil { diff --git a/internal/app/mail/manager_test.go b/internal/app/mail/manager_test.go new file mode 100644 index 00000000..2b11450b --- /dev/null +++ b/internal/app/mail/manager_test.go @@ -0,0 +1,103 @@ +package mail + +import ( + "io" + "strings" + "testing" + + "github.com/h44z/wg-portal/internal/config" + "github.com/h44z/wg-portal/internal/domain" +) + +func Test_base64UrlEncode_isReversibleWithHandlerDecode(t *testing.T) { + inputs := []string{ + "peer-identifier", + "aGVsbG8=", // ensure padding characters are handled + "abc/def+ghi", // ensure + and / are handled + "wgTestKey1234567890==", + } + + for _, in := range inputs { + encoded := domain.Base64UrlEncode(in) + + // The URL-safe variant must not contain characters that are unsafe in URLs. + if strings.ContainsAny(encoded, "+/=") { + t.Fatalf("encoded value %q still contains unsafe characters", encoded) + } + + decoded := domain.Base64UrlDecode(encoded) + if decoded != in { + t.Fatalf("round trip failed: got %q, want %q (encoded: %q)", decoded, in, encoded) + } + } +} + +func Test_getPeerConfigDownloadLink(t *testing.T) { + tests := []struct { + name string + externalUrl string + basePath string + peerId domain.PeerIdentifier + style string + want string + }{ + { + name: "no base path", + externalUrl: "https://wg.example.com", + basePath: "", + peerId: "peer1", + style: "wgquick", + want: "https://wg.example.com/app/#/peer/config/" + domain.Base64UrlEncode("peer1") + "?style=wgquick", + }, + { + name: "with base path", + externalUrl: "https://wg.example.com", + basePath: "/wg", + peerId: "peer1", + style: "", + want: "https://wg.example.com/wg/app/#/peer/config/" + domain.Base64UrlEncode("peer1"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.Config{} + cfg.Web.ExternalUrl = tt.externalUrl + cfg.Web.BasePath = tt.basePath + m := Manager{cfg: cfg} + + got := m.getPeerConfigDownloadLink(tt.peerId, tt.style) + if got != tt.want { + t.Fatalf("getPeerConfigDownloadLink() = %q, want %q", got, tt.want) + } + }) + } +} + +func Test_GetConfigMail_containsLink(t *testing.T) { + handler, err := newTemplateHandler("https://wg.example.com", "WireGuard Portal", "") + if err != nil { + t.Fatalf("failed to create template handler: %v", err) + } + + link := "https://wg.example.com/app/#/peer/config/abc?style=wgquick" + txtReader, htmlReader, err := handler.GetConfigMail(&domain.User{Firstname: "John", Lastname: "Doe"}, link) + if err != nil { + t.Fatalf("failed to render link mail: %v", err) + } + + txt, _ := io.ReadAll(txtReader) + html, _ := io.ReadAll(htmlReader) + + if !strings.Contains(string(txt), link) { + t.Errorf("text link mail does not contain the generated link.\n%s", string(txt)) + } + if !strings.Contains(string(html), link) { + t.Errorf("html link mail does not contain the generated link.\n%s", string(html)) + } + + // The link mail must not reference the placeholder that was used before the fix. + if strings.Contains(string(txt), "deep link TBD") || strings.Contains(string(html), "deep link TBD") { + t.Errorf("link mail still contains the placeholder link") + } +} diff --git a/internal/app/mail/tpl_files/mail_with_link.gohtml b/internal/app/mail/tpl_files/mail_with_link.gohtml index 41097cc7..d3ee410a 100644 --- a/internal/app/mail/tpl_files/mail_with_link.gohtml +++ b/internal/app/mail/tpl_files/mail_with_link.gohtml @@ -80,7 +80,7 @@ - +
@@ -89,28 +89,28 @@ -
- + {{if $.User.Firstname}} + + {{else}} + + {{end}} + + + + + + + - + + + + +
Hello {{$.User.Firstname}} {{$.User.Lastname}}Hello
You or your administrator probably requested this VPN configuration. Use the button below to download your personal WireGuard configuration file and open it in the WireGuard VPN client to establish a secure VPN connection.
+ - +
Download VPN Configuration
- -
- - - {{if $.User.Firstname}} - - {{else}} - - {{end}} - - - - -
Hello {{$.User.Firstname}} {{$.User.Lastname}}Hello
You or your administrator probably requested this VPN configuration. Scan the Qrcode or open the attached configuration file ({{$.Peer.GetConfigFileName}}) in the WireGuard VPN client to establish a secure VPN connection.
-
If the button does not work, copy and paste the following link into your browser:
{{$.Link}}
@@ -119,7 +119,7 @@
- + @@ -184,4 +184,4 @@
- \ No newline at end of file + diff --git a/internal/app/mail/tpl_files/mail_with_link.gotpl b/internal/app/mail/tpl_files/mail_with_link.gotpl index 837611af..4f725f6a 100644 --- a/internal/app/mail/tpl_files/mail_with_link.gotpl +++ b/internal/app/mail/tpl_files/mail_with_link.gotpl @@ -5,8 +5,10 @@ Hello, {{end}} You or your administrator probably requested this VPN configuration. -Scan the attached Qrcode or open the attached configuration file ({{$.ConfigFileName}}) -in the WireGuard VPN client to establish a secure VPN connection. +Follow the link below to download your personal WireGuard configuration file and open it +in the WireGuard VPN client to establish a secure VPN connection: + +{{$.Link}} @@ -21,4 +23,4 @@ https://www.wireguard.com/install/ This mail was generated by {{$.PortalName}}. -{{$.PortalUrl}} \ No newline at end of file +{{$.PortalUrl}} diff --git a/internal/domain/encoding.go b/internal/domain/encoding.go new file mode 100644 index 00000000..bc24843f --- /dev/null +++ b/internal/domain/encoding.go @@ -0,0 +1,29 @@ +package domain + +import ( + "encoding/base64" + "strings" +) + +// Base64UrlDecode decodes a base64 url encoded string. +// In comparison to the standard base64 encoding, the url encoding uses - instead of + and _ instead of / +// as well as . instead of =. +func Base64UrlDecode(in string) string { + in = strings.ReplaceAll(in, "-", "=") + in = strings.ReplaceAll(in, "_", "/") + in = strings.ReplaceAll(in, ".", "+") + + output, _ := base64.StdEncoding.DecodeString(in) + return string(output) +} + +// Base64UrlEncode encodes the given input using the URL-safe base64 variant that the WireGuard Portal +// API expects. In comparison to the standard base64 encoding, it uses . instead of +, _ instead of / +// and - instead of =. +func Base64UrlEncode(in string) string { + out := base64.StdEncoding.EncodeToString([]byte(in)) + out = strings.ReplaceAll(out, "+", ".") + out = strings.ReplaceAll(out, "/", "_") + out = strings.ReplaceAll(out, "=", "-") + return out +}