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 @@ [![Docker pulls)](https://img.shields.io/docker/pulls/bjdgyc/anylink.svg)](https://hub.docker.com/r/bjdgyc/anylink) ![LICENSE](https://img.shields.io/github/license/bjdgyc/anylink) -AnyLink 是一个企业级远程办公 sslvpn 的软件,可以支持多人同时在线使用。 +AnyLink 是一个企业级远程接入办公软件,可以支持多人同时在线使用。 使用 AnyLink,你可以随时随地安全的访问你的内部网络。 @@ -18,11 +18,11 @@ With AnyLink, you can securely access your internal network anytime and anywhere ## Sponsors -

重磅推荐 Anylink 企业版

+

重磅推荐 飞将企业级

- 飞将VPN 终端展示 + 飞将 终端展示
@@ -34,7 +34,7 @@ With AnyLink, you can securely access your internal network anytime and anywhere
- 飞将VPN|企业级远程办公 + 飞将|企业级远程办公 订阅 299元/年 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}} +
+ + + + + +
仅需输入密码(PIN码), 无需附加动态码
+ + +
至少 6 位
+ + + +
+
+ +` 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