Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .devin/wiki.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"repo_notes": [
{
"content": "使用中文生成wiki文档"
}
]
}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ artifact-dist

anylink_amd64
anylink_arm64

# macOS
.DS_Store
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,19 @@
[![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,你可以随时随地安全的访问你的内部网络。

With AnyLink, you can securely access your internal network anytime and anywhere.

## Sponsors

<div align="center"><h2><img src="https://readme-typing-svg.demolab.com?font=Noto+Sans+SC&weight=900&size=28&duration=1&pause=999999&color=D32F2F&center=true&vCenter=true&repeat=false&width=660&lines=%E9%87%8D%E7%A3%85%E6%8E%A8%E8%8D%90+Anylink+%E4%BC%81%E4%B8%9A%E7%89%88" alt="重磅推荐 Anylink 企业版" /></h2></div>
<div align="center"><h2><img src="https://readme-typing-svg.demolab.com?font=Noto+Sans+SC&weight=900&size=28&duration=1&pause=999999&color=D32F2F&center=true&vCenter=true&repeat=false&width=660&lines=%E9%87%8D%E7%A3%85%E6%8E%A8%E8%8D%90%20%E9%A3%9E%E5%B0%86%E4%BC%81%E4%B8%9A%E7%BA%A7%20%E8%BD%AF%E4%BB%B6" alt="重磅推荐 飞将企业级" /></h2></div>

<div align="center">
<a href="https://www.feijiangkeji.com">
<img src="https://www.feijiangkeji.com/assets/img/highlights/terminal.png" alt="飞将VPN 终端展示" width="900" />
<img src="https://www.feijiangkeji.com/assets/img/highlights/terminal.png" alt="飞将 终端展示" width="900" />
</a>

<br />
Expand All @@ -34,7 +34,7 @@ With AnyLink, you can securely access your internal network anytime and anywhere
<br />

<a href="https://www.feijiangkeji.com">
<img src="https://img.shields.io/badge/%E9%A3%9E%E5%B0%86VPN-%E4%BC%81%E4%B8%9A%E7%BA%A7%E8%BF%9C%E7%A8%8B%E5%8A%9E%E5%85%AC-red?style=for-the-badge" alt="飞将VPN|企业级远程办公">
<img src="https://img.shields.io/badge/%E9%A3%9E%E5%B0%86VPN-%E4%BC%81%E4%B8%9A%E7%BA%A7%E8%BF%9C%E7%A8%8B%E5%8A%9E%E5%85%AC-red?style=for-the-badge" alt="飞将|企业级远程办公">
</a>
<a href="https://www.feijiangkeji.com">
<img src="https://img.shields.io/badge/%E8%AE%A2%E9%98%85-299%E5%85%83%2F%E5%B9%B4-orange?style=for-the-badge" alt="订阅 299元/年">
Expand Down
45 changes: 45 additions & 0 deletions doc/question.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.管理员在后台编辑用户不会误清"需改密"标记。

### 登录防爆说明

```
Expand Down
2 changes: 2 additions & 0 deletions server/admin/api_uploaduser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -104,6 +105,7 @@ func UploadUser(file string) error {
Groups: group,
Status: status,
SendEmail: sendmail,
ChangePwd: base.Cfg.ForceFirstChangePwd,
// CreatedAt: createdAt,
// UpdatedAt: updatedAt,
}
Expand Down
4 changes: 4 additions & 0 deletions server/admin/api_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions server/base/cfg.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Expand Down
2 changes: 2 additions & 0 deletions server/base/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
5 changes: 5 additions & 0 deletions server/conf/server-sample.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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范围
Expand Down
2 changes: 2 additions & 0 deletions server/dbdata/tables.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down
33 changes: 33 additions & 0 deletions server/dbdata/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package dbdata
import (
"errors"
"fmt"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -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 {
Expand Down
135 changes: 135 additions & 0 deletions server/dbdata/user_change_pwd_test.go
Original file line number Diff line number Diff line change
@@ -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(), "停用或已过期")
}
}
32 changes: 30 additions & 2 deletions server/handler/link_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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验证计数
Expand Down
Loading