From 207d9ae1c3c4495f2c9148d7a11d1dfedffd1246 Mon Sep 17 00:00:00 2001 From: h44z Date: Thu, 23 Jul 2026 23:01:13 +0200 Subject: [PATCH] Merge commit from fork --- cmd/wg-portal/main.go | 2 +- .../app/api/v1/handlers/web_authentication.go | 17 ++- .../v1/handlers/web_authentication_test.go | 116 ++++++++++++++++++ 3 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 internal/app/api/v1/handlers/web_authentication_test.go diff --git a/cmd/wg-portal/main.go b/cmd/wg-portal/main.go index de4a49f..9d50a1e 100644 --- a/cmd/wg-portal/main.go +++ b/cmd/wg-portal/main.go @@ -153,7 +153,7 @@ func main() { // region API v1 (User REST API) - apiV1Auth := handlersV1.NewAuthenticationHandler(userManager) + apiV1Auth := handlersV1.NewAuthenticationHandler(authenticator, userManager) apiV1BackendUsers := backendV1.NewUserService(cfg, userManager) apiV1BackendPeers := backendV1.NewPeerService(cfg, wireGuardManager, userManager) apiV1BackendInterfaces := backendV1.NewInterfaceService(cfg, wireGuardManager) diff --git a/internal/app/api/v1/handlers/web_authentication.go b/internal/app/api/v1/handlers/web_authentication.go index 952f978..9365eab 100644 --- a/internal/app/api/v1/handlers/web_authentication.go +++ b/internal/app/api/v1/handlers/web_authentication.go @@ -16,16 +16,22 @@ const ( ) type UserAuthenticator interface { + IsUserValid(ctx context.Context, id domain.UserIdentifier) bool +} + +type UserRepository interface { GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) } type AuthenticationHandler struct { authenticator UserAuthenticator + userRepo UserRepository } -func NewAuthenticationHandler(authenticator UserAuthenticator) AuthenticationHandler { +func NewAuthenticationHandler(authenticator UserAuthenticator, userRepo UserRepository) AuthenticationHandler { return AuthenticationHandler{ authenticator: authenticator, + userRepo: userRepo, } } @@ -44,7 +50,7 @@ func (h AuthenticationHandler) LoggedIn(scopes ...Scope) func(next http.Handler) // check if user exists in DB ctx := domain.SetUserInfo(r.Context(), domain.SystemAdminContextUserInfo()) - user, err := h.authenticator.GetUser(ctx, domain.UserIdentifier(username)) + user, err := h.userRepo.GetUser(ctx, domain.UserIdentifier(username)) if err != nil { // Abort the request with the appropriate error code respond.JSON(w, http.StatusUnauthorized, @@ -60,6 +66,13 @@ func (h AuthenticationHandler) LoggedIn(scopes ...Scope) func(next http.Handler) return } + // ensure that user is still valid + if valid := h.authenticator.IsUserValid(r.Context(), domain.UserIdentifier(user.Identifier)); !valid { + respond.JSON(w, http.StatusForbidden, + model.Error{Code: http.StatusForbidden, Message: "account disabled or locked"}) + return + } + if !UserHasScopes(user, scopes...) { // Abort the request with the appropriate error code respond.JSON(w, http.StatusForbidden, diff --git a/internal/app/api/v1/handlers/web_authentication_test.go b/internal/app/api/v1/handlers/web_authentication_test.go new file mode 100644 index 0000000..9f85a66 --- /dev/null +++ b/internal/app/api/v1/handlers/web_authentication_test.go @@ -0,0 +1,116 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/h44z/wg-portal/internal/domain" +) + +type stubUserAuthenticator struct { + valid bool +} + +func (s stubUserAuthenticator) IsUserValid(ctx context.Context, id domain.UserIdentifier) bool { + return s.valid +} + +type stubUserRepository struct { + user *domain.User + err error +} + +func (s stubUserRepository) GetUser(ctx context.Context, id domain.UserIdentifier) (*domain.User, error) { + if s.err != nil { + return nil, s.err + } + + return s.user, nil +} + +func TestAuthenticationHandler_LoggedInRejectsDisabledOrLockedUser(t *testing.T) { + tests := []struct { + name string + configure func(*domain.User) + }{ + { + name: "disabled user", + configure: func(user *domain.User) { + user.Disabled = &[]time.Time{time.Now()}[0] + }, + }, + { + name: "locked user", + configure: func(user *domain.User) { + user.Locked = &[]time.Time{time.Now()}[0] + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + user := &domain.User{ + Identifier: "test-user", + ApiToken: "token", + } + tt.configure(user) + + handler := NewAuthenticationHandler(stubUserAuthenticator{valid: false}, stubUserRepository{user: user}) + nextCalled := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + w.WriteHeader(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.SetBasicAuth(string(user.Identifier), user.ApiToken) + rr := httptest.NewRecorder() + + handler.LoggedIn()(next).ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Fatalf("expected status %d, got %d", http.StatusForbidden, rr.Code) + } + + if nextCalled { + t.Fatal("expected downstream handler not to be called") + } + + if !strings.Contains(rr.Body.String(), "account disabled or locked") { + t.Fatalf("expected error message to mention disabled or locked, got %q", rr.Body.String()) + } + }) + } +} + +func TestAuthenticationHandler_LoggedInAllowsValidUser(t *testing.T) { + user := &domain.User{ + Identifier: "test-user", + ApiToken: "token", + } + + handler := NewAuthenticationHandler(stubUserAuthenticator{valid: true}, stubUserRepository{user: user}) + nextCalled := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + w.WriteHeader(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.SetBasicAuth(string(user.Identifier), user.ApiToken) + rr := httptest.NewRecorder() + + handler.LoggedIn()(next).ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) + } + + if !nextCalled { + t.Fatal("expected downstream handler to be called") + } +}