console: rework a bit user management

Notably, HTTP headers are configurable and a provider is used for the
frontend side.
This commit is contained in:
Vincent Bernat
2022-05-31 17:03:00 +02:00
parent 494fd25c86
commit f5252ce077
97 changed files with 468 additions and 295 deletions

View File

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

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

View File

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

Before

Width:  |  Height:  |  Size: 301 B

After

Width:  |  Height:  |  Size: 301 B

View File

Before

Width:  |  Height:  |  Size: 301 B

After

Width:  |  Height:  |  Size: 301 B

View File

Before

Width:  |  Height:  |  Size: 301 B

After

Width:  |  Height:  |  Size: 301 B

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 301 B

After

Width:  |  Height:  |  Size: 301 B

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 765 B

After

Width:  |  Height:  |  Size: 765 B

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 964 B

After

Width:  |  Height:  |  Size: 964 B

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 301 B

After

Width:  |  Height:  |  Size: 301 B

View File

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

Before

Width:  |  Height:  |  Size: 301 B

After

Width:  |  Height:  |  Size: 301 B

View File

Before

Width:  |  Height:  |  Size: 901 B

After

Width:  |  Height:  |  Size: 901 B

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1006 B

After

Width:  |  Height:  |  Size: 1006 B

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

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

View 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."},
},
})
})
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(() => [

View File

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

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

View File

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

View File

@@ -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!" },
},
],
});

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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