瀏覽代碼

Add support for token access in webdav server

X-Access-Token + X-Aroz-User now can be used to login webdav with autologin token instead of Basic Auth
Toby Chui 1 天之前
父節點
當前提交
1b8e0f8e35
共有 4 個文件被更改,包括 278 次插入13 次删除
  1. 12 0
      .claude/launch.json
  2. 2 0
      .gitignore
  3. 48 13
      src/mod/storage/webdav/webdav.go
  4. 216 0
      src/mod/storage/webdav/webdav_test.go

+ 12 - 0
.claude/launch.json

@@ -6,6 +6,18 @@
       "runtimeExecutable": "python",
       "runtimeArgs": ["-m", "http.server", "8123", "--directory", "src/web"],
       "port": 8123
+    },
+    {
+      "name": "framework-static",
+      "runtimeExecutable": "python",
+      "runtimeArgs": ["-m", "http.server", "8124", "--directory", "src/framework"],
+      "port": 8124
+    },
+    {
+      "name": "desktop-app",
+      "runtimeExecutable": "go",
+      "runtimeArgs": ["run", "./apps/desktop", "-noui", "-port", "8765", "-store", "./apps/desktop/.preview-servers.json"],
+      "port": 8765
     }
   ]
 }

+ 2 - 0
.gitignore

@@ -41,3 +41,5 @@ src/launcher.exe
 .DS_Store
 src/ffmpeg
 /src/dist
+/apps
+/src/framework

+ 48 - 13
src/mod/storage/webdav/webdav.go

