diff --git a/main.go b/main.go index b32fc1777..724560687 100644 --- a/main.go +++ b/main.go @@ -156,14 +156,16 @@ func faviconFsFunc(writer http.ResponseWriter, request *http.Request) { func runWebServer() error { // 启动静态文件服务 - http.HandleFunc("/static/", web.BasicAuth(staticFsFunc)) - http.HandleFunc("/favicon.ico", web.BasicAuth(faviconFsFunc)) - - http.HandleFunc("/", web.BasicAuth(web.Writing)) - http.HandleFunc("/save", web.BasicAuth(web.Save)) - http.HandleFunc("/logs", web.BasicAuth(web.Logs)) - http.HandleFunc("/clearLog", web.BasicAuth(web.ClearLog)) - http.HandleFunc("/webhookTest", web.BasicAuth(web.WebhookTest)) + http.HandleFunc("/static/", web.AuthAssert(staticFsFunc)) + http.HandleFunc("/favicon.ico", web.AuthAssert(faviconFsFunc)) + http.HandleFunc("/login", web.AuthAssert(web.Login)) + http.HandleFunc("/loginFunc", web.AuthAssert(web.LoginFunc)) + + http.HandleFunc("/", web.Auth(web.Writing)) + http.HandleFunc("/save", web.Auth(web.Save)) + http.HandleFunc("/logs", web.Auth(web.Logs)) + http.HandleFunc("/clearLog", web.Auth(web.ClearLog)) + http.HandleFunc("/webhookTest", web.Auth(web.WebhookTest)) util.Log("监听 %s", *listen) diff --git a/static/constant.js b/static/constant.js index b602e6323..2dd2e2a45 100644 --- a/static/constant.js +++ b/static/constant.js @@ -226,6 +226,7 @@ const I18N_MAP = { "Ipv4CmdHelp": "Get IPv4 through command, only use the first matching IPv4 address of standard output(stdout). Such as: ip -4 addr show eth1", "Ipv6CmdHelp": "Get IPv6 through command, only use the first matching IPv6 address of standard output(stdout). Such as: ip -6 addr show eth1", "NetInterfaceEmptyHelp": 'No available network card found', + "Login": 'Login', }, 'zh-cn': { 'Logs': '日志', @@ -287,5 +288,6 @@ const I18N_MAP = { 点击参考更多 `, "NetInterfaceEmptyHelp": '没有找到可用的网卡', + "Login": '登录', } }; diff --git a/static/theme.js b/static/theme.js new file mode 100644 index 000000000..d72c90064 --- /dev/null +++ b/static/theme.js @@ -0,0 +1,22 @@ +function toggleTheme(write = false) { + const docEle = document.documentElement; + if (docEle.getAttribute("data-theme") === "dark") { + docEle.removeAttribute("data-theme"); + write && localStorage.setItem("theme", "light"); + } else { + docEle.setAttribute("data-theme", "dark"); + write && localStorage.setItem("theme", "dark"); + } +} + +const theme = localStorage.getItem("theme") ?? + (window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"); + +if (theme === "dark") { + toggleTheme(); +} + +// 主题切换 +document.getElementById("themeButton").addEventListener('click', () => toggleTheme(true)); diff --git a/static/utils.js b/static/utils.js index ef9311056..06f29b9a4 100644 --- a/static/utils.js +++ b/static/utils.js @@ -95,6 +95,9 @@ const request = { }, get: async function(path, data, parseFunc) { const response = await fetch(`${this.baseURL}${path}?${this.stringify(data)}`) + if (response.redirected) { + window.location.href = response.url + } return await (parseFunc||this.parse)(response) }, post: async function(path, data, parseFunc) { @@ -105,6 +108,9 @@ const request = { method: 'POST', body: data }) + if (response.redirected) { + window.location.href = response.url + } return await (parseFunc||this.parse)(response) } } \ No newline at end of file diff --git a/util/messages.go b/util/messages.go index c2fc02e52..076bfdef4 100644 --- a/util/messages.go +++ b/util/messages.go @@ -53,11 +53,10 @@ func init() { message.SetString(language.English, "Callback调用失败, 异常信息: %s", "Webhook called failed! Exception: %s") // save - message.SetString(language.English, "若通过公网访问, 仅允许在ddns-go启动后 5 分钟内完成首次配置", "If accessed via the public network, only allow the first configuration to be completed within 5 minutes after ddns-go starts") - message.SetString(language.English, "若从未设置过帐号密码, 仅允许在ddns-go启动后 5 分钟内设置, 请重启ddns-go", "If you have never set an account password, you can only set it within 5 minutes after ddns-go starts, please restart ddns-go") - message.SetString(language.English, "启用外网访问, 必须输入登录用户名/密码", "Enable external network access, you must enter the login username/password") - message.SetString(language.English, "修改 '通过命令获取' 必须设置帐号密码,请先设置帐号密码", "Modify 'Get by command' must set username/password, please set username/password first") - message.SetString(language.English, "密码不安全!尝试使用更长的密码", "insecure password, try using a longer password") + message.SetString(language.English, "请在ddns-go启动后 5 分钟内完成首次配置", "Please complete the first configuration within 5 minutes after ddns-go starts") + message.SetString(language.English, "之前未设置帐号密码, 仅允许在ddns-go启动后 5 分钟内设置, 请重启ddns-go", "The username/password has not been set before, only allowed to set within 5 minutes after ddns-go starts, please restart ddns-go") + message.SetString(language.English, "必须输入登录用户名/密码", "Must enter login username/password") + message.SetString(language.English, "密码不安全!尝试使用更复杂的密码", "Password is not secure! Try using a more complex password") message.SetString(language.English, "数据解析失败, 请刷新页面重试", "Data parsing failed, please refresh the page and try again") message.SetString(language.English, "第 %s 个配置未填写域名", "The %s config does not fill in the domain") @@ -113,6 +112,11 @@ func init() { message.SetString(language.English, "失败", "failed") message.SetString(language.English, "成功", "success") + // Login + message.SetString(language.English, "%q 登陆成功", "%q login successfully") + message.SetString(language.English, "用户名或密码错误", "Username or password is incorrect") + message.SetString(language.English, "登录失败次数过多,请等待 %d 分钟后再试", "Too many login failures, please try again after %d minutes") + } func Log(key string, args ...interface{}) { diff --git a/util/net.go b/util/net.go index 4a03571e0..8ae894986 100644 --- a/util/net.go +++ b/util/net.go @@ -28,14 +28,6 @@ func IsPrivateNetwork(remoteAddr string) bool { ip.IsLinkLocalUnicast() // 169.254/16, fe80::/10 } - // localhost - if remoteAddr == "localhost" { - return true - } - // private domain eg. .cluster.local - if strings.HasSuffix(remoteAddr, ".local") { - return true - } return false } diff --git a/util/token.go b/util/token.go new file mode 100644 index 000000000..ce694bf59 --- /dev/null +++ b/util/token.go @@ -0,0 +1,31 @@ +package util + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "fmt" + "math/rand" + "time" +) + +// GenerateToken 生成Token +func GenerateToken(username string) string { + key := []byte(generateRandomKey()) + h := hmac.New(sha256.New, key) + msg := fmt.Sprintf("%s%d", username, time.Now().Unix()) + h.Write([]byte(msg)) + return base64.StdEncoding.EncodeToString(h.Sum(nil)) +} + +// generateRandomKey 生成随机密钥 +func generateRandomKey() string { + // 设置随机种子 + source := rand.NewSource(time.Now().UnixNano()) + random := rand.New(source) + + // 生成随机的64位整数 + randomNumber := random.Uint64() + + return fmt.Sprint(randomNumber) +} diff --git a/web/auth.go b/web/auth.go new file mode 100644 index 000000000..26d546d21 --- /dev/null +++ b/web/auth.go @@ -0,0 +1,70 @@ +package web + +import ( + "net/http" + "time" + + "github.com/jeessy2/ddns-go/v6/config" + "github.com/jeessy2/ddns-go/v6/util" +) + +// ViewFunc func +type ViewFunc func(http.ResponseWriter, *http.Request) + +// Auth 验证Token是否已经通过 +func Auth(f ViewFunc) ViewFunc { + return func(w http.ResponseWriter, r *http.Request) { + tokenInCookie, err := r.Cookie("token") + if err != nil { + http.Redirect(w, r, "./login", http.StatusTemporaryRedirect) + return + } + + conf, _ := config.GetConfigCached() + + // 禁止公网访问 + if conf.NotAllowWanAccess { + if !util.IsPrivateNetwork(r.RemoteAddr) { + w.WriteHeader(http.StatusForbidden) + util.Log("%q 被禁止从公网访问", util.GetRequestIPStr(r)) + return + } + } + + // 验证token + if tokenInSystem != "" && tokenInSystem == tokenInCookie.Value { + f(w, r) // 执行被装饰的函数 + return + } + + http.Redirect(w, r, "./login", http.StatusTemporaryRedirect) + } +} + +// AuthAssert 保护静态等文件不被公网访问 +func AuthAssert(f ViewFunc) ViewFunc { + return func(w http.ResponseWriter, r *http.Request) { + + conf, err := config.GetConfigCached() + + // 配置文件为空, 启动时间超过3小时禁止从公网访问 + if err != nil && + time.Now().Unix()-startTime > 3*60*60 && !util.IsPrivateNetwork(r.RemoteAddr) { + w.WriteHeader(http.StatusForbidden) + util.Log("%q 配置文件为空, 超过3小时禁止从公网访问", util.GetRequestIPStr(r)) + return + } + + // 禁止公网访问 + if conf.NotAllowWanAccess { + if !util.IsPrivateNetwork(r.RemoteAddr) { + w.WriteHeader(http.StatusForbidden) + util.Log("%q 被禁止从公网访问", util.GetRequestIPStr(r)) + return + } + } + + f(w, r) // 执行被装饰的函数 + + } +} diff --git a/web/basic_auth.go b/web/basic_auth.go deleted file mode 100644 index a1f50b591..000000000 --- a/web/basic_auth.go +++ /dev/null @@ -1,96 +0,0 @@ -package web - -import ( - "bytes" - "encoding/base64" - "net/http" - "strings" - "time" - - "github.com/jeessy2/ddns-go/v6/config" - "github.com/jeessy2/ddns-go/v6/util" -) - -// ViewFunc func -type ViewFunc func(http.ResponseWriter, *http.Request) - -type loginDetect struct { - FailTimes int -} - -var ld = &loginDetect{} - -// BasicAuth basic auth -func BasicAuth(f ViewFunc) ViewFunc { - return func(w http.ResponseWriter, r *http.Request) { - conf, err := config.GetConfigCached() - - // 配置文件为空, 启动时间超过3小时禁止从公网访问 - if err != nil && time.Now().Unix()-startTime > 3*60*60 && - (!util.IsPrivateNetwork(r.RemoteAddr) || !util.IsPrivateNetwork(r.Host)) { - w.WriteHeader(http.StatusForbidden) - util.Log("%q 配置文件为空, 超过3小时禁止从公网访问", util.GetRequestIPStr(r)) - return - } - - // 禁止公网访问 - if conf.NotAllowWanAccess { - if !util.IsPrivateNetwork(r.RemoteAddr) || !util.IsPrivateNetwork(r.Host) { - w.WriteHeader(http.StatusForbidden) - util.Log("%q 被禁止从公网访问", util.GetRequestIPStr(r)) - return - } - } - - // 帐号或密码为空。跳过 - if conf.Username == "" && conf.Password == "" { - // 执行被装饰的函数 - f(w, r) - return - } - - if ld.FailTimes >= 5 { - util.Log("%q 登陆失败超过5次! 并延时5分钟响应", util.GetRequestIPStr(r)) - time.Sleep(5 * time.Minute) - if ld.FailTimes >= 5 { - ld.FailTimes = 0 - } - w.WriteHeader(http.StatusUnauthorized) - return - } - - // 认证帐号密码 - basicAuthPrefix := "Basic " - - // 获取 request header - auth := r.Header.Get("Authorization") - // 如果是 http basic auth - if strings.HasPrefix(auth, basicAuthPrefix) { - // 解码认证信息 - payload, err := base64.StdEncoding.DecodeString( - auth[len(basicAuthPrefix):], - ) - if err == nil { - pair := bytes.SplitN(payload, []byte(":"), 2) - if len(pair) == 2 && - bytes.Equal(pair[0], []byte(conf.Username)) && - bytes.Equal(pair[1], []byte(conf.Password)) { - ld.FailTimes = 0 - // 执行被装饰的函数 - f(w, r) - return - } - } - - ld.FailTimes = ld.FailTimes + 1 - util.Log("%q 帐号密码不正确", util.GetRequestIPStr(r)) - } - - // 认证失败,提示 401 Unauthorized - // Restricted 可以改成其他的值 - w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) - // 401 状态码 - w.WriteHeader(http.StatusUnauthorized) - util.Log("%q 请求登陆", util.GetRequestIPStr(r)) - } -} diff --git a/web/login.go b/web/login.go new file mode 100755 index 000000000..1b1c9cb92 --- /dev/null +++ b/web/login.go @@ -0,0 +1,124 @@ +package web + +import ( + "embed" + "encoding/json" + "fmt" + "html/template" + "net/http" + "time" + + "github.com/jeessy2/ddns-go/v6/config" + "github.com/jeessy2/ddns-go/v6/util" +) + +//go:embed login.html +var loginEmbedFile embed.FS + +// only need one token +var tokenInSystem = "" + +// 登录检测 +type loginDetect struct { + failedTimes uint32 // 失败次数 + ticker *time.Ticker // 定时器 +} + +var ld = &loginDetect{ticker: time.NewTicker(5 * time.Minute)} + +// Login login page +func Login(writer http.ResponseWriter, request *http.Request) { + tmpl, err := template.ParseFS(loginEmbedFile, "login.html") + if err != nil { + fmt.Println("Error happened..") + fmt.Println(err) + return + } + + conf, _ := config.GetConfigCached() + + err = tmpl.Execute(writer, struct { + EmptyUser bool // 未填写用户名和密码 + }{ + EmptyUser: conf.Username == "" && conf.Password == "", + }) + if err != nil { + fmt.Println("Error happened..") + fmt.Println(err) + } +} + +// LoginFunc login func +func LoginFunc(w http.ResponseWriter, r *http.Request) { + + if ld.failedTimes >= 5 { + lockMinute := loginUnlock() + returnError(w, util.LogStr("登录失败次数过多,请等待 %d 分钟后再试", lockMinute)) + return + } + + // 从请求中读取 JSON 数据 + var data struct { + Username string `json:"Username"` + Password string `json:"Password"` + } + + err := json.NewDecoder(r.Body).Decode(&data) + + if err != nil { + returnError(w, err.Error()) + return + } + + conf, _ := config.GetConfigCached() + + // 登陆成功 + if data.Username == conf.Username && data.Password == conf.Password { + ld.ticker.Stop() + ld.failedTimes = 0 + tokenInSystem = util.GenerateToken(data.Username) + + // 设置cookie过期时间为1天 + cookieTimeout := 24 + if conf.NotAllowWanAccess { + // 内网访问cookie过期时间为30天 + cookieTimeout = 24 * 30 + } + + // return cookie + cookie := http.Cookie{ + Name: "token", + Value: tokenInSystem, + Path: "/", + Expires: time.Now().Add(time.Hour * time.Duration(cookieTimeout)), + } + http.SetCookie(w, &cookie) + + util.Log("%q 登陆成功", util.GetRequestIPStr(r)) + returnOK(w, util.LogStr("登陆成功"), tokenInSystem) + return + } + + ld.failedTimes = ld.failedTimes + 1 + util.Log("%q 帐号密码不正确", util.GetRequestIPStr(r)) + returnError(w, util.LogStr("用户名或密码错误")) +} + +// loginUnlock login unlock, return minute +func loginUnlock() (minute uint32) { + ld.failedTimes = ld.failedTimes + 1 + x := ld.failedTimes + if x > 1440 { + x = 1440 // 最多等待一天 + } + ld.ticker.Reset(time.Duration(x) * time.Minute) + + go func(ticker *time.Ticker) { + for range ticker.C { + ld.failedTimes = 0 + ticker.Stop() + } + }(ld.ticker) + + return x +} diff --git a/web/login.html b/web/login.html new file mode 100755 index 000000000..7e554d74e --- /dev/null +++ b/web/login.html @@ -0,0 +1,135 @@ + + +
+ + + + +