diff --git a/.devin/wiki.json b/.devin/wiki.json
new file mode 100644
index 00000000..ab5730e6
--- /dev/null
+++ b/.devin/wiki.json
@@ -0,0 +1,7 @@
+{
+ "repo_notes": [
+ {
+ "content": "使用中文生成wiki文档"
+ }
+ ]
+}
diff --git a/.gitignore b/.gitignore
index 7a2be1f0..156a8260 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,6 @@ artifact-dist
anylink_amd64
anylink_arm64
+
+# macOS
+.DS_Store
diff --git a/README.md b/README.md
index 4037266f..58050a0e 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
[](https://hub.docker.com/r/bjdgyc/anylink)

-AnyLink 是一个企业级远程办公 sslvpn 的软件,可以支持多人同时在线使用。
+AnyLink 是一个企业级远程接入办公软件,可以支持多人同时在线使用。
使用 AnyLink,你可以随时随地安全的访问你的内部网络。
@@ -18,11 +18,11 @@ With AnyLink, you can securely access your internal network anytime and anywhere
## Sponsors
-

+
-
+
@@ -34,7 +34,7 @@ With AnyLink, you can securely access your internal network anytime and anywhere
-
+
diff --git a/doc/question.md b/doc/question.md
index a8f24332..02aa0a60 100644
--- a/doc/question.md
+++ b/doc/question.md
@@ -117,6 +117,51 @@ anylink: tun模式 tcp传输
> 客户端tls加密协议、隧道header头都会占用一定带宽
+### 用户自助修改密码
+
+> AnyLink 内置了用户自助改密页面, 无需额外部署服务。用户浏览器访问 `https://你的VPN地址/pwd` 即可修改自己的密码。
+
+页面需要填写: 用户名、旧密码、新密码。校验规则:
+
+```
+1.旧密码只需输入密码(PIN码)本身, 无需附加 OTP 动态码。
+2.新密码长度不能少于 6 位, 且不能与旧密码相同。
+3.用户名不存在或旧密码错误, 均返回同一提示"用户名或旧密码错误", 防止用户名枚举。
+4.改密页面与 VPN 登录共享防爆锁定: 短时间内多次输错旧密码会被锁定, 锁定策略同登录防爆。
+```
+
+> 若开启了 `encryption_password`, 新密码会自动加密保存, 与后台设置密码一致。
+
+### 首次登录强制修改密码
+
+> 用于避免管理员设置的初始密码长期不改的安全隐患。开启后, **新建用户**首次连接 VPN 前, 必须先通过自助改密页修改一次密码, 否则登录被拒。
+
+配置方式(`conf/server.toml`):
+
+```yaml
+#新建用户首次登录前强制修改密码
+force_first_change_pwd = true
+#自助改密页地址(内置页填 https://你的VPN地址/pwd)
+portal_url = "https://你的VPN地址/pwd"
+```
+
+工作流程:
+
+```
+1.开启 force_first_change_pwd 后, 通过后台或批量导入新建的用户会被标记为"需改密"。
+2.该用户连接 VPN 时, 即使密码正确也会被拒绝, 并提示前往 portal_url 修改密码。
+3.用户访问自助改密页 /pwd 修改密码成功后, "需改密"标记自动清除。
+4.再次连接 VPN 即可正常登录。
+```
+
+> 说明:
+>
+> 1.仅对开启开关后**新建**的用户生效, 存量老用户不受影响, 可正常登录。
+>
+> 2.被拒绝登录不会计入防爆失败次数, 不会导致用户被锁定。
+>
+> 3.管理员在后台编辑用户不会误清"需改密"标记。
+
### 登录防爆说明
```
diff --git a/server/admin/api_uploaduser.go b/server/admin/api_uploaduser.go
index 26041407..6bce39f8 100644
--- a/server/admin/api_uploaduser.go
+++ b/server/admin/api_uploaduser.go
@@ -10,6 +10,7 @@ import (
"strings"
"time"
+ "github.com/bjdgyc/anylink/base"
"github.com/bjdgyc/anylink/dbdata"
"github.com/bjdgyc/anylink/pkg/utils"
mapset "github.com/deckarep/golang-set"
@@ -104,6 +105,7 @@ func UploadUser(file string) error {
Groups: group,
Status: status,
SendEmail: sendmail,
+ ChangePwd: base.Cfg.ForceFirstChangePwd,
// CreatedAt: createdAt,
// UpdatedAt: updatedAt,
}
diff --git a/server/admin/api_user.go b/server/admin/api_user.go
index 39499643..134cf1ae 100644
--- a/server/admin/api_user.go
+++ b/server/admin/api_user.go
@@ -103,6 +103,10 @@ func UserSet(w http.ResponseWriter, r *http.Request) {
data.PinCode = utils.RandomRunes(8)
base.Info("用户", data.Username, "随机密码为:", data.PinCode)
}
+ // 新建用户时按全局开关标记首次登录需修改密码
+ if data.Id == 0 && base.Cfg.ForceFirstChangePwd {
+ data.ChangePwd = true
+ }
plainpwd := data.PinCode
err = dbdata.SetUser(data)
if err != nil {
diff --git a/server/base/cfg.go b/server/base/cfg.go
index 65cb4486..a2d86de0 100644
--- a/server/base/cfg.go
+++ b/server/base/cfg.go
@@ -91,6 +91,9 @@ type ServerConfig struct {
AuthAloneOtp bool `json:"auth_alone_otp"`
EncryptionPassword bool `json:"encryption_password"`
+ ForceFirstChangePwd bool `json:"force_first_change_pwd"`
+ PortalUrl string `json:"portal_url"`
+
AntiBruteForce bool `json:"anti_brute_force"`
IPWhitelist string `json:"ip_whitelist"`
diff --git a/server/base/config.go b/server/base/config.go
index d5ee7006..4a46c99a 100644
--- a/server/base/config.go
+++ b/server/base/config.go
@@ -74,6 +74,8 @@ var configs = []config{
{Typ: cfgBool, Name: "exclude_export_ip", Usage: "排除出口ip路由(出口ip不加密传输)", ValBool: true},
{Typ: cfgBool, Name: "auth_alone_otp", Usage: "登录单独验证OTP窗口", ValBool: false},
{Typ: cfgBool, Name: "encryption_password", Usage: "用户密码是否加密保存", ValBool: false},
+ {Typ: cfgBool, Name: "force_first_change_pwd", Usage: "新建用户首次登录前强制修改密码", ValBool: false},
+ {Typ: cfgStr, Name: "portal_url", Usage: "自助改密门户地址(首次登录拒绝提示用),内置改密页填 https://vpn地址/pwd,留空则登录拒绝时自动使用内置/pwd路径", ValStr: ""},
{Typ: cfgBool, Name: "anti_brute_force", Usage: "是否开启防爆功能", ValBool: true},
{Typ: cfgStr, Name: "ip_whitelist", Usage: "全局IP白名单,多个用逗号分隔,支持单IP和CIDR范围", ValStr: "192.168.90.1,172.16.0.0/24"},
diff --git a/server/conf/server-sample.toml b/server/conf/server-sample.toml
index 24139250..0e8b29fc 100644
--- a/server/conf/server-sample.toml
+++ b/server/conf/server-sample.toml
@@ -123,6 +123,11 @@ auth_alone_otp = false
#加密保存用户密码
encryption_password = false
+#新建用户首次登录前强制修改密码(开启后, 新建用户须先通过自助改密页修改密码才能连接VPN)
+force_first_change_pwd = false
+#自助改密页地址, 用于首次登录被拒时的提示。内置改密页填 https://你的VPN地址/pwd (建议不含&参数)
+portal_url = ""
+
#防爆破全局开关
anti_brute_force = true
#全局IP白名单,多个用逗号分隔,支持单IP和CIDR范围
diff --git a/server/dbdata/tables.go b/server/dbdata/tables.go
index 6fb43647..25f9e5f7 100644
--- a/server/dbdata/tables.go
+++ b/server/dbdata/tables.go
@@ -37,6 +37,8 @@ type User struct {
Groups []string `json:"groups" xorm:"Text"`
Status int8 `json:"status" xorm:"Int"` // 1正常
SendEmail bool `json:"send_email" xorm:"Bool"`
+ ChangePwd bool `json:"change_pwd" xorm:"Bool"` // 首次登录需修改密码
+
CreatedAt time.Time `json:"created_at" xorm:"DateTime created"`
UpdatedAt time.Time `json:"updated_at" xorm:"DateTime updated"`
}
diff --git a/server/dbdata/user.go b/server/dbdata/user.go
index 6c84bb10..886f34c9 100644
--- a/server/dbdata/user.go
+++ b/server/dbdata/user.go
@@ -3,6 +3,7 @@ package dbdata
import (
"errors"
"fmt"
+ "strings"
"sync"
"time"
@@ -147,6 +148,38 @@ func checkLocalUser(name, pwd, group string, ext map[string]interface{}) error {
return nil
}
+// 用户自助修改密码: 校验旧密码(仅PIN码, 不含OTP动态码)后更新密码, 并清除首次登录改密标记。
+// 用户不存在与旧密码错误返回同一模糊提示, 防止用户名枚举。
+func UserChangePassword(username, oldPwd, newPwd string) error {
+ username = strings.TrimSpace(username)
+ if username == "" || oldPwd == "" {
+ return errors.New("用户名或旧密码错误")
+ }
+ if len(newPwd) < 6 {
+ return errors.New("新密码长度不能少于6位")
+ }
+ if newPwd == oldPwd {
+ return errors.New("新密码不能与旧密码相同")
+ }
+
+ v := &User{}
+ err := One("Username", username, v)
+ if err != nil {
+ return errors.New("用户名或旧密码错误")
+ }
+ if !utils.PasswordVerify(oldPwd, v.PinCode) {
+ return errors.New("用户名或旧密码错误")
+ }
+ // 旧密码校验通过后才提示账号状态, 避免枚举者探测账号状态
+ if v.Status != 1 {
+ return errors.New("账号已停用或已过期, 请联系管理员")
+ }
+
+ v.PinCode = newPwd
+ v.ChangePwd = false
+ return SetUser(v)
+}
+
// 用户过期时间到达后,更新用户状态,并返回一个状态为过期的用户切片
func CheckUserlimittime() (limitUser []interface{}) {
if _, err := xdb.Where("limittime <= ?", time.Now()).And("status = ?", 1).Update(&User{Status: 2}); err != nil {
diff --git a/server/dbdata/user_change_pwd_test.go b/server/dbdata/user_change_pwd_test.go
new file mode 100644
index 00000000..5d9cc948
--- /dev/null
+++ b/server/dbdata/user_change_pwd_test.go
@@ -0,0 +1,135 @@
+package dbdata
+
+import (
+ "testing"
+
+ "github.com/bjdgyc/anylink/base"
+ "github.com/bjdgyc/anylink/pkg/utils"
+ "github.com/stretchr/testify/assert"
+)
+
+// 覆盖用户自助改密的各条路径: 正常改密/旧密码错/用户不存在/新密码过短/
+// 新旧相同/OTP用户只校验PIN/明文与密文兼容/改密后清除ChangePwd标记。
+func TestUserChangePassword(t *testing.T) {
+ base.Test()
+ ast := assert.New(t)
+
+ preIpData()
+ defer closeIpdata()
+
+ group := "group1"
+ dns := []ValData{{Val: "114.114.114.114"}}
+ route := []ValData{{Val: "192.168.1.0/24"}}
+ g := Group{Name: group, Status: 1, ClientDns: dns, RouteInclude: route}
+ ast.Nil(SetGroup(&g))
+
+ // 新建一个需首次改密的用户
+ u := User{
+ Username: "alice",
+ PinCode: "oldpass1",
+ Groups: []string{group},
+ Status: 1,
+ ChangePwd: true,
+ }
+ ast.Nil(SetUser(&u))
+
+ // 用户不存在 -> 模糊提示, 不泄露账号是否存在
+ err := UserChangePassword("ghost", "whatever", "newpass1")
+ if ast.NotNil(err) {
+ ast.Equal("用户名或旧密码错误", err.Error())
+ }
+
+ // 旧密码错误 -> 同一模糊提示
+ err = UserChangePassword("alice", "wrongold", "newpass1")
+ if ast.NotNil(err) {
+ ast.Equal("用户名或旧密码错误", err.Error())
+ }
+
+ // 新密码过短
+ err = UserChangePassword("alice", "oldpass1", "123")
+ if ast.NotNil(err) {
+ ast.Contains(err.Error(), "不能少于6位")
+ }
+
+ // 新旧密码相同
+ err = UserChangePassword("alice", "oldpass1", "oldpass1")
+ if ast.NotNil(err) {
+ ast.Contains(err.Error(), "不能与旧密码相同")
+ }
+
+ // 正常改密成功
+ err = UserChangePassword("alice", "oldpass1", "newpass1")
+ ast.Nil(err)
+
+ // 校验: 新密码生效, 且 ChangePwd 标记被清除
+ after := &User{}
+ ast.Nil(One("Username", "alice", after))
+ ast.True(utils.PasswordVerify("newpass1", after.PinCode))
+ ast.False(after.ChangePwd)
+
+ // 旧密码不再有效
+ err = UserChangePassword("alice", "oldpass1", "another1")
+ if ast.NotNil(err) {
+ ast.Equal("用户名或旧密码错误", err.Error())
+ }
+}
+
+// 验证开启密码加密后, 旧密码校验(密文)与改密仍正确工作。
+func TestUserChangePasswordEncrypted(t *testing.T) {
+ base.Test()
+ ast := assert.New(t)
+
+ preIpData()
+ defer closeIpdata()
+
+ // 开启密码加密, 用例结束后复位, 避免影响其它用例
+ oldEnc := base.Cfg.EncryptionPassword
+ base.Cfg.EncryptionPassword = true
+ defer func() { base.Cfg.EncryptionPassword = oldEnc }()
+
+ group := "encgroup"
+ dns := []ValData{{Val: "114.114.114.114"}}
+ route := []ValData{{Val: "192.168.1.0/24"}}
+ g := Group{Name: group, Status: 1, ClientDns: dns, RouteInclude: route}
+ ast.Nil(SetGroup(&g))
+
+ u := User{Username: "bob", PinCode: "oldpass1", Groups: []string{group}, Status: 1}
+ ast.Nil(SetUser(&u))
+
+ // 确认入库后是 bcrypt 密文
+ stored := &User{}
+ ast.Nil(One("Username", "bob", stored))
+ ast.Equal(60, len(stored.PinCode))
+
+ // 用明文旧密码校验密文, 改密成功
+ ast.Nil(UserChangePassword("bob", "oldpass1", "newpass1"))
+
+ after := &User{}
+ ast.Nil(One("Username", "bob", after))
+ ast.Equal(60, len(after.PinCode))
+ ast.True(utils.PasswordVerify("newpass1", after.PinCode))
+}
+
+// 验证停用/过期账号: 旧密码正确也应拒绝, 且提示账号状态。
+func TestUserChangePasswordDisabled(t *testing.T) {
+ base.Test()
+ ast := assert.New(t)
+
+ preIpData()
+ defer closeIpdata()
+
+ group := "disgroup"
+ dns := []ValData{{Val: "114.114.114.114"}}
+ route := []ValData{{Val: "192.168.1.0/24"}}
+ g := Group{Name: group, Status: 1, ClientDns: dns, RouteInclude: route}
+ ast.Nil(SetGroup(&g))
+
+ // status=0 停用用户
+ u := User{Username: "carol", PinCode: "oldpass1", Groups: []string{group}, Status: 0}
+ ast.Nil(SetUser(&u))
+
+ err := UserChangePassword("carol", "oldpass1", "newpass1")
+ if ast.NotNil(err) {
+ ast.Contains(err.Error(), "停用或已过期")
+ }
+}
diff --git a/server/handler/link_auth.go b/server/handler/link_auth.go
index a9c8b97f..dd674bc3 100644
--- a/server/handler/link_auth.go
+++ b/server/handler/link_auth.go
@@ -117,15 +117,43 @@ func LinkAuth(w http.ResponseWriter, r *http.Request) {
tplRequest(tpl_request, w, data)
return
}
- dbdata.UserActLogIns.Add(*ua, userAgent)
-
v := &dbdata.User{}
err = dbdata.One("Username", cr.Auth.Username, v)
if err != nil {
+ // 第三方认证方式, 无本地用户记录, 记录登录成功日志
+ dbdata.UserActLogIns.Add(*ua, userAgent)
base.Info("正在使用第三方认证方式登录")
CreateSession(w, r, sessionData)
return
}
+
+ // 首次登录强制修改密码: 密码校验已通过, 但用户尚未修改初始密码, 拒绝登录
+ // 注意: 不调用 lockManager.UpdateLoginStatus(false), 避免正常用户被防爆逻辑锁定
+ if v.ChangePwd {
+ base.Info(cr.Auth.Username, "首次登录需先修改密码")
+ ua.Info = "首次登录需修改密码"
+ ua.Status = dbdata.UserAuthFail
+ dbdata.UserActLogIns.Add(*ua, userAgent)
+
+ // portal_url 未配置时自动使用内置改密页地址
+ portal := base.Cfg.PortalUrl
+ if portal == "" {
+ portal = "https://" + r.Host + "/pwd"
+ }
+ msg := "首次登录请先访问 " + portal + " 修改初始密码后再连接"
+ // auth_request 为 text/template 不转义, 需手动转义防止 URL 特殊字符破坏 XML
+ buf := new(bytes.Buffer)
+ _ = xml.EscapeText(buf, []byte(msg))
+
+ w.WriteHeader(http.StatusOK)
+ data := RequestData{Group: cr.GroupSelect, Groups: dbdata.GetGroupNamesNormal(), Error: buf.String()}
+ tplRequest(tpl_request, w, data)
+ return
+ }
+
+ // 密码校验通过且无需改密, 记录登录成功日志
+ dbdata.UserActLogIns.Add(*ua, userAgent)
+
// 用户otp验证
if base.Cfg.AuthAloneOtp && !v.DisableOtp {
lockManager.UpdateLoginStatus(cr.Auth.Username, r.RemoteAddr, true) // 重置OTP验证计数
diff --git a/server/handler/link_pwd.go b/server/handler/link_pwd.go
new file mode 100644
index 00000000..ff9c6351
--- /dev/null
+++ b/server/handler/link_pwd.go
@@ -0,0 +1,158 @@
+package handler
+
+import (
+ "bytes"
+ "crypto/subtle"
+ "html/template"
+ "net/http"
+
+ "github.com/bjdgyc/anylink/base"
+ "github.com/bjdgyc/anylink/dbdata"
+ "github.com/bjdgyc/anylink/pkg/utils"
+)
+
+// 自助修改密码页面: 挂载在 VPN 服务(:443)的公开路由 /pwd 上, 无需管理员鉴权。
+// 复用 dbdata.UserChangePassword 做核心校验, 复用 lockManager 做防爆限流。
+
+const pwdCsrfCookie = "pwd-csrf"
+
+// pwdPageData 用于渲染改密页面。Message 为结果提示, Success 决定提示样式。
+type pwdPageData struct {
+ CsrfToken string
+ Message string
+ Success bool
+}
+
+var pwdPageTpl = template.Must(template.New("pwd").Parse(pwdPageHTML))
+
+// LinkPwd 处理 GET /pwd: 下发 CSRF token 并渲染表单。
+func LinkPwd(w http.ResponseWriter, r *http.Request) {
+ token := issuePwdCsrf(w)
+ renderPwdPage(w, http.StatusOK, pwdPageData{CsrfToken: token})
+}
+
+// LinkPwdPost 处理 POST /pwd: 校验 CSRF、防爆限流, 调用核心逻辑修改密码。
+func LinkPwdPost(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ renderPwdResult(w, false, "请求解析失败, 请重试")
+ return
+ }
+
+ // CSRF 双提交校验: cookie 与表单字段必须一致。
+ cookieToken, _ := GetCookie(r, pwdCsrfCookie)
+ formToken := r.PostFormValue("csrf_token")
+ if cookieToken == "" || subtle.ConstantTimeCompare([]byte(cookieToken), []byte(formToken)) != 1 {
+ newToken := issuePwdCsrf(w)
+ renderPwdPage(w, http.StatusOK, pwdPageData{CsrfToken: newToken, Message: "会话已过期, 请重新提交", Success: false})
+ return
+ }
+
+ username := r.PostFormValue("username")
+ oldPwd := r.PostFormValue("old_password")
+ newPwd := r.PostFormValue("new_password")
+ confirmPwd := r.PostFormValue("confirm_password")
+
+ if newPwd != confirmPwd {
+ renderPwdResult(w, false, "两次输入的新密码不一致")
+ return
+ }
+
+ // 防爆限流: 与 VPN 登录共享 username+IP 锁定状态。
+ if !lockManager.CheckLocked(username, r.RemoteAddr) {
+ renderPwdResult(w, false, "尝试次数过多, 请稍后再试")
+ return
+ }
+
+ err := dbdata.UserChangePassword(username, oldPwd, newPwd)
+ if err != nil {
+ // 旧密码错误计入防爆(用户名不存在也走同一路径, 避免枚举)。
+ lockManager.UpdateLoginStatus(username, r.RemoteAddr, false)
+ base.Warn("修改密码失败", username, r.RemoteAddr, err.Error())
+ renderPwdResult(w, false, err.Error())
+ return
+ }
+
+ // 成功后重置该 username+IP 的锁定计数。
+ lockManager.UpdateLoginStatus(username, r.RemoteAddr, true)
+ base.Info("用户自助修改密码成功", username, r.RemoteAddr)
+ renderPwdResult(w, true, "密码修改成功, 请使用新密码登录 VPN")
+}
+
+// issuePwdCsrf 生成并下发一个新的 CSRF token(写入 cookie)。
+func issuePwdCsrf(w http.ResponseWriter) string {
+ token := utils.RandomRunes(32)
+ SetCookie(w, pwdCsrfCookie, token, 0)
+ return token
+}
+
+// renderPwdResult 渲染带结果提示的页面, 同时刷新 CSRF token 供再次提交。
+func renderPwdResult(w http.ResponseWriter, success bool, msg string) {
+ token := issuePwdCsrf(w)
+ renderPwdPage(w, http.StatusOK, pwdPageData{CsrfToken: token, Message: msg, Success: success})
+}
+
+// renderPwdPage 输出 HTML 页面。
+func renderPwdPage(w http.ResponseWriter, status int, data pwdPageData) {
+ buf := new(bytes.Buffer)
+ if err := pwdPageTpl.Execute(buf, data); err != nil {
+ base.Error("渲染改密页面失败", err)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ _, _ = w.Write(buf.Bytes())
+}
+
+const pwdPageHTML = `
+
+
+
+
+ 修改密码
+
+
+
+
+
修改密码
+ {{if .Message}}
+
{{.Message}}
+ {{end}}
+
+
+
+`
diff --git a/server/handler/server.go b/server/handler/server.go
index 4d9a4094..e1dc0155 100644
--- a/server/handler/server.go
+++ b/server/handler/server.go
@@ -116,6 +116,9 @@ func initRoute() http.Handler {
r.HandleFunc("/CSCOSSLC/tunnel", LinkTunnel).Methods(http.MethodConnect)
r.HandleFunc("/otp_qr", LinkOtpQr).Methods(http.MethodGet)
r.HandleFunc("/otp-verification", LinkAuth_otp).Methods(http.MethodPost)
+ // 用户自助修改密码页面(公开访问)
+ r.HandleFunc("/pwd", LinkPwd).Methods(http.MethodGet)
+ r.HandleFunc("/pwd", LinkPwdPost).Methods(http.MethodPost)
// r.Handle("/otp-verification", antiBruteForce(http.HandlerFunc(LinkAuth_otp))).Methods(http.MethodPost)
r.HandleFunc(fmt.Sprintf("/profile_%s.xml", base.Cfg.ProfileName), func(w http.ResponseWriter, r *http.Request) {
b, _ := os.ReadFile(base.Cfg.Profile)
diff --git a/version b/version
index e867cc2a..ac4a7962 100644
--- a/version
+++ b/version
@@ -1 +1 @@
-0.14.2
+0.14.3