diff --git a/cmd/web/flags.go b/cmd/web/flags.go index 3d0537b5..49af1377 100644 --- a/cmd/web/flags.go +++ b/cmd/web/flags.go @@ -26,9 +26,9 @@ func parse() *args { var cliArgs args cliArgs.Address = flag.String("A", "0.0.0.0", "Bind Address") cliArgs.Port = flag.Int("P", 9999, "Bind Port") - cliArgs.Username = flag.String("u", "gopeed", "HTTP Basic Auth Username") - cliArgs.Password = flag.String("p", "", "HTTP Basic Auth Pwd") - cliArgs.ApiToken = flag.String("T", "", "API token, it must be configured when using HTTP API in the case of enabling basic authentication") + cliArgs.Username = flag.String("u", "gopeed", "Web Authentication Username") + cliArgs.Password = flag.String("p", "", "Web Authentication Password, if no password is set, web authentication will not be enabled") + cliArgs.ApiToken = flag.String("T", "", "API token, it must be configured when using HTTP API in the case of enabling web authentication") cliArgs.StorageDir = flag.String("d", "", "Storage directory") cliArgs.configPath = flag.String("c", "./config.json", "Config file path") flag.Parse() diff --git a/cmd/web/main.go b/cmd/web/main.go index d6265570..32c2cb88 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -23,9 +23,9 @@ func main() { } args := parse() - var webBasicAuth *model.WebBasicAuth + var webAuth *model.WebAuth if isNotBlank(args.Username) && isNotBlank(args.Password) { - webBasicAuth = &model.WebBasicAuth{ + webAuth = &model.WebAuth{ Username: *args.Username, Password: *args.Password, } @@ -52,7 +52,7 @@ func main() { ProductionMode: true, WebEnable: true, WebFS: sub, - WebBasicAuth: webBasicAuth, + WebAuth: webAuth, } cmd.Start(cfg) } diff --git a/go.mod b/go.mod index b5ca05cf..0cdc6463 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/go-git/go-git/v5 v5.8.1 github.com/gorilla/handlers v1.5.1 github.com/gorilla/mux v1.8.0 + github.com/imroc/req/v3 v3.52.2 github.com/matoous/go-nanoid/v2 v2.0.0 github.com/mattn/go-ieproxy v0.0.12 github.com/pkg/errors v0.9.1 @@ -29,11 +30,9 @@ require ( github.com/ebitengine/purego v0.8.1 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/google/go-cmp v0.7.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/icholy/digest v1.1.0 // indirect - github.com/imroc/req/v3 v3.52.2 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c // indirect github.com/onsi/ginkgo/v2 v2.23.4 // indirect diff --git a/go.sum b/go.sum index f70d5c33..f3284324 100644 --- a/go.sum +++ b/go.sum @@ -347,10 +347,11 @@ github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJm github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU= +github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= @@ -399,6 +400,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c h1:NRoLoZvkBTKvR5gQLgA3e0hqjkY9u1wm+iOL45VN/qI= github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= +github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= @@ -497,6 +500,7 @@ github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad h1:QtuWk6dsrNXc/ugwCBEN0jG52q/4tXRzMvZm4fH6T9g= github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad/go.mod h1:Jj8p9bgKGTPQ+M8CdUMS9p7Mmdoxa3OAcAjJQBu0CcI= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= @@ -675,6 +679,8 @@ google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miE google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -696,6 +702,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE= diff --git a/pkg/rest/api.go b/pkg/rest/api.go index c6f4d738..7c42f9ee 100644 --- a/pkg/rest/api.go +++ b/pkg/rest/api.go @@ -309,6 +309,8 @@ func DoProxy(w http.ResponseWriter, r *http.Request) { r.RequestURI = "" r.URL = targetUrl r.Host = targetUrl.Host + r.Header.Del("Authorization") + r.Header.Del("X-Target-Uri") resp, err := http.DefaultClient.Do(r) if err != nil { writeError(w, err.Error()) diff --git a/pkg/rest/model/server.go b/pkg/rest/model/server.go index 45934909..f9bea8fd 100644 --- a/pkg/rest/model/server.go +++ b/pkg/rest/model/server.go @@ -1,7 +1,6 @@ package model import ( - "encoding/base64" "github.com/GopeedLab/gopeed/pkg/base" "io/fs" ) @@ -25,9 +24,9 @@ type StartConfig struct { ProductionMode bool - WebEnable bool - WebFS fs.FS - WebBasicAuth *WebBasicAuth + WebEnable bool + WebFS fs.FS + WebAuth *WebAuth } func (cfg *StartConfig) Init() *StartConfig { @@ -49,13 +48,7 @@ func (cfg *StartConfig) Init() *StartConfig { return cfg } -type WebBasicAuth struct { +type WebAuth struct { Username string Password string } - -// Authorization returns the value of the Authorization header to be used in HTTP requests. -func (cfg *WebBasicAuth) Authorization() string { - userId := cfg.Username + ":" + cfg.Password - return "Basic " + base64.StdEncoding.EncodeToString([]byte(userId)) -} diff --git a/pkg/rest/server.go b/pkg/rest/server.go index 0d7c4dc9..31c254a9 100644 --- a/pkg/rest/server.go +++ b/pkg/rest/server.go @@ -2,6 +2,10 @@ package rest import ( "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" "encoding/json" "fmt" "github.com/GopeedLab/gopeed/pkg/download" @@ -10,12 +14,14 @@ import ( "github.com/gorilla/handlers" "github.com/gorilla/mux" "github.com/pkg/errors" + "io" "io/fs" "net" "net/http" "os" "path/filepath" "regexp" + "strconv" "strings" "time" ) @@ -23,6 +29,7 @@ import ( var ( srv *http.Server runningPort int + aesKey []byte Downloader *download.Downloader ) @@ -95,6 +102,13 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error util.SafeRemove(startCfg.Address) } + if startCfg.WebEnable { + aesKey = make([]byte, 32) + if _, err := rand.Read(aesKey); err != nil { + return nil, nil, errors.Wrap(err, "generate aes key failed") + } + } + listener, err := net.Listen(startCfg.Network, startCfg.Address) if err != nil { return nil, nil, err @@ -125,15 +139,40 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error r.Methods(http.MethodGet).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateCheckExtension) r.Methods(http.MethodPost).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateExtension) r.Path("/api/v1/proxy").HandlerFunc(DoProxy) + + enableApiToken := startCfg.ApiToken != "" + enableBasicAuth := startCfg.WebEnable && startCfg.WebAuth != nil if startCfg.WebEnable { + if enableBasicAuth { + r.Methods(http.MethodPost).Path("/api/web/login").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var loginReq model.WebAuth + if ReadJson(r, w, &loginReq) { + if loginReq.Username == startCfg.WebAuth.Username && loginReq.Password == startCfg.WebAuth.Password { + // Generate a login token, Username:Password:Timestamp + timestamp := time.Now().Unix() + tokenData := fmt.Sprintf("%s:%s:%d", loginReq.Username, loginReq.Password, timestamp) + token, err := aesEncrypt(aesKey, []byte(tokenData)) + if err != nil { + WriteJson(w, model.NewErrorResult(err.Error())) + return + } + + WriteJson(w, model.NewOkResult(token)) + return + } + } + WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized)) + }) + } r.PathPrefix("/fs/tasks").Handler(http.FileServer(new(taskFileSystem))) r.PathPrefix("/fs/extensions").Handler(http.FileServer(new(extensionFileSystem))) r.PathPrefix("/").Handler(gzipMiddleware(http.FileServer(newEmbedCacheFileSystem(http.FS(startCfg.WebFS))))) } - - enableApiToken := startCfg.ApiToken != "" - enableBasicAuth := startCfg.WebEnable && startCfg.WebBasicAuth != nil if enableApiToken || enableBasicAuth { + writeUnauthorized := func(w http.ResponseWriter, r *http.Request) { + WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized)) + } + r.Use(func(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if enableApiToken { @@ -145,19 +184,49 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error return } - WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized)) + writeUnauthorized(w, r) return } } - if startCfg.WebEnable && startCfg.WebBasicAuth != nil { - if r.Header.Get("Authorization") == startCfg.WebBasicAuth.Authorization() { + if enableBasicAuth { + if r.URL.Path == "/api/web/login" { h.ServeHTTP(w, r) return } - w.Header().Set("WWW-Authenticate", "Basic realm=\"gopeed web\"") + + token := r.Header.Get("Authorization") + if token == "" { + writeUnauthorized(w, r) + return + } + + token = strings.TrimPrefix(token, "Bearer ") + tokenData, err := aesDecrypt(aesKey, token) + if err != nil { + writeUnauthorized(w, r) + return + } + parts := strings.SplitN(string(tokenData), ":", 3) + username := parts[0] + password := parts[1] + timestamp, _ := strconv.Atoi(parts[2]) + + if username != startCfg.WebAuth.Username || password != startCfg.WebAuth.Password { + writeUnauthorized(w, r) + return + } + + // Check if the token is expired (7 days) + if time.Now().Unix()-int64(timestamp) > 7*24*3600 { + writeUnauthorized(w, r) + return + } + + h.ServeHTTP(w, r) + return } - WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized)) + writeUnauthorized(w, r) }) }) } @@ -177,9 +246,10 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error }) srv = &http.Server{Handler: handlers.CORS( - handlers.AllowedHeaders([]string{"Content-Type", "X-Api-Token", "X-Target-Uri"}), + handlers.AllowedHeaders([]string{"Content-Type", "Authorization", "X-Api-Token", "X-Target-Uri"}), handlers.AllowedMethods([]string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}), handlers.AllowedOrigins([]string{"*"}), + handlers.AllowCredentials(), )(r)} return srv, listener, nil } @@ -315,3 +385,47 @@ func WriteStatusJson(w http.ResponseWriter, statusCode int, v any) { w.WriteHeader(statusCode) json.NewEncoder(w).Encode(v) } + +func aesEncrypt(key, data []byte) (string, error) { + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + cipherText := gcm.Seal(nonce, nonce, data, nil) + return base64.StdEncoding.EncodeToString(cipherText), nil +} + +func aesDecrypt(key []byte, encryptedData string) ([]byte, error) { + cipherText, err := base64.StdEncoding.DecodeString(encryptedData) + if err != nil { + return nil, err + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + if len(cipherText) < gcm.NonceSize() { + return nil, errors.New("ciphertext too short") + } + + nonce, cipherText := cipherText[:gcm.NonceSize()], cipherText[gcm.NonceSize():] + return gcm.Open(nil, nonce, cipherText, nil) +} diff --git a/pkg/rest/server_test.go b/pkg/rest/server_test.go index ee18f3fc..4ad67240 100644 --- a/pkg/rest/server_test.go +++ b/pkg/rest/server_test.go @@ -637,7 +637,7 @@ func TestAuthorization(t *testing.T) { cfg.Init() cfg.ApiToken = "123456" cfg.WebEnable = true - cfg.WebBasicAuth = &model.WebBasicAuth{ + cfg.WebAuth = &model.WebAuth{ Username: "admin", Password: "123456", } @@ -649,14 +649,61 @@ func TestAuthorization(t *testing.T) { Stop() }() - status, _ := doHttpRequest0(http.MethodGet, "/api/v1/config", nil, nil) + status, _ := doHttpRequest0(http.MethodPost, "/api/web/login", nil, &model.WebAuth{ + Username: "xxx", + Password: "xxx", + }) + if status != http.StatusUnauthorized { + t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized) + } + + token := httpRequestCheckOk[string](http.MethodPost, "/api/web/login", cfg.WebAuth) + authToken := fmt.Sprintf("Bearer %s", token) + authHeaders := map[string]string{ + "Authorization": authToken, + } + + status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", nil, nil) if status != http.StatusUnauthorized { t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized) } status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ - "Authorization": cfg.WebBasicAuth.Authorization(), + "Authorization": "xxx", }, nil) + if status != http.StatusUnauthorized { + t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized) + } + + status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ + "Authorization": "xxx", + }, nil) + if status != http.StatusUnauthorized { + t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized) + } + + buildToken := func(username, password string, ts int64) string { + token, _ := aesEncrypt(aesKey, []byte(fmt.Sprintf("%s:%s:%d", username, password, ts))) + return token + } + + fakeToken := buildToken("fake", "fake", time.Now().Unix()) + status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ + "Authorization": fmt.Sprintf("Bearer %s", fakeToken), + }, nil) + if status != http.StatusUnauthorized { + t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized) + } + + expireToken := buildToken(cfg.WebAuth.Username, cfg.WebAuth.Password, time.Now().Add(-time.Hour*8*24).Unix()) + status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ + "Authorization": fmt.Sprintf("Bearer %s", expireToken), + }, nil) + if status != http.StatusUnauthorized { + t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized) + } + + status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", authHeaders, nil) if status != http.StatusOK { t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusOK) } @@ -669,7 +716,7 @@ func TestAuthorization(t *testing.T) { } status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ - "Authorization": cfg.WebBasicAuth.Authorization(), + "Authorization": authToken, "X-Api-Token": cfg.ApiToken, }, nil) if status != http.StatusOK { @@ -677,7 +724,7 @@ func TestAuthorization(t *testing.T) { } status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{ - "Authorization": cfg.WebBasicAuth.Authorization(), + "Authorization": authToken, "X-Api-Token": "", }, nil) if status != http.StatusUnauthorized { diff --git a/ui/flutter/lib/api/api.dart b/ui/flutter/lib/api/api.dart index 3d2c9266..e8686daf 100644 --- a/ui/flutter/lib/api/api.dart +++ b/ui/flutter/lib/api/api.dart @@ -3,13 +3,17 @@ import 'dart:io'; import 'package:dio/dio.dart'; import 'package:dio/io.dart'; import 'package:flutter/foundation.dart'; +import 'package:get/get.dart' as getx; +import '../app/routes/app_pages.dart'; +import '../database/database.dart'; import '../util/util.dart'; import 'model/create_task.dart'; import 'model/create_task_batch.dart'; import 'model/downloader_config.dart'; import 'model/extension.dart'; import 'model/install_extension.dart'; +import 'model/login.dart'; import 'model/request.dart'; import 'model/resolve_result.dart'; import 'model/result.dart'; @@ -43,12 +47,27 @@ class _Client { dio.options.sendTimeout = const Duration(seconds: 5); dio.options.connectTimeout = const Duration(seconds: 5); dio.options.receiveTimeout = const Duration(seconds: 60); - dio.interceptors.add(InterceptorsWrapper(onRequest: (options, handler) { - if (apiToken.isNotEmpty) { - options.headers['X-Api-Token'] = apiToken; - } - handler.next(options); - })); + dio.interceptors.add(InterceptorsWrapper( + onRequest: (options, handler) { + if (apiToken.isNotEmpty) { + options.headers['X-Api-Token'] = apiToken; + } + if (Util.isWeb()) { + final token = Database.instance.getWebToken(); + if (token != null) { + options.headers['Authorization'] = 'Bearer $token'; + } + } + handler.next(options); + }, + onError: (error, handler) { + // Only web version has a login page + if (Util.isWeb() && error.response?.statusCode == 401) { + getx.Get.rootDelegate.offAndToNamed(Routes.LOGIN); + } + handler.next(error); + }, + )); _instance!.dio = dio; if (isUnixSocket) { @@ -96,7 +115,8 @@ Future _parse( } on DioException catch (e) { if (e.type == DioExceptionType.sendTimeout || e.type == DioExceptionType.receiveTimeout || - e.type == DioExceptionType.connectionTimeout) { + e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.connectionError) { throw TimeoutException("request timeout"); } throw Exception(Result(code: 1000, msg: e.message)); @@ -216,6 +236,11 @@ Future updateExtension(String identity) async { () => _client.dio.post("api/v1/extensions/$identity/update"), null); } +Future login(LoginReq loginReq) async { + return _parse(() => _client.dio.post("api/web/login", data: loginReq), + (data) => data as String); +} + Future> proxyRequest(String uri, {data, Options? options}) async { options ??= Options(); @@ -230,5 +255,9 @@ Future> proxyRequest(String uri, } String join(String path) { - return "${_client.dio.options.baseUrl}/${Util.cleanPath(path)}"; + final baseUrl = _client.dio.options.baseUrl; + final cleanBaseUrl = baseUrl.endsWith('/') + ? baseUrl.substring(0, baseUrl.length - 1) + : baseUrl; + return "$cleanBaseUrl/${Util.cleanPath(path)}"; } diff --git a/ui/flutter/lib/api/model/login.dart b/ui/flutter/lib/api/model/login.dart new file mode 100644 index 00000000..41be268e --- /dev/null +++ b/ui/flutter/lib/api/model/login.dart @@ -0,0 +1,18 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'login.g.dart'; + +@JsonSerializable() +class LoginReq { + String username; + String password; + + LoginReq({ + required this.username, + required this.password, + }); + + factory LoginReq.fromJson(Map json) => + _$LoginReqFromJson(json); + Map toJson() => _$LoginReqToJson(this); +} diff --git a/ui/flutter/lib/api/model/login.g.dart b/ui/flutter/lib/api/model/login.g.dart new file mode 100644 index 00000000..f347a387 --- /dev/null +++ b/ui/flutter/lib/api/model/login.g.dart @@ -0,0 +1,17 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'login.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +LoginReq _$LoginReqFromJson(Map json) => LoginReq( + username: json['username'] as String, + password: json['password'] as String, + ); + +Map _$LoginReqToJson(LoginReq instance) => { + 'username': instance.username, + 'password': instance.password, + }; diff --git a/ui/flutter/lib/app/modules/extension/views/extension_view.dart b/ui/flutter/lib/app/modules/extension/views/extension_view.dart index 1c92607a..422f6d75 100644 --- a/ui/flutter/lib/app/modules/extension/views/extension_view.dart +++ b/ui/flutter/lib/app/modules/extension/views/extension_view.dart @@ -16,6 +16,7 @@ import '../../../../api/model/extension.dart'; import '../../../../api/model/install_extension.dart'; import '../../../../api/model/switch_extension.dart'; import '../../../../api/model/update_extension_settings.dart'; +import '../../../../database/database.dart'; import '../../../../util/message.dart'; import '../../../../util/util.dart'; import '../../../views/icon_button_loading.dart'; @@ -135,6 +136,10 @@ class ExtensionView extends GetView { '/fs/extensions/${extension.identity}/${extension.icon}'), width: 48, height: 48, + headers: { + 'Authorization': + 'Bearer ${Database.instance.getWebToken()}' + }, ) : Image.file( extension.devMode diff --git a/ui/flutter/lib/app/modules/login/bindings/login_binding.dart b/ui/flutter/lib/app/modules/login/bindings/login_binding.dart new file mode 100644 index 00000000..ac119f4a --- /dev/null +++ b/ui/flutter/lib/app/modules/login/bindings/login_binding.dart @@ -0,0 +1,12 @@ +import 'package:get/get.dart'; + +import '../controllers/login_controller.dart'; + +class LoginBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut( + () => LoginController(), + ); + } +} diff --git a/ui/flutter/lib/app/modules/login/controllers/login_controller.dart b/ui/flutter/lib/app/modules/login/controllers/login_controller.dart new file mode 100644 index 00000000..16f185ec --- /dev/null +++ b/ui/flutter/lib/app/modules/login/controllers/login_controller.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../../../api/api.dart' as api; +import '../../../../api/api.dart'; +import '../../../../api/model/login.dart'; +import '../../../../database/database.dart'; +import '../../../../util/message.dart'; +import '../../../routes/app_pages.dart'; +import '../../app/controllers/app_controller.dart'; + +class LoginController extends GetxController { + final formKey = GlobalKey(); + final usernameController = TextEditingController(); + final passwordController = TextEditingController(); + + final isLoading = false.obs; + final passwordVisible = false.obs; + + @override + void onClose() { + usernameController.dispose(); + passwordController.dispose(); + super.onClose(); + } + + void togglePasswordVisibility() { + passwordVisible.value = !passwordVisible.value; + } + + Future login() async { + if (!formKey.currentState!.validate()) { + return; + } + + isLoading.value = true; + try { + final loginReq = LoginReq( + username: usernameController.text.trim(), + password: passwordController.text, + ); + + final token = await api.login(loginReq); + // Login successful, save the token + Database.instance.saveWebToken(token); + // Reload config + final controller = Get.put(AppController()); + await controller.loadDownloaderConfig(); + // Navigate to home page + Get.rootDelegate.offAndToNamed(Routes.HOME); + } catch (e) { + if (e is TimeoutException) { + showMessage('error'.tr, 'login_failed_network'.tr); + } else { + showMessage('error'.tr, 'login_failed'.tr); + } + } finally { + isLoading.value = false; + } + } +} diff --git a/ui/flutter/lib/app/modules/login/views/login_view.dart b/ui/flutter/lib/app/modules/login/views/login_view.dart new file mode 100644 index 00000000..4b94ff0e --- /dev/null +++ b/ui/flutter/lib/app/modules/login/views/login_view.dart @@ -0,0 +1,350 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:get/get.dart'; + +import '../../../views/responsive_builder.dart'; +import '../controllers/login_controller.dart'; + +class LoginView extends GetView { + const LoginView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final isNarrow = ResponsiveBuilder.isNarrow(context); + + return Scaffold( + body: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Get.theme.colorScheme.surface, + Get.theme.colorScheme.surface.withOpacity(0.8), + Get.theme.colorScheme.surface.withOpacity(0.9), + ], + ), + ), + child: Center( + child: SingleChildScrollView( + padding: EdgeInsets.symmetric( + horizontal: isNarrow ? 16.0 : 32.0, + vertical: isNarrow ? 24.0 : 32.0, + ), + child: Card( + elevation: 12, + shadowColor: Get.theme.colorScheme.shadow.withOpacity(0.3), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(isNarrow ? 20 : 24), + ), + color: Get.theme.colorScheme.surface, + child: Container( + constraints: BoxConstraints( + maxWidth: isNarrow ? double.infinity : 420, + ), + padding: EdgeInsets.all(isNarrow ? 24.0 : 40.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(isNarrow ? 20 : 24), + border: Border.all( + color: Get.theme.colorScheme.outline.withOpacity(0.1), + width: 1, + ), + ), + child: FocusTraversalGroup( + policy: OrderedTraversalPolicy(), + child: Form( + key: controller.formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Logo/Title Section + Container( + alignment: Alignment.center, + margin: EdgeInsets.only(bottom: isNarrow ? 32 : 48), + child: Column( + children: [ + Container( + padding: EdgeInsets.all(isNarrow ? 8 : 12), + decoration: BoxDecoration( + borderRadius: + BorderRadius.circular(isNarrow ? 20 : 24), + boxShadow: [ + BoxShadow( + color: Get.theme.colorScheme.primary + .withOpacity(0.2), + blurRadius: isNarrow ? 16 : 24, + offset: Offset(0, isNarrow ? 8 : 12), + spreadRadius: isNarrow ? 1 : 2, + ), + ], + ), + child: ClipRRect( + borderRadius: + BorderRadius.circular(isNarrow ? 16 : 20), + child: SvgPicture.asset( + 'assets/icon/icon.svg', + width: isNarrow ? 56 : 72, + height: isNarrow ? 56 : 72, + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(height: 8), + Text( + 'Gopeed', + style: Get.textTheme.headlineLarge?.copyWith( + fontWeight: FontWeight.w900, + color: Get.theme.colorScheme.onSurface, + letterSpacing: 2.0, + fontSize: isNarrow ? 28 : 36, + ), + ), + ], + ), + ), + + // Username Field + FocusTraversalOrder( + order: const NumericFocusOrder(1.0), + child: TextFormField( + controller: controller.usernameController, + autofillHints: const [ + AutofillHints.username, + ], + autofocus: true, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => controller.login(), + decoration: InputDecoration( + labelText: 'username'.tr, + labelStyle: TextStyle( + color: Get.theme.colorScheme.onSurface + .withOpacity(0.7), + fontWeight: FontWeight.w500, + fontSize: 16, + ), + prefixIcon: Container( + margin: const EdgeInsets.all(12), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Get.theme.colorScheme.primary + .withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.person_outline_rounded, + color: Get.theme.colorScheme.primary, + size: 20, + ), + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + borderSide: BorderSide( + color: Get.theme.colorScheme.outline + .withOpacity(0.2), + width: 1, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + borderSide: BorderSide( + color: Get.theme.colorScheme.outline + .withOpacity(0.3), + width: 1.5, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + borderSide: BorderSide( + color: Get.theme.colorScheme.primary, + width: 2.5, + ), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + borderSide: BorderSide( + color: Get.theme.colorScheme.error, + width: 2, + ), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + borderSide: BorderSide( + color: Get.theme.colorScheme.error, + width: 2.5, + ), + ), + filled: true, + fillColor: Get + .theme.colorScheme.surfaceContainerHighest + .withOpacity(0.8), + contentPadding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 20, + ), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'username_required'.tr; + } + return null; + }, + ), + ), + + SizedBox(height: isNarrow ? 16 : 24), + + // Password Field + FocusTraversalOrder( + order: const NumericFocusOrder(2.0), + child: Obx(() => TextFormField( + controller: controller.passwordController, + autofillHints: const [ + AutofillHints.password, + ], + obscureText: !controller.passwordVisible.value, + decoration: InputDecoration( + labelText: 'password'.tr, + labelStyle: TextStyle( + color: Get.theme.colorScheme.onSurface + .withOpacity(0.7), + fontWeight: FontWeight.w500, + fontSize: 16, + ), + prefixIcon: Container( + margin: const EdgeInsets.all(12), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Get.theme.colorScheme.primary + .withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.lock_outline_rounded, + color: Get.theme.colorScheme.primary, + size: 20, + ), + ), + suffixIcon: IconButton( + icon: Icon( + controller.passwordVisible.value + ? Icons.visibility_off_rounded + : Icons.visibility_rounded, + color: Get.theme.colorScheme.onSurface + .withOpacity(0.6), + ), + onPressed: + controller.togglePasswordVisibility, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + borderSide: BorderSide( + color: Get.theme.colorScheme.outline + .withOpacity(0.2), + width: 1, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + borderSide: BorderSide( + color: Get.theme.colorScheme.outline + .withOpacity(0.3), + width: 1.5, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + borderSide: BorderSide( + color: Get.theme.colorScheme.primary, + width: 2.5, + ), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + borderSide: BorderSide( + color: Get.theme.colorScheme.error, + width: 2, + ), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + borderSide: BorderSide( + color: Get.theme.colorScheme.error, + width: 2.5, + ), + ), + filled: true, + fillColor: Get + .theme.colorScheme.surfaceContainerHighest + .withOpacity(0.8), + contentPadding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 20, + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'password_required'.tr; + } + return null; + }, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => controller.login(), + )), + ), + + SizedBox(height: isNarrow ? 24 : 32), + + // Login Button + Obx(() => ElevatedButton( + onPressed: controller.isLoading.value + ? null + : controller.login, + style: ElevatedButton.styleFrom( + padding: EdgeInsets.symmetric( + vertical: isNarrow ? 16 : 18, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + backgroundColor: controller.isLoading.value + ? Get.theme.disabledColor + : Get.theme.colorScheme.primary, + foregroundColor: + Get.theme.colorScheme.onPrimary, + elevation: controller.isLoading.value ? 0 : 4, + shadowColor: Get.theme.colorScheme.primary + .withOpacity(0.4), + ), + child: controller.isLoading.value + ? SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator( + strokeWidth: 2.5, + valueColor: + AlwaysStoppedAnimation(Get + .theme.colorScheme.onPrimary), + ), + ) + : Text( + 'login'.tr, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + )), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/ui/flutter/lib/app/routes/app_pages.dart b/ui/flutter/lib/app/routes/app_pages.dart index 4773bb33..f3965d4d 100644 --- a/ui/flutter/lib/app/routes/app_pages.dart +++ b/ui/flutter/lib/app/routes/app_pages.dart @@ -6,6 +6,8 @@ import '../modules/extension/bindings/extension_binding.dart'; import '../modules/extension/views/extension_view.dart'; import '../modules/home/bindings/home_binding.dart'; import '../modules/home/views/home_view.dart'; +import '../modules/login/bindings/login_binding.dart'; +import '../modules/login/views/login_view.dart'; import '../modules/redirect/bindings/redirect_binding.dart'; import '../modules/redirect/views/redirect_view.dart'; import '../modules/root/bindings/root_binding.dart'; @@ -63,6 +65,12 @@ class AppPages { binding: SettingBinding(), ), ]), + GetPage( + name: _Paths.LOGIN, + page: () => const LoginView(), + binding: LoginBinding(), + transition: Transition.fadeIn, + ), GetPage( name: _Paths.CREATE, transition: Transition.downToUp, diff --git a/ui/flutter/lib/app/routes/app_routes.dart b/ui/flutter/lib/app/routes/app_routes.dart index 95faa3f8..17599085 100644 --- a/ui/flutter/lib/app/routes/app_routes.dart +++ b/ui/flutter/lib/app/routes/app_routes.dart @@ -7,6 +7,7 @@ abstract class Routes { static const ROOT = _Paths.ROOT; static const HOME = _Paths.HOME; static const CREATE = _Paths.CREATE; + static const LOGIN = _Paths.LOGIN; static const TASK = _Paths.HOME + _Paths.TASK; static const TASK_FILES = TASK + _Paths.TASK_FILES; static const EXTENSION = _Paths.HOME + _Paths.EXTENSION; @@ -20,6 +21,7 @@ abstract class _Paths { static const ROOT = '/'; static const HOME = '/home'; static const CREATE = '/create'; + static const LOGIN = '/login'; static const TASK = '/task'; static const TASK_FILES = '/files'; static const EXTENSION = '/extension'; diff --git a/ui/flutter/lib/database/database.dart b/ui/flutter/lib/database/database.dart index a0867e21..d939b048 100644 --- a/ui/flutter/lib/database/database.dart +++ b/ui/flutter/lib/database/database.dart @@ -6,10 +6,10 @@ import 'package:hive/hive.dart'; import 'entity.dart'; const String _startConfig = 'startConfig'; -const String _lastRunningConfig = 'lastRunningConfig'; const String _windowState = 'windowState'; const String _bookmark = 'bookmark'; const String _createHistory = 'createHistory'; +const String _webToken = 'webToken'; class Database { static final Database _instance = Database._internal(); @@ -82,6 +82,14 @@ class Database { }); } + void saveWebToken(String token) { + save(_webToken, token); + } + + String? getWebToken() { + return get(_webToken, (json) => json.toString()); + } + void saveCreateHistory(String url) { var list = getCreateHistory() ?? []; list.remove(url); diff --git a/ui/flutter/lib/i18n/langs/en_us.dart b/ui/flutter/lib/i18n/langs/en_us.dart index 75946428..da6f5547 100644 --- a/ui/flutter/lib/i18n/langs/en_us.dart +++ b/ui/flutter/lib/i18n/langs/en_us.dart @@ -118,5 +118,12 @@ const enUS = { 'fileSelectedSize': 'Size: ', 'httpHeaderName': 'Header Name', 'httpHeaderValue': 'Header Value', + 'login': 'Login', + 'username_required': 'Please enter your username', + 'password_required': 'Please enter your password', + 'login_success': 'Login successful', + 'login_failed': 'Login failed, please check your username and password', + 'login_failed_network': + 'Login failed, please check your network connection', }, }; diff --git a/ui/flutter/lib/i18n/langs/zh_cn.dart b/ui/flutter/lib/i18n/langs/zh_cn.dart index 947e6438..acd437b6 100644 --- a/ui/flutter/lib/i18n/langs/zh_cn.dart +++ b/ui/flutter/lib/i18n/langs/zh_cn.dart @@ -116,5 +116,11 @@ const zhCN = { 'fileSelectedSize': '大小:', 'httpHeaderName': '请求头名称', 'httpHeaderValue': '请求头值', + 'login': '登录', + 'username_required': '请输入用户名', + 'password_required': '请输入密码', + 'login_success': '登录成功', + 'login_failed': '登录失败,请检查用户名和密码', + 'login_failed_network': '登录失败,请检查网络连接', } }; diff --git a/ui/flutter/pubspec.lock b/ui/flutter/pubspec.lock index 56b2ad33..d1534448 100644 --- a/ui/flutter/pubspec.lock +++ b/ui/flutter/pubspec.lock @@ -448,6 +448,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.23" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: c200fd79c918a40c5cd50ea0877fa13f81bdaf6f0a5d3dbcc2a13e3285d6aa1b + url: "https://pub.dev" + source: hosted + version: "2.0.17" flutter_test: dependency: "direct dev" description: flutter @@ -803,6 +811,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -1329,6 +1345,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.1" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "44cc7104ff32563122a929e4620cf3efd584194eec6d1d913eb5ba593dbcf6de" + url: "https://pub.dev" + source: hosted + version: "1.1.18" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad" + url: "https://pub.dev" + source: hosted + version: "1.1.16" vector_math: dependency: transitive description: diff --git a/ui/flutter/pubspec.yaml b/ui/flutter/pubspec.yaml index 08dd2b9f..b7ceac55 100644 --- a/ui/flutter/pubspec.yaml +++ b/ui/flutter/pubspec.yaml @@ -77,6 +77,7 @@ dependencies: git: url: https://github.com/hui-z/flutter_install_plugin.git ref: cf08af829f4a4145634f8a047108f505fdbe5eaa + flutter_svg: ^2.0.17 dependency_overrides: permission_handler_windows: git: @@ -113,6 +114,7 @@ flutter: assets: - assets/tray_icon/ - assets/extension/ + - assets/icon/ # Browser extension native host binary, see https://github.com/GopeedLab/gopeed/tree/main/cmd/host - assets/exec/