feat: add link-based configuration emails (#725) (#754)

* feat: add link-based configuration emails (#725)

* fix tests
This commit is contained in:
h44z
2026-09-10 22:04:20 +02:00
committed by GitHub
parent ebeaadd7de
commit 32ef6048fb
23 changed files with 550 additions and 114 deletions
-18
View File
@@ -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)
}
@@ -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,
@@ -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)
}
}
@@ -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"})
+10 -10
View File
@@ -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"})
@@ -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"})
@@ -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