diff --git a/server/admin/api_set_audit.go b/server/admin/api_set_audit.go index 872c7a6..1b423e8 100644 --- a/server/admin/api_set_audit.go +++ b/server/admin/api_set_audit.go @@ -51,3 +51,29 @@ func SetAuditExport(w http.ResponseWriter, r *http.Request) { gocsv.Marshal(datas, w) } + +func UserActLogList(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + pageS := r.FormValue("page") + page, _ := strconv.Atoi(pageS) + if page < 1 { + page = 1 + } + var datas []dbdata.UserActLog + session := dbdata.UserActLogIns.GetSession(r.Form) + count, err := dbdata.FindAndCount(session, &datas, dbdata.PageSize, page) + if err != nil && !dbdata.CheckErrNotFound(err) { + RespError(w, RespInternalErr, err) + return + } + data := map[string]interface{}{ + "count": count, + "page_size": dbdata.PageSize, + "datas": datas, + "statusOps": dbdata.UserActLogIns.GetStatusOpsWithTag(), + "osOps": dbdata.UserActLogIns.OsOps, + "clientOps": dbdata.UserActLogIns.ClientOps, + } + + RespSucess(w, data) +} diff --git a/server/admin/api_user.go b/server/admin/api_user.go index 62f80ac..a869da3 100644 --- a/server/admin/api_user.go +++ b/server/admin/api_user.go @@ -178,7 +178,7 @@ func UserOnline(w http.ResponseWriter, r *http.Request) { func UserOffline(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() token := r.FormValue("token") - sessdata.CloseSess(token) + sessdata.CloseSess(token, dbdata.UserLogoutAdmin) RespSucess(w, nil) } diff --git a/server/admin/server.go b/server/admin/server.go index 50a24ce..2f0bae0 100644 --- a/server/admin/server.go +++ b/server/admin/server.go @@ -45,6 +45,7 @@ func StartAdmin() { r.HandleFunc("/set/other/audit_log/edit", SetOtherAuditLogEdit) r.HandleFunc("/set/audit/list", SetAuditList) r.HandleFunc("/set/audit/export", SetAuditExport) + r.HandleFunc("/set/audit/act_log_list", UserActLogList) r.HandleFunc("/user/list", UserList) r.HandleFunc("/user/detail", UserDetail) diff --git a/server/cron/clear_user_act_log.go b/server/cron/clear_user_act_log.go new file mode 100644 index 0000000..b407d8c --- /dev/null +++ b/server/cron/clear_user_act_log.go @@ -0,0 +1,20 @@ +package cron + +import ( + "github.com/bjdgyc/anylink/base" + "github.com/bjdgyc/anylink/dbdata" +) + +// 清除用户活动日志 +func ClearUserActLog() { + lifeDay, timesUp := isClearTime() + if !timesUp { + return + } + // 当审计日志永久保存时,则退出 + if lifeDay <= 0 { + return + } + affected, err := dbdata.UserActLogIns.ClearUserActLog(getTimeAgo(lifeDay)) + base.Info("Cron ClearUserActLog: ", affected, err) +} diff --git a/server/cron/start.go b/server/cron/start.go index 96159ab..c67405f 100644 --- a/server/cron/start.go +++ b/server/cron/start.go @@ -11,6 +11,7 @@ func Start() { s := gocron.NewScheduler(time.Local) s.Cron("0 * * * *").Do(ClearAudit) s.Cron("0 * * * *").Do(ClearStatsInfo) + s.Cron("0 * * * *").Do(ClearUserActLog) s.Every(1).Day().At("00:00").Do(sessdata.CloseUserLimittimeSession) s.StartAsync() } diff --git a/server/dbdata/audit.go b/server/dbdata/audit.go index d54f315..603b061 100644 --- a/server/dbdata/audit.go +++ b/server/dbdata/audit.go @@ -14,6 +14,7 @@ type SearchCon struct { AccessProto string `json:"access_proto"` Date []string `json:"date"` Info string `json:"info"` + Sort int `json:"sort"` } func GetAuditSession(search string) *xorm.Session { @@ -47,6 +48,11 @@ func GetAuditSession(search string) *xorm.Session { if searchData.Info != "" { session.And("info LIKE ?", "%"+searchData.Info+"%") } + if searchData.Sort == 1 { + session.OrderBy("id desc") + } else { + session.OrderBy("id asc") + } return session } diff --git a/server/dbdata/db.go b/server/dbdata/db.go index d65266b..aff9084 100644 --- a/server/dbdata/db.go +++ b/server/dbdata/db.go @@ -33,7 +33,7 @@ func initDb() { } // 初始化数据库 - err = xdb.Sync2(&User{}, &Setting{}, &Group{}, &IpMap{}, &AccessAudit{}, &Policy{}, &StatsNetwork{}, &StatsCpu{}, &StatsMem{}, &StatsOnline{}) + err = xdb.Sync2(&User{}, &Setting{}, &Group{}, &IpMap{}, &AccessAudit{}, &Policy{}, &StatsNetwork{}, &StatsCpu{}, &StatsMem{}, &StatsOnline{}, &UserActLog{}) if err != nil { base.Fatal(err) } diff --git a/server/dbdata/tables.go b/server/dbdata/tables.go index 53dff7a..fccb102 100644 --- a/server/dbdata/tables.go +++ b/server/dbdata/tables.go @@ -40,6 +40,20 @@ type User struct { UpdatedAt time.Time `json:"updated_at" xorm:"DateTime updated"` } +type UserActLog struct { + Id int `json:"id" xorm:"pk autoincr not null"` + Username string `json:"username" xorm:"varchar(60)"` + GroupName string `json:"group_name" xorm:"varchar(60)"` + IpAddr string `json:"ip_addr" xorm:"varchar(32)"` + RemoteAddr string `json:"remote_addr" xorm:"varchar(32)"` + Os uint8 `json:"os" xorm:"not null default 0 Int"` + Client uint8 `json:"client" xorm:"not null default 0 Int"` + Version string `json:"version" xorm:"varchar(15)"` + Status uint8 `json:"status" xorm:"not null default 0 Int"` + Info string `json:"info" xorm:"varchar(255) not null default ''"` // 详情 + CreatedAt time.Time `json:"created_at" xorm:"DateTime created"` +} + type IpMap struct { Id int `json:"id" xorm:"pk autoincr not null"` IpAddr string `json:"ip_addr" xorm:"varchar(32) not null unique"` diff --git a/server/dbdata/user_act_log.go b/server/dbdata/user_act_log.go new file mode 100644 index 0000000..c3f4ce2 --- /dev/null +++ b/server/dbdata/user_act_log.go @@ -0,0 +1,184 @@ +package dbdata + +import ( + "net/url" + "regexp" + "strings" + + "github.com/ivpusic/grpool" + "github.com/spf13/cast" + "xorm.io/xorm" +) + +const ( + UserAuthFail = 0 // 认证失败 + UserAuthSuccess = 1 // 认证成功 + UserConnected = 2 // 连线成功 + UserLogout = 3 // 用户登出 + UserLogoutLose = 0 // 用户掉线 + UserLogoutBanner = 1 // 用户banner弹窗取消 + UserLogoutClient = 2 // 用户主动登出 + UserLogoutTimeout = 3 // 用户超时登出 + UserLogoutAdmin = 4 // 账号被管理员踢下线 + UserLogoutExpire = 5 // 账号过期被踢下线 +) + +type UserActLogProcess struct { + Pool *grpool.Pool + StatusOps []string + OsOps []string + ClientOps []string + InfoOps []string +} + +var ( + UserActLogIns = &UserActLogProcess{ + Pool: grpool.NewPool(1, 100), + StatusOps: []string{ // 操作类型 + UserAuthFail: "认证失败", + UserAuthSuccess: "认证成功", + UserConnected: "连接成功", + UserLogout: "用户登出", + }, + OsOps: []string{ // 操作系统 + 0: "Windows", + 1: "macOS", + 2: "Linux", + 3: "Android", + 4: "iOS", + 5: "Unknown", + }, + ClientOps: []string{ // 客户端 + 0: "AnyConnect", + 1: "OpenConnect", + 2: "Unknown", + }, + InfoOps: []string{ // 信息 + UserLogoutLose: "用户掉线", + UserLogoutBanner: "用户取消弹窗", + UserLogoutClient: "用户/客户端主动断开", + UserLogoutTimeout: "Session过期被踢下线", + UserLogoutAdmin: "账号被管理员踢下线", + UserLogoutExpire: "账号过期被踢下线", + }, + } +) + +// 异步写入用户操作日志 +func (ua *UserActLogProcess) Add(u UserActLog, userAgent string) { + // os, client, ver + os_idx, client_idx, ver := ua.ParseUserAgent(userAgent) + u.Os = os_idx + u.Client = client_idx + u.Version = ver + u.RemoteAddr = strings.Split(u.RemoteAddr, ":")[0] + // remove extra characters + infoSlice := strings.Split(u.Info, " ") + infoLen := len(infoSlice) + if infoLen > 1 { + if u.Username == infoSlice[0] { + u.Info = strings.Join(infoSlice[1:], " ") + } + // delete - char + if infoLen > 2 && infoSlice[1] == "-" { + u.Info = u.Info[2:] + } + } + UserActLogIns.Pool.JobQueue <- func() { + _ = Add(u) + } +} + +// 转义操作类型, 方便vue显示 +func (ua *UserActLogProcess) GetStatusOpsWithTag() interface{} { + type StatusTag struct { + Key int `json:"key"` + Value string `json:"value"` + Tag string `json:"tag"` + } + var res []StatusTag + for k, v := range ua.StatusOps { + tag := "info" + switch k { + case UserAuthFail: + tag = "danger" + case UserAuthSuccess: + tag = "success" + case UserConnected: + tag = "" + } + res = append(res, StatusTag{k, v, tag}) + } + return res +} + +func (ua *UserActLogProcess) GetInfoOpsById(id uint8) string { + return ua.InfoOps[id] +} + +func (ua *UserActLogProcess) ParseUserAgent(userAgent string) (os_idx, client_idx uint8, ver string) { + // Unknown + if len(userAgent) == 0 { + return 5, 2, "" + } + // os + os_idx = 2 + if strings.Contains(userAgent, "windows") { + os_idx = 0 + } else if strings.Contains(userAgent, "mac os") || strings.Contains(userAgent, "darwin_i386") { + os_idx = 1 + } else if strings.Contains(userAgent, "darwin_arm") || strings.Contains(userAgent, "apple") { + os_idx = 4 + } else if strings.Contains(userAgent, "android") { + os_idx = 3 + } + // client + client_idx = 2 + if strings.Contains(userAgent, "anyconnect") { + client_idx = 0 + } else if strings.Contains(userAgent, "openconnect") { + client_idx = 1 + } + // ver + uaSlice := strings.Split(userAgent, " ") + ver = uaSlice[len(uaSlice)-1] + if ver[0] == 'v' { + ver = ver[1:] + } + if !regexp.MustCompile(`^(\d+\.?)+$`).MatchString(ver) { + ver = "" + } + return +} + +// 清除用户操作日志 +func (ua *UserActLogProcess) ClearUserActLog(ts string) (int64, error) { + affected, err := xdb.Where("created_at < '" + ts + "'").Delete(&UserActLog{}) + return affected, err +} + +// 后台筛选用户操作日志 +func (ua *UserActLogProcess) GetSession(values url.Values) *xorm.Session { + session := xdb.Where("1=1") + if values.Get("username") != "" { + session.And("username = ?", values.Get("username")) + } + if values.Get("sdate") != "" { + session.And("created_at >= ?", values.Get("sdate")+" 00:00:00'") + } + if values.Get("edate") != "" { + session.And("created_at <= ?", values.Get("edate")+" 23:59:59'") + } + if values.Get("status") != "" { + session.And("status = ?", cast.ToUint8(values.Get("status"))-1) + } + if values.Get("os") != "" { + session.And("os = ?", cast.ToUint8(values.Get("os"))-1) + } + if values.Get("sort") == "1" { + session.OrderBy("id desc") + } else { + session.OrderBy("id asc") + } + return session +} diff --git a/server/dbdata/user_act_log_test.go b/server/dbdata/user_act_log_test.go new file mode 100644 index 0000000..0628a8b --- /dev/null +++ b/server/dbdata/user_act_log_test.go @@ -0,0 +1,72 @@ +package dbdata + +import "testing" + +func TestParseUserAgent(t *testing.T) { + type args struct { + userAgent string + } + type res struct { + os_idx uint8 + client_idx uint8 + ver string + } + tests := []struct { + name string + args args + want res + }{ + { + name: "mac os 1", + args: args{userAgent: "cisco anyconnect vpn agent for mac os x 4.10.05085"}, + want: res{os_idx: 1, client_idx: 0, ver: "4.10.05085"}, + }, + { + name: "mac os 2", + args: args{userAgent: "anyconnect darwin_i386 4.10.05085"}, + want: res{os_idx: 1, client_idx: 0, ver: "4.10.05085"}, + }, + { + name: "windows", + args: args{userAgent: "cisco anyconnect vpn agent for windows 4.8.02042"}, + want: res{os_idx: 0, client_idx: 0, ver: "4.8.02042"}, + }, + { + name: "iPad", + args: args{userAgent: "anyconnect applesslvpn_darwin_arm (ipad) 4.10.04060"}, + want: res{os_idx: 4, client_idx: 0, ver: "4.10.04060"}, + }, + { + name: "iPhone", + args: args{userAgent: "cisco anyconnect vpn agent for apple iphone 4.10.04060"}, + want: res{os_idx: 4, client_idx: 0, ver: "4.10.04060"}, + }, + { + name: "android", + args: args{userAgent: "anyconnect android 4.10.05096"}, + want: res{os_idx: 3, client_idx: 0, ver: "4.10.05096"}, + }, + { + name: "linux", + args: args{userAgent: "open anyconnect vpn agent v7.08"}, + want: res{os_idx: 2, client_idx: 0, ver: "7.08"}, + }, + { + name: "openconnect", + args: args{userAgent: "openconnect-gui 1.5.3 v7.08"}, + want: res{os_idx: 2, client_idx: 1, ver: "7.08"}, + }, + { + name: "unknown", + args: args{userAgent: "unknown 1.4.3 aabcd"}, + want: res{os_idx: 2, client_idx: 2, ver: ""}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if os_idx, client_idx, ver := UserActLogIns.ParseUserAgent(tt.args.userAgent); os_idx != tt.want.os_idx || client_idx != tt.want.client_idx || ver != tt.want.ver { + t.Errorf("ParseUserAgent() = %v, %v, %v, want %v, %v, %v", os_idx, client_idx, ver, tt.want.os_idx, tt.want.client_idx, tt.want.ver) + } + }) + } +} diff --git a/server/handler/link_auth.go b/server/handler/link_auth.go index 5a8138d..698f4b2 100644 --- a/server/handler/link_auth.go +++ b/server/handler/link_auth.go @@ -43,7 +43,6 @@ func LinkAuth(w http.ResponseWriter, r *http.Request) { return } // fmt.Printf("%+v \n", cr) - setCommonHeader(w) if cr.Type == "logout" { // 退出删除session信息 @@ -66,16 +65,27 @@ func LinkAuth(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) return } - + // 用户活动日志 + ua := dbdata.UserActLog{ + Username: cr.Auth.Username, + GroupName: cr.GroupSelect, + RemoteAddr: r.RemoteAddr, + Status: dbdata.UserAuthSuccess, + } // TODO 用户密码校验 err = dbdata.CheckUser(cr.Auth.Username, cr.Auth.Password, cr.GroupSelect) if err != nil { base.Warn(err) + ua.Info = err.Error() + ua.Status = dbdata.UserAuthFail + dbdata.UserActLogIns.Add(ua, userAgent) + w.WriteHeader(http.StatusOK) data := RequestData{Group: cr.GroupSelect, Groups: dbdata.GetGroupNames(), Error: "用户名或密码错误"} tplRequest(tpl_request, w, data) return } + dbdata.UserActLogIns.Add(ua, userAgent) // if !ok { // w.WriteHeader(http.StatusOK) // data := RequestData{Group: cr.GroupSelect, Groups: base.Cfg.UserGroups, Error: "请先激活用户"} @@ -89,6 +99,8 @@ func LinkAuth(w http.ResponseWriter, r *http.Request) { sess.Group = cr.GroupSelect sess.MacAddr = strings.ToLower(cr.MacAddressList.MacAddress) sess.UniqueIdGlobal = cr.DeviceId.UniqueIdGlobal + sess.UserAgent = userAgent + sess.RemoteAddr = r.RemoteAddr // 获取客户端mac地址 macHw, err := net.ParseMAC(sess.MacAddr) if err != nil { @@ -109,7 +121,7 @@ func LinkAuth(w http.ResponseWriter, r *http.Request) { Banner: other.Banner, ProfileHash: profileHash} w.WriteHeader(http.StatusOK) tplRequest(tpl_complete, w, rd) - base.Debug("login", cr.Auth.Username) + base.Debug("login", cr.Auth.Username, userAgent) } const ( diff --git a/server/handler/link_cstp.go b/server/handler/link_cstp.go index 97a9fb3..0275c16 100644 --- a/server/handler/link_cstp.go +++ b/server/handler/link_cstp.go @@ -7,6 +7,7 @@ import ( "time" "github.com/bjdgyc/anylink/base" + "github.com/bjdgyc/anylink/dbdata" "github.com/bjdgyc/anylink/pkg/utils" "github.com/bjdgyc/anylink/sessdata" ) @@ -55,6 +56,7 @@ func LinkCstp(conn net.Conn, bufRW *bufio.ReadWriter, cSess *sessdata.ConnSessio // do nothing // base.Debug("recv keepalive", cSess.IpAddr) case 0x05: // DISCONNECT + cSess.UserLogoutCode = dbdata.UserLogoutClient base.Debug("DISCONNECT", cSess.IpAddr) return case 0x03: // DPD-REQ diff --git a/server/handler/link_dtls.go b/server/handler/link_dtls.go index 23c6e86..4a88b8a 100644 --- a/server/handler/link_dtls.go +++ b/server/handler/link_dtls.go @@ -5,6 +5,7 @@ import ( "time" "github.com/bjdgyc/anylink/base" + "github.com/bjdgyc/anylink/dbdata" "github.com/bjdgyc/anylink/pkg/utils" "github.com/bjdgyc/anylink/sessdata" ) @@ -57,6 +58,7 @@ func LinkDtls(conn net.Conn, cSess *sessdata.ConnSession) { // do nothing // base.Debug("recv keepalive", cSess.IpAddr) case 0x05: // DISCONNECT + cSess.UserLogoutCode = dbdata.UserLogoutClient base.Debug("DISCONNECT DTLS", cSess.IpAddr) return case 0x03: // DPD-REQ diff --git a/server/handler/link_tunnel.go b/server/handler/link_tunnel.go index b9017c4..dda97b0 100644 --- a/server/handler/link_tunnel.go +++ b/server/handler/link_tunnel.go @@ -69,6 +69,7 @@ func LinkTunnel(w http.ResponseWriter, r *http.Request) { cSess.SetMtu(cstpMtu) cSess.MasterSecret = masterSecret cSess.RemoteAddr = r.RemoteAddr + cSess.UserAgent = strings.ToLower(r.UserAgent()) cSess.LocalIp = net.ParseIP(localIp) cstpKeepalive := base.Cfg.CstpKeepalive cstpDpd := base.Cfg.CstpDpd @@ -194,6 +195,13 @@ func LinkTunnel(w http.ResponseWriter, r *http.Request) { base.Error(err) return } + dbdata.UserActLogIns.Add(dbdata.UserActLog{ + Username: sess.Username, + GroupName: sess.Group, + IpAddr: cSess.IpAddr.String(), + RemoteAddr: cSess.RemoteAddr, + Status: dbdata.UserConnected, + }, cSess.UserAgent) go LinkCstp(conn, bufRW, cSess) } diff --git a/server/sessdata/session.go b/server/sessdata/session.go index 721af83..4638f9b 100644 --- a/server/sessdata/session.go +++ b/server/sessdata/session.go @@ -36,6 +36,8 @@ type ConnSession struct { Mtu int IfName string Client string // 客户端 mobile pc + UserAgent string // 客户端信息 + UserLogoutCode uint8 // 用户/客户端主动登出 CstpDpd int Group *dbdata.Group Limit *LimitRater @@ -73,6 +75,8 @@ type Session struct { Group string AuthStep string AuthPass string + RemoteAddr string + UserAgent string LastLogin time.Time IsActive bool @@ -110,7 +114,7 @@ func checkSession() { // 删除过期session for _, v := range outToken { - CloseSess(v) + CloseSess(v, dbdata.UserLogoutTimeout) } } }() @@ -130,7 +134,7 @@ func CloseUserLimittimeSession() { } sessMux.RUnlock() for _, v := range limitTimeToken { - CloseSess(v) + CloseSess(v, dbdata.UserLogoutExpire) } } @@ -247,6 +251,7 @@ func (cs *ConnSession) Close() { ReleaseIp(cs.IpAddr, cs.Sess.MacAddr) LimitClient(cs.Username, true) + AddUserActLog(cs) }) } @@ -407,7 +412,7 @@ func DelSess(token string) { // sessions.Delete(token) } -func CloseSess(token string) { +func CloseSess(token string, code ...uint8) { sessMux.Lock() defer sessMux.Unlock() sess, ok := sessions[token] @@ -419,8 +424,13 @@ func CloseSess(token string) { delete(dtlsIds, sess.DtlsSid) if sess.CSess != nil { + if len(code) > 0 { + sess.CSess.UserLogoutCode = code[0] + } sess.CSess.Close() + return } + AddUserActLogBySess(sess) } func CloseCSess(token string) { @@ -440,5 +450,29 @@ func DelSessByStoken(stoken string) { stoken = strings.TrimSpace(stoken) sarr := strings.Split(stoken, "@") token := sarr[1] - CloseSess(token) + CloseSess(token, dbdata.UserLogoutBanner) +} + +func AddUserActLog(cs *ConnSession) { + ua := dbdata.UserActLog{ + Username: cs.Sess.Username, + GroupName: cs.Sess.Group, + IpAddr: cs.IpAddr.String(), + RemoteAddr: cs.RemoteAddr, + Status: dbdata.UserLogout, + } + ua.Info = dbdata.UserActLogIns.GetInfoOpsById(cs.UserLogoutCode) + dbdata.UserActLogIns.Add(ua, cs.UserAgent) +} + +func AddUserActLogBySess(sess *Session) { + ua := dbdata.UserActLog{ + Username: sess.Username, + GroupName: sess.Group, + IpAddr: "", + RemoteAddr: sess.RemoteAddr, + Status: dbdata.UserLogout, + } + ua.Info = dbdata.UserActLogIns.GetInfoOpsById(1) + dbdata.UserActLogIns.Add(ua, sess.UserAgent) } diff --git a/web/src/components/audit/Access.vue b/web/src/components/audit/Access.vue new file mode 100644 index 0000000..bdab61b --- /dev/null +++ b/web/src/components/audit/Access.vue @@ -0,0 +1,321 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/audit/ActLog.vue b/web/src/components/audit/ActLog.vue new file mode 100644 index 0000000..0f1f4fc --- /dev/null +++ b/web/src/components/audit/ActLog.vue @@ -0,0 +1,256 @@ + + + + + \ No newline at end of file diff --git a/web/src/layout/LayoutAside.vue b/web/src/layout/LayoutAside.vue index 332190a..cfbbc59 100644 --- a/web/src/layout/LayoutAside.vue +++ b/web/src/layout/LayoutAside.vue @@ -56,7 +56,7 @@ 用户组列表 - +