Refactor authentication and authorization

This commit is contained in:
cuigh 2021-12-22 17:43:26 +08:00
parent dfe15524a2
commit 487d73d643
52 changed files with 1348 additions and 942 deletions

View File

@ -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"))
}

View File

@ -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)
}
}

View File

@ -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

82
api/dashboard.go Normal file
View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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(),
})
}
}

View File

@ -130,4 +130,6 @@ func init() {
container.Put(NewMetric)
container.Put(NewChart)
container.Put(NewSystem)
container.Put(NewSession)
container.Put(NewDashboard)
}

View File

@ -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
}

266
biz/dashboard.go Normal file
View File

@ -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
}

View File

@ -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

View File

@ -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
}

45
biz/session.go Normal file
View File

@ -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)
}

View File

@ -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 {

View File

@ -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

View File

@ -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:

View File

@ -3,7 +3,7 @@ banner: false
web:
entries:
- address: :8002
- address: :8001
authorize: '?'
swirl:

View File

@ -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
}

56
dao/bolt/session.go Normal file
View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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 {

38
dao/mongo/session.go Normal file
View File

@ -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)
}

View File

@ -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
}

1
go.mod
View File

@ -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

2
go.sum
View File

@ -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=

View File

@ -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)

View File

@ -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"`
}

306
security/auth.go Normal file
View File

@ -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
}

View File

@ -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)
}

View File

@ -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<<ActionBits) > 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 == ""
}

View File

@ -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"))
}

View File

@ -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;
},

View File

@ -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<Result<Object>>('/chart/delete', { id, title })
}
fetchData(key: string, charts: string[], period: number) {
return ajax.get<any>('/chart/fetch-data', { key, charts: charts.join(","), period })
}
findDashboard(name: string, key: string) {
return ajax.get<Dashboard>('/chart/find-dashboard', { name, key })
}
saveDashboard(dashboard: Dashboard) {
return ajax.post<Result<Object>>('/chart/save-dashboard', dashboard)
}
}
export default new ChartApi

40
ui/src/api/dashboard.ts Normal file
View File

@ -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<any>('/dashboard/fetch-data', { key, charts: charts.join(","), period })
}
find(name: string, key: string) {
return ajax.get<Dashboard>('/dashboard/find', { name, key })
}
save(dashboard: Dashboard) {
return ajax.post<Result<Object>>('/dashboard/save', dashboard)
}
}
export default new DashboardApi

View File

@ -2,8 +2,8 @@ import ajax, { Result } from './ajax'
export interface AuthUser {
token: string;
id: string;
name: string;
perms: string[];
}
export interface User {

View File

@ -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)

View File

@ -64,7 +64,7 @@
<PersonOutline />
</n-icon>
</template>
{{ store.state.name }}
{{ store.state.user?.name }}
</n-button>
</n-dropdown>
<n-tooltip trigger="hover">

View File

@ -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",
},

View File

@ -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": "设置",
},

View File

@ -74,7 +74,7 @@ const rules = {
password: requiredRule(),
};
const { submit, submiting } = useForm<AuthUser>(form, () => userApi.login(model), (user: AuthUser) => {
store.commit(Mutations.Login, user);
store.commit(Mutations.SetUser, user);
let redirect = decodeURIComponent(<string>route.query.redirect || "/");
router.push({ path: redirect });
})

View File

@ -52,10 +52,12 @@
<x-code :code="raw" language="json" />
</n-tab-pane>
<n-tab-pane name="logs" :tab="t('fields.logs')" display-directive="show:lazy">
<x-logs type="container" :node="node" :id="model.id"></x-logs>
<x-logs type="container" :node="node" :id="model.id" v-if="store.getters.allow('container.logs')"></x-logs>
<n-alert type="info" v-else>{{ t('texts.403') }}</n-alert>
</n-tab-pane>
<n-tab-pane name="exec" :tab="t('fields.execute')" display-directive="show:lazy">
<execute :node="node" :id="model.id"></execute>
<execute :node="node" :id="model.id" v-if="store.getters.allow('container.execute')"></execute>
<n-alert type="info" v-else>{{ t('texts.403') }}</n-alert>
</n-tab-pane>
</n-tabs>
</div>
@ -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 || '';

View File

@ -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 });

View File

@ -27,7 +27,11 @@
<td width="75" style="font-weight: 500">{{ t('objects.' + g.key) }}</td>
<td>
<n-space item-style="display: flex;">
<n-checkbox :value="p.key" :label="t('perms.' + p.perm)" v-for="p in g.items" />
<n-checkbox
:value="g.key + '.' + action"
:label="t('perms.' + action)"
v-for="action in g.actions"
/>
</n-space>
</td>
<td width="100">
@ -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() {

View File

@ -33,14 +33,14 @@
<x-description-item :label="t('fields.updated_at')">
<n-time :time="model.updatedAt" format="y-MM-dd HH:mm:ss" />
</x-description-item>
<x-description-item :span="2" :label="t('fields.perms')">
<n-grid cols="1 480:2 960:3 1440:4" x-gap="6">
</x-description>
<x-panel :title="t('fields.perms')">
<n-grid cols="1 640:2 960:3 1440:4" x-gap="6" y-gap="6">
<n-gi span="1" v-for="g in ps">
<x-pair-tag type="warning" :label="g.group" :value="g.items" />
</n-gi>
</n-grid>
</x-description-item>
</x-description>
</x-panel>
</n-space>
</template>
@ -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

View File

@ -471,7 +471,8 @@
</n-table>
</n-tab-pane>
<n-tab-pane name="logs" :tab="t('fields.logs')" display-directive="show:lazy">
<x-logs type="service" :id="service.name"></x-logs>
<x-logs type="service" :id="service.name" v-if="store.getters.allow('service.logs')"></x-logs>
<n-alert type="info" v-else>{{ t('texts.403') }}</n-alert>
</n-tab-pane>
<n-tab-pane name="status" :tab="t('fields.status')">
<x-dashboard type="service" :name="service.name" />
@ -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: {},

View File

@ -93,7 +93,8 @@
<x-code :code="raw" language="json" />
</n-tab-pane>
<n-tab-pane name="logs" :tab="t('fields.logs')" display-directive="show:lazy">
<x-logs type="task" :id="route.params.id as string"></x-logs>
<x-logs type="task" :id="route.params.id as string" v-if="store.getters.allow('task.logs')"></x-logs>
<n-alert type="info" v-else>{{ t('texts.403') }}</n-alert>
</n-tab-pane>
</n-tabs>
</div>
@ -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('');

View File

@ -78,7 +78,7 @@
</template>
<script setup lang="ts">
import { onMounted, reactive } from "vue";
import { onMounted, reactive, watch } from "vue";
import {
NButton,
NTag,
@ -110,5 +110,7 @@ async function fetchData() {
roles.data?.forEach(r => model.roles.set(r.id, r.name))
}
watch(() => route.params.id, fetchData)
onMounted(fetchData);
</script>

View File

@ -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
}
}

View File

@ -3,9 +3,14 @@ import { Mutations } from "./mutations";
const debug = import.meta.env.DEV
interface User {
name: string;
token: string;
perms: Set<string>;
}
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<State>({
@ -35,25 +42,20 @@ export const store = createStore<State>({
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));

View File

@ -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",
}

View File

@ -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))
}

View File

@ -28,7 +28,7 @@ export default defineConfig({
port: 3002,
proxy: {
'/api': {
target: 'http://localhost:8002',
target: 'http://localhost:8001',
changeOrigin: true,
},
}