console: add minimal user management
@@ -40,10 +40,12 @@ func Diff(a, b interface{}) string {
|
|||||||
type HTTPEndpointCases []struct {
|
type HTTPEndpointCases []struct {
|
||||||
Description string
|
Description string
|
||||||
URL string
|
URL string
|
||||||
|
Header http.Header
|
||||||
|
JSONInput interface{}
|
||||||
|
|
||||||
ContentType string
|
ContentType string
|
||||||
StatusCode int
|
StatusCode int
|
||||||
FirstLines []string
|
FirstLines []string
|
||||||
JSONInput interface{}
|
|
||||||
JSONOutput interface{}
|
JSONOutput interface{}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +65,13 @@ func TestHTTPEndpoints(t *testing.T, serverAddr net.Addr, cases HTTPEndpointCase
|
|||||||
var resp *http.Response
|
var resp *http.Response
|
||||||
var err error
|
var err error
|
||||||
if tc.JSONInput == nil {
|
if tc.JSONInput == nil {
|
||||||
resp, err = http.Get(fmt.Sprintf("http://%s%s", serverAddr, tc.URL))
|
req, _ := http.NewRequest("GET",
|
||||||
|
fmt.Sprintf("http://%s%s", serverAddr, tc.URL),
|
||||||
|
nil)
|
||||||
|
if tc.Header != nil {
|
||||||
|
req.Header = tc.Header
|
||||||
|
}
|
||||||
|
resp, err = http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GET %s:\n%+v", tc.URL, err)
|
t.Fatalf("GET %s:\n%+v", tc.URL, err)
|
||||||
}
|
}
|
||||||
@@ -73,8 +81,14 @@ func TestHTTPEndpoints(t *testing.T, serverAddr net.Addr, cases HTTPEndpointCase
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Encode() error:\n%+v", err)
|
t.Fatalf("Encode() error:\n%+v", err)
|
||||||
}
|
}
|
||||||
resp, err = http.Post(fmt.Sprintf("http://%s%s", serverAddr, tc.URL),
|
req, _ := http.NewRequest("POST",
|
||||||
"application/json", payload)
|
fmt.Sprintf("http://%s%s", serverAddr, tc.URL),
|
||||||
|
payload)
|
||||||
|
if tc.Header != nil {
|
||||||
|
req.Header = tc.Header
|
||||||
|
}
|
||||||
|
req.Header.Add("Content-Type", "application/json")
|
||||||
|
resp, err = http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("POST %s:\n%+v", tc.URL, err)
|
t.Fatalf("POST %s:\n%+v", tc.URL, err)
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
console/data/avatars/accessorie_1.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
console/data/avatars/accessorie_10.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
console/data/avatars/accessorie_11.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
console/data/avatars/accessorie_12.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
console/data/avatars/accessorie_13.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
console/data/avatars/accessorie_14.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
console/data/avatars/accessorie_15.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
console/data/avatars/accessorie_16.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
console/data/avatars/accessorie_17.png
Normal file
|
After Width: | Height: | Size: 301 B |
BIN
console/data/avatars/accessorie_18.png
Normal file
|
After Width: | Height: | Size: 301 B |
BIN
console/data/avatars/accessorie_19.png
Normal file
|
After Width: | Height: | Size: 301 B |
BIN
console/data/avatars/accessorie_2.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
console/data/avatars/accessorie_20.png
Normal file
|
After Width: | Height: | Size: 301 B |
BIN
console/data/avatars/accessorie_3.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
console/data/avatars/accessorie_4.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
console/data/avatars/accessorie_5.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
console/data/avatars/accessorie_6.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
console/data/avatars/accessorie_7.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
console/data/avatars/accessorie_8.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
console/data/avatars/accessorie_9.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
console/data/avatars/body_1.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
console/data/avatars/body_10.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
console/data/avatars/body_11.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
console/data/avatars/body_12.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
console/data/avatars/body_13.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
console/data/avatars/body_14.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
console/data/avatars/body_15.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
console/data/avatars/body_2.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
console/data/avatars/body_3.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
console/data/avatars/body_4.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
console/data/avatars/body_5.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
console/data/avatars/body_6.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
console/data/avatars/body_7.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
console/data/avatars/body_8.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
console/data/avatars/body_9.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
console/data/avatars/eyes_1.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
console/data/avatars/eyes_10.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
console/data/avatars/eyes_11.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
console/data/avatars/eyes_12.png
Normal file
|
After Width: | Height: | Size: 765 B |
BIN
console/data/avatars/eyes_13.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
console/data/avatars/eyes_14.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
console/data/avatars/eyes_15.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
console/data/avatars/eyes_2.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
console/data/avatars/eyes_3.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
console/data/avatars/eyes_4.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
console/data/avatars/eyes_5.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
console/data/avatars/eyes_6.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
console/data/avatars/eyes_7.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
console/data/avatars/eyes_8.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
console/data/avatars/eyes_9.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
console/data/avatars/fur_1.png
Normal file
|
After Width: | Height: | Size: 964 B |
BIN
console/data/avatars/fur_10.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
console/data/avatars/fur_2.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
console/data/avatars/fur_3.png
Normal file
|
After Width: | Height: | Size: 301 B |
BIN
console/data/avatars/fur_4.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
console/data/avatars/fur_5.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
console/data/avatars/fur_6.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
console/data/avatars/fur_7.png
Normal file
|
After Width: | Height: | Size: 301 B |
BIN
console/data/avatars/fur_8.png
Normal file
|
After Width: | Height: | Size: 901 B |
BIN
console/data/avatars/fur_9.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
console/data/avatars/mouth_1.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
console/data/avatars/mouth_10.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
console/data/avatars/mouth_2.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
console/data/avatars/mouth_3.png
Normal file
|
After Width: | Height: | Size: 1006 B |
BIN
console/data/avatars/mouth_4.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
console/data/avatars/mouth_5.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
console/data/avatars/mouth_6.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
console/data/avatars/mouth_7.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
console/data/avatars/mouth_8.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
console/data/avatars/mouth_9.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
5
console/data/avatars/partlist.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
body
|
||||||
|
fur
|
||||||
|
eyes
|
||||||
|
mouth
|
||||||
|
accessorie
|
||||||
@@ -334,3 +334,27 @@ resolutions:
|
|||||||
The main components of the console service are `http` and `console`.
|
The main components of the console service are `http` and `console`.
|
||||||
`http` accepts the [same configuration](#http) as for the inlet
|
`http` accepts the [same configuration](#http) as for the inlet
|
||||||
service. The `console` has no configuration.
|
service. The `console` has no configuration.
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Only the login header is mandatory.
|
||||||
|
|
||||||
|
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)
|
||||||
|
- [Keycloak](https://www.keycloak.org/)
|
||||||
|
|||||||
57
console/frontend/src/components/ActiveUser.vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<Popover v-if="logged" 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"
|
||||||
|
/>
|
||||||
|
</PopoverButton>
|
||||||
|
<transition
|
||||||
|
enter-active-class="transition duration-200 ease-out"
|
||||||
|
enter-from-class="translate-y-1 opacity-0"
|
||||||
|
enter-to-class="translate-y-0 opacity-100"
|
||||||
|
leave-active-class="transition duration-150 ease-in"
|
||||||
|
leave-from-class="translate-y-0 opacity-100"
|
||||||
|
leave-to-class="translate-y-1 opacity-0"
|
||||||
|
>
|
||||||
|
<PopoverPanel
|
||||||
|
class="absolute right-0 z-50 my-4 max-w-xs list-none divide-y divide-gray-100 rounded bg-white text-base shadow dark:divide-gray-600 dark:bg-gray-700"
|
||||||
|
>
|
||||||
|
<div class="py-3 px-4">
|
||||||
|
<span class="block text-sm text-gray-900 dark:text-white">
|
||||||
|
{{ user.name || user.email || user.login }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="user.name && user.email"
|
||||||
|
class="block truncate text-sm font-medium text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ user.email }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul class="py-1">
|
||||||
|
<li v-if="user['logout-url']">
|
||||||
|
<a
|
||||||
|
:href="user['logout-url']"
|
||||||
|
class="block py-2 px-4 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-600 dark:hover:text-white"
|
||||||
|
>Logout</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</PopoverPanel>
|
||||||
|
</transition>
|
||||||
|
</Popover>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useFetch } from "@vueuse/core";
|
||||||
|
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);
|
||||||
|
</script>
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
<div class="flex md:order-2">
|
<div class="flex md:order-2">
|
||||||
<DarkModeSwitcher />
|
<DarkModeSwitcher />
|
||||||
|
<ActiveUser />
|
||||||
<DisclosureButton
|
<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"
|
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"
|
||||||
>
|
>
|
||||||
@@ -71,6 +72,7 @@ import {
|
|||||||
PresentationChartLineIcon,
|
PresentationChartLineIcon,
|
||||||
} from "@heroicons/vue/solid";
|
} from "@heroicons/vue/solid";
|
||||||
import DarkModeSwitcher from "@/components/DarkModeSwitcher.vue";
|
import DarkModeSwitcher from "@/components/DarkModeSwitcher.vue";
|
||||||
|
import ActiveUser from "@/components/ActiveUser.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const navigation = computed(() => [
|
const navigation = computed(() => [
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ export default defineConfig({
|
|||||||
"/api": {
|
"/api": {
|
||||||
target: "http://localhost:8080",
|
target: "http://localhost:8080",
|
||||||
changeOrigin: true,
|
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",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -75,16 +75,19 @@ func (c *Component) Start() error {
|
|||||||
c.r.Info().Msg("starting console component")
|
c.r.Info().Msg("starting console component")
|
||||||
|
|
||||||
c.d.HTTP.AddHandler("/", netHTTP.HandlerFunc(c.assetsHandlerFunc))
|
c.d.HTTP.AddHandler("/", netHTTP.HandlerFunc(c.assetsHandlerFunc))
|
||||||
c.d.HTTP.GinRouter.GET("/api/v0/console/docs/:name", c.docsHandlerFunc)
|
endpoint := c.d.HTTP.GinRouter.Group("/api/v0/console", c.userAuthentication())
|
||||||
c.d.HTTP.GinRouter.GET("/api/v0/console/widget/flow-last", c.widgetFlowLastHandlerFunc)
|
endpoint.GET("/docs/:name", c.docsHandlerFunc)
|
||||||
c.d.HTTP.GinRouter.GET("/api/v0/console/widget/flow-rate", c.widgetFlowRateHandlerFunc)
|
endpoint.GET("/widget/flow-last", c.widgetFlowLastHandlerFunc)
|
||||||
c.d.HTTP.GinRouter.GET("/api/v0/console/widget/exporters", c.widgetExportersHandlerFunc)
|
endpoint.GET("/widget/flow-rate", c.widgetFlowRateHandlerFunc)
|
||||||
c.d.HTTP.GinRouter.GET("/api/v0/console/widget/top/:name", c.widgetTopHandlerFunc)
|
endpoint.GET("/widget/exporters", c.widgetExportersHandlerFunc)
|
||||||
c.d.HTTP.GinRouter.GET("/api/v0/console/widget/graph", c.widgetGraphHandlerFunc)
|
endpoint.GET("/widget/top/:name", c.widgetTopHandlerFunc)
|
||||||
c.d.HTTP.GinRouter.POST("/api/v0/console/graph", c.graphHandlerFunc)
|
endpoint.GET("/widget/graph", c.widgetGraphHandlerFunc)
|
||||||
c.d.HTTP.GinRouter.POST("/api/v0/console/sankey", c.sankeyHandlerFunc)
|
endpoint.POST("/graph", c.graphHandlerFunc)
|
||||||
c.d.HTTP.GinRouter.POST("/api/v0/console/filter/validate", c.filterValidateHandlerFunc)
|
endpoint.POST("/sankey", c.sankeyHandlerFunc)
|
||||||
c.d.HTTP.GinRouter.POST("/api/v0/console/filter/complete", c.filterCompleteHandlerFunc)
|
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)
|
||||||
|
|
||||||
c.t.Go(func() error {
|
c.t.Go(func() error {
|
||||||
ticker := time.NewTicker(10 * time.Second)
|
ticker := time.NewTicker(10 * time.Second)
|
||||||
|
|||||||
136
console/user.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
97
console/user_test.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -29,3 +29,39 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:8123:8123/tcp
|
- 127.0.0.1:8123:8123/tcp
|
||||||
- 127.0.0.1:9000:9000/tcp
|
- 127.0.0.1:9000:9000/tcp
|
||||||
|
|
||||||
|
# Authentik
|
||||||
|
authentik:
|
||||||
|
image: &authentik-image ghcr.io/goauthentik/server:2022.5.3
|
||||||
|
command: server
|
||||||
|
environment: &authentik-env
|
||||||
|
AUTHENTIK_REDIS__HOST: authentik-redis
|
||||||
|
AUTHENTIK_POSTGRESQL__HOST: authentik-postgresql
|
||||||
|
AUTHENTIK_POSTGRESQL__USER: authentik
|
||||||
|
AUTHENTIK_POSTGRESQL__NAME: authentik
|
||||||
|
AUTHENTIK_POSTGRESQL__PASSWORD: dummypassword
|
||||||
|
AUTHENTIK_DISABLE_UPDATE_CHECK: "true"
|
||||||
|
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
|
||||||
|
AUTHENTIK_DISABLE_STARTUP_ANALYTICS: "true"
|
||||||
|
AUTHENTIK_AVATARS: none
|
||||||
|
AUTHENTIK_SECRET_KEY: dummykey
|
||||||
|
AK_ADMIN_PASS: akadmin
|
||||||
|
AK_ADMIN_TOKEN: aktoken
|
||||||
|
ports:
|
||||||
|
- 127.0.0.1:8999:9000/tcp
|
||||||
|
depends_on:
|
||||||
|
- authentik-postgresql
|
||||||
|
- authentik-redis
|
||||||
|
- authentik-worker
|
||||||
|
authentik-postgresql:
|
||||||
|
image: postgres:12-alpine
|
||||||
|
environment:
|
||||||
|
- POSTGRES_PASSWORD=dummypassword
|
||||||
|
- POSTGRES_USER=authentik
|
||||||
|
- POSTGRES_DB=authentik
|
||||||
|
authentik-redis:
|
||||||
|
image: redis:alpine
|
||||||
|
authentik-worker:
|
||||||
|
image: *authentik-image
|
||||||
|
command: worker
|
||||||
|
environment: *authentik-env
|
||||||
|
|||||||