Login page (#1094)

* feat: Add a login page

* feat: Modify save rules, more secure

* remove remoteAddr == "localhost"

* "登录失败次数过多,请等待 %d 分钟后再试

* cookie remove secure

* set cookie expires time by `NotAllowWanAccess`

* prettier

* fix: rename

* feat: auto login if unfilled

* feat: auto login if there is no username/password

* auto login if no username/password
This commit is contained in:
jeessy2
2024-04-26 19:35:08 -07:00
committed by GitHub
parent 76f5e35ff5
commit 63b510bef2
14 changed files with 456 additions and 163 deletions

16
main.go
View File

@ -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("/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.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("/", 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)

View File

@ -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": '<span style="color: red">No available network card found</span>',
"Login": 'Login',
},
'zh-cn': {
'Logs': '日志',
@ -287,5 +288,6 @@ const I18N_MAP = {
<a target="blank" href="https://github.com/jeessy2/ddns-go/wiki/通过命令获取IP参考">点击参考更多</a>
`,
"NetInterfaceEmptyHelp": '<span style="color: red">没有找到可用的网卡</span>',
"Login": '登录',
}
};

22
static/theme.js Normal file
View File

@ -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));

View File

@ -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)
}
}

View File

@ -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{}) {

View File

@ -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
}

31
util/token.go Normal file
View File

@ -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)
}

70
web/auth.go Normal file
View File

@ -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) // 执行被装饰的函数
}
}

View File

@ -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))
}
}

124
web/login.go Executable file
View File

@ -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
}

135
web/login.html Executable file
View File

@ -0,0 +1,135 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="author" content="jeessy2" />
<title>DDNS-GO</title>
<link
class="theme"
rel="stylesheet"
type="text/css"
href="./static/common.css"
/>
<link rel="stylesheet" href="./static/bootstrap.min.css" />
<link rel="stylesheet" href="./static/theme-button.css" />
<script src="./static/constant.js"></script>
<script src="./static/utils.js"></script>
<script src="./static/i18n.js"></script>
</head>
<body>
<header>
<div class="navbar navbar-dark bg-dark shadow-sm">
<div class="button-container container d-flex justify-content-between">
<a
target="blank"
href="https://github.com/jeessy2/ddns-go"
class="navbar-brand d-flex align-items-center"
>
<strong>DDNS-GO</strong>
</a>
<span class="theme-button gg-dark-mode" id="themeButton"></span>
</div>
</div>
</header>
<main role="main">
<div class="row" style="margin-top: 10%">
<div class="col-md-4 offset-md-4 align-self-center">
<form id="login">
<div class="portlet">
<h5 data-i18n="Login" class="portlet__head">Login</h5>
<div
class="portlet__body"
style="justify-content: center; align-items: center"
>
<div class="form-group row">
<label
for="Username"
data-i18n="Username"
class="col-sm-2 col-form-label"
>Username</label
>
<div class="col-sm-10">
<input
class="form-control form"
name="Username"
id="Username"
/>
</div>
</div>
<div class="form-group row">
<label
for="Password"
data-i18n="Password"
class="col-sm-2 col-form-label"
>Password</label
>
<div class="col-sm-10">
<input
class="form-control form"
type="password"
name="Password"
id="Password"
/>
</div>
</div>
<div class="form-group row">
<div class="col-sm-10 offset-sm-2">
<button data-i18n="Login" class="btn btn-primary login_btn">
Login
</button>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
</main>
</body>
<script src="./static/theme.js"></script>
<script>
// 登录
document.querySelectorAll(".login_btn").forEach(($el) => {
$el.addEventListener("click", async (e) => {
e.preventDefault();
try {
const resp = await request.post("./loginFunc", {
Username: document.getElementById("Username").value,
Password: document.getElementById("Password").value,
});
if (resp.Code !== 200) {
showMessage({
content: resp.Msg,
type: "error",
duration: 5000,
});
} else {
window.location.href = "./";
}
} catch (err) {
showMessage({
content: err.toString(),
type: "error",
duration: 5000,
});
}
});
});
</script>
<script>
// 用户为空尝试自动登录
if ('{{.EmptyUser}}' === 'true') {
document.querySelectorAll(".login_btn").forEach(($el) => {
$el.click()
})
}
</script>
</html>

34
web/return_json.go Normal file
View File

@ -0,0 +1,34 @@
package web
import (
"encoding/json"
"net/http"
)
// Result Result
type Result struct {
Code int // 状态
Msg string // 消息
Data interface{} // 数据
}
// returnError 返回错误信息
func returnError(w http.ResponseWriter, msg string) {
result := &Result{}
result.Code = http.StatusInternalServerError
result.Msg = msg
json.NewEncoder(w).Encode(result)
}
// returnOK 返回成功信息
func returnOK(w http.ResponseWriter, msg string, data interface{}) {
result := &Result{}
result.Code = http.StatusOK
result.Msg = msg
result.Data = data
json.NewEncoder(w).Encode(result)
}

View File

@ -54,19 +54,15 @@ func checkAndSave(request *http.Request) string {
accept := request.Header.Get("Accept-Language")
conf.Lang = util.InitLogLang(accept)
// 验证安全性后才允许设置保存配置文件:
// 首次设置 && 必需在服务启动的 5 分钟内
if time.Now().Unix()-startTime > 5*60 {
// 首次设置 && 通过外网访问 必需在服务启动的 5 分钟内
if firstTime &&
(!util.IsPrivateNetwork(request.RemoteAddr) || !util.IsPrivateNetwork(request.Host)) {
return util.LogStr("若通过公网访问, 仅允许在ddns-go启动后 5 分钟内完成首次配置")
if firstTime {
return util.LogStr("请在ddns-go启动后 5 分钟内完成首次配置")
}
// 非首次设置 && 从未设置过帐号密码 && 本次设置了帐号或密码 必须在5分钟内
if !firstTime &&
(conf.Username == "" && conf.Password == "") &&
// 之前未设置帐号密码 && 本次设置了帐号或密码 必须在5分钟内
if (conf.Username == "" && conf.Password == "") &&
(usernameNew != "" || passwordNew != "") {
return util.LogStr("若从未设置帐号密码, 仅允许在ddns-go启动后 5 分钟内设置, 请重启ddns-go")
return util.LogStr("之前未设置帐号密码, 仅允许在ddns-go启动后 5 分钟内设置, 请重启ddns-go")
}
}
@ -77,25 +73,25 @@ func checkAndSave(request *http.Request) string {
conf.WebhookRequestBody = strings.TrimSpace(data.WebhookRequestBody)
conf.WebhookHeaders = strings.TrimSpace(data.WebhookHeaders)
// 如启用公网访问,帐号密码不能为空
if !conf.NotAllowWanAccess && (conf.Username == "" || conf.Password == "") {
return util.LogStr("启用外网访问, 必须输入登录用户名/密码")
// 帐号密码不能为空
if conf.Username == "" || conf.Password == "" {
return util.LogStr("必须输入登录用户名/密码")
}
// 如果密码不为空则检查是否够强, 内/外网要求强度不同
if passwordNew != "" {
if conf.Password != "" {
var minEntropyBits float64 = 50
if conf.NotAllowWanAccess {
minEntropyBits = 25
}
err = passwordvalidator.Validate(passwordNew, minEntropyBits)
err = passwordvalidator.Validate(conf.Password, minEntropyBits)
if err != nil {
return util.LogStr("密码不安全!尝试使用更的密码")
return util.LogStr("密码不安全!尝试使用更复杂的密码")
}
}
dnsConfFromJS := data.DnsConf
dnsConfArray := []config.DnsConfig{}
var dnsConfArray []config.DnsConfig
empty := dnsConf4JS{}
for k, v := range dnsConfFromJS {
if v == empty {
@ -135,12 +131,6 @@ func checkAndSave(request *http.Request) string {
if dnsConf.DNS.Secret == secretHide {
dnsConf.DNS.Secret = c.DNS.Secret
}
// 修改cmd需要验证必须设置帐号密码
if (conf.Username == "" && conf.Password == "") &&
(c.Ipv4.Cmd != dnsConf.Ipv4.Cmd || c.Ipv6.Cmd != dnsConf.Ipv6.Cmd) {
return util.LogStr("修改 '通过命令获取' 必须设置帐号密码,请先设置帐号密码")
}
}
dnsConfArray = append(dnsConfArray, dnsConf)

View File

@ -1096,30 +1096,7 @@
</script>
<!-- 主题色相关的函数和初始化 -->
<script>
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));
</script>
<script src="./static/theme.js"></script>
<!-- 测试相关 -->
<script>