From 487d73d643701be0dcf73938cb969c77989280d1 Mon Sep 17 00:00:00 2001 From: cuigh Date: Wed, 22 Dec 2021 17:43:26 +0800 Subject: [PATCH] Refactor authentication and authorization --- api/api.go | 1 + api/chart.go | 80 +----- api/container.go | 2 +- api/dashboard.go | 82 ++++++ api/node.go | 16 +- api/user.go | 24 +- biz/biz.go | 2 + biz/chart.go | 239 ---------------- biz/dashboard.go | 266 ++++++++++++++++++ biz/node.go | 10 +- biz/role.go | 22 ++ biz/session.go | 45 +++ biz/setting.go | 4 +- biz/user.go | 18 -- compose.yml | 57 +++- config/app.yml | 2 +- dao/bolt/bolt.go | 5 + dao/bolt/session.go | 56 ++++ dao/bolt/user.go | 16 -- dao/dao.go | 5 +- dao/mongo/mongo.go | 12 +- dao/mongo/session.go | 38 +++ dao/mongo/user.go | 22 +- go.mod | 1 - go.sum | 2 - main.go | 2 +- model/model.go | 6 +- security/auth.go | 306 +++++++++++++++++++++ security/jwt.go | 151 ---------- security/perm.go | 171 +++++++----- security/security.go | 107 +------ ui/src/api/ajax.ts | 8 +- ui/src/api/chart.ts | 35 --- ui/src/api/dashboard.ts | 40 +++ ui/src/api/user.ts | 2 +- ui/src/components/Dashboard.vue | 12 +- ui/src/layouts/Default.vue | 2 +- ui/src/locales/en.ts | 3 +- ui/src/locales/zh.ts | 3 +- ui/src/pages/Login.vue | 2 +- ui/src/pages/container/View.vue | 9 +- ui/src/pages/container/modules/Execute.vue | 6 +- ui/src/pages/role/Edit.vue | 31 ++- ui/src/pages/role/View.vue | 11 +- ui/src/pages/service/View.vue | 6 +- ui/src/pages/task/View.vue | 6 +- ui/src/pages/user/View.vue | 4 +- ui/src/router/router.ts | 166 ++++++++++- ui/src/store/index.ts | 60 ++-- ui/src/store/mutations.ts | 5 +- ui/src/utils/perm.ts | 107 ++----- ui/vite.config.ts | 2 +- 52 files changed, 1348 insertions(+), 942 deletions(-) create mode 100644 api/dashboard.go create mode 100644 biz/dashboard.go create mode 100644 biz/session.go create mode 100644 dao/bolt/session.go create mode 100644 dao/mongo/session.go create mode 100644 security/auth.go delete mode 100644 security/jwt.go create mode 100644 ui/src/api/dashboard.ts diff --git a/api/api.go b/api/api.go index eb302a5..5d8c49c 100644 --- a/api/api.go +++ b/api/api.go @@ -35,4 +35,5 @@ func init() { container.Put(NewRole, container.Name("api.role")) container.Put(NewEvent, container.Name("api.event")) container.Put(NewChart, container.Name("api.chart")) + container.Put(NewDashboard, container.Name("api.dashboard")) } diff --git a/api/chart.go b/api/chart.go index 58c6ae4..bd8235a 100644 --- a/api/chart.go +++ b/api/chart.go @@ -1,11 +1,7 @@ package api import ( - "strings" - "github.com/cuigh/auxo/data" - "github.com/cuigh/auxo/errors" - "github.com/cuigh/auxo/ext/times" "github.com/cuigh/auxo/net/web" "github.com/cuigh/swirl/biz" "github.com/cuigh/swirl/model" @@ -13,25 +9,19 @@ import ( // ChartHandler encapsulates chart related handlers. type ChartHandler struct { - Search web.HandlerFunc `path:"/search" auth:"chart.view" desc:"search charts"` - Find web.HandlerFunc `path:"/find" auth:"chart.view" desc:"find chart by id"` - Save web.HandlerFunc `path:"/save" method:"post" auth:"chart.edit" desc:"create or update chart"` - Delete web.HandlerFunc `path:"/delete" method:"post" auth:"chart.delete" desc:"delete chart"` - FetchData web.HandlerFunc `path:"/fetch-data" auth:"?" desc:"fetch chart data"` - FindDashboard web.HandlerFunc `path:"/find-dashboard" auth:"?" desc:"find dashboard by name and key"` - SaveDashboard web.HandlerFunc `path:"/save-dashboard" method:"post" auth:"chart.dashboard" desc:"save dashboard"` + Search web.HandlerFunc `path:"/search" auth:"chart.view" desc:"search charts"` + Find web.HandlerFunc `path:"/find" auth:"chart.view" desc:"find chart by id"` + Save web.HandlerFunc `path:"/save" method:"post" auth:"chart.edit" desc:"create or update chart"` + Delete web.HandlerFunc `path:"/delete" method:"post" auth:"chart.delete" desc:"delete chart"` } // NewChart creates an instance of ChartHandler func NewChart(b biz.ChartBiz) *ChartHandler { return &ChartHandler{ - Search: chartSearch(b), - Find: chartFind(b), - Delete: chartDelete(b), - Save: chartSave(b), - FetchData: chartFetchData(b), - FindDashboard: chartFindDashboard(b), - SaveDashboard: chartSaveDashboard(b), + Search: chartSearch(b), + Find: chartFind(b), + Delete: chartDelete(b), + Save: chartSave(b), } } @@ -97,57 +87,3 @@ func chartSave(b biz.ChartBiz) web.HandlerFunc { return ajax(ctx, err) } } - -func chartFetchData(b biz.ChartBiz) web.HandlerFunc { - type Args struct { - Key string `json:"key" bind:"key"` - Charts string `json:"charts" bind:"charts"` - Period int32 `json:"period" bind:"period"` - } - return func(ctx web.Context) (err error) { - var ( - args = &Args{} - d data.Map - ) - if err = ctx.Bind(args); err == nil { - d, err = b.FetchData(args.Key, strings.Split(args.Charts, ","), times.Minutes(args.Period)) - } - if err != nil { - return err - } - return success(ctx, d) - } -} - -func chartFindDashboard(b biz.ChartBiz) web.HandlerFunc { - return func(ctx web.Context) (err error) { - var ( - d *model.Dashboard - name = ctx.Query("name") - key = ctx.Query("key") - ) - d, err = b.FindDashboard(name, key) - if err != nil { - return err - } - return success(ctx, d) - } -} - -func chartSaveDashboard(b biz.ChartBiz) web.HandlerFunc { - return func(ctx web.Context) error { - dashboard := &model.Dashboard{} - err := ctx.Bind(dashboard) - if err != nil { - return err - } - - switch dashboard.Name { - case "home", "service": - err = b.UpdateDashboard(dashboard, ctx.User()) - default: - err = errors.New("unknown dashboard: " + dashboard.Name) - } - return ajax(ctx, err) - } -} diff --git a/api/container.go b/api/container.go index d996761..3956949 100644 --- a/api/container.go +++ b/api/container.go @@ -17,7 +17,7 @@ type ContainerHandler struct { Find web.HandlerFunc `path:"/find" auth:"container.view" desc:"find container by name"` Delete web.HandlerFunc `path:"/delete" method:"post" auth:"container.delete" desc:"delete container"` FetchLogs web.HandlerFunc `path:"/fetch-logs" auth:"container.logs" desc:"fetch logs of container"` - Connect web.HandlerFunc `path:"/connect" auth:"*" desc:"connect to a running container"` + Connect web.HandlerFunc `path:"/connect" auth:"container.execute" desc:"connect to a running container"` } // NewContainer creates an instance of ContainerHandler diff --git a/api/dashboard.go b/api/dashboard.go new file mode 100644 index 0000000..564f569 --- /dev/null +++ b/api/dashboard.go @@ -0,0 +1,82 @@ +package api + +import ( + "strings" + + "github.com/cuigh/auxo/data" + "github.com/cuigh/auxo/errors" + "github.com/cuigh/auxo/ext/times" + "github.com/cuigh/auxo/net/web" + "github.com/cuigh/swirl/biz" + "github.com/cuigh/swirl/model" +) + +// DashboardHandler encapsulates dashboard related handlers. +type DashboardHandler struct { + Find web.HandlerFunc `path:"/find" auth:"?" desc:"find dashboard by name and key"` + Save web.HandlerFunc `path:"/save" method:"post" auth:"dashboard.edit" desc:"save dashboard"` + FetchData web.HandlerFunc `path:"/fetch-data" auth:"?" desc:"fetch data of dashboard charts"` +} + +// NewDashboard creates an instance of DashboardHandler +func NewDashboard(b biz.DashboardBiz) *DashboardHandler { + return &DashboardHandler{ + Find: dashboardFind(b), + Save: dashboardSave(b), + FetchData: dashboardFetchData(b), + } +} + +func dashboardFind(b biz.DashboardBiz) web.HandlerFunc { + return func(ctx web.Context) (err error) { + var ( + d *model.Dashboard + name = ctx.Query("name") + key = ctx.Query("key") + ) + d, err = b.FindDashboard(name, key) + if err != nil { + return err + } + return success(ctx, d) + } +} + +func dashboardSave(b biz.DashboardBiz) web.HandlerFunc { + return func(ctx web.Context) error { + dashboard := &model.Dashboard{} + err := ctx.Bind(dashboard) + if err != nil { + return err + } + + switch dashboard.Name { + case "home", "service": + err = b.UpdateDashboard(dashboard, ctx.User()) + default: + err = errors.New("unknown dashboard: " + dashboard.Name) + } + return ajax(ctx, err) + } +} + +func dashboardFetchData(b biz.DashboardBiz) web.HandlerFunc { + type Args struct { + Key string `json:"key" bind:"key"` + Dashboards string `json:"charts" bind:"charts"` + Period int32 `json:"period" bind:"period"` + } + return func(ctx web.Context) (err error) { + var ( + args = &Args{} + d data.Map + ) + if err = ctx.Bind(args); err == nil { + d, err = b.FetchData(args.Key, strings.Split(args.Dashboards, ","), times.Minutes(args.Period)) + } + if err != nil { + return err + } + return success(ctx, d) + } +} diff --git a/api/node.go b/api/node.go index e03f29d..f210eca 100644 --- a/api/node.go +++ b/api/node.go @@ -3,6 +3,7 @@ package api import ( "github.com/cuigh/auxo/data" "github.com/cuigh/auxo/net/web" + "github.com/cuigh/auxo/util/cast" "github.com/cuigh/swirl/biz" ) @@ -28,22 +29,11 @@ func NewNode(nb biz.NodeBiz) *NodeHandler { func nodeList(nb biz.NodeBiz) web.HandlerFunc { return func(ctx web.Context) error { - nodes, err := nb.List() + agent := cast.ToBool(ctx.Query("agent")) + nodes, err := nb.List(agent) if err != nil { return err } - - if ctx.Query("agent") == "true" { - i := 0 - for j := 0; j < len(nodes); j++ { - if nodes[j].Agent != "" { - nodes[i] = nodes[j] - i++ - } - } - nodes = nodes[:i] - } - return success(ctx, nodes) } } diff --git a/api/user.go b/api/user.go index ad5158f..fc2a298 100644 --- a/api/user.go +++ b/api/user.go @@ -1,7 +1,6 @@ package api import ( - "github.com/cuigh/auxo/app/container" "github.com/cuigh/auxo/data" "github.com/cuigh/auxo/net/web" "github.com/cuigh/swirl/biz" @@ -22,7 +21,7 @@ type UserHandler struct { } // NewUser creates an instance of UserHandler -func NewUser(b biz.UserBiz, eb biz.EventBiz, auth *security.Authenticator) *UserHandler { +func NewUser(b biz.UserBiz, eb biz.EventBiz, auth *security.Identifier) *UserHandler { return &UserHandler{ SignIn: userSignIn(auth, eb), Search: userSearch(b), @@ -35,7 +34,7 @@ func NewUser(b biz.UserBiz, eb biz.EventBiz, auth *security.Authenticator) *User } } -func userSignIn(auth *security.Authenticator, eb biz.EventBiz) web.HandlerFunc { +func userSignIn(auth *security.Identifier, eb biz.EventBiz) web.HandlerFunc { type SignInArgs struct { Name string `json:"name"` Password string `json:"password"` @@ -43,27 +42,24 @@ func userSignIn(auth *security.Authenticator, eb biz.EventBiz) web.HandlerFunc { return func(ctx web.Context) (err error) { var ( - args = &SignInArgs{} - user web.User - token string + args = &SignInArgs{} + user security.Identity ) - if err = ctx.Bind(args); err == nil { - if user, err = auth.Login(args.Name, args.Password); err == nil { - jwt := container.Find("identifier").(*security.JWT) - token, err = jwt.CreateToken(user.ID(), user.Name()) - } + if err = ctx.Bind(args); err != nil { + return err } - if err != nil { + if user, err = auth.Identify(args.Name, args.Password); err != nil { return err } eb.CreateUser(biz.EventActionLogin, user.ID(), user.Name(), user) + return success(ctx, data.Map{ - "token": token, - "id": user.ID(), "name": user.Name(), + "token": user.Token(), + "perms": user.Perms(), }) } } diff --git a/biz/biz.go b/biz/biz.go index 471e9b9..d92d14a 100644 --- a/biz/biz.go +++ b/biz/biz.go @@ -130,4 +130,6 @@ func init() { container.Put(NewMetric) container.Put(NewChart) container.Put(NewSystem) + container.Put(NewSession) + container.Put(NewDashboard) } diff --git a/biz/chart.go b/biz/chart.go index 59394ec..fff959e 100644 --- a/biz/chart.go +++ b/biz/chart.go @@ -2,25 +2,12 @@ package biz import ( "context" - "os" - "time" - "github.com/cuigh/auxo/data" - "github.com/cuigh/auxo/errors" - "github.com/cuigh/auxo/log" "github.com/cuigh/auxo/net/web" "github.com/cuigh/swirl/dao" "github.com/cuigh/swirl/model" - "github.com/jinzhu/copier" ) -var builtins = []*model.Chart{ - model.NewChart("service", "$cpu", "CPU", "${name}", `rate(container_cpu_user_seconds_total{container_label_com_docker_swarm_service_name="${service}"}[5m]) * 100`, "percent:100", 60), - model.NewChart("service", "$memory", "Memory", "${name}", `container_memory_usage_bytes{container_label_com_docker_swarm_service_name="${service}"}`, "size:bytes", 60), - model.NewChart("service", "$network_in", "Network Receive", "${name}", `sum(irate(container_network_receive_bytes_total{container_label_com_docker_swarm_service_name="${service}"}[5m])) by(name)`, "size:bytes", 60), - model.NewChart("service", "$network_out", "Network Send", "${name}", `sum(irate(container_network_transmit_bytes_total{container_label_com_docker_swarm_service_name="${service}"}[5m])) by(name)`, "size:bytes", 60), -} - type ChartBiz interface { Search(args *model.ChartSearchArgs) (charts []*model.Chart, total int, err error) Delete(id, title string, user web.User) (err error) @@ -28,9 +15,6 @@ type ChartBiz interface { Batch(ids ...string) (charts []*model.Chart, err error) Create(chart *model.Chart, user web.User) (err error) Update(chart *model.Chart, user web.User) (err error) - FetchData(key string, ids []string, period time.Duration) (data.Map, error) - FindDashboard(name, key string) (dashboard *model.Dashboard, err error) - UpdateDashboard(dashboard *model.Dashboard, user web.User) (err error) } func NewChart(d dao.Interface, mb MetricBiz, eb EventBiz) ChartBiz { @@ -90,226 +74,3 @@ func (b *chartBiz) Update(chart *model.Chart, user web.User) (err error) { } return } - -func (b *chartBiz) FindDashboard(name, key string) (dashboard *model.Dashboard, err error) { - if dashboard, err = b.d.DashboardGet(context.TODO(), name, key); err != nil { - return - } - if dashboard == nil { - dashboard = defaultDashboard(name, key) - } - err = b.fillCharts(dashboard) - return -} - -func (b *chartBiz) UpdateDashboard(dashboard *model.Dashboard, user web.User) (err error) { - dashboard.UpdatedAt = now() - dashboard.UpdatedBy = newOperator(user) - return b.d.DashboardUpdate(context.TODO(), dashboard) -} - -func (b *chartBiz) FetchData(key string, ids []string, period time.Duration) (data.Map, error) { - if !b.mb.Enabled() { - return data.Map{}, nil - } - - charts, err := b.getCharts(ids) - if err != nil { - return nil, err - } - - type Data struct { - id string - data interface{} - err error - } - - ch := make(chan Data, len(charts)) - end := time.Now() - start := end.Add(-period) - for _, chart := range charts { - go func(c *model.Chart) { - d := Data{id: c.ID} - switch c.Type { - case "line", "bar": - d.data, d.err = b.fetchMatrixData(c, key, start, end) - case "pie": - d.data, d.err = b.fetchVectorData(c, key, end) - case "gauge": - d.data, d.err = b.fetchScalarData(c, key, end) - default: - d.err = errors.New("invalid chart type: " + c.Type) - } - ch <- d - }(chart) - } - - ds := data.Map{} - for range charts { - d := <-ch - if d.err != nil { - log.Get("metric").Error(d.err) - } else { - ds.Set(d.id, d.data) - } - } - close(ch) - return ds, nil -} - -func (b *chartBiz) fetchMatrixData(chart *model.Chart, key string, start, end time.Time) (md *MatrixData, err error) { - var ( - q string - d *MatrixData - ) - for i, m := range chart.Metrics { - q, err = b.formatQuery(m.Query, chart.Dashboard, key) - if err != nil { - return nil, err - } - - if d, err = b.mb.GetMatrix(q, m.Legend, start, end); err != nil { - log.Get("metric").Error(err) - } else if i == 0 { - md = d - } else { - md.Legend = append(md.Legend, d.Legend...) - md.Series = append(md.Series, d.Series...) - } - } - return md, nil -} - -func (b *chartBiz) fetchVectorData(chart *model.Chart, key string, end time.Time) (cvd *VectorData, err error) { - var ( - q string - d *VectorData - ) - for i, m := range chart.Metrics { - q, err = b.formatQuery(m.Query, chart.Dashboard, key) - if err != nil { - return nil, err - } - - if d, err = b.mb.GetVector(q, m.Legend, end); err != nil { - log.Get("metric").Error(err) - } else if i == 0 { - cvd = d - } else { - cvd.Legend = append(cvd.Legend, d.Legend...) - cvd.Data = append(cvd.Data, d.Data...) - } - } - return cvd, nil -} - -func (b *chartBiz) fetchScalarData(chart *model.Chart, key string, end time.Time) (*VectorValue, error) { - query, err := b.formatQuery(chart.Metrics[0].Query, chart.Dashboard, key) - if err != nil { - return nil, err - } - - v, err := b.mb.GetScalar(query, end) - if err != nil { - return nil, err - } - - return &VectorValue{ - //Name: "", - Value: v, - }, nil -} - -func (b *chartBiz) formatQuery(query, dashboard, key string) (string, error) { - if dashboard == "home" { - return query, nil - } - - var errs []error - m := map[string]string{dashboard: key} - q := os.Expand(query, func(k string) string { - if v, ok := m[k]; ok { - return v - } - errs = append(errs, errors.New("invalid argument in query: "+query)) - return "" - }) - if len(errs) == 0 { - return q, nil - } - return "", errs[0] -} - -func (b *chartBiz) getCharts(ids []string) (charts map[string]*model.Chart, err error) { - var ( - customIds []string - customCharts []*model.Chart - ) - - charts = make(map[string]*model.Chart) - for _, id := range ids { - if id[0] == '$' { - for _, c := range builtins { - if c.ID == id { - charts[id] = c - } - } - } else { - customIds = append(customIds, id) - } - } - - if len(customIds) > 0 { - if customCharts, err = b.Batch(customIds...); err == nil { - for _, chart := range customCharts { - charts[chart.ID] = chart - } - } - } - return -} - -func (b *chartBiz) fillCharts(d *model.Dashboard) (err error) { - if len(d.Charts) == 0 { - return - } - - var ( - m map[string]*model.Chart - ids = make([]string, len(d.Charts)) - ) - - for i, c := range d.Charts { - ids[i] = c.ID - } - - m, err = b.getCharts(ids) - if err != nil { - return err - } - - for i := range d.Charts { - if c := m[d.Charts[i].ID]; c != nil { - _ = copier.CopyWithOption(&d.Charts[i], c, copier.Option{IgnoreEmpty: true}) - } - } - return nil -} - -func defaultDashboard(name, key string) *model.Dashboard { - d := &model.Dashboard{ - Name: name, - Key: key, - Period: 30, - Interval: 15, - } - if name == "service" { - d.Charts = []model.ChartInfo{ - {ID: "$cpu"}, - {ID: "$memory"}, - {ID: "$network_in"}, - {ID: "$network_out"}, - } - } - return d -} diff --git a/biz/dashboard.go b/biz/dashboard.go new file mode 100644 index 0000000..b1310a2 --- /dev/null +++ b/biz/dashboard.go @@ -0,0 +1,266 @@ +package biz + +import ( + "context" + "os" + "time" + + "github.com/cuigh/auxo/data" + "github.com/cuigh/auxo/errors" + "github.com/cuigh/auxo/log" + "github.com/cuigh/auxo/net/web" + "github.com/cuigh/swirl/dao" + "github.com/cuigh/swirl/model" + "github.com/jinzhu/copier" +) + +var builtins = []*model.Chart{ + model.NewChart("service", "$cpu", "CPU", "${name}", `rate(container_cpu_user_seconds_total{container_label_com_docker_swarm_service_name="${service}"}[5m]) * 100`, "percent:100", 60), + model.NewChart("service", "$memory", "Memory", "${name}", `container_memory_usage_bytes{container_label_com_docker_swarm_service_name="${service}"}`, "size:bytes", 60), + model.NewChart("service", "$network_in", "Network Receive", "${name}", `sum(irate(container_network_receive_bytes_total{container_label_com_docker_swarm_service_name="${service}"}[5m])) by(name)`, "size:bytes", 60), + model.NewChart("service", "$network_out", "Network Send", "${name}", `sum(irate(container_network_transmit_bytes_total{container_label_com_docker_swarm_service_name="${service}"}[5m])) by(name)`, "size:bytes", 60), +} + +type DashboardBiz interface { + FetchData(key string, ids []string, period time.Duration) (data.Map, error) + FindDashboard(name, key string) (dashboard *model.Dashboard, err error) + UpdateDashboard(dashboard *model.Dashboard, user web.User) (err error) +} + +func NewDashboard(d dao.Interface, mb MetricBiz, eb EventBiz) DashboardBiz { + return &dashboardBiz{ + d: d, + mb: mb, + eb: eb, + } +} + +type dashboardBiz struct { + d dao.Interface + cb ChartBiz + mb MetricBiz + eb EventBiz +} + +func (b *dashboardBiz) FindDashboard(name, key string) (dashboard *model.Dashboard, err error) { + if dashboard, err = b.d.DashboardGet(context.TODO(), name, key); err != nil { + return + } + if dashboard == nil { + dashboard = b.defaultDashboard(name, key) + } + err = b.fillCharts(dashboard) + return +} + +func (b *dashboardBiz) UpdateDashboard(dashboard *model.Dashboard, user web.User) (err error) { + dashboard.UpdatedAt = now() + dashboard.UpdatedBy = newOperator(user) + return b.d.DashboardUpdate(context.TODO(), dashboard) +} + +func (b *dashboardBiz) FetchData(key string, ids []string, period time.Duration) (data.Map, error) { + if !b.mb.Enabled() { + return data.Map{}, nil + } + + charts, err := b.getCharts(ids) + if err != nil { + return nil, err + } + + type Data struct { + id string + data interface{} + err error + } + + ch := make(chan Data, len(charts)) + end := time.Now() + start := end.Add(-period) + for _, chart := range charts { + go func(c *model.Chart) { + d := Data{id: c.ID} + switch c.Type { + case "line", "bar": + d.data, d.err = b.fetchMatrixData(c, key, start, end) + case "pie": + d.data, d.err = b.fetchVectorData(c, key, end) + case "gauge": + d.data, d.err = b.fetchScalarData(c, key, end) + default: + d.err = errors.New("invalid chart type: " + c.Type) + } + ch <- d + }(chart) + } + + ds := data.Map{} + for range charts { + d := <-ch + if d.err != nil { + log.Get("metric").Error(d.err) + } else { + ds.Set(d.id, d.data) + } + } + close(ch) + return ds, nil +} + +func (b *dashboardBiz) fetchMatrixData(chart *model.Chart, key string, start, end time.Time) (md *MatrixData, err error) { + var ( + q string + d *MatrixData + ) + for i, m := range chart.Metrics { + q, err = b.formatQuery(m.Query, chart.Dashboard, key) + if err != nil { + return nil, err + } + + if d, err = b.mb.GetMatrix(q, m.Legend, start, end); err != nil { + log.Get("metric").Error(err) + } else if i == 0 { + md = d + } else { + md.Legend = append(md.Legend, d.Legend...) + md.Series = append(md.Series, d.Series...) + } + } + return md, nil +} + +func (b *dashboardBiz) fetchVectorData(chart *model.Chart, key string, end time.Time) (cvd *VectorData, err error) { + var ( + q string + d *VectorData + ) + for i, m := range chart.Metrics { + q, err = b.formatQuery(m.Query, chart.Dashboard, key) + if err != nil { + return nil, err + } + + if d, err = b.mb.GetVector(q, m.Legend, end); err != nil { + log.Get("metric").Error(err) + } else if i == 0 { + cvd = d + } else { + cvd.Legend = append(cvd.Legend, d.Legend...) + cvd.Data = append(cvd.Data, d.Data...) + } + } + return cvd, nil +} + +func (b *dashboardBiz) fetchScalarData(chart *model.Chart, key string, end time.Time) (*VectorValue, error) { + query, err := b.formatQuery(chart.Metrics[0].Query, chart.Dashboard, key) + if err != nil { + return nil, err + } + + v, err := b.mb.GetScalar(query, end) + if err != nil { + return nil, err + } + + return &VectorValue{ + //Name: "", + Value: v, + }, nil +} + +func (b *dashboardBiz) formatQuery(query, dashboard, key string) (string, error) { + if dashboard == "home" { + return query, nil + } + + var errs []error + m := map[string]string{dashboard: key} + q := os.Expand(query, func(k string) string { + if v, ok := m[k]; ok { + return v + } + errs = append(errs, errors.New("invalid argument in query: "+query)) + return "" + }) + if len(errs) == 0 { + return q, nil + } + return "", errs[0] +} + +func (b *dashboardBiz) getCharts(ids []string) (charts map[string]*model.Chart, err error) { + var ( + customIds []string + customCharts []*model.Chart + ) + + charts = make(map[string]*model.Chart) + for _, id := range ids { + if id[0] == '$' { + for _, c := range builtins { + if c.ID == id { + charts[id] = c + } + } + } else { + customIds = append(customIds, id) + } + } + + if len(customIds) > 0 { + if customCharts, err = b.d.ChartGetBatch(context.TODO(), customIds...); err == nil { + for _, chart := range customCharts { + charts[chart.ID] = chart + } + } + } + return +} + +func (b *dashboardBiz) fillCharts(d *model.Dashboard) (err error) { + if len(d.Charts) == 0 { + return + } + + var ( + m map[string]*model.Chart + ids = make([]string, len(d.Charts)) + ) + + for i, c := range d.Charts { + ids[i] = c.ID + } + + m, err = b.getCharts(ids) + if err != nil { + return err + } + + for i := range d.Charts { + if c := m[d.Charts[i].ID]; c != nil { + _ = copier.CopyWithOption(&d.Charts[i], c, copier.Option{IgnoreEmpty: true}) + } + } + return nil +} + +func (b *dashboardBiz) defaultDashboard(name, key string) *model.Dashboard { + d := &model.Dashboard{ + Name: name, + Key: key, + Period: 30, + Interval: 15, + } + if name == "service" { + d.Charts = []model.ChartInfo{ + {ID: "$cpu"}, + {ID: "$memory"}, + {ID: "$network_in"}, + {ID: "$network_out"}, + } + } + return d +} diff --git a/biz/node.go b/biz/node.go index c21010e..2c8c504 100644 --- a/biz/node.go +++ b/biz/node.go @@ -11,7 +11,7 @@ import ( ) type NodeBiz interface { - List() ([]*docker.Node, error) + List(agent bool) ([]*docker.Node, error) Search() ([]*Node, error) Find(id string) (node *Node, raw string, err error) Delete(id, name string, user web.User) (err error) @@ -42,15 +42,17 @@ func (b *nodeBiz) Find(id string) (node *Node, raw string, err error) { return } -func (b *nodeBiz) List() ([]*docker.Node, error) { +func (b *nodeBiz) List(agent bool) ([]*docker.Node, error) { m, err := b.d.NodeMap() if err != nil { return nil, err } - nodes := make([]*docker.Node, 0, len(m)) + var nodes []*docker.Node for _, n := range m { - nodes = append(nodes, n) + if !agent || n.Agent != "" { + nodes = append(nodes, n) + } } sort.Slice(nodes, func(i, j int) bool { return nodes[i].Name < nodes[j].Name diff --git a/biz/role.go b/biz/role.go index 9dda2ee..f3e3f67 100644 --- a/biz/role.go +++ b/biz/role.go @@ -14,6 +14,7 @@ type RoleBiz interface { Create(role *model.Role, user web.User) (err error) Delete(id, name string, user web.User) (err error) Update(r *model.Role, user web.User) (err error) + GetPerms(ids []string) ([]string, error) } func NewRole(d dao.Interface, eb EventBiz) RoleBiz { @@ -74,3 +75,24 @@ func (b *roleBiz) Update(role *model.Role, user web.User) (err error) { } return } + +func (b *roleBiz) GetPerms(ids []string) ([]string, error) { + m := make(map[string]struct{}) + + for _, id := range ids { + r, err := b.d.RoleGet(context.TODO(), id) + if err != nil { + return nil, err + } + + for _, p := range r.Perms { + m[p] = struct{}{} + } + } + + perms := make([]string, 0, len(m)) + for p := range m { + perms = append(perms, p) + } + return perms, nil +} diff --git a/biz/session.go b/biz/session.go new file mode 100644 index 0000000..6f60bc2 --- /dev/null +++ b/biz/session.go @@ -0,0 +1,45 @@ +package biz + +import ( + "context" + "time" + + "github.com/cuigh/swirl/dao" + "github.com/cuigh/swirl/model" +) + +type SessionBiz interface { + Find(token string) (session *model.Session, err error) + Create(session *model.Session) (err error) + Update(session *model.Session) (err error) + UpdateExpiry(id string, expiry time.Time) (err error) +} + +func NewSession(d dao.Interface, rb RoleBiz) SessionBiz { + return &sessionBiz{d: d, rb: rb} +} + +type sessionBiz struct { + d dao.Interface + rb RoleBiz +} + +func (b *sessionBiz) Find(token string) (session *model.Session, err error) { + return b.d.SessionGet(context.TODO(), token) +} + +func (b *sessionBiz) Create(session *model.Session) (err error) { + session.CreatedAt = time.Now() + session.UpdatedAt = session.CreatedAt + return b.d.SessionCreate(context.TODO(), session) +} + +func (b *sessionBiz) Update(session *model.Session) (err error) { + session.Dirty = false + session.UpdatedAt = time.Now() + return b.d.SessionUpdate(context.TODO(), session) +} + +func (b *sessionBiz) UpdateExpiry(id string, expiry time.Time) (err error) { + return b.d.SessionUpdateExpiry(context.TODO(), id, expiry) +} diff --git a/biz/setting.go b/biz/setting.go index e4423b1..b544a3d 100644 --- a/biz/setting.go +++ b/biz/setting.go @@ -63,7 +63,9 @@ func (b *settingBiz) Save(id string, options data.Map, user web.User) (err error ID: id, Options: b.toOptions(options), UpdatedAt: time.Now(), - UpdatedBy: model.Operator{ID: user.ID(), Name: user.Name()}, + } + if user != nil { + setting.UpdatedBy = model.Operator{ID: user.ID(), Name: user.Name()} } err = b.d.SettingUpdate(context.TODO(), setting) if err == nil && user != nil { diff --git a/biz/user.go b/biz/user.go index 1aef503..a1e084f 100644 --- a/biz/user.go +++ b/biz/user.go @@ -176,24 +176,6 @@ func (b *userBiz) Count() (count int, err error) { return b.d.UserCount(context.TODO()) } -//func (b *userBiz) UpdateSession(id string) (token string, err error) { -// session := &model.Session{ -// UserID: id, -// Token: guid.New().String(), -// UpdatedAt: time.Now(), -// } -// session.Expires = session.UpdatedAt.Add(time.Hour * 24) -// err = b.d.SessionUpdate(context.TODO(), session) -// if err == nil { -// token = session.Token -// } -// return -//} - -func (b *userBiz) GetSession(token string) (session *model.Session, err error) { - return b.d.SessionGet(context.TODO(), token) -} - type UserPrivacy struct { ID string Name string diff --git a/compose.yml b/compose.yml index 41d4b0a..20294ac 100644 --- a/compose.yml +++ b/compose.yml @@ -5,16 +5,62 @@ services: image: cuigh/swirl environment: DB_ADDRESS: mongodb://mongo:27017/swirl - volumes: - - /var/run/docker.sock:/var/run/docker.sock + DOCKER_ENDPOINT: tcp://swirl_manager_agent:2375 + AGENTS: swirl_manager_agent,swirl_worker_agent ports: - "8001:8001" networks: - net deploy: - replicas: 1 + replicas: 2 placement: - constraints: [node.role == manager] + constraints: [ node.role == worker ] + + manager_agent: + image: cuigh/socat + volumes: + - /var/run/docker.sock:/var/run/docker.sock + networks: + - net + deploy: + mode: global + placement: + constraints: [ node.role == manager ] + + worker_agent: + image: cuigh/socat + volumes: + - /var/run/docker.sock:/var/run/docker.sock + networks: + - net + deploy: + mode: global + placement: + constraints: [ node.role == worker ] + + # prometheus: + # image: prom/prometheus + # volumes: + # - prometheus:/prometheus + # networks: + # - net + # deploy: + # replicas: 1 + # placement: + # constraints: [ node.labels.app.prometheus == true ] + + # cadvisor: + # image: gcr.io/cadvisor/cadvisor + # volumes: + # - /:/rootfs:ro + # - /sys:/sys:ro + # - /var/lib/docker:/var/lib/docker:ro + # - /var/run:/var/run:ro + # - /var/run/docker.sock:/var/run/docker.sock:ro + # networks: + # - net + # deploy: + # mode: global mongo: image: mongo @@ -25,9 +71,10 @@ services: deploy: replicas: 1 # placement: -# constraints: [node.hostname == mongo] +# constraints: [ node.labels.app.mongo == true ] volumes: + prometheus: mongo: networks: diff --git a/config/app.yml b/config/app.yml index 6703131..754262c 100644 --- a/config/app.yml +++ b/config/app.yml @@ -3,7 +3,7 @@ banner: false web: entries: - - address: :8002 + - address: :8001 authorize: '?' swirl: diff --git a/dao/bolt/bolt.go b/dao/bolt/bolt.go index 4355fc1..f143f72 100644 --- a/dao/bolt/bolt.go +++ b/dao/bolt/bolt.go @@ -3,10 +3,12 @@ package bolt import ( "path/filepath" "strings" + "time" "github.com/boltdb/bolt" "github.com/cuigh/auxo/errors" "github.com/cuigh/auxo/log" + "github.com/cuigh/auxo/util/run" "go.mongodb.org/mongo-driver/bson" ) @@ -41,6 +43,9 @@ func New(addr string) (*Dao, error) { logger: log.Get("bolt"), db: db, } + run.Schedule(time.Hour, d.SessionPrune, func(err interface{}) { + d.logger.Error("failed to clean up expired sessions: ", err) + }) return d, nil } diff --git a/dao/bolt/session.go b/dao/bolt/session.go new file mode 100644 index 0000000..f16351e --- /dev/null +++ b/dao/bolt/session.go @@ -0,0 +1,56 @@ +package bolt + +import ( + "context" + "time" + + "github.com/boltdb/bolt" + "github.com/cuigh/swirl/model" +) + +const Session = "session" + +func (d *Dao) SessionGet(ctx context.Context, id string) (session *model.Session, err error) { + s := &model.Session{} + err = d.get(Session, id, s) + if err == ErrNoRecords { + return nil, nil + } else if err != nil { + s = nil + } + return +} + +func (d *Dao) SessionCreate(ctx context.Context, session *model.Session) (err error) { + return d.replace(Session, session.ID, session) +} + +func (d *Dao) SessionUpdate(ctx context.Context, session *model.Session) (err error) { + return d.replace(Session, session.UserID, session) +} + +func (d *Dao) SessionUpdateExpiry(ctx context.Context, id string, expiry time.Time) (err error) { + old := &model.Session{} + return d.update(Session, id, old, func() interface{} { + old.Expiry = expiry + old.UpdatedAt = time.Now() + return old + }) +} + +// SessionPrune cleans up expired logs. +func (d *Dao) SessionPrune() { + err := d.db.Update(func(tx *bolt.Tx) (err error) { + b := tx.Bucket([]byte(Session)) + return b.ForEach(func(k, v []byte) error { + session := &model.Session{} + if err = decode(v, session); err == nil && session.Expiry.Add(time.Hour).Before(time.Now()) { + err = b.Delete(k) + } + return err + }) + }) + if err != nil { + d.logger.Error("failed to clean up expired sessions: ", err) + } +} diff --git a/dao/bolt/user.go b/dao/bolt/user.go index c6ce097..5d272c7 100644 --- a/dao/bolt/user.go +++ b/dao/bolt/user.go @@ -8,7 +8,6 @@ import ( ) const User = "user" -const Session = "session" func (d *Dao) UserCount(ctx context.Context) (count int, err error) { return d.count(User) @@ -124,18 +123,3 @@ func (d *Dao) UserUpdatePassword(ctx context.Context, user *model.User) (err err return old }) } - -func (d *Dao) SessionGet(ctx context.Context, token string) (session *model.Session, err error) { - s := &model.Session{} - err = d.get(Session, token, s) - if err == ErrNoRecords { - return nil, nil - } else if err != nil { - s = nil - } - return -} - -func (d *Dao) SessionUpdate(ctx context.Context, session *model.Session) (err error) { - return d.replace(Session, session.UserID, session) -} diff --git a/dao/dao.go b/dao/dao.go index 1aa9130..716eb65 100644 --- a/dao/dao.go +++ b/dao/dao.go @@ -2,6 +2,7 @@ package dao import ( "context" + "time" "github.com/cuigh/auxo/app/container" "github.com/cuigh/auxo/errors" @@ -32,8 +33,10 @@ type Interface interface { UserUpdatePassword(ctx context.Context, user *model.User) error UserDelete(ctx context.Context, id string) error - SessionGet(ctx context.Context, token string) (*model.Session, error) + SessionGet(ctx context.Context, id string) (*model.Session, error) + SessionCreate(ctx context.Context, session *model.Session) error SessionUpdate(ctx context.Context, session *model.Session) error + SessionUpdateExpiry(ctx context.Context, id string, expiry time.Time) (err error) RegistryGet(ctx context.Context, id string) (*model.Registry, error) RegistryGetByURL(ctx context.Context, url string) (registry *model.Registry, err error) diff --git a/dao/mongo/mongo.go b/dao/mongo/mongo.go index d240f86..46980db 100644 --- a/dao/mongo/mongo.go +++ b/dao/mongo/mongo.go @@ -34,12 +34,12 @@ var indexes = map[string][]mongo.IndexModel{ mongo.IndexModel{Keys: bson.D{{"type", 1}}}, mongo.IndexModel{Keys: bson.D{{"name", 1}}}, }, - //"session": { - // mongo.IndexModel{ - // Keys: bson.D{{"token", 1}}, - // Options: options.Index().SetUnique(true), - // }, - //}, + "session": { + mongo.IndexModel{ + Keys: bson.D{{"expiry", 1}}, + Options: options.Index().SetExpireAfterSeconds(3600), + }, + }, } type Dao struct { diff --git a/dao/mongo/session.go b/dao/mongo/session.go new file mode 100644 index 0000000..17868af --- /dev/null +++ b/dao/mongo/session.go @@ -0,0 +1,38 @@ +package mongo + +import ( + "context" + "time" + + "github.com/cuigh/swirl/model" + "go.mongodb.org/mongo-driver/bson" +) + +const Session = "session" + +func (d *Dao) SessionGet(ctx context.Context, id string) (session *model.Session, err error) { + session = &model.Session{} + found, err := d.find(ctx, Session, id, session) + if !found { + return nil, err + } + return +} + +func (d *Dao) SessionCreate(ctx context.Context, session *model.Session) (err error) { + return d.create(ctx, Session, session) +} + +func (d *Dao) SessionUpdate(ctx context.Context, session *model.Session) (err error) { + return d.update(ctx, Session, session.ID, session) +} + +func (d *Dao) SessionUpdateExpiry(ctx context.Context, id string, expiry time.Time) (err error) { + update := bson.M{ + "$set": bson.M{ + "expiry": expiry, + "updated_by": time.Now(), + }, + } + return d.update(ctx, Session, id, update) +} diff --git a/dao/mongo/user.go b/dao/mongo/user.go index 495fa8f..a2efbf8 100644 --- a/dao/mongo/user.go +++ b/dao/mongo/user.go @@ -6,13 +6,9 @@ import ( "github.com/cuigh/swirl/model" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" - "go.mongodb.org/mongo-driver/mongo/options" ) -const ( - User = "user" - Session = "session" -) +const User = "user" func (d *Dao) UserCount(ctx context.Context) (int, error) { count, err := d.db.Collection(User).CountDocuments(ctx, bson.M{}) @@ -118,19 +114,3 @@ func (d *Dao) UserUpdatePassword(ctx context.Context, user *model.User) (err err } return d.update(ctx, User, user.ID, update) } - -func (d *Dao) SessionGet(ctx context.Context, token string) (session *model.Session, err error) { - session = &model.Session{} - err = d.db.Collection(Session).FindOne(ctx, bson.M{"token": token}).Decode(session) - if err == mongo.ErrNoDocuments { - return nil, nil - } else if err != nil { - return nil, err - } - return -} - -func (d *Dao) SessionUpdate(ctx context.Context, session *model.Session) (err error) { - _, err = d.db.Collection(Session).UpdateByID(ctx, session.UserID, session, options.Update().SetUpsert(true)) - return -} diff --git a/go.mod b/go.mod index 566a35f..4295f10 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,6 @@ require ( github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee // indirect github.com/gobwas/pool v0.2.0 // indirect github.com/gobwas/ws v1.0.0 - github.com/golang-jwt/jwt v3.2.2+incompatible github.com/imdario/mergo v0.3.12 github.com/jinzhu/copier v0.3.2 github.com/mattn/go-shellwords v1.0.3 diff --git a/go.sum b/go.sum index 5e88726..4537415 100644 --- a/go.sum +++ b/go.sum @@ -344,8 +344,6 @@ github.com/gogo/protobuf v1.3.0/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXP github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= -github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= diff --git a/main.go b/main.go index 422b86a..15449ff 100644 --- a/main.go +++ b/main.go @@ -30,7 +30,7 @@ var ( func main() { app.Name = "Swirl" - app.Version = "1.0.0beta5" + app.Version = "1.0.0beta6" app.Desc = "A web management UI for Docker, focused on swarm cluster" app.Action = func(ctx *app.Context) error { return run.Pipeline(misc.LoadOptions, initSystem, scaler.Start, startServer) diff --git a/model/model.go b/model/model.go index b4ada77..982fdbe 100644 --- a/model/model.go +++ b/model/model.go @@ -253,10 +253,14 @@ func (cd *Dashboard) ID() string { type Session struct { ID string `json:"id" bson:"_id"` // token UserID string `json:"userId" bson:"user_id"` + Username string `json:"username" bson:"username"` + Admin bool `json:"admin" bson:"admin"` Roles []string `json:"roles" bson:"roles"` - Perm int64 `json:"perm" bson:"perm"` + Perm uint64 `json:"perm" bson:"perm"` + Perms []string `json:"-" bson:"-"` Dirty bool `json:"dirty" bson:"dirty"` Expiry time.Time `json:"expiry" bson:"expiry"` MaxExpiry time.Time `json:"maxExpiry" bson:"max_expiry"` + CreatedAt time.Time `json:"createdAt" bson:"created_at"` UpdatedAt time.Time `json:"updatedAt" bson:"updated_at"` } diff --git a/security/auth.go b/security/auth.go new file mode 100644 index 0000000..c5d44e1 --- /dev/null +++ b/security/auth.go @@ -0,0 +1,306 @@ +package security + +import ( + "strings" + "time" + + "github.com/cuigh/auxo/errors" + "github.com/cuigh/auxo/log" + "github.com/cuigh/auxo/net/web" + "github.com/cuigh/auxo/security" + "github.com/cuigh/auxo/security/certify" + "github.com/cuigh/auxo/security/certify/ldap" + "github.com/cuigh/auxo/security/passwd" + "github.com/cuigh/swirl/biz" + "github.com/cuigh/swirl/misc" + "github.com/cuigh/swirl/model" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// Identifier identifies the user. +type Identifier struct { + ub biz.UserBiz + rb biz.RoleBiz + sb biz.SessionBiz + realms []RealmFunc + extractors []TokenExtractor + logger log.Logger +} + +func NewIdentifier(s *misc.Setting, ub biz.UserBiz, rb biz.RoleBiz, sb biz.SessionBiz) *Identifier { + return &Identifier{ + ub: ub, + rb: rb, + sb: sb, + realms: []RealmFunc{internalRealm(), ldapRealm(s, ub)}, + extractors: []TokenExtractor{headerExtractor, queryExtractor}, + logger: log.Get(PkgName), + } +} + +func (c *Identifier) Apply(next web.HandlerFunc) web.HandlerFunc { + return func(ctx web.Context) error { + token := c.extractToken(ctx) + if token != "" { + user := c.identifyUser(token) + ctx.SetUser(user) + } + return next(ctx) + } +} + +func (c *Identifier) Identify(loginName, password string) (identify Identity, err error) { + var ( + u security.User + s *model.Session + ) + + u, err = c.signIn(loginName, password) + if err != nil { + return + } + + s, err = c.createSession(u) + if err != nil { + return nil, err + } + + return &UserInfo{ + id: u.ID(), + name: u.Name(), + token: s.ID, + perms: s.Perms, + }, nil +} + +func (c *Identifier) signIn(loginName, password string) (user security.User, err error) { + privacy, err := c.ub.FindPrivacy(loginName) + if err != nil { + return nil, err + } + + if privacy != nil && privacy.Status == biz.UserStatusBlocked { + return nil, misc.Error(misc.ErrAccountDisabled, certify.ErrAccountDisabled) + } + + for _, login := range c.realms { + user, err = login(privacy, loginName, password) + if user != nil && err == nil { + return + } + } + + if err == nil { + err = misc.Error(misc.ErrInvalidToken, certify.ErrInvalidToken) + } + return +} + +func (c *Identifier) extractToken(ctx web.Context) (token string) { + for _, e := range c.extractors { + if token = e(ctx); token != "" { + return + } + } + return +} + +func (c *Identifier) identifyUser(token string) web.User { + session, err := c.sb.Find(token) + if err != nil { + c.logger.Error("failed to find session: ", err) + return nil + } else if session == nil || session.Expiry.Before(time.Now()) { + return nil + } + + if session.Dirty { + if err = c.updateSession(session); err != nil { + c.logger.Error("failed to refresh session: ", err) + return nil + } + } else if time.Now().Add(time.Minute * 5).After(session.Expiry) { + c.renewSession(session) + } + + return c.createUser(session) +} + +func (c *Identifier) createUser(s *model.Session) web.User { + return &User{ + token: s.ID, + id: s.UserID, + name: s.Username, + admin: s.Admin, + perm: PermMap(s.Perm), + } +} + +func (c *Identifier) createSession(user security.User) (s *model.Session, err error) { + s = &model.Session{ + ID: primitive.NewObjectID().Hex(), + UserID: user.ID(), + Username: user.Name(), + Expiry: time.Now().Add(misc.Options.TokenExpiry), + } + s.MaxExpiry = s.Expiry.Add(24 * time.Hour) + if err = c.fillSession(s); err == nil { + err = c.sb.Create(s) + } + return +} + +func (c *Identifier) updateSession(s *model.Session) (err error) { + if err = c.fillSession(s); err == nil { + err = c.sb.Update(s) + } + return +} + +func (c *Identifier) fillSession(s *model.Session) (err error) { + u, err := c.ub.FindByID(s.UserID) + if err != nil { + return err + } else if u == nil { + return errors.New("user not found") + } + + if u.Admin { + s.Perms = []string{"*"} + } else { + s.Perms, err = c.rb.GetPerms(u.Roles) + if err != nil { + return err + } + s.Perm = uint64(NewPermMap(s.Perms)) + } + + s.Username = u.Name + s.Admin = u.Admin + s.Roles = u.Roles + return nil +} + +func (c *Identifier) renewSession(s *model.Session) { + expiry := time.Now().Add(misc.Options.TokenExpiry) + if expiry.After(s.MaxExpiry) { + expiry = s.MaxExpiry + } + err := c.sb.UpdateExpiry(s.ID, expiry) + if err != nil { + c.logger.Errorf("failed to renew token '%s': %s", s.ID, err) + } +} + +type TokenExtractor func(ctx web.Context) string + +func headerExtractor(ctx web.Context) (token string) { + const prefix = "Bearer " + if value := ctx.Header(web.HeaderAuthorization); strings.HasPrefix(value, prefix) { + token = value[len(prefix):] + } + return +} + +func queryExtractor(ctx web.Context) (token string) { + return ctx.Query("token") +} + +type RealmFunc func(u *biz.UserPrivacy, loginName, password string) (security.User, error) + +func internalRealm() RealmFunc { + return func(u *biz.UserPrivacy, loginName, password string) (security.User, error) { + if u == nil || u.Type != biz.UserTypeInternal { + return nil, nil + } + + if passwd.Validate(password, u.Password, u.Salt) { + return security.NewUser(u.ID, u.Name), nil + } + return nil, misc.Error(misc.ErrInvalidToken, certify.ErrInvalidToken) + } +} + +func ldapRealm(s *misc.Setting, ub biz.UserBiz) RealmFunc { + const authBind = "bind" + var r certify.Realm + + if s.LDAP.Enabled { + opts := []ldap.Option{ + ldap.NameAttr(s.LDAP.NameAttr), + ldap.EmailAttr(s.LDAP.EmailAttr), + ldap.UserFilter(s.LDAP.UserFilter), + ldap.Security(ldap.SecurityPolicy(s.LDAP.Security)), + } + if s.LDAP.Authentication == authBind { + opts = append(opts, ldap.Binding(s.LDAP.BindDN, s.LDAP.BindPassword)) + } + r = ldap.New(s.LDAP.Address, s.LDAP.BaseDN, s.LDAP.UserDN, opts...) + } + + return func(u *biz.UserPrivacy, loginName, password string) (security.User, error) { + if r == nil || (u != nil && u.Type != biz.UserTypeLDAP) { + return nil, nil + } + + user, err := r.Login(certify.NewSimpleToken(loginName, password)) + if err != nil { + return nil, err + } + + var ( + id string + lu = user.(*ldap.User) + ) + if u == nil { + id, err = ub.Create(&model.User{ + Type: biz.UserTypeLDAP, + LoginName: loginName, + Name: lu.Name(), + Email: lu.Email(), + }, nil) + if err != nil { + return nil, err + } + lu.SetID(id) + } else { + lu.SetID(u.ID) + } + return user, nil + } +} + +type Identity interface { + ID() string + Name() string + Anonymous() bool + Token() string + Perms() []string +} + +type UserInfo struct { + id string + name string + token string + perms []string +} + +func (u *UserInfo) ID() string { + return u.id +} + +func (u *UserInfo) Name() string { + return u.name +} + +func (u *UserInfo) Anonymous() bool { + return u.id == "" +} + +func (u *UserInfo) Token() string { + return u.token +} + +func (u *UserInfo) Perms() []string { + return u.perms +} diff --git a/security/jwt.go b/security/jwt.go deleted file mode 100644 index adb1458..0000000 --- a/security/jwt.go +++ /dev/null @@ -1,151 +0,0 @@ -package security - -import ( - "errors" - "strings" - "time" - - "github.com/cuigh/auxo/data" - "github.com/cuigh/auxo/log" - "github.com/cuigh/auxo/net/web" - "github.com/cuigh/auxo/security" - "github.com/cuigh/auxo/util/cast" - "github.com/cuigh/swirl/misc" - "github.com/golang-jwt/jwt" -) - -var ErrNoNeedRefresh = errors.New("no need to refresh") - -type JWT struct { - Schema string - Sources data.Options - KeyFunc jwt.Keyfunc - Identifier func(token *jwt.Token) web.User - tokenExpiry int64 - logger log.Logger -} - -func NewIdentifier() web.Filter { - logger := log.Get("security") - key := misc.Options.TokenKey - expiry := misc.Options.TokenExpiry - if key == "" { - key = "swirl" - logger.Warnf("Swirl is using default token key as token_key isn't configured, this may cause security problems") - } - if expiry == 0 { - expiry = 30 * time.Minute - } - - return &JWT{ - logger: logger, - tokenExpiry: int64(expiry.Seconds()), - Schema: "Bearer", - Sources: data.Options{ - {Name: "header", Value: web.HeaderAuthorization}, - }, - KeyFunc: func(token *jwt.Token) (interface{}, error) { - // TODO: use user salt as key? - return []byte(key), nil - }, - Identifier: func(token *jwt.Token) web.User { - claims := token.Claims.(jwt.MapClaims) - return security.NewUser(cast.ToString(claims["sub"]), cast.ToString(claims["name"])) - }, - } -} - -func (j *JWT) Apply(next web.HandlerFunc) web.HandlerFunc { - if j.KeyFunc == nil { - panic("KeyFunc is required") - } - if j.Schema == "" { - j.Schema = "Bearer" - } - if len(j.Sources) == 0 { - j.Sources = data.Options{ - {Name: "header", Value: web.HeaderAuthorization}, - } - } - if j.Identifier == nil { - j.Identifier = func(token *jwt.Token) web.User { - claims := token.Claims.(jwt.MapClaims) - return security.NewUser(cast.ToString(claims["sub"]), cast.ToString(claims["name"])) - } - } - - return func(ctx web.Context) error { - ts := j.extractToken(ctx) - if ts != "" { - token, err := jwt.Parse(ts, j.KeyFunc) - if err != nil { - j.logger.Debugf("failed to parse token: %s", err) - } else { - user := j.Identifier(token) - ctx.SetUser(user) - if ts, err = j.refreshToken(user, token); err == nil { - ctx.SetHeader(web.HeaderAuthorization, ts) - } else if err != ErrNoNeedRefresh { - j.logger.Errorf("failed to refresh token: %s", err) - } - } - } - - return next(ctx) - } -} - -func (j *JWT) extractToken(ctx web.Context) (token string) { - for _, src := range j.Sources { - switch src.Name { - case "header": - token = ctx.Header(src.Value) - if strings.HasPrefix(token, j.Schema) { - return token[len(j.Schema)+1:] - } - case "cookie": - if cookie, err := ctx.Cookie(src.Value); err == nil { - token = cookie.Value - } - case "form": - token = ctx.Form(src.Value) - case "query": - token = ctx.Query(src.Value) - } - if token != "" { - return - } - } - return -} - -func (j *JWT) refreshToken(user web.User, token *jwt.Token) (string, error) { - claims := token.Claims.(jwt.MapClaims) - expiry := cast.ToInt64(claims["exp"]) - now := time.Now().Unix() - // refresh token when remaining expiry is less than 5 minutes - if (expiry - now) < 5*60 { - ts, err := j.CreateToken(user.ID(), user.Name()) - if err != nil { - return "", err - } - return ts, nil - } - return "", ErrNoNeedRefresh -} - -func (j *JWT) CreateToken(id, name string) (string, error) { - now := time.Now().Unix() - claims := jwt.MapClaims{ - "name": name, - "sub": id, - "iat": now, - "exp": now + j.tokenExpiry, - } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - key, err := j.KeyFunc(token) - if err != nil { - return "", err - } - return token.SignedString(key) -} diff --git a/security/perm.go b/security/perm.go index ae9c741..73bc05d 100644 --- a/security/perm.go +++ b/security/perm.go @@ -2,34 +2,79 @@ package security import ( "net/http" - "time" + "strings" - "github.com/cuigh/auxo/cache" - "github.com/cuigh/auxo/log" "github.com/cuigh/auxo/net/web" - "github.com/cuigh/swirl/biz" ) -type Authorizer struct { - ub biz.UserBiz - perms *cache.Value - logger log.Logger +const ActionBits = 24 + +// Resources holds all resources requiring authorization. Up to 40 resources are supported. +// WARN: DO NOT CHANGE VALUES!!! +var Resources = map[string]uint64{ + "registry": 1, + "node": 1 << 1, + "network": 1 << 2, + "service": 1 << 3, + "task": 1 << 4, + "stack": 1 << 5, + "config": 1 << 6, + "secret": 1 << 7, + "image": 1 << 8, + "container": 1 << 9, + "volume": 1 << 10, + "user": 1 << 11, + "role": 1 << 12, + "chart": 1 << 13, + "dashboard": 1 << 14, + "event": 1 << 15, + "setting": 1 << 16, } -func NewAuthorizer(ub biz.UserBiz, rb biz.RoleBiz) web.Filter { - v := cache.Value{ - TTL: 5 * time.Minute, - Load: func() (interface{}, error) { return loadPerms(rb) }, - } - return &Authorizer{ - ub: ub, - perms: &v, - logger: log.Get("security"), - } +// Actions holds all actions requiring authorization. Up to 24 actions are supported. +// WARN: DO NOT CHANGE VALUES!!! +var Actions = map[string]uint64{ + "view": 1, + "edit": 1 << 1, + "delete": 1 << 2, + "disconnect": 1 << 3, + "restart": 1 << 4, + "rollback": 1 << 5, + "logs": 1 << 6, + "deploy": 1 << 7, + "shutdown": 1 << 8, + "execute": 1 << 9, +} + +var Perms = map[string][]string{ + "registry": {"view", "edit", "delete"}, + "node": {"view", "edit", "delete"}, + "network": {"view", "edit", "delete", "disconnect"}, + "service": {"view", "edit", "delete", "restart", "rollback", "logs"}, + "task": {"view", "logs"}, + "stack": {"view", "edit", "delete", "deploy", "shutdown"}, + "config": {"view", "edit", "delete"}, + "secret": {"view", "edit", "delete"}, + "image": {"view", "delete"}, + "container": {"view", "delete", "logs", "execute"}, + "volume": {"view", "edit", "delete"}, + "user": {"view", "edit", "delete"}, + "role": {"view", "edit", "delete"}, + "chart": {"view", "edit", "delete"}, + "dashboard": {"edit"}, + "event": {"view"}, + "setting": {"view", "edit"}, +} + +type Authorizer struct { +} + +func NewAuthorizer() Authorizer { + return Authorizer{} } // Apply implements `web.Filter` interface. -func (a *Authorizer) Apply(next web.HandlerFunc) web.HandlerFunc { +func (p Authorizer) Apply(next web.HandlerFunc) web.HandlerFunc { return func(ctx web.Context) error { auth := ctx.Handler().Authorize() @@ -43,65 +88,65 @@ func (a *Authorizer) Apply(next web.HandlerFunc) web.HandlerFunc { return web.NewError(http.StatusUnauthorized, "You are not logged in") } - if auth != web.AuthAuthenticated && !a.check(user, auth) { + if auth != web.AuthAuthenticated && !p.check(user, auth) { return web.NewError(http.StatusForbidden, "You do not have access to this resource") } return next(ctx) } } -func (a *Authorizer) check(user web.User, auth string) bool { - u, err := a.ub.FindByID(user.ID()) - if err != nil { - a.logger.Errorf("failed to query user '%s': %s", user.ID(), err) - return false - } - - if u == nil || u.Status == biz.UserStatusBlocked { - return false - } else if u.Admin { +func (p Authorizer) check(user web.User, auth string) bool { + u := user.(*User) + if u.admin { return true - } else if auth == web.AuthAdministrator { - return u.Admin } + return u.perm.Contains(auth) +} - v, err := a.perms.Get(true) - if err != nil { - a.logger.Error("failed to load role perms: ", err) - return false - } +type PermMap uint64 - perms := v.(map[string]PermSet) - for _, r := range u.Roles { - if set, ok := perms[r]; ok { - if set.Contains(auth) { - return true - } +func NewPermMap(perms []string) PermMap { + var p uint64 + for _, perm := range perms { + pair := strings.SplitN(perm, ".", 2) + if len(pair) == 2 { + r, a := Resources[pair[0]], Actions[pair[1]] + p |= r << ActionBits + p |= a } } + return PermMap(p) +} + +func (p PermMap) Contains(perm string) bool { + pair := strings.SplitN(perm, ".", 2) + if len(pair) == 2 { + r, a := Resources[pair[0]], Actions[pair[1]] + return uint64(p)&(r< 0 && uint64(p)&a > 0 + } return false } -func loadPerms(rb biz.RoleBiz) (interface{}, error) { - roles, err := rb.Search("") - if err != nil { - return nil, err - } - - perms := make(map[string]PermSet) - for _, role := range roles { - set := make(PermSet) - for _, p := range role.Perms { - set[p] = struct{}{} - } - perms[role.ID] = set - } - return perms, nil +type User struct { + token string + id string + name string + admin bool + perm PermMap } -type PermSet map[string]struct{} - -func (s PermSet) Contains(perm string) (ok bool) { - _, ok = s[perm] - return +func (u *User) Token() string { + return u.token +} + +func (u *User) ID() string { + return u.id +} + +func (u *User) Name() string { + return u.name +} + +func (u *User) Anonymous() bool { + return u.id == "" } diff --git a/security/security.go b/security/security.go index 3eb8895..9fcd458 100644 --- a/security/security.go +++ b/security/security.go @@ -2,116 +2,11 @@ package security import ( "github.com/cuigh/auxo/app/container" - "github.com/cuigh/auxo/security" - "github.com/cuigh/auxo/security/certify" - "github.com/cuigh/auxo/security/certify/ldap" - "github.com/cuigh/auxo/security/passwd" - "github.com/cuigh/swirl/biz" - "github.com/cuigh/swirl/misc" - "github.com/cuigh/swirl/model" ) -type Authenticator struct { - ub biz.UserBiz - realms []RealmFunc -} - -func NewAuthenticator(s *misc.Setting, ub biz.UserBiz) *Authenticator { - return &Authenticator{ - ub: ub, - realms: []RealmFunc{internalRealm(), ldapRealm(s, ub)}, - } -} - -func (a *Authenticator) Login(loginName, password string) (user security.User, err error) { - privacy, err := a.ub.FindPrivacy(loginName) - if err != nil { - return nil, err - } - - if privacy != nil && privacy.Status == biz.UserStatusBlocked { - return nil, misc.Error(misc.ErrAccountDisabled, certify.ErrAccountDisabled) - } - - for _, login := range a.realms { - user, err = login(privacy, loginName, password) - if user != nil && err == nil { - return - } - } - - if err == nil { - err = misc.Error(misc.ErrInvalidToken, certify.ErrInvalidToken) - } - return -} - -type RealmFunc func(u *biz.UserPrivacy, loginName, password string) (security.User, error) - -func internalRealm() RealmFunc { - return func(u *biz.UserPrivacy, loginName, password string) (security.User, error) { - if u == nil || u.Type != biz.UserTypeInternal { - return nil, nil - } - - if passwd.Validate(password, u.Password, u.Salt) { - return security.NewUser(u.ID, u.Name), nil - } - return nil, misc.Error(misc.ErrInvalidToken, certify.ErrInvalidToken) - } -} - -func ldapRealm(s *misc.Setting, ub biz.UserBiz) RealmFunc { - const authBind = "bind" - var r certify.Realm - - if s.LDAP.Enabled { - opts := []ldap.Option{ - ldap.NameAttr(s.LDAP.NameAttr), - ldap.EmailAttr(s.LDAP.EmailAttr), - ldap.UserFilter(s.LDAP.UserFilter), - ldap.Security(ldap.SecurityPolicy(s.LDAP.Security)), - } - if s.LDAP.Authentication == authBind { - opts = append(opts, ldap.Binding(s.LDAP.BindDN, s.LDAP.BindPassword)) - } - r = ldap.New(s.LDAP.Address, s.LDAP.BaseDN, s.LDAP.UserDN, opts...) - } - - return func(u *biz.UserPrivacy, loginName, password string) (security.User, error) { - if r == nil || (u != nil && u.Type != biz.UserTypeLDAP) { - return nil, nil - } - - user, err := r.Login(certify.NewSimpleToken(loginName, password)) - if err != nil { - return nil, err - } - - var ( - id string - lu = user.(*ldap.User) - ) - if u == nil { - id, err = ub.Create(&model.User{ - Type: biz.UserTypeLDAP, - LoginName: loginName, - Name: lu.Name(), - Email: lu.Email(), - }, nil) - if err != nil { - return nil, err - } - lu.SetID(id) - } else { - lu.SetID(u.ID) - } - return user, nil - } -} +const PkgName = "security" func init() { - container.Put(NewAuthenticator) container.Put(NewIdentifier, container.Name("identifier")) container.Put(NewAuthorizer, container.Name("authorizer")) } diff --git a/ui/src/api/ajax.ts b/ui/src/api/ajax.ts index 078c34d..36bac4a 100644 --- a/ui/src/api/ajax.ts +++ b/ui/src/api/ajax.ts @@ -25,23 +25,19 @@ class Ajax { this.ajax.interceptors.request.use( (config: any) => { - if (store.state.token) { - config.headers.Authorization = "Bearer " + store.state.token + if (store.state.user?.token) { + config.headers.Authorization = "Bearer " + store.state.user.token } // store.commit(Mutations.SetAjaxLoading, true); return config; }, (error: any) => { - console.error(error); // for debug return Promise.reject(error); } ) this.ajax.interceptors.response.use( (response: any) => { - if (response.headers.authorization) { - store.commit(Mutations.SetToken, response.headers.authorization) - } // store.commit(Mutations.SetAjaxLoading, false); return response; }, diff --git a/ui/src/api/chart.ts b/ui/src/api/chart.ts index 35e7922..d71339c 100644 --- a/ui/src/api/chart.ts +++ b/ui/src/api/chart.ts @@ -32,29 +32,6 @@ export interface Chart { }; } -export interface Dashboard { - name: string; - key: string; - period: number; - interval: number; - charts: ChartInfo[]; -} - -export interface ChartInfo { - id: string; - title: string; - type: 'line' | 'bar' | 'pie' | 'gauge'; - unit: string; - width: number; - height: number; - margin: { - left?: number; - right?: number; - top?: number; - bottom?: number; - }; -} - export interface SearchArgs { name?: string; dashboard?: string; @@ -83,18 +60,6 @@ export class ChartApi { delete(id: string, title: string) { return ajax.post>('/chart/delete', { id, title }) } - - fetchData(key: string, charts: string[], period: number) { - return ajax.get('/chart/fetch-data', { key, charts: charts.join(","), period }) - } - - findDashboard(name: string, key: string) { - return ajax.get('/chart/find-dashboard', { name, key }) - } - - saveDashboard(dashboard: Dashboard) { - return ajax.post>('/chart/save-dashboard', dashboard) - } } export default new ChartApi diff --git a/ui/src/api/dashboard.ts b/ui/src/api/dashboard.ts new file mode 100644 index 0000000..37325f4 --- /dev/null +++ b/ui/src/api/dashboard.ts @@ -0,0 +1,40 @@ +import ajax, { Result } from './ajax' + +export interface Dashboard { + name: string; + key: string; + period: number; + interval: number; + charts: ChartInfo[]; +} + +export interface ChartInfo { + id: string; + title: string; + type: 'line' | 'bar' | 'pie' | 'gauge'; + unit: string; + width: number; + height: number; + margin: { + left?: number; + right?: number; + top?: number; + bottom?: number; + }; +} + +export class DashboardApi { + fetchData(key: string, charts: string[], period: number) { + return ajax.get('/dashboard/fetch-data', { key, charts: charts.join(","), period }) + } + + find(name: string, key: string) { + return ajax.get('/dashboard/find', { name, key }) + } + + save(dashboard: Dashboard) { + return ajax.post>('/dashboard/save', dashboard) + } +} + +export default new DashboardApi diff --git a/ui/src/api/user.ts b/ui/src/api/user.ts index cb6a275..2fd6366 100644 --- a/ui/src/api/user.ts +++ b/ui/src/api/user.ts @@ -2,8 +2,8 @@ import ajax, { Result } from './ajax' export interface AuthUser { token: string; - id: string; name: string; + perms: string[]; } export interface User { diff --git a/ui/src/components/Dashboard.vue b/ui/src/components/Dashboard.vue index d6f1ae1..eda0518 100644 --- a/ui/src/components/Dashboard.vue +++ b/ui/src/components/Dashboard.vue @@ -89,8 +89,10 @@ import { import { XChart } from "@/components/chart"; import dragula from 'dragula' import 'dragula/dist/dragula.css' -import chartApi, { ChartInfo } from "@/api/chart"; -import type { Chart, Dashboard } from "@/api/chart"; +import dashboardApi from "@/api/dashboard"; +import type { Dashboard, ChartInfo } from "@/api/dashboard"; +import chartApi from "@/api/chart"; +import type { Chart } from "@/api/chart"; import { isEmpty, useTimer } from "@/utils"; import { useI18n } from 'vue-i18n' @@ -170,7 +172,7 @@ const setChartRefs = (c: any) => c && charts.set(c.id, c) var stop: () => void async function saveDashboard() { - await chartApi.saveDashboard(dashboard.value); + await dashboardApi.save(dashboard.value); window.message.success(t('texts.action_success')) } @@ -219,7 +221,7 @@ function removeChart(id: string) { async function refreshData() { if (dashboard.value != null && !isEmpty(dashboard.value.charts)) { const ids = dashboard.value.charts.map(i => i.id) - const r = await chartApi.fetchData(props.name, ids, dashboard.value.period || 30); + const r = await dashboardApi.fetchData(props.name, ids, dashboard.value.period || 30); charts.forEach((v, k) => { r.data[k] && v.setData(r.data[k]) }) @@ -258,7 +260,7 @@ function sortCharts() { } onMounted(() => { - chartApi.findDashboard(props.type, props.name).then(r => { + dashboardApi.find(props.type, props.name).then(r => { dashboard.value = r.data as Dashboard initDrag() stop = useTimer(refreshData, (dashboard.value.interval || 10) * 1000) diff --git a/ui/src/layouts/Default.vue b/ui/src/layouts/Default.vue index 0481b87..5d578dc 100644 --- a/ui/src/layouts/Default.vue +++ b/ui/src/layouts/Default.vue @@ -64,7 +64,7 @@ - {{ store.state.name }} + {{ store.state.user?.name }} diff --git a/ui/src/locales/en.ts b/ui/src/locales/en.ts index ba77a00..28e3bda 100644 --- a/ui/src/locales/en.ts +++ b/ui/src/locales/en.ts @@ -40,7 +40,7 @@ export default { "logs": "View logs", "deploy": "Deploy", "shutdown": "Shutdown", - "dashboard": "Dashboard", + "execute": "Execute", }, "fields": { "home": "Home", @@ -253,6 +253,7 @@ export default { "user": "User | Users", "role": "Role | Roles", "chart": "Chart | Charts", + "dashboard": "Dashboard | Dashboards", "event": "Event | Events", "setting": "Setting | Settings", }, diff --git a/ui/src/locales/zh.ts b/ui/src/locales/zh.ts index 8cbcbab..b6ec307 100644 --- a/ui/src/locales/zh.ts +++ b/ui/src/locales/zh.ts @@ -40,7 +40,7 @@ export default { "logs": "查看日志", "deploy": "发布", "shutdown": "下线", - "dashboard": "仪表盘", + "execute": "执行", }, "fields": { "home": "首页", @@ -253,6 +253,7 @@ export default { "user": "用户", "role": "角色", "chart": "图表", + "dashboard": "仪表盘", "event": "事件", "setting": "设置", }, diff --git a/ui/src/pages/Login.vue b/ui/src/pages/Login.vue index b08cd93..b6f99eb 100644 --- a/ui/src/pages/Login.vue +++ b/ui/src/pages/Login.vue @@ -74,7 +74,7 @@ const rules = { password: requiredRule(), }; const { submit, submiting } = useForm(form, () => userApi.login(model), (user: AuthUser) => { - store.commit(Mutations.Login, user); + store.commit(Mutations.SetUser, user); let redirect = decodeURIComponent(route.query.redirect || "/"); router.push({ path: redirect }); }) diff --git a/ui/src/pages/container/View.vue b/ui/src/pages/container/View.vue index bd28121..a8aaf0c 100644 --- a/ui/src/pages/container/View.vue +++ b/ui/src/pages/container/View.vue @@ -52,10 +52,12 @@ - + + {{ t('texts.403') }} - + + {{ t('texts.403') }} @@ -71,8 +73,10 @@ import { NTable, NTabs, NTabPane, + NAlert, } from "naive-ui"; import { ArrowBackCircleOutline as BackIcon } from "@vicons/ionicons5"; +import { useStore } from "vuex"; import XPageHeader from "@/components/PageHeader.vue"; import XCode from "@/components/Code.vue"; import XPanel from "@/components/Panel.vue"; @@ -86,6 +90,7 @@ import { useI18n } from 'vue-i18n' const { t } = useI18n() const route = useRoute(); +const store = useStore(); const model = ref({} as Container); const raw = ref(''); const node = route.params.node as string || ''; diff --git a/ui/src/pages/container/modules/Execute.vue b/ui/src/pages/container/modules/Execute.vue index 5c42694..2298268 100644 --- a/ui/src/pages/container/modules/Execute.vue +++ b/ui/src/pages/container/modules/Execute.vue @@ -19,6 +19,7 @@ import { NInputGroup, NInputGroupLabel, } from "naive-ui"; +import { useStore } from "vuex"; import 'xterm/css/xterm.css' import { Terminal } from 'xterm' import { FitAddon } from 'xterm-addon-fit' @@ -26,6 +27,7 @@ import { AttachAddon } from 'xterm-addon-attach' import { useI18n } from 'vue-i18n' const { t } = useI18n() +const store = useStore() const props = defineProps({ node: { type: String, @@ -49,9 +51,9 @@ function connect() { active.value = true let protocol = (location.protocol === "https:") ? "wss://" : "ws://"; - let host = import.meta.env.DEV ? 'localhost:8002' : location.host; + let host = import.meta.env.DEV ? 'localhost:8001' : location.host; let cmd = encodeURIComponent(command.value) - socket = new WebSocket(`${protocol}${host}/api/container/connect?node=${props.node}&id=${props.id}&cmd=${cmd}`); + socket = new WebSocket(`${protocol}${host}/api/container/connect?token=${store.state.user.token}&node=${props.node}&id=${props.id}&cmd=${cmd}`); socket.onopen = () => { const fit = new FitAddon(); term = new Terminal({ fontSize: 14, cursorBlink: true }); diff --git a/ui/src/pages/role/Edit.vue b/ui/src/pages/role/Edit.vue index dac17e3..078ba63 100644 --- a/ui/src/pages/role/Edit.vue +++ b/ui/src/pages/role/Edit.vue @@ -27,7 +27,11 @@ {{ t('objects.' + g.key) }} - + @@ -112,18 +116,21 @@ const { submit, submiting } = useForm(form, () => roleApi.save(model.value), () function checkGroup(key: string, checked: boolean = true) { const g = perms.find(g => g.key === key) - if (g) { - g.items.forEach(p => { - if (checked) { - !model.value.perms.includes(p.key) && model.value.perms.push(p.key) - } else { - const index = model.value.perms.indexOf(p.key) - if (index !== -1) { - model.value.perms.splice(index, 1) - } - } - }) + if (!g) { + return } + + g.actions.forEach(action => { + const perm = g.key + '.' + action + if (checked) { + !model.value.perms.includes(perm) && model.value.perms.push(perm) + } else { + const index = model.value.perms.indexOf(perm) + if (index !== -1) { + model.value.perms.splice(index, 1) + } + } + }) } async function fetchData() { diff --git a/ui/src/pages/role/View.vue b/ui/src/pages/role/View.vue index 06ba987..5dd952c 100644 --- a/ui/src/pages/role/View.vue +++ b/ui/src/pages/role/View.vue @@ -33,14 +33,14 @@ - - + + + - - + @@ -57,6 +57,7 @@ import { import { useRoute } from "vue-router"; import { ArrowBackCircleOutline as BackIcon } from "@vicons/ionicons5"; import XPageHeader from "@/components/PageHeader.vue"; +import XPanel from "@/components/Panel.vue"; import XPairTag from "@/components/PairTag.vue"; import XAnchor from "@/components/Anchor.vue"; import { XDescription, XDescriptionItem } from "@/components/description"; @@ -72,7 +73,7 @@ const ps = computed(() => { const set = new Set(model.value.perms) const arr: any = [] perms.forEach(g => { - const items = g.items.filter(p => set.has(p.key)).map(p => t('perms.' + p.perm)) + const items = g.actions.filter(action => set.has(g.key + '.' + action)).map(p => t('perms.' + p)) items.length && arr.push({ group: t('objects.' + g.key), items: items }) }) return arr diff --git a/ui/src/pages/service/View.vue b/ui/src/pages/service/View.vue index aa5aefb..2f39cc2 100644 --- a/ui/src/pages/service/View.vue +++ b/ui/src/pages/service/View.vue @@ -471,7 +471,8 @@ - + + {{ t('texts.403') }} @@ -491,8 +492,10 @@ import { NTabs, NTabPane, NInputNumber, + NAlert, } from "naive-ui"; import { ArrowBackCircleOutline as BackIcon } from "@vicons/ionicons5"; +import { useStore } from "vuex"; import XPageHeader from "@/components/PageHeader.vue"; import XAnchor from "@/components/Anchor.vue"; import XPairTag from "@/components/PairTag.vue"; @@ -513,6 +516,7 @@ import { useI18n } from 'vue-i18n' const { t } = useI18n() const route = useRoute(); +const store = useStore(); const service = ref({ resource: { limit: {}, diff --git a/ui/src/pages/task/View.vue b/ui/src/pages/task/View.vue index 9f338eb..69b15d2 100644 --- a/ui/src/pages/task/View.vue +++ b/ui/src/pages/task/View.vue @@ -93,7 +93,8 @@ - + + {{ t('texts.403') }} @@ -110,8 +111,10 @@ import { NTabs, NTabPane, NText, + NAlert, } from "naive-ui"; import { ArrowBackCircleOutline as BackIcon } from "@vicons/ionicons5"; +import { useStore } from "vuex"; import XPageHeader from "@/components/PageHeader.vue"; import XAnchor from "@/components/Anchor.vue"; import XCode from "@/components/Code.vue"; @@ -125,6 +128,7 @@ import { useI18n } from 'vue-i18n' const { t } = useI18n() const route = useRoute(); +const store = useStore(); const model = ref({} as Task); const raw = ref(''); diff --git a/ui/src/pages/user/View.vue b/ui/src/pages/user/View.vue index 36824a3..e6821f5 100644 --- a/ui/src/pages/user/View.vue +++ b/ui/src/pages/user/View.vue @@ -78,7 +78,7 @@ \ No newline at end of file diff --git a/ui/src/router/router.ts b/ui/src/router/router.ts index bfaacc4..e83f76e 100644 --- a/ui/src/router/router.ts +++ b/ui/src/router/router.ts @@ -23,6 +23,9 @@ const routes: RouteRecordRaw[] = [ name: 'home', path: "/", component: () => import('../pages/Home.vue'), + meta: { + auth: '?', + } }, { name: 'login', @@ -30,7 +33,7 @@ const routes: RouteRecordRaw[] = [ component: LoginPage, meta: { layout: "empty", - anonymous: true, + auth: '*', } }, { @@ -39,68 +42,104 @@ const routes: RouteRecordRaw[] = [ component: InitPage, meta: { layout: "empty", - anonymous: true, + auth: '*', } }, { name: 'profile', path: "/profile", component: () => import('../pages/Profile.vue'), + meta: { + auth: '?', + } }, { name: 'node_list', path: "/swarm/nodes", component: () => import('../pages/node/List.vue'), + meta: { + auth: 'node.view', + } }, { name: 'node_detail', path: "/swarm/nodes/:id", component: () => import('../pages/node/View.vue'), + meta: { + auth: 'node.view', + } }, { name: 'node_edit', path: "/swarm/nodes/:id/edit", component: () => import('../pages/node/Edit.vue'), + meta: { + auth: 'node.edit', + } }, { name: 'registry_list', path: "/swarm/registries", component: () => import('../pages/registry/List.vue'), + meta: { + auth: 'registry.view', + } }, { name: 'registry_detail', path: "/swarm/registries/:id", component: () => import('../pages/registry/View.vue'), + meta: { + auth: 'registry.view', + } }, { name: 'registry_new', path: "/swarm/registries/new", component: () => import('../pages/registry/Edit.vue'), + meta: { + auth: 'registry.edit', + } }, { name: 'registry_edit', path: "/swarm/registries/:id/edit", component: () => import('../pages/registry/Edit.vue'), + meta: { + auth: 'registry.edit', + } }, { name: 'network_list', path: "/swarm/networks", component: () => import('../pages/network/List.vue'), + meta: { + auth: 'network.view', + } }, { name: 'network_new', path: "/swarm/networks/new", component: () => import('../pages/network/New.vue'), + meta: { + auth: 'network.edit', + } }, { name: 'network_detail', path: "/swarm/networks/:name", component: () => import('../pages/network/View.vue'), + meta: { + auth: 'network.view', + } }, { name: "service_list", path: "/swarm/services", component: () => import('../pages/service/List.vue'), + meta: { + auth: 'service.view', + } }, { name: "service_detail", @@ -130,176 +169,281 @@ const routes: RouteRecordRaw[] = [ name: "task_list", path: "/swarm/tasks", component: () => import('../pages/task/List.vue'), + meta: { + auth: 'task.view', + } }, { name: "task_detail", path: "/swarm/tasks/:id", component: () => import('../pages/task/View.vue'), + meta: { + auth: 'task.view', + } }, { name: "config_list", path: "/swarm/configs", component: () => import('../pages/config/List.vue'), + meta: { + auth: 'config.view', + } }, { name: "config_detail", path: "/swarm/configs/:id", component: () => import('../pages/config/View.vue'), + meta: { + auth: 'config.view', + } }, { name: "config_new", path: "/swarm/configs/new", component: () => import('../pages/config/Edit.vue'), + meta: { + auth: 'config.edit', + } }, { name: "config_edit", path: "/swarm/configs/:id/edit", component: () => import('../pages/config/Edit.vue'), + meta: { + auth: 'config.edit', + } }, { name: "secret_list", path: "/swarm/secrets", component: () => import('../pages/secret/List.vue'), + meta: { + auth: 'secret.view', + } }, { name: "secret_detail", path: "/swarm/secrets/:id", component: () => import('../pages/secret/View.vue'), + meta: { + auth: 'secret.view', + } }, { name: "secret_new", path: "/swarm/secrets/new", component: () => import('../pages/secret/Edit.vue'), + meta: { + auth: 'secret.edit', + } }, { name: "secret_edit", path: "/swarm/secrets/:id/edit", component: () => import('../pages/secret/Edit.vue'), + meta: { + auth: 'secret.edit', + } }, { name: "stack_list", path: "/swarm/stacks", component: () => import('../pages/stack/List.vue'), + meta: { + auth: 'stack.view', + } }, { name: "stack_detail", path: "/swarm/stacks/:name", component: () => import('../pages/stack/View.vue'), + meta: { + auth: 'stack.view', + } }, { name: "stack_new", path: "/swarm/stacks/new", component: () => import('../pages/stack/Edit.vue'), + meta: { + auth: 'stack.edit', + } }, { name: "stack_edit", path: "/swarm/stacks/:name/edit", component: () => import('../pages/stack/Edit.vue'), + meta: { + auth: 'stack.edit', + } }, { name: "image_list", path: "/local/images", component: () => import('../pages/image/List.vue'), + meta: { + auth: 'image.view', + } }, { name: "image_detail", path: "/local/images/:node/:id", component: () => import('../pages/image/View.vue'), + meta: { + auth: 'image.view', + } }, { name: "container_list", path: "/local/containers", component: () => import('../pages/container/List.vue'), + meta: { + auth: 'container.view', + } }, { name: "container_detail", path: "/local/containers/:node/:id", component: () => import('../pages/container/View.vue'), + meta: { + auth: 'container.view', + } }, { name: "volume_list", path: "/local/volumes", component: () => import('../pages/volume/List.vue'), + meta: { + auth: 'volume.view', + } }, { name: "volume_detail", path: "/local/volumes/:node/:name", component: () => import('../pages/volume/View.vue'), + meta: { + auth: 'volume.view', + } }, { name: "volume_new", path: "/local/volumes/:node/new", component: () => import('../pages/volume/New.vue'), + meta: { + auth: 'volume.edit', + } }, { name: "user_list", path: "/system/users", component: () => import('../pages/user/List.vue'), + meta: { + auth: 'user.view', + } }, { name: "user_new", path: "/system/users/new", component: () => import('../pages/user/Edit.vue'), + meta: { + auth: 'user.edit', + } }, { name: "user_detail", path: "/system/users/:id", component: () => import('../pages/user/View.vue'), + meta: { + auth: 'user.view', + } }, { name: "user_edit", path: "/system/users/:id/edit", component: () => import('../pages/user/Edit.vue'), + meta: { + auth: 'user.edit', + } }, { name: "role_list", path: "/system/roles", component: () => import('../pages/role/List.vue'), + meta: { + auth: 'role.view', + } }, { name: "role_new", path: "/system/roles/new", component: () => import('../pages/role/Edit.vue'), + meta: { + auth: 'role.edit', + } }, { name: "role_detail", path: "/system/roles/:id", component: () => import('../pages/role/View.vue'), + meta: { + auth: 'role.view', + } }, { name: "role_edit", path: "/system/roles/:id/edit", component: () => import('../pages/role/Edit.vue'), + meta: { + auth: 'role.edit', + } }, { name: "event_list", path: "/system/events", component: () => import('../pages/event/List.vue'), + meta: { + auth: 'event.view', + } }, { name: "chart_list", path: "/system/charts", component: () => import('../pages/chart/List.vue'), + meta: { + auth: 'chart.view', + } }, { name: "chart_detail", path: "/system/charts/:id", component: () => import('../pages/chart/View.vue'), + meta: { + auth: 'chart.view', + } }, { name: "chart_new", path: "/system/charts/new", component: () => import('../pages/chart/Edit.vue'), + meta: { + auth: 'chart.edit', + } }, { name: "chart_edit", path: "/system/charts/:id/edit", component: () => import('../pages/chart/Edit.vue'), + meta: { + auth: 'chart.edit', + } }, { name: "setting", path: "/system/settings", component: () => import('../pages/setting/Setting.vue'), + meta: { + auth: 'setting.view', + } }, { name: '403', @@ -307,7 +451,7 @@ const routes: RouteRecordRaw[] = [ component: ForbiddenPage, meta: { layout: "simple", - anonymous: true, + auth: '*', } }, { @@ -316,7 +460,7 @@ const routes: RouteRecordRaw[] = [ component: NotFoundPage, meta: { layout: "simple", - anonymous: true, + auth: '*', } }, { @@ -338,13 +482,15 @@ function createSiteRouter() { window.document.title = t(`titles.${to.name as string}`) + ' - Swirl' } - if (to.matched.some(record => !record.meta.anonymous)) { - // this route requires auth, if not logged in, redirect to login page. + const auth = to.meta.auth || '*' + if (auth !== '*') { if (store.getters.anonymous) { - next({ - path: '/login', - query: { redirect: to.fullPath } - }) + next({ name: 'login', query: { redirect: to.fullPath } }) + return + } + + if (auth !== '?' && !store.getters.allow(auth)) { + next({ name: '403' }) return } } diff --git a/ui/src/store/index.ts b/ui/src/store/index.ts index 997040c..52cdb35 100644 --- a/ui/src/store/index.ts +++ b/ui/src/store/index.ts @@ -3,9 +3,14 @@ import { Mutations } from "./mutations"; const debug = import.meta.env.DEV +interface User { + name: string; + token: string; + perms: Set; +} + export interface State { - name: string | null; - token: string | null; + user?: User | null; preference: { theme: string | null; locale: string | null; @@ -13,21 +18,23 @@ export interface State { ajaxLoading: boolean; } -function initState(): State { - const state: any = { - name: localStorage.getItem("name"), - token: localStorage.getItem("token"), - ajaxLoading: false, - } - - const locale = navigator.language.startsWith('zh') ? 'zh' : 'en' - state.preference = { theme: 'light', locale: locale } +function loadObject(key: string) { + let value = null try { - state.preference = Object.assign(state.preference, JSON.parse(localStorage.getItem("preference") as string)) + value = JSON.parse(localStorage.getItem(key) as string) } catch { } - - return state + return value +} + +function initState(): State { + const user = Object.assign({}, loadObject('user')) + const locale = navigator.language.startsWith('zh') ? 'zh' : 'en' + return { + user: { perms: new Set(user.perms), name: user.name, token: user.token }, + preference: Object.assign({ theme: 'light', locale: locale }, loadObject('preference')), + ajaxLoading: false, + } } export const store = createStore({ @@ -35,25 +42,20 @@ export const store = createStore({ state: initState(), getters: { anonymous(state) { - return !state.token - } + return !state.user?.token + }, + allow(state) { + return (perm: string) => state.user?.perms?.has('*') || state.user?.perms?.has(perm) + }, }, mutations: { - [Mutations.Login](state, user) { - localStorage.setItem("name", user.name); - localStorage.setItem("token", user.token); - state.name = user.name; - state.token = user.token; - }, [Mutations.Logout](state) { - localStorage.removeItem("name"); - localStorage.removeItem("token"); - state.name = null; - state.token = null; + localStorage.removeItem("user"); + state.user = null; }, - [Mutations.SetToken](state, token) { - localStorage.setItem("token", token); - state.token = token; + [Mutations.SetUser](state, user) { + localStorage.setItem("user", JSON.stringify(user)); + state.user = { perms: new Set(user.perms), name: user.name, token: user.token }; }, [Mutations.SetPreference](state, preference) { localStorage.setItem("preference", JSON.stringify(preference)); diff --git a/ui/src/store/mutations.ts b/ui/src/store/mutations.ts index 9bff006..dec51ac 100644 --- a/ui/src/store/mutations.ts +++ b/ui/src/store/mutations.ts @@ -1,7 +1,6 @@ export enum Mutations { - Login = "LOGIN", Logout = "LOGOUT", - SetToken = "SET_TOKEN", - SetPreference = "SET_THEME", + SetUser = "SET_USER", + SetPreference = "SET_PREFERENCE", SetAjaxLoading = "SET_AJAX_LOADING", } \ No newline at end of file diff --git a/ui/src/utils/perm.ts b/ui/src/utils/perm.ts index 97ef98d..472242a 100644 --- a/ui/src/utils/perm.ts +++ b/ui/src/utils/perm.ts @@ -1,137 +1,70 @@ export const perms = [ { key: 'registry', - items: [ - { key: "registry.view", perm: "view" }, - { key: "registry.edit", perm: "edit" }, - { key: "registry.delete", perm: "delete" }, - ], + actions: ['view', 'edit', 'delete'], }, { key: 'node', - items: [ - { key: "node.view", perm: "view" }, - { key: "node.edit", perm: "edit" }, - { key: "node.delete", perm: "delete" }, - ], + actions: ['view', 'edit', 'delete'], }, { key: 'network', - items: [ - { key: "network.view", perm: "view" }, - { key: "network.edit", perm: "edit" }, - { key: "network.delete", perm: "delete" }, - { key: "network.disconnect", perm: "disconnect" }, - ], + actions: ['view', 'edit', 'delete', 'disconnect'], }, { key: 'service', - items: [ - { key: "service.view", perm: "view" }, - { key: "service.edit", perm: "edit" }, - { key: "service.delete", perm: "delete" }, - { key: "service.restart", perm: "restart" }, - { key: "service.rollback", perm: "rollback" }, - { key: "service.logs", perm: "logs" }, - ], + actions: ['view', 'edit', 'delete', 'restart', 'rollback', 'logs'], }, { key: 'task', - items: [ - { key: "task.view", perm: "view" }, - { key: "task.logs", perm: "logs" }, - ], + actions: ['view', 'logs'], }, { key: 'stack', - items: [ - { key: "stack.view", perm: "view" }, - { key: "stack.edit", perm: "edit" }, - { key: "stack.delete", perm: "delete" }, - { key: "stack.deploy", perm: "deploy" }, - { key: "stack.shutdown", perm: "shutdown" }, - ], + actions: ['view', 'edit', 'delete', 'deploy', 'shutdown'], }, { key: 'config', - items: [ - { key: "config.view", perm: "view" }, - { key: "config.edit", perm: "edit" }, - { key: "config.delete", perm: "delete" }, - ], + actions: ['view', 'edit', 'delete'], }, { key: 'secret', - items: [ - { key: "secret.view", perm: "view" }, - { key: "secret.edit", perm: "edit" }, - { key: "secret.delete", perm: "delete" }, - ], + actions: ['view', 'edit', 'delete'], }, { key: 'image', - items: [ - { key: "image.view", perm: "view" }, - { key: "image.delete", perm: "delete" }, - ], + actions: ['view', 'delete'], }, { key: 'container', - items: [ - { key: "container.view", perm: "view" }, - { key: "container.delete", perm: "delete" }, - { key: "container.logs", perm: "logs" }, - ], + actions: ['view', 'delete', 'logs', 'execute'], }, { key: 'volume', - items: [ - { key: "volume.view", perm: "view" }, - { key: "volume.edit", perm: "edit" }, - { key: "volume.delete", perm: "delete" }, - ], + actions: ['view', 'edit', 'delete'], }, { key: 'user', - items: [ - { key: "user.view", perm: "view" }, - { key: "user.edit", perm: "edit" }, - { key: "user.delete", perm: "delete" }, - ], + actions: ['view', 'edit', 'delete'], }, { key: 'role', - items: [ - { key: "role.view", perm: "view" }, - { key: "role.edit", perm: "edit" }, - { key: "role.delete", perm: "delete" }, - ], + actions: ['view', 'edit', 'delete'], }, { key: 'chart', - items: [ - { key: "chart.view", perm: "view" }, - { key: "chart.edit", perm: "edit" }, - { key: "chart.delete", perm: "delete" }, - { key: "chart.dashboard", perm: "dashboard" }, - ], + actions: ['view', 'edit', 'delete'], + }, + { + key: 'dashboard', + actions: ['edit'], }, { key: 'event', - items: [ - { key: "event.view", perm: "view" }, - ], + actions: ['view'], }, { key: 'setting', - items: [ - { key: "setting.view", perm: "view" }, - { key: "setting.edit", perm: "edit" }, - ], + actions: ['view', 'edit'], }, ] - -function contains(arr1: string[], arr2: string[]): boolean { - const set = new Set(arr1); - return arr2.every(s => set.has(s)) -} \ No newline at end of file diff --git a/ui/vite.config.ts b/ui/vite.config.ts index b36066f..b0bf8c0 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -28,7 +28,7 @@ export default defineConfig({ port: 3002, proxy: { '/api': { - target: 'http://localhost:8002', + target: 'http://localhost:8001', changeOrigin: true, }, }