console: rework a bit user management
Notably, HTTP headers are configurable and a provider is used for the frontend side.
@@ -21,9 +21,9 @@ func TestServeAssets(t *testing.T) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
r := reporter.NewMock(t)
|
||||
h := http.NewMock(t, r)
|
||||
c, err := New(r, Configuration{
|
||||
ServeLiveFS: live,
|
||||
}, Dependencies{
|
||||
conf := DefaultConfiguration()
|
||||
conf.ServeLiveFS = live
|
||||
c, err := New(r, conf, Dependencies{
|
||||
Daemon: daemon.NewMock(t),
|
||||
HTTP: h,
|
||||
})
|
||||
|
||||
35
console/authentication/config.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package authentication
|
||||
|
||||
// Configuration describes the configuration for the authentication component.
|
||||
type Configuration struct {
|
||||
// Headers define authentication headers
|
||||
Headers ConfigurationHeaders
|
||||
// DefaultUser define the default user when no authentication
|
||||
// headers are present. Leave `User' empty to not allow access
|
||||
// without authentication.
|
||||
DefaultUser UserInformation
|
||||
}
|
||||
|
||||
// ConfigurationHeaders define headers used for authentication
|
||||
type ConfigurationHeaders struct {
|
||||
Login string
|
||||
Name string
|
||||
Email string
|
||||
LogoutURL string
|
||||
}
|
||||
|
||||
// DefaultConfiguration represents the default configuration for the console component.
|
||||
func DefaultConfiguration() Configuration {
|
||||
return Configuration{
|
||||
Headers: ConfigurationHeaders{
|
||||
Login: "Remote-User",
|
||||
Name: "Remote-Name",
|
||||
Email: "Remote-Email",
|
||||
LogoutURL: "X-Logout-URL",
|
||||
},
|
||||
DefaultUser: UserInformation{
|
||||
Login: "default",
|
||||
Name: "Default User",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 301 B After Width: | Height: | Size: 301 B |
|
Before Width: | Height: | Size: 301 B After Width: | Height: | Size: 301 B |
|
Before Width: | Height: | Size: 301 B After Width: | Height: | Size: 301 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 301 B After Width: | Height: | Size: 301 B |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 765 B After Width: | Height: | Size: 765 B |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 964 B After Width: | Height: | Size: 964 B |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 301 B After Width: | Height: | Size: 301 B |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 301 B After Width: | Height: | Size: 301 B |
|
Before Width: | Height: | Size: 901 B After Width: | Height: | Size: 901 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1006 B After Width: | Height: | Size: 1006 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
93
console/authentication/handlers.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package authentication
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"embed"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"io/fs"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed data/avatars
|
||||
avatarParts embed.FS
|
||||
avatarRegexp = regexp.MustCompile(`^([a-z]+)_([0-9]+)\.png$`)
|
||||
)
|
||||
|
||||
// UserInfoHandlerFunc returns the information about the currently logged user.
|
||||
func (c *Component) UserInfoHandlerFunc(gc *gin.Context) {
|
||||
info := gc.MustGet("user").(UserInformation)
|
||||
gc.JSON(http.StatusOK, info)
|
||||
}
|
||||
|
||||
// UserAvatarHandlerFunc returns an avatar for the currently logger user.
|
||||
func (c *Component) UserAvatarHandlerFunc(gc *gin.Context) {
|
||||
// Hash user login as a source
|
||||
info := gc.MustGet("user").(UserInformation)
|
||||
hash := fnv.New64()
|
||||
hash.Write([]byte(info.Login))
|
||||
randSource := rand.New(rand.NewSource(int64(hash.Sum64())))
|
||||
|
||||
// Grab list of parts
|
||||
parts := []string{}
|
||||
partList, err := avatarParts.Open("data/avatars/partlist.txt")
|
||||
if err != nil {
|
||||
c.r.Err(err).Msg("cannot open partlist.txt")
|
||||
gc.JSON(http.StatusInternalServerError, gin.H{"message": "Cannot build avatar."})
|
||||
return
|
||||
}
|
||||
defer partList.Close()
|
||||
scanner := bufio.NewScanner(partList)
|
||||
for scanner.Scan() {
|
||||
parts = append(parts, scanner.Text())
|
||||
}
|
||||
|
||||
// Choose an image for each part
|
||||
for idx, part := range parts {
|
||||
// Choose a file for each part
|
||||
p, _ := fs.Glob(avatarParts, fmt.Sprintf("data/avatars/%s_*", part))
|
||||
if len(p) == 0 {
|
||||
c.r.Error().Msgf("missing part %s", part)
|
||||
gc.JSON(http.StatusInternalServerError, gin.H{"message": "Cannot build avatar."})
|
||||
return
|
||||
}
|
||||
parts[idx] = p[randSource.Intn(len(p))]
|
||||
}
|
||||
|
||||
// Compose the images
|
||||
var img *image.RGBA
|
||||
for _, part := range parts {
|
||||
filePart, err := avatarParts.Open(part)
|
||||
if err != nil {
|
||||
c.r.Err(err).Msgf("cannot open part %s", part)
|
||||
gc.JSON(http.StatusInternalServerError, gin.H{"message": "Cannot build avatar."})
|
||||
return
|
||||
}
|
||||
imgPart, err := png.Decode(filePart)
|
||||
filePart.Close()
|
||||
if err != nil {
|
||||
c.r.Err(err).Msgf("cannot decode part %s", part)
|
||||
gc.JSON(http.StatusInternalServerError, gin.H{"message": "Cannot build avatar."})
|
||||
return
|
||||
}
|
||||
if img == nil {
|
||||
img = image.NewRGBA(imgPart.Bounds())
|
||||
}
|
||||
draw.Draw(img, img.Bounds(), imgPart, imgPart.Bounds().Min, draw.Over)
|
||||
}
|
||||
|
||||
// Serve the result
|
||||
gc.Header("Content-Type", "image/png")
|
||||
gc.Header("Cache-Control", "max-age=86400")
|
||||
gc.Header("Vary", "Remote-User")
|
||||
gc.Status(http.StatusOK)
|
||||
png.Encode(gc.Writer, img)
|
||||
}
|
||||
121
console/authentication/handlers_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package authentication
|
||||
|
||||
import (
|
||||
netHTTP "net/http"
|
||||
"testing"
|
||||
|
||||
"akvorado/common/helpers"
|
||||
"akvorado/common/http"
|
||||
"akvorado/common/reporter"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestUserHandler(t *testing.T) {
|
||||
r := reporter.NewMock(t)
|
||||
h := http.NewMock(t, r)
|
||||
c, err := New(r, DefaultConfiguration())
|
||||
if err != nil {
|
||||
t.Fatalf("New() error:\n%+v", err)
|
||||
}
|
||||
|
||||
// Configure the two endpoints
|
||||
endpoint := h.GinRouter.Group("/api/v0/console/user", c.UserAuthentication())
|
||||
endpoint.GET("/info", c.UserInfoHandlerFunc)
|
||||
endpoint.GET("/avatar", c.UserAvatarHandlerFunc)
|
||||
|
||||
t.Run("default user configured", func(t *testing.T) {
|
||||
helpers.TestHTTPEndpoints(t, h.Address, helpers.HTTPEndpointCases{
|
||||
{
|
||||
Description: "user info, no user logged in",
|
||||
URL: "/api/v0/console/user/info",
|
||||
StatusCode: 200,
|
||||
JSONOutput: gin.H{"login": "default", "name": "Default User"},
|
||||
}, {
|
||||
Description: "user info, minimal user logged in",
|
||||
URL: "/api/v0/console/user/info",
|
||||
Header: func() netHTTP.Header {
|
||||
headers := make(netHTTP.Header)
|
||||
headers.Add("Remote-User", "alfred")
|
||||
return headers
|
||||
}(),
|
||||
StatusCode: 200,
|
||||
JSONOutput: gin.H{
|
||||
"login": "alfred",
|
||||
},
|
||||
}, {
|
||||
Description: "user info, complete user logged in",
|
||||
URL: "/api/v0/console/user/info",
|
||||
Header: func() netHTTP.Header {
|
||||
headers := make(netHTTP.Header)
|
||||
headers.Add("Remote-User", "alfred")
|
||||
headers.Add("Remote-Name", "Alfred Pennyworth")
|
||||
headers.Add("Remote-Email", "alfred@batman.com")
|
||||
headers.Add("X-Logout-URL", "/logout")
|
||||
return headers
|
||||
}(),
|
||||
StatusCode: 200,
|
||||
JSONOutput: gin.H{
|
||||
"login": "alfred",
|
||||
"name": "Alfred Pennyworth",
|
||||
"email": "alfred@batman.com",
|
||||
"logout-url": "/logout",
|
||||
},
|
||||
}, {
|
||||
Description: "user info, invalid user logged in",
|
||||
URL: "/api/v0/console/user/info",
|
||||
Header: func() netHTTP.Header {
|
||||
headers := make(netHTTP.Header)
|
||||
headers.Add("Remote-User", "alfred")
|
||||
headers.Add("Remote-Email", "alfrednooo")
|
||||
return headers
|
||||
}(),
|
||||
StatusCode: 200,
|
||||
JSONOutput: gin.H{"login": "default", "name": "Default User"},
|
||||
}, {
|
||||
Description: "avatar, no user logged in",
|
||||
URL: "/api/v0/console/user/avatar",
|
||||
ContentType: "image/png",
|
||||
StatusCode: 200,
|
||||
}, {
|
||||
Description: "avatar, simple user",
|
||||
URL: "/api/v0/console/user/avatar",
|
||||
Header: func() netHTTP.Header {
|
||||
headers := make(netHTTP.Header)
|
||||
headers.Add("Remote-User", "alfred")
|
||||
return headers
|
||||
}(),
|
||||
ContentType: "image/png",
|
||||
StatusCode: 200,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("no default user", func(t *testing.T) {
|
||||
c.config.DefaultUser.Login = ""
|
||||
helpers.TestHTTPEndpoints(t, h.Address, helpers.HTTPEndpointCases{
|
||||
{
|
||||
Description: "user info, no user logged in",
|
||||
URL: "/api/v0/console/user/info",
|
||||
StatusCode: 401,
|
||||
JSONOutput: gin.H{"message": "No user logged in."},
|
||||
}, {
|
||||
Description: "user info, invalid user logged in",
|
||||
URL: "/api/v0/console/user/info",
|
||||
Header: func() netHTTP.Header {
|
||||
headers := make(netHTTP.Header)
|
||||
headers.Add("Remote-User", "alfred")
|
||||
headers.Add("Remote-Email", "alfrednooo")
|
||||
return headers
|
||||
}(),
|
||||
StatusCode: 401,
|
||||
JSONOutput: gin.H{"message": "No user logged in."},
|
||||
}, {
|
||||
Description: "avatar, no user logged in",
|
||||
URL: "/api/v0/console/user/avatar",
|
||||
StatusCode: 401,
|
||||
JSONOutput: gin.H{"message": "No user logged in."},
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
80
console/authentication/middleware.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package authentication
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
)
|
||||
|
||||
// UserInformation contains information about the current user.
|
||||
type UserInformation struct {
|
||||
Login string `json:"login" header:"LOGIN" binding:"required"`
|
||||
Name string `json:"name,omitempty" header:"NAME"`
|
||||
Email string `json:"email,omitempty" header:"EMAIL" binding:"omitempty,email"`
|
||||
LogoutURL string `json:"logout-url,omitempty" header:"LOGOUT" binding:"omitempty,uri"`
|
||||
}
|
||||
|
||||
// UserAuthentication is a middleware to fill information about the
|
||||
// current user. It does not really perform authentication but relies
|
||||
// on HTTP headers.
|
||||
func (c *Component) UserAuthentication() gin.HandlerFunc {
|
||||
return func(gc *gin.Context) {
|
||||
var info UserInformation
|
||||
if err := gc.ShouldBindWith(&info, customHeaderBinding{c}); err != nil {
|
||||
if c.config.DefaultUser.Login == "" {
|
||||
gc.JSON(http.StatusUnauthorized, gin.H{"message": "No user logged in."})
|
||||
gc.Abort()
|
||||
return
|
||||
}
|
||||
info = c.config.DefaultUser
|
||||
}
|
||||
gc.Set("user", info)
|
||||
gc.Next()
|
||||
}
|
||||
}
|
||||
|
||||
type customHeaderBinding struct {
|
||||
c *Component
|
||||
}
|
||||
|
||||
func (customHeaderBinding) Name() string {
|
||||
return "header"
|
||||
}
|
||||
|
||||
// Bind will bind struct fields to HTTP headers using the configured mapping.
|
||||
func (b customHeaderBinding) Bind(req *http.Request, obj interface{}) error {
|
||||
value := reflect.ValueOf(obj).Elem()
|
||||
tValue := reflect.TypeOf(obj).Elem()
|
||||
if value.Kind() != reflect.Struct {
|
||||
panic("should be a struct")
|
||||
}
|
||||
for i := 0; i < tValue.NumField(); i++ {
|
||||
sf := tValue.Field(i)
|
||||
if sf.PkgPath != "" && !sf.Anonymous { // unexported
|
||||
continue
|
||||
}
|
||||
tag := sf.Tag.Get("header")
|
||||
if tag == "" || tag == "-" {
|
||||
continue
|
||||
}
|
||||
var header string
|
||||
switch tag {
|
||||
case "LOGIN":
|
||||
header = b.c.config.Headers.Login
|
||||
case "NAME":
|
||||
header = b.c.config.Headers.Name
|
||||
case "EMAIL":
|
||||
header = b.c.config.Headers.Email
|
||||
case "LOGOUT":
|
||||
header = b.c.config.Headers.LogoutURL
|
||||
}
|
||||
if header == "" {
|
||||
continue
|
||||
}
|
||||
value.Field(i).SetString(req.Header.Get(header))
|
||||
}
|
||||
|
||||
return binding.Validator.ValidateStruct(obj)
|
||||
}
|
||||
19
console/authentication/root.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package authentication
|
||||
|
||||
import "akvorado/common/reporter"
|
||||
|
||||
// Component represents the authentication compomenent.
|
||||
type Component struct {
|
||||
r *reporter.Reporter
|
||||
config Configuration
|
||||
}
|
||||
|
||||
// New creates a new authentication component.
|
||||
func New(r *reporter.Reporter, configuration Configuration) (*Component, error) {
|
||||
c := Component{
|
||||
r: r,
|
||||
config: configuration,
|
||||
}
|
||||
|
||||
return &c, nil
|
||||
}
|
||||
@@ -17,7 +17,7 @@ func TestRefreshFlowsTables(t *testing.T) {
|
||||
r := reporter.NewMock(t)
|
||||
ch, mockConn := clickhousedb.NewMock(t, r)
|
||||
h := http.NewMock(t, r)
|
||||
c, err := New(r, Configuration{}, Dependencies{
|
||||
c, err := New(r, DefaultConfiguration(), Dependencies{
|
||||
Daemon: daemon.NewMock(t),
|
||||
HTTP: h,
|
||||
ClickHouseDB: ch,
|
||||
@@ -186,7 +186,7 @@ func TestQueryFlowsTables(t *testing.T) {
|
||||
r := reporter.NewMock(t)
|
||||
ch, _ := clickhousedb.NewMock(t, r)
|
||||
h := http.NewMock(t, r)
|
||||
c, err := New(r, Configuration{}, Dependencies{
|
||||
c, err := New(r, DefaultConfiguration(), Dependencies{
|
||||
Daemon: daemon.NewMock(t),
|
||||
HTTP: h,
|
||||
ClickHouseDB: ch,
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
package console
|
||||
|
||||
import "akvorado/console/authentication"
|
||||
|
||||
// Configuration describes the configuration for the console component.
|
||||
type Configuration struct {
|
||||
// ServeLiveFS serve files from the filesystem instead of the embedded versions.
|
||||
ServeLiveFS bool
|
||||
// Authentication describes authentication configuration
|
||||
Authentication authentication.Configuration
|
||||
}
|
||||
|
||||
// DefaultConfiguration represents the default configuration for the console component.
|
||||
func DefaultConfiguration() Configuration {
|
||||
return Configuration{}
|
||||
return Configuration{
|
||||
Authentication: authentication.DefaultConfiguration(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,7 +333,7 @@ resolutions:
|
||||
|
||||
The main components of the console service are `http` and `console`.
|
||||
`http` accepts the [same configuration](#http) as for the inlet
|
||||
service. The `console` has no configuration.
|
||||
service.
|
||||
|
||||
### Authentication
|
||||
|
||||
@@ -341,20 +341,44 @@ The console does not store user identities and is unable to
|
||||
authenticate them. It expects an authenticating proxy will add some
|
||||
headers to the API endpoints:
|
||||
|
||||
- `X-Akvorado-User-Login` is the user login,
|
||||
- `X-Akvorado-User-Name` is the user display name,
|
||||
- `X-Akvorado-User-Email` is the user email address,
|
||||
- `X-Akvorado-User-Avatar` is the user's avatar URL,
|
||||
- `X-Akvorago-User-Logout` is a link to the logout link.
|
||||
- `Remote-User` is the user login,
|
||||
- `Remote-Name` is the user display name,
|
||||
- `Remote-Email` is the user email address,
|
||||
- `X-Logout-URL` is a link to the logout link.
|
||||
|
||||
Only the login header is mandatory.
|
||||
Only the first header is mandatory. The name of the headers can be
|
||||
changed by providing a different mapping under the `headers` key. It
|
||||
is also possible to modify the default user (when no header is
|
||||
present) by tweaking the `default-user` key:
|
||||
|
||||
```yaml
|
||||
console:
|
||||
authentication:
|
||||
headers:
|
||||
login: Remote-User
|
||||
name: Remote-Name
|
||||
email: Remote-Email
|
||||
logout-url: X-Logout-URL
|
||||
default-user:
|
||||
login: default
|
||||
name: Default User
|
||||
```
|
||||
|
||||
To prevent access when not authenticated, the `login` field for the
|
||||
`default-user` key should be empty.
|
||||
|
||||
There are several systems providing user management with all the bells
|
||||
and whistles, including OAuth2 support, multi-factor authentication
|
||||
and API tokens. Here is a short selection of solutions able to act as
|
||||
an authenticating reverse-proxy for Akvorado:
|
||||
|
||||
- [Authentik](https://goauthentik.io/)
|
||||
- [Authelia](https://www.authelia.com/)
|
||||
- [Ory](https://www.ory.sh/) (no web interface provided in the open source edition)
|
||||
- [Authentik](https://goauthentik.io/)
|
||||
- [Gluu](https://gluu.org/)
|
||||
- [Keycloak](https://www.keycloak.org/)
|
||||
- [Ory](https://www.ory.sh/) (no web interface provided in the open source edition)
|
||||
|
||||
There also exist simpler solutions only providing authentication:
|
||||
|
||||
- [OAuth2 Proxy](https://oauth2-proxy.github.io/oauth2-proxy/)
|
||||
- [Ory](https://www.ory.sh)
|
||||
|
||||
@@ -30,9 +30,9 @@ func TestServeDocs(t *testing.T) {
|
||||
t.Run(fmt.Sprintf("%s-%s", name, tc.Path), func(t *testing.T) {
|
||||
r := reporter.NewMock(t)
|
||||
h := http.NewMock(t, r)
|
||||
c, err := New(r, Configuration{
|
||||
ServeLiveFS: live,
|
||||
}, Dependencies{
|
||||
conf := DefaultConfiguration()
|
||||
conf.ServeLiveFS = live
|
||||
c, err := New(r, conf, Dependencies{
|
||||
Daemon: daemon.NewMock(t),
|
||||
HTTP: h,
|
||||
})
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestFilterHandlers(t *testing.T) {
|
||||
r := reporter.NewMock(t)
|
||||
ch, mockConn := clickhousedb.NewMock(t, r)
|
||||
h := http.NewMock(t, r)
|
||||
c, err := New(r, Configuration{}, Dependencies{
|
||||
c, err := New(r, DefaultConfiguration(), Dependencies{
|
||||
Daemon: daemon.NewMock(t),
|
||||
HTTP: h,
|
||||
ClickHouseDB: ch,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<template>
|
||||
<ThemeProvider>
|
||||
<TitleProvider>
|
||||
<div class="flex h-full max-h-screen flex-col">
|
||||
<NavigationBar class="flex-none" />
|
||||
<main class="relative flex grow overflow-y-auto">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
<UserProvider>
|
||||
<div class="flex h-full max-h-screen flex-col">
|
||||
<NavigationBar class="flex-none" />
|
||||
<main class="relative flex grow overflow-y-auto">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</UserProvider>
|
||||
</TitleProvider>
|
||||
</ThemeProvider>
|
||||
</template>
|
||||
@@ -17,4 +19,5 @@ import "./tailwind.css";
|
||||
import NavigationBar from "@/components/NavigationBar.vue";
|
||||
import TitleProvider from "@/components/TitleProvider.vue";
|
||||
import ThemeProvider from "@/components/ThemeProvider.vue";
|
||||
import UserProvider from "@/components/UserProvider.vue";
|
||||
</script>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</router-link>
|
||||
<div class="flex md:order-2">
|
||||
<DarkModeSwitcher />
|
||||
<ActiveUser />
|
||||
<UserMenu />
|
||||
<DisclosureButton
|
||||
class="ml-3 inline-flex items-center rounded-lg p-2 text-sm text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-300 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-blue-800 md:hidden"
|
||||
>
|
||||
@@ -72,7 +72,7 @@ import {
|
||||
PresentationChartLineIcon,
|
||||
} from "@heroicons/vue/solid";
|
||||
import DarkModeSwitcher from "@/components/DarkModeSwitcher.vue";
|
||||
import ActiveUser from "@/components/ActiveUser.vue";
|
||||
import UserMenu from "@/components/UserMenu.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const navigation = computed(() => [
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
<template>
|
||||
<Popover v-if="logged" class="relative px-2" as="div">
|
||||
<Popover v-if="isAuthenticated" class="relative px-2" as="div">
|
||||
<PopoverButton
|
||||
class="flex rounded-full bg-gray-200 focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-800"
|
||||
>
|
||||
<span class="sr-only">Open user menu</span>
|
||||
<img
|
||||
class="h-10 w-10 rounded-full"
|
||||
:src="user['avatar-url']"
|
||||
alt="User avatar"
|
||||
/>
|
||||
<img class="h-10 w-10 rounded-full" :src="avatarURL" alt="User avatar" />
|
||||
</PopoverButton>
|
||||
<transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
@@ -23,7 +19,7 @@
|
||||
>
|
||||
<div class="py-3 px-4">
|
||||
<span class="block text-sm text-gray-900 dark:text-white">
|
||||
{{ user.name || user.email || user.login }}
|
||||
{{ user.name || user.email || user.user }}
|
||||
</span>
|
||||
<span
|
||||
v-if="user.name && user.email"
|
||||
@@ -47,11 +43,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useFetch } from "@vueuse/core";
|
||||
import { inject } from "vue";
|
||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/vue";
|
||||
|
||||
const { data } = useFetch("/api/v0/console/user/info").get().json();
|
||||
const user = computed(() => data.value);
|
||||
const logged = computed(() => data.value?.login !== undefined);
|
||||
const { user, isAuthenticated } = inject("user");
|
||||
const avatarURL = "/api/v0/console/user/avatar";
|
||||
</script>
|
||||
15
console/frontend/src/components/UserProvider.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<slot></slot>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { provide, computed, readonly } from "vue";
|
||||
import { useFetch } from "@vueuse/core";
|
||||
|
||||
const { data } = useFetch("/api/v0/console/user/info").get().json();
|
||||
|
||||
provide("user", {
|
||||
isAuthenticated: computed(() => data.value !== null),
|
||||
user: readonly(data),
|
||||
});
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import App from "@/App.vue";
|
||||
import router from "@/router";
|
||||
|
||||
createApp(App).use(router).mount("#app");
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createRouter, createWebHistory } from "vue-router";
|
||||
import HomePage from "@/views/HomePage.vue";
|
||||
import VisualizePage from "@/views/VisualizePage.vue";
|
||||
import DocumentationPage from "@/views/DocumentationPage.vue";
|
||||
import NotFoundPage from "@/views/NotFoundPage.vue";
|
||||
import ErrorPage from "@/views/ErrorPage.vue";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -32,8 +32,9 @@ const router = createRouter({
|
||||
{
|
||||
path: "/:pathMatch(.*)",
|
||||
name: "404",
|
||||
component: NotFoundPage,
|
||||
component: ErrorPage,
|
||||
meta: { title: "Not found" },
|
||||
props: { error: "Not found!" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
13
console/frontend/src/views/ErrorPage.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<img class="mx-auto mt-10" src="@/assets/images/akvorado.svg" />
|
||||
<h1 class="mt-10 text-center text-5xl font-bold">{{ error }}</h1>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
error: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -1,4 +0,0 @@
|
||||
<template>
|
||||
<img class="mx-auto mt-10" src="@/assets/images/akvorado.svg" />
|
||||
<h1 class="mt-10 text-center text-5xl font-bold">Not found!</h1>
|
||||
</template>
|
||||
@@ -23,11 +23,10 @@ export default defineConfig({
|
||||
target: "http://localhost:8080",
|
||||
changeOrigin: true,
|
||||
headers: {
|
||||
"X-Akvorado-User-Login": "alfred",
|
||||
"X-Akvorado-User-Name": "Alfred Pennyworth",
|
||||
"X-Akvorado-User-Email": "alfred@dccomics.example.com",
|
||||
"X-Akvorado-User-Logout":
|
||||
"https://en.wikipedia.org/wiki/Alfred_Pennyworth",
|
||||
"Remote-User": "alfred",
|
||||
"Remote-Name": "Alfred Pennyworth",
|
||||
"Remote-Email": "alfred@dccomics.example.com",
|
||||
"X-Logout-URL": "https://en.wikipedia.org/wiki/Alfred_Pennyworth",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -126,7 +126,7 @@ func TestGraphHandler(t *testing.T) {
|
||||
r := reporter.NewMock(t)
|
||||
ch, mockConn := clickhousedb.NewMock(t, r)
|
||||
h := http.NewMock(t, r)
|
||||
c, err := New(r, Configuration{}, Dependencies{
|
||||
c, err := New(r, DefaultConfiguration(), Dependencies{
|
||||
Daemon: daemon.NewMock(t),
|
||||
HTTP: h,
|
||||
ClickHouseDB: ch,
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"akvorado/common/daemon"
|
||||
"akvorado/common/http"
|
||||
"akvorado/common/reporter"
|
||||
"akvorado/console/authentication"
|
||||
|
||||
"github.com/benbjohnson/clock"
|
||||
"gopkg.in/tomb.v2"
|
||||
@@ -45,6 +46,7 @@ type Dependencies struct {
|
||||
HTTP *http.Component
|
||||
ClickHouseDB *clickhousedb.Component
|
||||
Clock clock.Clock
|
||||
auth *authentication.Component
|
||||
}
|
||||
|
||||
// New creates a new console component.
|
||||
@@ -52,6 +54,11 @@ func New(r *reporter.Reporter, config Configuration, dependencies Dependencies)
|
||||
if dependencies.Clock == nil {
|
||||
dependencies.Clock = clock.New()
|
||||
}
|
||||
auth, err := authentication.New(r, config.Authentication)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dependencies.auth = auth
|
||||
c := Component{
|
||||
r: r,
|
||||
d: &dependencies,
|
||||
@@ -75,7 +82,7 @@ func (c *Component) Start() error {
|
||||
c.r.Info().Msg("starting console component")
|
||||
|
||||
c.d.HTTP.AddHandler("/", netHTTP.HandlerFunc(c.assetsHandlerFunc))
|
||||
endpoint := c.d.HTTP.GinRouter.Group("/api/v0/console", c.userAuthentication())
|
||||
endpoint := c.d.HTTP.GinRouter.Group("/api/v0/console", c.d.auth.UserAuthentication())
|
||||
endpoint.GET("/docs/:name", c.docsHandlerFunc)
|
||||
endpoint.GET("/widget/flow-last", c.widgetFlowLastHandlerFunc)
|
||||
endpoint.GET("/widget/flow-rate", c.widgetFlowRateHandlerFunc)
|
||||
@@ -86,8 +93,8 @@ func (c *Component) Start() error {
|
||||
endpoint.POST("/sankey", c.sankeyHandlerFunc)
|
||||
endpoint.POST("/filter/validate", c.filterValidateHandlerFunc)
|
||||
endpoint.POST("/filter/complete", c.filterCompleteHandlerFunc)
|
||||
endpoint.GET("/user/info", c.requireUserAuthentication(), c.userInfoHandlerFunc)
|
||||
endpoint.GET("/user/avatar", c.requireUserAuthentication(), c.userAvatarHandlerFunc)
|
||||
endpoint.GET("/user/info", c.d.auth.UserInfoHandlerFunc)
|
||||
endpoint.GET("/user/avatar", c.d.auth.UserAvatarHandlerFunc)
|
||||
|
||||
c.t.Go(func() error {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
|
||||
@@ -103,7 +103,7 @@ func TestSankeyHandler(t *testing.T) {
|
||||
r := reporter.NewMock(t)
|
||||
ch, mockConn := clickhousedb.NewMock(t, r)
|
||||
h := http.NewMock(t, r)
|
||||
c, err := New(r, Configuration{}, Dependencies{
|
||||
c, err := New(r, DefaultConfiguration(), Dependencies{
|
||||
Daemon: daemon.NewMock(t),
|
||||
HTTP: h,
|
||||
ClickHouseDB: ch,
|
||||
|
||||
136
console/user.go
@@ -1,136 +0,0 @@
|
||||
package console
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"embed"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"io/fs"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed data/avatars
|
||||
embeddedAvatarParts embed.FS
|
||||
avatarRegexp = regexp.MustCompile(`^([a-z]+)_([0-9]+)\.png$`)
|
||||
)
|
||||
|
||||
// UserInformation contains information about the current user.
|
||||
type userInformation struct {
|
||||
Login string `json:"login" header:"X-Akvorado-User-Login" binding:"required"`
|
||||
Name string `json:"name,omitempty" header:"X-Akvorado-User-Name"`
|
||||
Email string `json:"email,omitempty" header:"X-Akvorado-User-Email" binding:"omitempty,email"`
|
||||
AvatarURL string `json:"avatar-url" header:"X-Akvorado-User-Avatar" binding:"omitempty,uri"`
|
||||
LogoutURL string `json:"logout-url,omitempty" header:"X-Akvorado-User-Logout" binding:"omitempty,uri"`
|
||||
}
|
||||
|
||||
// UserAuthentication is a middleware to fill information about the
|
||||
// current user. It does not really perform authentication but relies
|
||||
// on HTTP headers.
|
||||
func (c *Component) userAuthentication() gin.HandlerFunc {
|
||||
return func(gc *gin.Context) {
|
||||
var info userInformation
|
||||
if err := gc.ShouldBindHeader(&info); err != nil {
|
||||
gc.Next()
|
||||
return
|
||||
}
|
||||
if info.AvatarURL == "" {
|
||||
info.AvatarURL = "/api/v0/console/user/avatar"
|
||||
}
|
||||
gc.Set("user", info)
|
||||
gc.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// requireUserAuthentication requires user to be logged in. It returns
|
||||
// 401 if not.
|
||||
func (c *Component) requireUserAuthentication() gin.HandlerFunc {
|
||||
return func(gc *gin.Context) {
|
||||
_, ok := gc.Get("user")
|
||||
if !ok {
|
||||
gc.JSON(http.StatusUnauthorized, gin.H{"message": "No user logged in."})
|
||||
gc.Abort()
|
||||
return
|
||||
}
|
||||
gc.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// userInfoHandlerFunc returns the information about the currently logged user.
|
||||
func (c *Component) userInfoHandlerFunc(gc *gin.Context) {
|
||||
info := gc.MustGet("user").(userInformation)
|
||||
gc.JSON(http.StatusOK, info)
|
||||
}
|
||||
|
||||
// userAvatarHandlerFunc returns an avatar for the currently logger user.
|
||||
func (c *Component) userAvatarHandlerFunc(gc *gin.Context) {
|
||||
avatarParts := c.embedOrLiveFS(embeddedAvatarParts, "data/avatars")
|
||||
|
||||
// Hash user login as a source
|
||||
info := gc.MustGet("user").(userInformation)
|
||||
hash := fnv.New64()
|
||||
hash.Write([]byte(info.Login))
|
||||
randSource := rand.New(rand.NewSource(int64(hash.Sum64())))
|
||||
|
||||
// Grab list of parts
|
||||
parts := []string{}
|
||||
partList, err := avatarParts.Open("partlist.txt")
|
||||
if err != nil {
|
||||
c.r.Err(err).Msg("cannot open partlist.txt")
|
||||
gc.JSON(http.StatusInternalServerError, gin.H{"message": "Cannot build avatar."})
|
||||
return
|
||||
}
|
||||
defer partList.Close()
|
||||
scanner := bufio.NewScanner(partList)
|
||||
for scanner.Scan() {
|
||||
parts = append(parts, scanner.Text())
|
||||
}
|
||||
|
||||
// Choose an image for each part
|
||||
for idx, part := range parts {
|
||||
// Choose a file for each part
|
||||
p, _ := fs.Glob(avatarParts, fmt.Sprintf("%s_*", part))
|
||||
if len(p) == 0 {
|
||||
c.r.Error().Msgf("missing part %s", part)
|
||||
gc.JSON(http.StatusInternalServerError, gin.H{"message": "Cannot build avatar."})
|
||||
return
|
||||
}
|
||||
parts[idx] = p[randSource.Intn(len(p))]
|
||||
}
|
||||
|
||||
// Compose the images
|
||||
var img *image.RGBA
|
||||
for _, part := range parts {
|
||||
filePart, err := avatarParts.Open(part)
|
||||
if err != nil {
|
||||
c.r.Err(err).Msgf("cannot open part %s", part)
|
||||
gc.JSON(http.StatusInternalServerError, gin.H{"message": "Cannot build avatar."})
|
||||
return
|
||||
}
|
||||
imgPart, err := png.Decode(filePart)
|
||||
filePart.Close()
|
||||
if err != nil {
|
||||
c.r.Err(err).Msgf("cannot decode part %s", part)
|
||||
gc.JSON(http.StatusInternalServerError, gin.H{"message": "Cannot build avatar."})
|
||||
return
|
||||
}
|
||||
if img == nil {
|
||||
img = image.NewRGBA(imgPart.Bounds())
|
||||
}
|
||||
draw.Draw(img, img.Bounds(), imgPart, imgPart.Bounds().Min, draw.Over)
|
||||
}
|
||||
|
||||
// Serve the result
|
||||
gc.Header("Content-Type", "image/png")
|
||||
gc.Header("Cache-Control", "max-age=86400")
|
||||
gc.Header("Vary", "X-Akvorado-User-Login")
|
||||
gc.Status(http.StatusOK)
|
||||
png.Encode(gc.Writer, img)
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package console
|
||||
|
||||
import (
|
||||
netHTTP "net/http"
|
||||
"testing"
|
||||
|
||||
"akvorado/common/clickhousedb"
|
||||
"akvorado/common/daemon"
|
||||
"akvorado/common/helpers"
|
||||
"akvorado/common/http"
|
||||
"akvorado/common/reporter"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestUserHandler(t *testing.T) {
|
||||
r := reporter.NewMock(t)
|
||||
ch, _ := clickhousedb.NewMock(t, r)
|
||||
h := http.NewMock(t, r)
|
||||
c, err := New(r, Configuration{}, Dependencies{
|
||||
Daemon: daemon.NewMock(t),
|
||||
HTTP: h,
|
||||
ClickHouseDB: ch,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error:\n%+v", err)
|
||||
}
|
||||
helpers.StartStop(t, c)
|
||||
|
||||
helpers.TestHTTPEndpoints(t, h.Address, helpers.HTTPEndpointCases{
|
||||
{
|
||||
Description: "user info, no user logged in",
|
||||
URL: "/api/v0/console/user/info",
|
||||
StatusCode: 401,
|
||||
JSONOutput: gin.H{"message": "No user logged in."},
|
||||
}, {
|
||||
Description: "user info, minimal user logged in",
|
||||
URL: "/api/v0/console/user/info",
|
||||
Header: func() netHTTP.Header {
|
||||
headers := make(netHTTP.Header)
|
||||
headers.Add("X-Akvorado-User-Login", "alfred")
|
||||
return headers
|
||||
}(),
|
||||
StatusCode: 200,
|
||||
JSONOutput: gin.H{
|
||||
"login": "alfred",
|
||||
"avatar-url": "/api/v0/console/user/avatar",
|
||||
},
|
||||
}, {
|
||||
Description: "user info, complete user logged in",
|
||||
URL: "/api/v0/console/user/info",
|
||||
Header: func() netHTTP.Header {
|
||||
headers := make(netHTTP.Header)
|
||||
headers.Add("X-Akvorado-User-Login", "alfred")
|
||||
headers.Add("X-Akvorado-User-Name", "Alfred Pennyworth")
|
||||
headers.Add("X-Akvorado-User-Email", "alfred@batman.com")
|
||||
headers.Add("X-Akvorado-User-Avatar", "https://some.example.com/avatar.png")
|
||||
headers.Add("X-Akvorado-User-Logout", "/logout")
|
||||
return headers
|
||||
}(),
|
||||
StatusCode: 200,
|
||||
JSONOutput: gin.H{
|
||||
"login": "alfred",
|
||||
"name": "Alfred Pennyworth",
|
||||
"email": "alfred@batman.com",
|
||||
"avatar-url": "https://some.example.com/avatar.png",
|
||||
"logout-url": "/logout",
|
||||
},
|
||||
}, {
|
||||
Description: "user info, invalid user logged in",
|
||||
URL: "/api/v0/console/user/info",
|
||||
Header: func() netHTTP.Header {
|
||||
headers := make(netHTTP.Header)
|
||||
headers.Add("X-Akvorado-User-Login", "alfred")
|
||||
headers.Add("X-Akvorado-User-Email", "alfrednooo")
|
||||
return headers
|
||||
}(),
|
||||
StatusCode: 401,
|
||||
JSONOutput: gin.H{"message": "No user logged in."},
|
||||
}, {
|
||||
Description: "avatar, no user logged in",
|
||||
URL: "/api/v0/console/user/avatar",
|
||||
StatusCode: 401,
|
||||
JSONOutput: gin.H{"message": "No user logged in."},
|
||||
}, {
|
||||
Description: "avatar, simple user",
|
||||
URL: "/api/v0/console/user/avatar",
|
||||
Header: func() netHTTP.Header {
|
||||
headers := make(netHTTP.Header)
|
||||
headers.Add("X-Akvorado-User-Login", "alfred")
|
||||
return headers
|
||||
}(),
|
||||
ContentType: "image/png",
|
||||
StatusCode: 200,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -23,7 +23,7 @@ func TestWidgetLastFlow(t *testing.T) {
|
||||
r := reporter.NewMock(t)
|
||||
ch, mockConn := clickhousedb.NewMock(t, r)
|
||||
h := http.NewMock(t, r)
|
||||
c, err := New(r, Configuration{}, Dependencies{
|
||||
c, err := New(r, DefaultConfiguration(), Dependencies{
|
||||
Daemon: daemon.NewMock(t),
|
||||
HTTP: h,
|
||||
ClickHouseDB: ch,
|
||||
@@ -109,7 +109,7 @@ func TestFlowRate(t *testing.T) {
|
||||
r := reporter.NewMock(t)
|
||||
ch, mockConn := clickhousedb.NewMock(t, r)
|
||||
h := http.NewMock(t, r)
|
||||
c, err := New(r, Configuration{}, Dependencies{
|
||||
c, err := New(r, DefaultConfiguration(), Dependencies{
|
||||
Daemon: daemon.NewMock(t),
|
||||
HTTP: h,
|
||||
ClickHouseDB: ch,
|
||||
@@ -143,7 +143,7 @@ func TestWidgetExporters(t *testing.T) {
|
||||
r := reporter.NewMock(t)
|
||||
ch, mockConn := clickhousedb.NewMock(t, r)
|
||||
h := http.NewMock(t, r)
|
||||
c, err := New(r, Configuration{}, Dependencies{
|
||||
c, err := New(r, DefaultConfiguration(), Dependencies{
|
||||
Daemon: daemon.NewMock(t),
|
||||
HTTP: h,
|
||||
ClickHouseDB: ch,
|
||||
@@ -184,7 +184,7 @@ func TestWidgetTop(t *testing.T) {
|
||||
r := reporter.NewMock(t)
|
||||
ch, mockConn := clickhousedb.NewMock(t, r)
|
||||
h := http.NewMock(t, r)
|
||||
c, err := New(r, Configuration{}, Dependencies{
|
||||
c, err := New(r, DefaultConfiguration(), Dependencies{
|
||||
Daemon: daemon.NewMock(t),
|
||||
HTTP: h,
|
||||
ClickHouseDB: ch,
|
||||
@@ -263,7 +263,7 @@ func TestWidgetGraph(t *testing.T) {
|
||||
ch, mockConn := clickhousedb.NewMock(t, r)
|
||||
h := http.NewMock(t, r)
|
||||
mockClock := clock.NewMock()
|
||||
c, err := New(r, Configuration{}, Dependencies{
|
||||
c, err := New(r, DefaultConfiguration(), Dependencies{
|
||||
Daemon: daemon.NewMock(t),
|
||||
HTTP: h,
|
||||
ClickHouseDB: ch,
|
||||
|
||||