Repository: GopeedLab/gopeed

Issue:
希望登录页面支持输入用户名和密码，避免使用弹窗，提升密码管理器兼容性
目前系统登录使用的是浏览器弹窗（window.prompt 或 basic auth 方式）来输入用户名和密码。这种方式无法被常用的密码管理器（如 Bitwarden、1Password 等）识别和自动填充，导致用户每次登录都需要手动输入，也无法在客户端记录登录状态，非常不便。

建议的改进方式：
希望将当前的弹窗登录方式，替换为标准的 HTML 登录表单页面（包含用户名和密码输入框），这样密码管理器就可以识别并自动填充凭据，从而提升用户体验和使用效率。

环境信息
部署方式：使用 Docker 部署服务
访问方式：通过 Web 浏览器访问

Discussion:
@monkeyWie: 确实有这个想法，我看看怎么优化吧

Solution patch:
diff --git a/cmd/web/flags.go b/cmd/web/flags.go
index 3d0537b..49af137 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 d626557..32c2cb8 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 b5ca05c..0cdc646 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 f70d5c3..f328432 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 c6f4d73..7c42f9e 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 4593490..f9bea8f 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 0d7c4dc..31c254a 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/ui/flutter/lib/api/api.dart b/ui/flutter/lib/api/api.dart
index 3d2c926..e8686da 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<T> _parse<T>(
   } 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<void> updateExtension(String identity) async {
       () => _client.dio.post("api/v1/extensions/$identity/update"), null);
 }
 
+Future<String> login(LoginReq loginReq) async {
+  return _parse(() => _client.dio.post("api/web/login", data: loginReq),
+      (data) => data as String);
+}
+
 Future<Response<String>> proxyRequest<T>(String uri,
     {data, Options? options}) async {
   options ??= Options();
@@ -230,5 +255,9 @@ Future<Response<String>> proxyRequest<T>(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 0000000..41be268
--- /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<String, dynamic> json) =>
+      _$LoginReqFromJson(json);
+  Map<String, dynamic> 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 0000000..f347a38
--- /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<String, dynamic> json) => LoginReq(
+      username: json['username'] as String,
+      password: json['password'] as String,
+    );
+
+Map<String, dynamic> _$LoginReqToJson(LoginReq instance) => <String, dynamic>{
+      '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 1c92607..422f6d7 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<ExtensionController> {
                                                   '/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 0000000..ac119f4
--- /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>(
+      () => 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 0000000..16f185e
--- /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<FormState>();
+  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<void> 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 0000000..4b94ff0
--- /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<LoginController> {
+  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<Color>(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 4773bb3..f3965d4 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 95faa3f..1759908 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 a0867e2..d939b04 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<String>(_webToken, token);
+  }
+
+  String? getWebToken() {
+    return get<String>(_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 7594642..da6f554 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 947e643..acd437b 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 56b2ad3..d153444 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 08dd2b9..b7ceac5 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/
 



Test patch:
diff --git a/pkg/rest/server_test.go b/pkg/rest/server_test.go
index ee18f3f..4ad6724 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": "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": cfg.WebBasicAuth.Authorization(),
+		"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 {