mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-13 03:43:41 +00:00
feat: web login page (#1058)
This commit is contained in:
22 files changed
+771
-43
No files matched your search
@@ -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())
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
+123
-9
@@ -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)
|
||||
}
|
||||
+52
-5
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user