@@ -19,6 +19,7 @@ import (
 	"sync"
 	"time"
 
+	"imuslab.com/arozos/mod/auth"
 	"imuslab.com/arozos/mod/filesystem"
 	"imuslab.com/arozos/mod/filesystem/hidden"
 	"imuslab.com/arozos/mod/filesystem/metadata"
@@ -195,9 +196,6 @@ func (s *Server) HandleConnectionList(w http.ResponseWriter, r *http.Request) {
 }
 
 func (s *Server) HandleRequest(w http.ResponseWriter, r *http.Request) {
-	//log.Println(r.Header)
-	//log.Println("Request Method: ", r.Method)
-
 	//Check if this is enabled
 	if !s.Enabled {
 		http.NotFound(w, r)
@@ -231,10 +229,13 @@ func (s *Server) HandleRequest(w http.ResponseWriter, r *http.Request) {
 		}
 	*/
 
-	username, password, ok := r.BasicAuth()
-	if !ok {
+	//Support two authentication modes: an auto-login access token (issued from
+	//Auto Login Settings) passed via X-Access-Token + X-Aroz-User, or the
+	//classic HTTP Basic Auth username/password.
+	accessToken := r.Header.Get("X-Access-Token")
+	basicAuthUsername, password, hasBasicAuth := r.BasicAuth()
+	if accessToken == "" && !hasBasicAuth {
 		//User not logged in.
-		//log.Println("Not logged in!")
 		w.Header().Set("WWW-Authenticate", `Basic realm="Login with your `+s.hostname+` account"`)
 		w.WriteHeader(http.StatusUnauthorized)
 		return
@@ -246,16 +247,32 @@ func (s *Server) HandleRequest(w http.ResponseWriter, r *http.Request) {
 	//Validate request origin
 	allowAccess, err := authAgent.ValidateLoginRequest(w, r)
 	if !allowAccess {
-		logger.PrintAndLog("Webdav", "Someone from "+r.RemoteAddr+" try to log into "+username+" WebDAV endpoint but got rejected: "+err.Error(), nil)
+		logger.PrintAndLog("Webdav", "Someone from "+r.RemoteAddr+" try to access WebDAV endpoint but got rejected: "+err.Error(), nil)
 		http.Error(w, err.Error(), http.StatusUnauthorized)
 		return
 	}
-	passwordValid, rejectionReason := authAgent.ValidateUsernameAndPasswordWithReason(username, password)
-	if !passwordValid {
-		authAgent.Logger.LogAuthByRequestInfo(username, r.RemoteAddr, time.Now().Unix(), false, "webdav")
-		logger.PrintAndLog("Webdav", "Someone from "+r.RemoteAddr+" try to log into "+username+" WebDAV endpoint but got rejected: "+rejectionReason, nil)
-		http.Error(w, rejectionReason, http.StatusUnauthorized)
-		return
+
+	var username string
+	if accessToken != "" {
+		//Authenticate using an auto-login access token instead of Basic Auth
+		claimedUsername := r.Header.Get("X-Aroz-User")
+		tokenValid, tokenOwner := autoLoginTokenMatchesUsername(authAgent, accessToken, claimedUsername)
+		if !tokenValid {
+			authAgent.Logger.LogAuthByRequestInfo(claimedUsername, r.RemoteAddr, time.Now().Unix(), false, "webdav")
+			logger.PrintAndLog("Webdav", "Someone from "+r.RemoteAddr+" try to log into "+claimedUsername+" WebDAV endpoint but got rejected: invalid access token", nil)
+			http.Error(w, "Invalid access token", http.StatusUnauthorized)
+			return
+		}
+		username = tokenOwner
+	} else {
+		passwordValid, rejectionReason := authAgent.ValidateUsernameAndPasswordWithReason(basicAuthUsername, password)
+		if !passwordValid {
+			authAgent.Logger.LogAuthByRequestInfo(basicAuthUsername, r.RemoteAddr, time.Now().Unix(), false, "webdav")
+			logger.PrintAndLog("Webdav", "Someone from "+r.RemoteAddr+" try to log into "+basicAuthUsername+" WebDAV endpoint but got rejected: "+rejectionReason, nil)
+			http.Error(w, rejectionReason, http.StatusUnauthorized)
+			return
+		}
+		username = basicAuthUsername
 	}
 
 	//Resolve the vroot to realpath
@@ -291,6 +308,24 @@ func (s *Server) HandleRequest(w http.ResponseWriter, r *http.Request) {
 
 }
 
+// autoLoginTokenMatchesUsername validates accessToken as an ArozOS auto-login
+// token (see Auto Login Settings / mod/auth's AutoLoginToken) and reports
+// whether it is owned by claimedUsername. Both the token and the claimed
+// username must be supplied and agree, mirroring how X-Access-Token and
+// X-Aroz-User are required together on the wire.
+func autoLoginTokenMatchesUsername(authAgent *auth.AuthAgent, accessToken string, claimedUsername string) (bool, string) {
+	if accessToken == "" || claimedUsername == "" {
+		return false, ""
+	}
+
+	tokenValid, tokenOwner := authAgent.ValidateAutoLoginToken(accessToken)
+	if !tokenValid || tokenOwner != claimedUsername {
+		return false, ""
+	}
+
+	return true, tokenOwner
+}
+
 /*
 Serve ReadOnly WebDAV Server
 

+ 216 - 0
src/mod/storage/webdav/webdav_test.go

@@ -4,10 +4,18 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"os"
+	"path/filepath"
 	"strings"
 	"sync"
 	"testing"
 	"time"
+
+	"imuslab.com/arozos/mod/auth"
+	db "imuslab.com/arozos/mod/database"
+	"imuslab.com/arozos/mod/permission"
+	"imuslab.com/arozos/mod/share/shareEntry"
+	"imuslab.com/arozos/mod/storage"
+	"imuslab.com/arozos/mod/user"
 )
 
 // newTestServer builds a Server with nil userHandler – sufficient for testing
@@ -26,6 +34,78 @@ func newTestServer() *Server {
 	}
 }
 
+// webdavTestEnv wires a Server to a real AuthAgent/UserHandler backed by a
+// temp-dir database, so authentication (Basic Auth and access-token) paths
+// can be exercised end-to-end. Two users are created: "alice"/"alicepw" and
+// "bob"/"bobpw".
+type webdavTestEnv struct {
+	server    *Server
+	authAgent *auth.AuthAgent
+	cleanup   func()
+}
+
+func newAuthedTestEnv(t *testing.T) *webdavTestEnv {
+	t.Helper()
+	tmpDir := t.TempDir()
+
+	// The authlogger writes to ./system/auth/ relative to cwd; redirect to tmp.
+	origDir, _ := os.Getwd()
+	if err := os.Chdir(tmpDir); err != nil {
+		t.Fatalf("os.Chdir: %v", err)
+	}
+
+	sysdb, err := db.NewDatabase(filepath.Join(tmpDir, "system.db"), false)
+	if err != nil {
+		t.Fatalf("NewDatabase: %v", err)
+	}
+
+	authAgent := auth.NewAuthenticationAgent("testsession", []byte("supersecretkey0123456789"), sysdb, false, nil)
+
+	ph, err := permission.NewPermissionHandler(sysdb)
+	if err != nil {
+		t.Fatalf("NewPermissionHandler: %v", err)
+	}
+	ph.NewPermissionGroup("users", false, 0, []string{}, "")
+
+	if err := authAgent.CreateUserAccount("alice", "alicepw", []string{"users"}); err != nil {
+		t.Fatalf("CreateUserAccount (alice): %v", err)
+	}
+	if err := authAgent.CreateUserAccount("bob", "bobpw", []string{"users"}); err != nil {
+		t.Fatalf("CreateUserAccount (bob): %v", err)
+	}
+
+	sp, err := storage.NewStoragePool(nil, "system")
+	if err != nil {
+		t.Fatalf("NewStoragePool: %v", err)
+	}
+
+	set := shareEntry.NewShareEntryTable(sysdb)
+	uh, err := user.NewUserHandler(sysdb, authAgent, ph, sp, &set)
+	if err != nil {
+		t.Fatalf("NewUserHandler: %v", err)
+	}
+
+	s := &Server{
+		hostname:    "testhost",
+		userHandler: uh,
+		filesystems: sync.Map{},
+		prefix:      "/webdav",
+		Enabled:     true,
+	}
+
+	cleanup := func() {
+		authAgent.Close()
+		sysdb.Close()
+		os.Chdir(origDir)
+	}
+
+	return &webdavTestEnv{
+		server:    s,
+		authAgent: authAgent,
+		cleanup:   cleanup,
+	}
+}
+
 func TestServerFields(t *testing.T) {
 	s := newTestServer()
 	if s.hostname != "testhost" {
@@ -365,6 +445,142 @@ func TestHandleRequest_EmptyVroot(t *testing.T) {
 	}
 }
 
+// --- auto-login access token authentication (X-Access-Token / X-Aroz-User) ---
+
+func TestAutoLoginTokenMatchesUsername(t *testing.T) {
+	env := newAuthedTestEnv(t)
+	defer env.cleanup()
+
+	token := env.authAgent.NewAutologinToken("alice")
+
+	tests := []struct {
+		name            string
+		accessToken     string
+		claimedUsername string
+		wantValid       bool
+		wantUsername    string
+	}{
+		{"valid token and matching username", token, "alice", true, "alice"},
+		{"valid token but wrong claimed username", token, "bob", false, ""},
+		{"unknown token", "bogus-token", "alice", false, ""},
+		{"empty token", "", "alice", false, ""},
+		{"empty claimed username", token, "", false, ""},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			valid, username := autoLoginTokenMatchesUsername(env.authAgent, tc.accessToken, tc.claimedUsername)
+			if valid != tc.wantValid {
+				t.Errorf("valid: got %v, want %v", valid, tc.wantValid)
+			}
+			if username != tc.wantUsername {
+				t.Errorf("username: got %q, want %q", username, tc.wantUsername)
+			}
+		})
+	}
+}
+
+func TestHandleRequest_AccessToken_Valid(t *testing.T) {
+	env := newAuthedTestEnv(t)
+	defer env.cleanup()
+
+	token := env.authAgent.NewAutologinToken("alice")
+
+	req := httptest.NewRequest(http.MethodGet, "/webdav/user/file.txt", nil)
+	req.Header.Set("X-Access-Token", token)
+	req.Header.Set("X-Aroz-User", "alice")
+	w := httptest.NewRecorder()
+	env.server.HandleRequest(w, req)
+
+	if w.Code == http.StatusUnauthorized {
+		t.Errorf("expected access-token auth to pass the credential check (not 401), got 401: %s", w.Body.String())
+	}
+	if wwwAuth := w.Header().Get("WWW-Authenticate"); wwwAuth != "" {
+		t.Errorf("did not expect a Basic Auth challenge for token auth, got WWW-Authenticate=%q", wwwAuth)
+	}
+}
+
+func TestHandleRequest_AccessToken_InvalidToken(t *testing.T) {
+	env := newAuthedTestEnv(t)
+	defer env.cleanup()
+
+	req := httptest.NewRequest(http.MethodGet, "/webdav/user/file.txt", nil)
+	req.Header.Set("X-Access-Token", "not-a-real-token")
+	req.Header.Set("X-Aroz-User", "alice")
+	w := httptest.NewRecorder()
+	env.server.HandleRequest(w, req)
+
+	if w.Code != http.StatusUnauthorized {
+		t.Errorf("expected 401 for invalid access token, got %d", w.Code)
+	}
+	if wwwAuth := w.Header().Get("WWW-Authenticate"); wwwAuth != "" {
+		t.Errorf("token auth failure should not trigger a Basic Auth challenge, got WWW-Authenticate=%q", wwwAuth)
+	}
+}
+
+func TestHandleRequest_AccessToken_UsernameMismatch(t *testing.T) {
+	env := newAuthedTestEnv(t)
+	defer env.cleanup()
+
+	// Token belongs to alice, but the request claims to be bob.
+	token := env.authAgent.NewAutologinToken("alice")
+
+	req := httptest.NewRequest(http.MethodGet, "/webdav/user/file.txt", nil)
+	req.Header.Set("X-Access-Token", token)
+	req.Header.Set("X-Aroz-User", "bob")
+	w := httptest.NewRecorder()
+	env.server.HandleRequest(w, req)
+
+	if w.Code != http.StatusUnauthorized {
+		t.Errorf("expected 401 when X-Aroz-User does not match the token owner, got %d", w.Code)
+	}
+}
+
+func TestHandleRequest_AccessToken_MissingUserHeader(t *testing.T) {
+	env := newAuthedTestEnv(t)
+	defer env.cleanup()
+
+	token := env.authAgent.NewAutologinToken("alice")
+
+	req := httptest.NewRequest(http.MethodGet, "/webdav/user/file.txt", nil)
+	req.Header.Set("X-Access-Token", token)
+	// X-Aroz-User intentionally omitted
+	w := httptest.NewRecorder()
+	env.server.HandleRequest(w, req)
+
+	if w.Code != http.StatusUnauthorized {
+		t.Errorf("expected 401 when X-Aroz-User is missing, got %d", w.Code)
+	}
+}
+
+func TestHandleRequest_BasicAuth_StillWorksAlongsideAccessToken(t *testing.T) {
+	env := newAuthedTestEnv(t)
+	defer env.cleanup()
+
+	req := httptest.NewRequest(http.MethodGet, "/webdav/user/file.txt", nil)
+	req.SetBasicAuth("alice", "alicepw")
+	w := httptest.NewRecorder()
+	env.server.HandleRequest(w, req)
+
+	if w.Code == http.StatusUnauthorized {
+		t.Errorf("expected valid Basic Auth to pass the credential check (not 401), got 401: %s", w.Body.String())
+	}
+}
+
+func TestHandleRequest_BasicAuth_WrongPassword(t *testing.T) {
+	env := newAuthedTestEnv(t)
+	defer env.cleanup()
+
+	req := httptest.NewRequest(http.MethodGet, "/webdav/user/file.txt", nil)
+	req.SetBasicAuth("alice", "wrongpassword")
+	w := httptest.NewRecorder()
+	env.server.HandleRequest(w, req)
+
+	if w.Code != http.StatusUnauthorized {
+		t.Errorf("expected 401 for wrong password, got %d", w.Code)
+	}
+}
+
 func TestHandleConnectionList_Empty(t *testing.T) {
 	s := newTestServer()