mirror of
https://github.com/SecurityBrewery/catalyst.git
synced 2025-12-06 15:22:47 +01:00
Add simple auth (#186)
This commit is contained in:
41
.github/workflows/ci.yml
vendored
41
.github/workflows/ci.yml
vendored
@@ -66,7 +66,7 @@ jobs:
|
||||
- run: |
|
||||
mkdir -p ui/dist/img
|
||||
touch ui/dist/index.html ui/dist/favicon.ico ui/dist/manifest.json ui/dist/img/fake.png
|
||||
- run: docker-compose up -d
|
||||
- run: docker-compose -f docker-compose-with-keycloak.yml up --quiet-pull --detach
|
||||
working-directory: dev
|
||||
- name: Install ArangoDB
|
||||
run: |
|
||||
@@ -95,6 +95,9 @@ jobs:
|
||||
|
||||
cypress:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
auth: [ simple, keycloak ]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
@@ -108,21 +111,34 @@ jobs:
|
||||
yarn install
|
||||
yarn serve &
|
||||
working-directory: ui
|
||||
- uses: cugu/wait_for_response@v1.8.0
|
||||
- name: Wait for frontend
|
||||
uses: cugu/wait_for_response@v1.12.0
|
||||
with:
|
||||
url: 'http://localhost:8080'
|
||||
responseCode: 200
|
||||
|
||||
# run containers
|
||||
# run containers
|
||||
- run: sed -i 's/host.docker.internal/172.17.0.1/g' dev/nginx.conf
|
||||
shell: bash
|
||||
- run: sed -i 's/host.docker.internal/172.17.0.1/g' dev/nginx-with-keycloak.conf
|
||||
shell: bash
|
||||
- run: docker-compose up --quiet-pull --detach
|
||||
working-directory: dev
|
||||
shell: bash
|
||||
- uses: cugu/wait_for_response@v1.8.0
|
||||
if: ${{ matrix.auth == 'simple' }}
|
||||
- run: docker-compose -f docker-compose-with-keycloak.yml up --quiet-pull --detach
|
||||
working-directory: dev
|
||||
shell: bash
|
||||
if: ${{ matrix.auth == 'keycloak' }}
|
||||
- name: Wait for keycloak
|
||||
uses: cugu/wait_for_response@v1.12.0
|
||||
with:
|
||||
url: 'http://localhost:9002/auth/realms/catalyst'
|
||||
responseCode: 200
|
||||
verbose: true
|
||||
timeout: 3m
|
||||
interval: 10s
|
||||
if: ${{ matrix.auth == 'keycloak' }}
|
||||
|
||||
# run catalyst
|
||||
- run: |
|
||||
@@ -130,16 +146,25 @@ jobs:
|
||||
touch ui/dist/index.html ui/dist/favicon.ico ui/dist/manifest.json ui/dist/img/fake.png
|
||||
- run: go mod download
|
||||
- run: bash start_dev.sh &
|
||||
- uses: cugu/wait_for_response@v1.8.0
|
||||
working-directory: dev
|
||||
if: ${{ matrix.auth == 'simple' }}
|
||||
- run: bash start_dev_with_keycloak.sh &
|
||||
working-directory: dev
|
||||
if: ${{ matrix.auth == 'keycloak' }}
|
||||
- name: Wait for catalyst
|
||||
uses: cugu/wait_for_response@v1.12.0
|
||||
with:
|
||||
url: 'http://localhost:8000'
|
||||
method: GET
|
||||
responseCode: 302
|
||||
verbose: true
|
||||
timeout: 3m # 3 minutes
|
||||
interval: 10s # every 10 seconds
|
||||
timeout: 3m
|
||||
interval: 10s
|
||||
|
||||
# run cypress
|
||||
- run: ./node_modules/.bin/cypress run
|
||||
- run: ./node_modules/.bin/cypress run --spec "cypress/integration/catalyst.js"
|
||||
env:
|
||||
CYPRESS_AUTH: ${{ matrix.auth }}
|
||||
working-directory: ui
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
|
||||
@@ -101,6 +101,7 @@ linters-settings:
|
||||
- go-driver.Cursor
|
||||
- go-driver.Collection
|
||||
- go-driver.Database
|
||||
- go-driver.Client
|
||||
- chi.Router
|
||||
issues:
|
||||
exclude-rules:
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package catalyst
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/exp/slices"
|
||||
@@ -22,25 +25,32 @@ import (
|
||||
"github.com/SecurityBrewery/catalyst/role"
|
||||
)
|
||||
|
||||
type AuthConfig struct {
|
||||
OIDCIssuer string
|
||||
OAuth2 *oauth2.Config
|
||||
type Config struct {
|
||||
SimpleAuthEnable bool
|
||||
APIKeyAuthEnable bool
|
||||
OIDCAuthEnable bool
|
||||
|
||||
OIDCClaimUsername string
|
||||
OIDCClaimEmail string
|
||||
// OIDCClaimGroups string
|
||||
OIDCClaimName string
|
||||
AuthBlockNew bool
|
||||
AuthDefaultRoles []role.Role
|
||||
AuthAdminUsers []string
|
||||
OIDCIssuer string
|
||||
OAuth2 *oauth2.Config
|
||||
UserCreateConfig *UserCreateConfig
|
||||
|
||||
provider *oidc.Provider
|
||||
}
|
||||
|
||||
func (c *AuthConfig) Verifier(ctx context.Context) (*oidc.IDTokenVerifier, error) {
|
||||
type UserCreateConfig struct {
|
||||
AuthBlockNew bool
|
||||
AuthDefaultRoles []role.Role
|
||||
AuthAdminUsers []string
|
||||
|
||||
OIDCClaimUsername string
|
||||
OIDCClaimEmail string
|
||||
OIDCClaimName string
|
||||
// OIDCClaimGroups string
|
||||
}
|
||||
|
||||
func (c *Config) Verifier(ctx context.Context) (*oidc.IDTokenVerifier, error) {
|
||||
if c.provider == nil {
|
||||
err := c.Load(ctx)
|
||||
if err != nil {
|
||||
if err := c.Load(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -48,37 +58,55 @@ func (c *AuthConfig) Verifier(ctx context.Context) (*oidc.IDTokenVerifier, error
|
||||
return c.provider.Verifier(&oidc.Config{SkipClientIDCheck: true}), nil
|
||||
}
|
||||
|
||||
func (c *AuthConfig) Load(ctx context.Context) error {
|
||||
provider, err := oidc.NewProvider(ctx, c.OIDCIssuer)
|
||||
if err != nil {
|
||||
return err
|
||||
func (c *Config) Load(ctx context.Context) error {
|
||||
for {
|
||||
provider, err := oidc.NewProvider(ctx, c.OIDCIssuer)
|
||||
if err == nil {
|
||||
c.provider = provider
|
||||
c.OAuth2.Endpoint = provider.Endpoint()
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return errors.New("could not load provider")
|
||||
}
|
||||
|
||||
log.Printf("could not load oidc provider: %s, retrying in 10 seconds\n", err)
|
||||
time.Sleep(time.Second * 10)
|
||||
}
|
||||
c.provider = provider
|
||||
c.OAuth2.Endpoint = provider.Endpoint()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Authenticate(db *database.Database, config *AuthConfig) func(next http.Handler) http.Handler {
|
||||
func Authenticate(db *database.Database, config *Config, jar *Jar) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
keyHeader := r.Header.Get("PRIVATE-TOKEN")
|
||||
authHeader := r.Header.Get("User")
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
|
||||
switch {
|
||||
case keyHeader != "":
|
||||
keyAuth(db, keyHeader)(next).ServeHTTP(w, r)
|
||||
if config.APIKeyAuthEnable {
|
||||
keyAuth(db, keyHeader)(next).ServeHTTP(w, r)
|
||||
} else {
|
||||
api.JSONErrorStatus(w, http.StatusUnauthorized, errors.New("API Key authentication not enabled"))
|
||||
}
|
||||
case authHeader != "":
|
||||
iss := config.OIDCIssuer
|
||||
bearerAuth(db, authHeader, iss, config)(next).ServeHTTP(w, r)
|
||||
if config.OIDCAuthEnable {
|
||||
iss := config.OIDCIssuer
|
||||
bearerAuth(db, authHeader, iss, config, jar)(next).ServeHTTP(w, r)
|
||||
} else {
|
||||
api.JSONErrorStatus(w, http.StatusUnauthorized, errors.New("OIDC authentication not enabled"))
|
||||
}
|
||||
default:
|
||||
sessionAuth(db, config)(next).ServeHTTP(w, r)
|
||||
sessionAuth(db, config, jar)(next).ServeHTTP(w, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func bearerAuth(db *database.Database, authHeader string, iss string, config *AuthConfig) func(next http.Handler) http.Handler {
|
||||
func bearerAuth(db *database.Database, authHeader string, iss string, config *Config, jar *Jar) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
@@ -99,7 +127,7 @@ func bearerAuth(db *database.Database, authHeader string, iss string, config *Au
|
||||
// return
|
||||
// }
|
||||
|
||||
setClaimsCookie(w, claims)
|
||||
jar.setClaimsCookie(w, claims)
|
||||
|
||||
r, err := setContextClaims(r, db, claims, config)
|
||||
if err != nil {
|
||||
@@ -118,7 +146,7 @@ func keyAuth(db *database.Database, keyHeader string) func(next http.Handler) ht
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := fmt.Sprintf("%x", sha256.Sum256([]byte(keyHeader)))
|
||||
|
||||
key, err := db.UserByHash(r.Context(), h)
|
||||
key, err := db.UserAPIKeyByHash(r.Context(), h)
|
||||
if err != nil {
|
||||
api.JSONErrorStatus(w, http.StatusInternalServerError, fmt.Errorf("could not verify private token: %w", err))
|
||||
|
||||
@@ -132,17 +160,19 @@ func keyAuth(db *database.Database, keyHeader string) func(next http.Handler) ht
|
||||
}
|
||||
}
|
||||
|
||||
func sessionAuth(db *database.Database, config *AuthConfig) func(next http.Handler) http.Handler {
|
||||
func sessionAuth(db *database.Database, config *Config, jar *Jar) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims, noCookie, err := claimsCookie(r)
|
||||
claims, noCookie, err := jar.claimsCookie(r)
|
||||
if err != nil {
|
||||
deleteClaimsCookie(w)
|
||||
|
||||
api.JSONError(w, err)
|
||||
|
||||
return
|
||||
}
|
||||
if noCookie {
|
||||
redirectToLogin(w, r, config.OAuth2)
|
||||
redirectToLogin(config, jar)(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -159,8 +189,8 @@ func sessionAuth(db *database.Database, config *AuthConfig) func(next http.Handl
|
||||
}
|
||||
}
|
||||
|
||||
func setContextClaims(r *http.Request, db *database.Database, claims map[string]any, config *AuthConfig) (*http.Request, error) {
|
||||
newUser, newSetting, err := mapUserAndSettings(claims, config)
|
||||
func setContextClaims(r *http.Request, db *database.Database, claims map[string]any, config *Config) (*http.Request, error) {
|
||||
newUser, newSetting, err := mapUserAndSettings(claims, config.UserCreateConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -174,8 +204,7 @@ func setContextClaims(r *http.Request, db *database.Database, claims map[string]
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = db.UserDataGetOrCreate(r.Context(), newUser.ID, newSetting)
|
||||
if err != nil {
|
||||
if _, err = db.UserDataGetOrCreate(r.Context(), newUser.ID, newSetting); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -191,7 +220,7 @@ func setContextUser(r *http.Request, user *model.UserResponse, hooks *hooks.Hook
|
||||
return busdb.SetContext(r, user)
|
||||
}
|
||||
|
||||
func mapUserAndSettings(claims map[string]any, config *AuthConfig) (*model.UserForm, *model.UserData, error) {
|
||||
func mapUserAndSettings(claims map[string]any, config *UserCreateConfig) (*model.UserForm, *model.UserData, error) {
|
||||
// handle Bearer tokens
|
||||
// if typ, ok := claims["typ"]; ok && typ == "Bearer" {
|
||||
// return &model.User{
|
||||
@@ -207,6 +236,7 @@ func mapUserAndSettings(claims map[string]any, config *AuthConfig) (*model.UserF
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
email, err := getString(claims, config.OIDCClaimEmail)
|
||||
if err != nil {
|
||||
email = ""
|
||||
@@ -244,19 +274,35 @@ func getString(m map[string]any, key string) (string, error) {
|
||||
return "", fmt.Errorf("mapping of %s failed, missing value", key)
|
||||
}
|
||||
|
||||
func redirectToLogin(w http.ResponseWriter, r *http.Request, oauth2Config *oauth2.Config) {
|
||||
state, err := state()
|
||||
if err != nil {
|
||||
api.JSONErrorStatus(w, http.StatusInternalServerError, errors.New("generating state failed"))
|
||||
|
||||
return
|
||||
func redirectToLogin(config *Config, jar *Jar) func(http.ResponseWriter, *http.Request) {
|
||||
if config.SimpleAuthEnable {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
setStateCookie(w, state)
|
||||
if config.OIDCAuthEnable {
|
||||
return redirectToOIDCLogin(config, jar)
|
||||
}
|
||||
|
||||
http.Redirect(w, r, oauth2Config.AuthCodeURL(state), http.StatusFound)
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
api.JSONErrorStatus(writer, http.StatusForbidden, errors.New("unauthenticated"))
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
func redirectToOIDCLogin(config *Config, jar *Jar) func(http.ResponseWriter, *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
state, err := state()
|
||||
if err != nil {
|
||||
api.JSONErrorStatus(w, http.StatusInternalServerError, errors.New("generating state failed"))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
jar.setStateCookie(w, state)
|
||||
|
||||
http.Redirect(w, r, config.OAuth2.AuthCodeURL(state), http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
func AuthorizeBlockedUser() func(http.Handler) http.Handler {
|
||||
@@ -301,9 +347,56 @@ func AuthorizeRole(roles []string) func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
func callback(config *AuthConfig) http.HandlerFunc {
|
||||
func login(db *database.Database, jar *Jar) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
state, err := stateCookie(r)
|
||||
type credentials struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
cr := credentials{}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&cr); err != nil {
|
||||
api.JSONErrorStatus(w, http.StatusUnauthorized, errors.New("wrong username or password"))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
user, err := db.UserByIDAndPassword(r.Context(), cr.Username, cr.Password)
|
||||
if err != nil {
|
||||
api.JSONErrorStatus(w, http.StatusUnauthorized, errors.New("wrong username or password"))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
userdata, err := db.UserDataGet(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
api.JSONErrorStatus(w, http.StatusUnauthorized, errors.New("no userdata"))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
jar.setClaimsCookie(w, map[string]any{
|
||||
"preferred_username": user.ID,
|
||||
"name": userdata.Name,
|
||||
"email": userdata.Email,
|
||||
})
|
||||
|
||||
b, _ := json.Marshal(map[string]string{"login": "successful"})
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
}
|
||||
|
||||
func logout() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
deleteClaimsCookie(w)
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
func callback(config *Config, jar *Jar) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
state, err := jar.stateCookie(r)
|
||||
if err != nil || state == "" {
|
||||
api.JSONErrorStatus(w, http.StatusInternalServerError, errors.New("state missing"))
|
||||
|
||||
@@ -338,9 +431,9 @@ func callback(config *AuthConfig) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
setClaimsCookie(w, claims)
|
||||
jar.setClaimsCookie(w, claims)
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
http.Redirect(w, r, "/ui/", http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,11 +446,12 @@ func state() (string, error) {
|
||||
return base64.URLEncoding.EncodeToString(rnd), nil
|
||||
}
|
||||
|
||||
func verifyClaims(r *http.Request, config *AuthConfig, rawIDToken string) (map[string]any, *api.HTTPError) {
|
||||
func verifyClaims(r *http.Request, config *Config, rawIDToken string) (map[string]any, *api.HTTPError) {
|
||||
verifier, err := config.Verifier(r.Context())
|
||||
if err != nil {
|
||||
return nil, &api.HTTPError{Status: http.StatusUnauthorized, Internal: fmt.Errorf("could not verify: %w", err)}
|
||||
}
|
||||
|
||||
authToken, err := verifier.Verify(r.Context(), rawIDToken)
|
||||
if err != nil {
|
||||
return nil, &api.HTTPError{Status: http.StatusInternalServerError, Internal: fmt.Errorf("could not verify bearer token: %w", err)}
|
||||
84
auth/cookie.go
Normal file
84
auth/cookie.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/securecookie"
|
||||
"golang.org/x/crypto/argon2"
|
||||
|
||||
"github.com/SecurityBrewery/catalyst/generated/time"
|
||||
)
|
||||
|
||||
const (
|
||||
stateSessionCookie = "state"
|
||||
userSessionCookie = "user"
|
||||
)
|
||||
|
||||
type Jar struct {
|
||||
store *securecookie.SecureCookie
|
||||
}
|
||||
|
||||
func NewJar(secret []byte) *Jar {
|
||||
hashSalt := securecookie.GenerateRandomKey(64)
|
||||
blockSalt := securecookie.GenerateRandomKey(64)
|
||||
|
||||
return &Jar{
|
||||
store: securecookie.New(
|
||||
argon2.IDKey(secret, hashSalt, 1, 64*1024, 4, 64),
|
||||
argon2.IDKey(secret, blockSalt, 1, 64*1024, 4, 32),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func (j *Jar) setStateCookie(w http.ResponseWriter, state string) {
|
||||
encoded, err := j.store.Encode(userSessionCookie, state)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
tomorrow := time.Now().AddDate(0, 0, 1)
|
||||
http.SetCookie(w, &http.Cookie{Name: stateSessionCookie, Value: encoded, Path: "/", Expires: tomorrow})
|
||||
}
|
||||
|
||||
func (j *Jar) stateCookie(r *http.Request) (string, error) {
|
||||
stateCookie, err := r.Cookie(stateSessionCookie)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var state string
|
||||
err = j.store.Decode(userSessionCookie, stateCookie.Value, &state)
|
||||
|
||||
return state, err
|
||||
}
|
||||
|
||||
func (j *Jar) setClaimsCookie(w http.ResponseWriter, claims map[string]any) {
|
||||
encoded, err := j.store.Encode(userSessionCookie, claims)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
tomorrow := time.Now().AddDate(0, 0, 1)
|
||||
http.SetCookie(w, &http.Cookie{Name: userSessionCookie, Value: encoded, Path: "/", Expires: tomorrow})
|
||||
}
|
||||
|
||||
func deleteClaimsCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{Name: userSessionCookie, Value: "", MaxAge: -1})
|
||||
}
|
||||
|
||||
func (j *Jar) claimsCookie(r *http.Request) (map[string]any, bool, error) {
|
||||
userCookie, err := r.Cookie(userSessionCookie)
|
||||
if err != nil {
|
||||
return nil, true, nil
|
||||
}
|
||||
|
||||
var claims map[string]any
|
||||
err = j.store.Decode(userSessionCookie, userCookie.Value, &claims)
|
||||
|
||||
return claims, false, err
|
||||
}
|
||||
43
auth/server.go
Normal file
43
auth/server.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
|
||||
"github.com/SecurityBrewery/catalyst/database"
|
||||
)
|
||||
|
||||
func Server(config *Config, catalystDatabase *database.Database, jar *Jar) *chi.Mux {
|
||||
server := chi.NewRouter()
|
||||
|
||||
server.Get("/config", hasOIDC(config))
|
||||
|
||||
if config.OIDCAuthEnable {
|
||||
server.Get("/callback", callback(config, jar))
|
||||
server.Get("/oidclogin", redirectToOIDCLogin(config, jar))
|
||||
}
|
||||
if config.SimpleAuthEnable {
|
||||
server.Post("/login", login(catalystDatabase, jar))
|
||||
}
|
||||
server.Post("/logout", logout())
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
func hasOIDC(config *Config) func(writer http.ResponseWriter, request *http.Request) {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
b, err := json.Marshal(map[string]any{
|
||||
"simple": config.SimpleAuthEnable,
|
||||
"oidc": config.OIDCAuthEnable,
|
||||
})
|
||||
if err != nil {
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = writer.Write(b)
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/arangodb/go-driver"
|
||||
|
||||
@@ -41,37 +41,34 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
_, _ = theCatalyst.DB.UserCreate(context.Background(), &model.UserForm{ID: "eve", Roles: []string{"admin"}})
|
||||
_, _ = theCatalyst.DB.UserCreate(context.Background(), &model.UserForm{ID: "eve", Roles: []string{"admin"}, Password: pointer.String("eve")})
|
||||
_ = theCatalyst.DB.UserDataCreate(context.Background(), "eve", &model.UserData{
|
||||
Name: pointer.String("Eve"),
|
||||
Email: pointer.String("eve@example.com"),
|
||||
Image: &avatarEve,
|
||||
})
|
||||
_, _ = theCatalyst.DB.UserCreate(context.Background(), &model.UserForm{ID: "kevin", Roles: []string{"admin"}})
|
||||
_, _ = theCatalyst.DB.UserCreate(context.Background(), &model.UserForm{ID: "kevin", Roles: []string{"admin"}, Password: pointer.String("kevin")})
|
||||
_ = theCatalyst.DB.UserDataCreate(context.Background(), "kevin", &model.UserData{
|
||||
Name: pointer.String("Kevin"),
|
||||
Email: pointer.String("kevin@example.com"),
|
||||
Image: &avatarKevin,
|
||||
})
|
||||
|
||||
// proxy static requests
|
||||
middlewares := []func(next http.Handler) http.Handler{
|
||||
catalyst.Authenticate(theCatalyst.DB, config.Auth),
|
||||
catalyst.AuthorizeBlockedUser(),
|
||||
}
|
||||
theCatalyst.Server.With(middlewares...).NotFound(func(writer http.ResponseWriter, request *http.Request) {
|
||||
var handler http.Handler = http.HandlerFunc(api.Proxy("http://localhost:8080/static/"))
|
||||
|
||||
if strings.HasPrefix(request.URL.Path, "/static/") {
|
||||
handler = http.StripPrefix("/static/", handler)
|
||||
} else {
|
||||
request.URL.Path = "/"
|
||||
}
|
||||
|
||||
handler.ServeHTTP(writer, request)
|
||||
_, _ = theCatalyst.DB.UserCreate(context.Background(), &model.UserForm{ID: "tom", Roles: []string{"admin"}, Password: pointer.String("tom")})
|
||||
_ = theCatalyst.DB.UserDataCreate(context.Background(), "tom", &model.UserData{
|
||||
Name: pointer.String("tom"),
|
||||
Email: pointer.String("tom@example.com"),
|
||||
Image: &avatarKevin,
|
||||
})
|
||||
|
||||
if err := http.ListenAndServe(":8000", theCatalyst.Server); err != nil {
|
||||
// proxy static requests
|
||||
theCatalyst.Server.Get("/ui/*", func(writer http.ResponseWriter, request *http.Request) {
|
||||
log.Println("proxy request", request.URL.Path)
|
||||
|
||||
api.Proxy("http://localhost:8080/")(writer, request)
|
||||
})
|
||||
|
||||
if err := http.ListenAndServe(fmt.Sprintf(":%d", config.Port), theCatalyst.Server); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/SecurityBrewery/catalyst"
|
||||
"github.com/SecurityBrewery/catalyst/cmd"
|
||||
"github.com/SecurityBrewery/catalyst/generated/api"
|
||||
"github.com/SecurityBrewery/catalyst/hooks"
|
||||
"github.com/SecurityBrewery/catalyst/ui"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -22,7 +26,10 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := http.ListenAndServe(":8000", theCatalyst.Server); err != nil {
|
||||
fsys, _ := fs.Sub(ui.UI, "dist")
|
||||
theCatalyst.Server.Get("/ui/*", api.Static(fsys))
|
||||
|
||||
if err := http.ListenAndServe(fmt.Sprintf(":%d", config.Port), theCatalyst.Server); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
55
cmd/cmd.go
55
cmd/cmd.go
@@ -8,6 +8,7 @@ import (
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/SecurityBrewery/catalyst"
|
||||
"github.com/SecurityBrewery/catalyst/auth"
|
||||
"github.com/SecurityBrewery/catalyst/database"
|
||||
"github.com/SecurityBrewery/catalyst/role"
|
||||
"github.com/SecurityBrewery/catalyst/storage"
|
||||
@@ -18,7 +19,17 @@ type CLI struct {
|
||||
ExternalAddress string `env:"EXTERNAL_ADDRESS" required:""`
|
||||
CatalystAddress string `env:"CATALYST_ADDRESS" default:"http://catalyst:8000"`
|
||||
Network string `env:"CATALYST_NETWORK" default:"catalyst"`
|
||||
Port int `env:"PORT" default:"8000"`
|
||||
|
||||
AuthBlockNew bool `env:"AUTH_BLOCK_NEW" default:"true" help:"Block newly created users"`
|
||||
AuthDefaultRoles []string `env:"AUTH_DEFAULT_ROLES" help:"Default roles for new users"`
|
||||
AuthAdminUsers []string `env:"AUTH_ADMIN_USERS" help:"Username of admins"`
|
||||
InitialAPIKey string `env:"INITIAL_API_KEY"`
|
||||
|
||||
SimpleAuthEnable bool `env:"SIMPLE_AUTH_ENABLE" default:"true"`
|
||||
APIKeyAuthEnable bool `env:"API_KEY_AUTH_ENABLE" default:"true"`
|
||||
|
||||
OIDCEnable bool `env:"OIDC_ENABLE" default:"false"`
|
||||
OIDCIssuer string `env:"OIDC_ISSUER" required:""`
|
||||
OIDCClientID string `env:"OIDC_CLIENT_ID" default:"catalyst"`
|
||||
OIDCClientSecret string `env:"OIDC_CLIENT_SECRET" required:""`
|
||||
@@ -26,9 +37,6 @@ type CLI struct {
|
||||
OIDCClaimUsername string `env:"OIDC_CLAIM_USERNAME" default:"preferred_username" help:"username field in the OIDC claim"`
|
||||
OIDCClaimEmail string `env:"OIDC_CLAIM_EMAIL" default:"email" help:"email field in the OIDC claim"`
|
||||
OIDCClaimName string `env:"OIDC_CLAIM_NAME" default:"name" help:"name field in the OIDC claim"`
|
||||
AuthBlockNew bool `env:"AUTH_BLOCK_NEW" default:"true" help:"Block newly created users"`
|
||||
AuthDefaultRoles []string `env:"AUTH_DEFAULT_ROLES" help:"Default roles for new users"`
|
||||
AuthAdminUsers []string `env:"AUTH_ADMIN_USERS" help:"Username of admins"`
|
||||
|
||||
IndexPath string `env:"INDEX_PATH" default:"index.bleve" help:"Path for the bleve index"`
|
||||
|
||||
@@ -39,8 +47,6 @@ type CLI struct {
|
||||
S3Host string `env:"S3_HOST" default:"http://minio:9000" name:"s3-host"`
|
||||
S3User string `env:"S3_USER" default:"minio" name:"s3-user"`
|
||||
S3Password string `env:"S3_PASSWORD" required:"" name:"s3-password"`
|
||||
|
||||
InitialAPIKey string `env:"INITIAL_API_KEY"`
|
||||
}
|
||||
|
||||
func ParseCatalystConfig() (*catalyst.Config, error) {
|
||||
@@ -61,22 +67,37 @@ func MapConfig(cli CLI) (*catalyst.Config, error) {
|
||||
|
||||
scopes := slices.Compact(append([]string{oidc.ScopeOpenID, "profile", "email"}, cli.OIDCScopes...))
|
||||
config := &catalyst.Config{
|
||||
IndexPath: cli.IndexPath,
|
||||
Network: cli.Network,
|
||||
DB: &database.Config{Host: cli.ArangoDBHost, User: cli.ArangoDBUser, Password: cli.ArangoDBPassword},
|
||||
IndexPath: cli.IndexPath,
|
||||
Network: cli.Network,
|
||||
DB: &database.Config{
|
||||
Host: cli.ArangoDBHost,
|
||||
User: cli.ArangoDBUser,
|
||||
Password: cli.ArangoDBPassword,
|
||||
},
|
||||
Storage: &storage.Config{Host: cli.S3Host, User: cli.S3User, Password: cli.S3Password},
|
||||
Secret: []byte(cli.Secret),
|
||||
ExternalAddress: cli.ExternalAddress,
|
||||
InternalAddress: cli.CatalystAddress,
|
||||
Auth: &catalyst.AuthConfig{
|
||||
OIDCIssuer: cli.OIDCIssuer,
|
||||
OAuth2: &oauth2.Config{ClientID: cli.OIDCClientID, ClientSecret: cli.OIDCClientSecret, RedirectURL: cli.ExternalAddress + "/callback", Scopes: scopes},
|
||||
OIDCClaimUsername: cli.OIDCClaimUsername,
|
||||
OIDCClaimEmail: cli.OIDCClaimEmail,
|
||||
OIDCClaimName: cli.OIDCClaimName,
|
||||
AuthBlockNew: cli.AuthBlockNew,
|
||||
AuthDefaultRoles: roles,
|
||||
AuthAdminUsers: cli.AuthAdminUsers,
|
||||
Port: cli.Port,
|
||||
Auth: &auth.Config{
|
||||
SimpleAuthEnable: cli.SimpleAuthEnable,
|
||||
APIKeyAuthEnable: cli.APIKeyAuthEnable,
|
||||
OIDCAuthEnable: cli.OIDCEnable,
|
||||
OIDCIssuer: cli.OIDCIssuer,
|
||||
OAuth2: &oauth2.Config{
|
||||
ClientID: cli.OIDCClientID,
|
||||
ClientSecret: cli.OIDCClientSecret,
|
||||
RedirectURL: cli.ExternalAddress + "/auth/callback",
|
||||
Scopes: scopes,
|
||||
},
|
||||
UserCreateConfig: &auth.UserCreateConfig{
|
||||
AuthBlockNew: cli.AuthBlockNew,
|
||||
AuthDefaultRoles: roles,
|
||||
AuthAdminUsers: cli.AuthAdminUsers,
|
||||
OIDCClaimUsername: cli.OIDCClaimUsername,
|
||||
OIDCClaimEmail: cli.OIDCClaimEmail,
|
||||
OIDCClaimName: cli.OIDCClaimName,
|
||||
},
|
||||
},
|
||||
InitialAPIKey: cli.InitialAPIKey,
|
||||
}
|
||||
|
||||
57
cookie.go
57
cookie.go
@@ -1,57 +0,0 @@
|
||||
package catalyst
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const (
|
||||
stateSessionCookie = "state"
|
||||
userSessionCookie = "user"
|
||||
)
|
||||
|
||||
func setStateCookie(w http.ResponseWriter, state string) {
|
||||
http.SetCookie(w, &http.Cookie{Name: stateSessionCookie, Value: state})
|
||||
}
|
||||
|
||||
func stateCookie(r *http.Request) (string, error) {
|
||||
stateCookie, err := r.Cookie(stateSessionCookie)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return stateCookie.Value, nil
|
||||
}
|
||||
|
||||
func setClaimsCookie(w http.ResponseWriter, claims map[string]any) {
|
||||
b, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: userSessionCookie, Value: base64.StdEncoding.EncodeToString(b)})
|
||||
}
|
||||
|
||||
func claimsCookie(r *http.Request) (map[string]any, bool, error) {
|
||||
userCookie, err := r.Cookie(userSessionCookie)
|
||||
if err != nil {
|
||||
return nil, true, nil
|
||||
}
|
||||
|
||||
b, err := base64.StdEncoding.DecodeString(userCookie.Value)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("could not decode cookie: %w", err)
|
||||
}
|
||||
|
||||
var claims map[string]any
|
||||
if err := json.Unmarshal(b, &claims); err != nil {
|
||||
return nil, false, errors.New("claims not in session")
|
||||
}
|
||||
|
||||
return claims, false, err
|
||||
}
|
||||
@@ -2,8 +2,10 @@ package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/arangodb/go-driver"
|
||||
"github.com/arangodb/go-driver/http"
|
||||
@@ -67,17 +69,25 @@ func New(ctx context.Context, index *index.Index, bus *bus.Bus, hooks *hooks.Hoo
|
||||
name = Name
|
||||
}
|
||||
|
||||
conn, err := http.NewConnection(http.ConnectionConfig{Endpoints: []string{config.Host}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var err error
|
||||
var client driver.Client
|
||||
for {
|
||||
deadline, ok := ctx.Deadline()
|
||||
if ok && time.Until(deadline) < 0 {
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
|
||||
client, err := driver.NewClient(driver.ClientConfig{
|
||||
Connection: conn,
|
||||
Authentication: driver.BasicAuthentication(config.User, config.Password),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
client, err = getClient(ctx, config)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil, errors.New("could not load database, connection timed out")
|
||||
}
|
||||
|
||||
log.Printf("could not connect to database: %s, retrying in 10 seconds\n", err)
|
||||
time.Sleep(time.Second * 10)
|
||||
}
|
||||
|
||||
hooks.DatabaseAfterConnect(ctx, client, name)
|
||||
@@ -162,10 +172,31 @@ func New(ctx context.Context, index *index.Index, bus *bus.Bus, hooks *hooks.Hoo
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func getClient(ctx context.Context, config *Config) (driver.Client, error) {
|
||||
conn, err := http.NewConnection(http.ConnectionConfig{Endpoints: []string{config.Host}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client, err := driver.NewClient(driver.ClientConfig{
|
||||
Connection: conn,
|
||||
Authentication: driver.BasicAuthentication(config.User, config.Password),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := client.Version(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func SetupDB(ctx context.Context, client driver.Client, dbName string) (driver.Database, error) {
|
||||
databaseExists, err := client.DatabaseExists(ctx, dbName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("could not check if database exists: %w", err)
|
||||
}
|
||||
|
||||
var db driver.Database
|
||||
@@ -175,12 +206,12 @@ func SetupDB(ctx context.Context, client driver.Client, dbName string) (driver.D
|
||||
db, err = client.Database(ctx, dbName)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("could not create database: %w", err)
|
||||
}
|
||||
|
||||
collectionExists, err := db.CollectionExists(ctx, migrations.MigrationCollection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("could not check if collection exists: %w", err)
|
||||
}
|
||||
|
||||
if !collectionExists {
|
||||
|
||||
@@ -60,6 +60,8 @@ func generateMigrations() ([]Migration, error) {
|
||||
&createCollection{ID: "create-dashboard-collection", Name: "dashboards", DataType: "dashboards", Schema: `{"type":"object","properties":{"name":{"type":"string"},"widgets":{"items":{"type":"object","properties":{"aggregation":{"type":"string"},"filter":{"type":"string"},"name":{"type":"string"},"type":{"enum":[ "bar", "line", "pie" ]},"width": { "type": "integer", "minimum": 1, "maximum": 12 }},"required":["name","aggregation", "type", "width"]},"type":"array"}},"required":["name","widgets"]}`},
|
||||
|
||||
&updateDocument[model.Settings]{ID: "update-settings-global-1", Collection: "settings", Key: "global", Document: &model.Settings{ArtifactStates: []*model.Type{{Icon: "mdi-help-circle-outline", ID: "unknown", Name: "Unknown", Color: pointer.String(model.TypeColorInfo)}, {Icon: "mdi-skull", ID: "malicious", Name: "Malicious", Color: pointer.String(model.TypeColorError)}, {Icon: "mdi-check", ID: "clean", Name: "Clean", Color: pointer.String(model.TypeColorSuccess)}}, ArtifactKinds: []*model.Type{{Icon: "mdi-server", ID: "asset", Name: "Asset"}, {Icon: "mdi-bullseye", ID: "ioc", Name: "IOC"}}, Timeformat: "yyyy-MM-dd hh:mm:ss"}},
|
||||
|
||||
&updateSchema{ID: "update-user-simple-login", Name: "users", DataType: "user", Schema: `{"type":"object","properties":{"apikey":{"type":"boolean"},"blocked":{"type":"boolean"},"roles":{"items":{"type":"string"},"type":"array"},"salt":{"type":"string"},"sha256":{"type":"string"},"sha512":{"type":"string"}},"required":["blocked","apikey","roles"],"$id":"#/definitions/User"}`},
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
108
database/user.go
108
database/user.go
@@ -3,8 +3,10 @@ package database
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
|
||||
"github.com/arangodb/go-driver"
|
||||
@@ -32,13 +34,15 @@ func generateKey() string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func toUser(user *model.UserForm, sha256 *string) *model.User {
|
||||
func toUser(user *model.UserForm, salt, sha256, sha512 *string) *model.User {
|
||||
roles := []string{}
|
||||
roles = append(roles, role.Strings(role.Explodes(user.Roles))...)
|
||||
u := &model.User{
|
||||
Blocked: user.Blocked,
|
||||
Roles: roles,
|
||||
Salt: salt,
|
||||
Sha256: sha256,
|
||||
Sha512: sha512,
|
||||
Apikey: user.Apikey,
|
||||
}
|
||||
|
||||
@@ -87,21 +91,21 @@ func (db *Database) UserGetOrCreate(ctx context.Context, newUser *model.UserForm
|
||||
}
|
||||
|
||||
func (db *Database) UserCreate(ctx context.Context, newUser *model.UserForm) (*model.NewUserResponse, error) {
|
||||
var key string
|
||||
var hash *string
|
||||
var key, salt, sha256Hash, sha512Hash *string
|
||||
if newUser.Apikey {
|
||||
key = generateKey()
|
||||
hash = pointer.String(fmt.Sprintf("%x", sha256.Sum256([]byte(key))))
|
||||
key, sha256Hash = generateAPIKey()
|
||||
} else {
|
||||
salt, sha512Hash = hashUserPassword(newUser)
|
||||
}
|
||||
|
||||
var doc model.User
|
||||
newctx := driver.WithReturnNew(ctx, &doc)
|
||||
meta, err := db.userCollection.CreateDocument(ctx, newctx, strcase.ToKebab(newUser.ID), toUser(newUser, hash))
|
||||
meta, err := db.userCollection.CreateDocument(ctx, newctx, strcase.ToKebab(newUser.ID), toUser(newUser, salt, sha256Hash, sha512Hash))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toNewUserResponse(meta.Key, &doc, pointer.String(key)), nil
|
||||
return toNewUserResponse(meta.Key, &doc, key), nil
|
||||
}
|
||||
|
||||
func (db *Database) UserCreateSetupAPIKey(ctx context.Context, key string) (*model.UserResponse, error) {
|
||||
@@ -111,11 +115,42 @@ func (db *Database) UserCreateSetupAPIKey(ctx context.Context, key string) (*mod
|
||||
Apikey: true,
|
||||
Blocked: false,
|
||||
}
|
||||
hash := pointer.String(fmt.Sprintf("%x", sha256.Sum256([]byte(key))))
|
||||
sha256Hash := pointer.String(fmt.Sprintf("%x", sha256.Sum256([]byte(key))))
|
||||
|
||||
var doc model.User
|
||||
newctx := driver.WithReturnNew(ctx, &doc)
|
||||
meta, err := db.userCollection.CreateDocument(ctx, newctx, strcase.ToKebab(newUser.ID), toUser(newUser, hash))
|
||||
meta, err := db.userCollection.CreateDocument(ctx, newctx, strcase.ToKebab(newUser.ID), toUser(newUser, nil, sha256Hash, nil))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toUserResponse(meta.Key, &doc), nil
|
||||
}
|
||||
|
||||
func (db *Database) UserUpdate(ctx context.Context, id string, user *model.UserForm) (*model.UserResponse, error) {
|
||||
var doc model.User
|
||||
_, err := db.userCollection.ReadDocument(ctx, id, &doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if doc.Apikey {
|
||||
return nil, errors.New("cannot update an API key")
|
||||
}
|
||||
|
||||
var salt, sha512Hash *string
|
||||
if user.Password != nil {
|
||||
salt, sha512Hash = hashUserPassword(user)
|
||||
} else {
|
||||
salt = doc.Salt
|
||||
sha512Hash = doc.Sha512
|
||||
}
|
||||
|
||||
ctx = driver.WithReturnNew(ctx, &doc)
|
||||
|
||||
user.ID = id
|
||||
|
||||
meta, err := db.userCollection.ReplaceDocument(ctx, id, toUser(user, salt, nil, sha512Hash))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -162,12 +197,13 @@ func (db *Database) UserList(ctx context.Context) ([]*model.UserResponse, error)
|
||||
return docs, err
|
||||
}
|
||||
|
||||
func (db *Database) UserByHash(ctx context.Context, sha256 string) (*model.UserResponse, error) {
|
||||
func (db *Database) UserAPIKeyByHash(ctx context.Context, sha256 string) (*model.UserResponse, error) {
|
||||
query := `FOR d in @@collection
|
||||
FILTER d.sha256 == @sha256
|
||||
FILTER d.apikey && d.sha256 == @sha256
|
||||
RETURN d`
|
||||
|
||||
cursor, _, err := db.Query(ctx, query, map[string]any{"@collection": UserCollectionName, "sha256": sha256}, busdb.ReadOperation)
|
||||
vars := map[string]any{"@collection": UserCollectionName, "sha256": sha256}
|
||||
cursor, _, err := db.Query(ctx, query, vars, busdb.ReadOperation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -182,25 +218,41 @@ func (db *Database) UserByHash(ctx context.Context, sha256 string) (*model.UserR
|
||||
return toUserResponse(meta.Key, &doc), err
|
||||
}
|
||||
|
||||
func (db *Database) UserUpdate(ctx context.Context, id string, user *model.UserForm) (*model.UserResponse, error) {
|
||||
func (db *Database) UserByIDAndPassword(ctx context.Context, id, password string) (*model.UserResponse, error) {
|
||||
log.Println("UserByIDAndPassword", id, password)
|
||||
query := `FOR d in @@collection
|
||||
FILTER d._key == @id && !d.apikey && d.sha512 == SHA512(CONCAT(d.salt, @password))
|
||||
RETURN d`
|
||||
|
||||
vars := map[string]any{"@collection": UserCollectionName, "id": id, "password": password}
|
||||
cursor, _, err := db.Query(ctx, query, vars, busdb.ReadOperation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close()
|
||||
|
||||
var doc model.User
|
||||
_, err := db.userCollection.ReadDocument(ctx, id, &doc)
|
||||
meta, err := cursor.ReadDocument(ctx, &doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if doc.Sha256 != nil {
|
||||
return nil, errors.New("cannot update an API key")
|
||||
}
|
||||
|
||||
ctx = driver.WithReturnNew(ctx, &doc)
|
||||
|
||||
user.ID = id
|
||||
|
||||
meta, err := db.userCollection.ReplaceDocument(ctx, id, toUser(user, nil))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return toUserResponse(meta.Key, &doc), nil
|
||||
return toUserResponse(meta.Key, &doc), err
|
||||
}
|
||||
|
||||
func generateAPIKey() (key, sha256Hash *string) {
|
||||
newKey := generateKey()
|
||||
sha256Hash = pointer.String(fmt.Sprintf("%x", sha256.Sum256([]byte(newKey))))
|
||||
|
||||
return &newKey, sha256Hash
|
||||
}
|
||||
|
||||
func hashUserPassword(newUser *model.UserForm) (salt, sha512Hash *string) {
|
||||
if newUser.Password != nil {
|
||||
saltKey := generateKey()
|
||||
salt = &saltKey
|
||||
sha512Hash = pointer.String(fmt.Sprintf("%x", sha512.Sum512([]byte(saltKey+*newUser.Password))))
|
||||
}
|
||||
|
||||
return salt, sha512Hash
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ definitions:
|
||||
required: [ id, blocked, roles, apikey ]
|
||||
properties:
|
||||
id: { type: string }
|
||||
password: { type: string }
|
||||
blocked: { type: boolean }
|
||||
apikey: { type: boolean }
|
||||
roles: { type: array, items: { type: string } }
|
||||
@@ -101,7 +102,9 @@ definitions:
|
||||
blocked: { type: boolean }
|
||||
apikey: { type: boolean }
|
||||
roles: { type: array, items: { type: string } }
|
||||
salt: { type: string }
|
||||
sha256: { type: string } # for api keys
|
||||
sha512: { type: string } # for users
|
||||
|
||||
UserResponse:
|
||||
type: object
|
||||
|
||||
52
dev/docker-compose-with-keycloak.yml
Normal file
52
dev/docker-compose-with-keycloak.yml
Normal file
@@ -0,0 +1,52 @@
|
||||
version: '2.4'
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:1.21
|
||||
volumes:
|
||||
- ./nginx-with-keycloak.conf:/etc/nginx/nginx.conf:ro
|
||||
ports: [ "80:80", "8529:8529", "9000:9000", "9002:9002", "9003:9003" ]
|
||||
networks: [ catalyst ]
|
||||
|
||||
arangodb:
|
||||
image: arangodb/arangodb:3.8.1
|
||||
environment:
|
||||
ARANGO_ROOT_PASSWORD: foobar
|
||||
networks: [ catalyst ]
|
||||
|
||||
minio:
|
||||
image: minio/minio:RELEASE.2021-12-10T23-03-39Z
|
||||
environment:
|
||||
MINIO_ROOT_USER: minio
|
||||
MINIO_ROOT_PASSWORD: minio123
|
||||
command: server /data -console-address ":9003"
|
||||
networks: [ catalyst ]
|
||||
|
||||
postgres:
|
||||
image: postgres:13
|
||||
environment:
|
||||
POSTGRES_DB: keycloak
|
||||
POSTGRES_USER: keycloak
|
||||
POSTGRES_PASSWORD: password
|
||||
networks: [ catalyst ]
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:14.0.0
|
||||
environment:
|
||||
DB_VENDOR: POSTGRES
|
||||
DB_ADDR: postgres
|
||||
DB_DATABASE: keycloak
|
||||
DB_USER: keycloak
|
||||
DB_SCHEMA: public
|
||||
DB_PASSWORD: password
|
||||
KEYCLOAK_USER: admin
|
||||
KEYCLOAK_PASSWORD: admin
|
||||
KEYCLOAK_IMPORT: /tmp/realm.json
|
||||
PROXY_ADDRESS_FORWARDING: "true"
|
||||
volumes:
|
||||
- ./keycloak/realm.json:/tmp/realm.json
|
||||
depends_on: [ postgres ]
|
||||
networks: [ catalyst ]
|
||||
|
||||
networks:
|
||||
catalyst:
|
||||
name: catalyst
|
||||
@@ -4,7 +4,7 @@ services:
|
||||
image: nginx:1.21
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
ports: [ "80:80", "8529:8529", "9000:9000", "9001:9001", "9002:9002", "9003:9003" ]
|
||||
ports: [ "80:80", "8529:8529", "9000:9000", "9003:9003" ]
|
||||
networks: [ catalyst ]
|
||||
|
||||
arangodb:
|
||||
@@ -21,32 +21,6 @@ services:
|
||||
command: server /data -console-address ":9003"
|
||||
networks: [ catalyst ]
|
||||
|
||||
postgres:
|
||||
image: postgres:13
|
||||
environment:
|
||||
POSTGRES_DB: keycloak
|
||||
POSTGRES_USER: keycloak
|
||||
POSTGRES_PASSWORD: password
|
||||
networks: [ catalyst ]
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:14.0.0
|
||||
environment:
|
||||
DB_VENDOR: POSTGRES
|
||||
DB_ADDR: postgres
|
||||
DB_DATABASE: keycloak
|
||||
DB_USER: keycloak
|
||||
DB_SCHEMA: public
|
||||
DB_PASSWORD: password
|
||||
KEYCLOAK_USER: admin
|
||||
KEYCLOAK_PASSWORD: admin
|
||||
KEYCLOAK_IMPORT: /tmp/realm.json
|
||||
PROXY_ADDRESS_FORWARDING: "true"
|
||||
volumes:
|
||||
- ./keycloak/realm.json:/tmp/realm.json
|
||||
depends_on: [ postgres ]
|
||||
networks: [ catalyst ]
|
||||
|
||||
networks:
|
||||
catalyst:
|
||||
name: catalyst
|
||||
|
||||
@@ -455,8 +455,8 @@
|
||||
"secret": "d3ec0d91-b6ea-482d-8a4e-2f5a7ca0b4cb",
|
||||
"redirectUris": [
|
||||
"http://catalyst.internal.com/*",
|
||||
"http://localhost:8000/callback",
|
||||
"http://localhost/callback"
|
||||
"http://localhost:8000/auth/callback",
|
||||
"http://localhost/auth/callback"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://catalyst.internal.com",
|
||||
|
||||
112
dev/nginx-with-keycloak.conf
Normal file
112
dev/nginx-with-keycloak.conf
Normal file
@@ -0,0 +1,112 @@
|
||||
user www-data;
|
||||
worker_processes 5;
|
||||
error_log /var/log/nginx/error.log;
|
||||
|
||||
events {
|
||||
worker_connections 4096;
|
||||
}
|
||||
|
||||
http {
|
||||
include mime.types;
|
||||
index index.html index.htm;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] $status '
|
||||
'"$request" $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
|
||||
location / {
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $upstream_catalyst host.docker.internal;
|
||||
proxy_pass http://$upstream_catalyst:8000;
|
||||
}
|
||||
|
||||
location /wss {
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $upstream_catalyst host.docker.internal;
|
||||
proxy_pass http://$upstream_catalyst:8000;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8529 default_server;
|
||||
server_name _;
|
||||
|
||||
location / {
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $upstream_arangodb arangodb;
|
||||
proxy_pass http://$upstream_arangodb:8529;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 9000 default_server;
|
||||
server_name _;
|
||||
|
||||
location / {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $http_host;
|
||||
|
||||
proxy_connect_timeout 300;
|
||||
# Default is HTTP/1, keepalive is only enabled in HTTP/1.1
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
chunked_transfer_encoding off;
|
||||
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $upstream_minio minio;
|
||||
proxy_pass http://$upstream_minio:9000;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 9002 default_server;
|
||||
server_name _;
|
||||
|
||||
location / {
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $upstream_keycloak keycloak;
|
||||
proxy_pass http://$upstream_keycloak:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Server $host;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 9003 default_server;
|
||||
server_name _;
|
||||
|
||||
location / {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $http_host;
|
||||
|
||||
proxy_connect_timeout 300;
|
||||
# Default is HTTP/1, keepalive is only enabled in HTTP/1.1
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
chunked_transfer_encoding off;
|
||||
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $upstream_minio minio;
|
||||
proxy_pass http://$upstream_minio:9003;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,24 +70,6 @@ http {
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 9002 default_server;
|
||||
server_name _;
|
||||
|
||||
location / {
|
||||
resolver 127.0.0.11 valid=30s;
|
||||
set $upstream_keycloak keycloak;
|
||||
proxy_pass http://$upstream_keycloak:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Server $host;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 9003 default_server;
|
||||
server_name _;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export SECRET=4ef5b29539b70233dd40c02a1799d25079595565e05a193b09da2c3e60ada1cd
|
||||
|
||||
# export OIDC_ENABLE=true
|
||||
export OIDC_ISSUER=http://localhost:9002/auth/realms/catalyst
|
||||
export OIDC_CLIENT_SECRET=d3ec0d91-b6ea-482d-8a4e-2f5a7ca0b4cb
|
||||
|
||||
@@ -15,4 +16,4 @@ export EXTERNAL_ADDRESS=http://localhost
|
||||
export CATALYST_ADDRESS=http://host.docker.internal
|
||||
export INITIAL_API_KEY=d0169af94c40981eb4452a42fae536b6caa9be3a
|
||||
|
||||
go run cmd/catalyst-dev/*.go
|
||||
go run ../cmd/catalyst-dev/*.go
|
||||
20
dev/start_dev_with_keycloak.sh
Normal file
20
dev/start_dev_with_keycloak.sh
Normal file
@@ -0,0 +1,20 @@
|
||||
export SECRET=4ef5b29539b70233dd40c02a1799d25079595565e05a193b09da2c3e60ada1cd
|
||||
|
||||
export SIMPLE_AUTH_ENABLE=false
|
||||
export OIDC_ENABLE=true
|
||||
export OIDC_ISSUER=http://localhost:9002/auth/realms/catalyst
|
||||
export OIDC_CLIENT_SECRET=d3ec0d91-b6ea-482d-8a4e-2f5a7ca0b4cb
|
||||
|
||||
export ARANGO_DB_HOST=http://localhost:8529
|
||||
export ARANGO_DB_PASSWORD=foobar
|
||||
export S3_HOST=http://localhost:9000
|
||||
export S3_PASSWORD=minio123
|
||||
|
||||
export AUTH_BLOCK_NEW=false
|
||||
export AUTH_DEFAULT_ROLES=analyst,admin
|
||||
|
||||
export EXTERNAL_ADDRESS=http://localhost
|
||||
export CATALYST_ADDRESS=http://host.docker.internal
|
||||
export INITIAL_API_KEY=d0169af94c40981eb4452a42fae536b6caa9be3a
|
||||
|
||||
go run ../cmd/catalyst-dev/*.go
|
||||
@@ -118,7 +118,7 @@ func parseQueryOptionalBoolArray(r *http.Request, key string) ([]bool, error) {
|
||||
return parseQueryBoolArray(r, key)
|
||||
}
|
||||
|
||||
func parseBody(b []byte, i any) error {
|
||||
func parseBody(b []byte, i interface{}) error {
|
||||
dec := json.NewDecoder(bytes.NewBuffer(b))
|
||||
err := dec.Decode(i)
|
||||
if err != nil {
|
||||
@@ -137,7 +137,7 @@ func JSONErrorStatus(w http.ResponseWriter, status int, err error) {
|
||||
w.Write(b)
|
||||
}
|
||||
|
||||
func response(w http.ResponseWriter, v any, err error) {
|
||||
func response(w http.ResponseWriter, v interface{}, err error) {
|
||||
if err != nil {
|
||||
var httpError *HTTPError
|
||||
if errors.As(err, &httpError) {
|
||||
@@ -172,7 +172,7 @@ func validateSchema(body []byte, schema *gojsonschema.Schema, w http.ResponseWri
|
||||
validationErrors = append(validationErrors, valdiationError.String())
|
||||
}
|
||||
|
||||
b, _ := json.Marshal(map[string]any{"error": "wrong input", "errors": validationErrors})
|
||||
b, _ := json.Marshal(map[string]interface{}{"error": "wrong input", "errors": validationErrors})
|
||||
w.Write(b)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ type Service interface {
|
||||
CurrentUser(context.Context) (*model.UserResponse, error)
|
||||
CurrentUserData(context.Context) (*model.UserDataResponse, error)
|
||||
UpdateCurrentUserData(context.Context, *model.UserData) (*model.UserDataResponse, error)
|
||||
DashboardData(context.Context, string, *string) (map[string]any, error)
|
||||
DashboardData(context.Context, string, *string) (map[string]interface{}, error)
|
||||
ListDashboards(context.Context) ([]*model.DashboardResponse, error)
|
||||
CreateDashboard(context.Context, *model.Dashboard) (*model.DashboardResponse, error)
|
||||
GetDashboard(context.Context, string) (*model.DashboardResponse, error)
|
||||
@@ -60,8 +60,8 @@ type Service interface {
|
||||
RemoveComment(context.Context, int64, int) (*model.TicketWithTickets, error)
|
||||
AddTicketPlaybook(context.Context, int64, *model.PlaybookTemplateForm) (*model.TicketWithTickets, error)
|
||||
RemoveTicketPlaybook(context.Context, int64, string) (*model.TicketWithTickets, error)
|
||||
SetTaskData(context.Context, int64, string, string, map[string]any) (*model.TicketWithTickets, error)
|
||||
CompleteTask(context.Context, int64, string, string, map[string]any) (*model.TicketWithTickets, error)
|
||||
SetTaskData(context.Context, int64, string, string, map[string]interface{}) (*model.TicketWithTickets, error)
|
||||
CompleteTask(context.Context, int64, string, string, map[string]interface{}) (*model.TicketWithTickets, error)
|
||||
SetTaskOwner(context.Context, int64, string, string, string) (*model.TicketWithTickets, error)
|
||||
RunTask(context.Context, int64, string, string) error
|
||||
SetReferences(context.Context, int64, *model.ReferenceArray) (*model.TicketWithTickets, error)
|
||||
@@ -901,7 +901,7 @@ func (s *server) setTaskDataHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var dataP map[string]any
|
||||
var dataP map[string]interface{}
|
||||
if err := parseBody(body, &dataP); err != nil {
|
||||
JSONError(w, err)
|
||||
return
|
||||
@@ -928,7 +928,7 @@ func (s *server) completeTaskHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var dataP map[string]any
|
||||
var dataP map[string]interface{}
|
||||
if err := parseBody(body, &dataP); err != nil {
|
||||
JSONError(w, err)
|
||||
return
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -7089,8 +7089,14 @@
|
||||
},
|
||||
"type" : "array"
|
||||
},
|
||||
"salt" : {
|
||||
"type" : "string"
|
||||
},
|
||||
"sha256" : {
|
||||
"type" : "string"
|
||||
},
|
||||
"sha512" : {
|
||||
"type" : "string"
|
||||
}
|
||||
},
|
||||
"required" : [ "apikey", "blocked", "roles" ],
|
||||
@@ -7149,6 +7155,9 @@
|
||||
"id" : {
|
||||
"type" : "string"
|
||||
},
|
||||
"password" : {
|
||||
"type" : "string"
|
||||
},
|
||||
"roles" : {
|
||||
"items" : {
|
||||
"type" : "string"
|
||||
|
||||
@@ -1285,8 +1285,12 @@ definitions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
salt:
|
||||
type: string
|
||||
sha256:
|
||||
type: string
|
||||
sha512:
|
||||
type: string
|
||||
required:
|
||||
- blocked
|
||||
- apikey
|
||||
@@ -1334,6 +1338,8 @@ definitions:
|
||||
type: boolean
|
||||
id:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
roles:
|
||||
items:
|
||||
type: string
|
||||
|
||||
@@ -6510,8 +6510,14 @@
|
||||
},
|
||||
"type" : "array"
|
||||
},
|
||||
"salt" : {
|
||||
"type" : "string"
|
||||
},
|
||||
"sha256" : {
|
||||
"type" : "string"
|
||||
},
|
||||
"sha512" : {
|
||||
"type" : "string"
|
||||
}
|
||||
},
|
||||
"required" : [ "apikey", "blocked", "roles" ],
|
||||
@@ -6570,6 +6576,9 @@
|
||||
"id" : {
|
||||
"type" : "string"
|
||||
},
|
||||
"password" : {
|
||||
"type" : "string"
|
||||
},
|
||||
"roles" : {
|
||||
"items" : {
|
||||
"type" : "string"
|
||||
|
||||
@@ -1166,8 +1166,12 @@ definitions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
salt:
|
||||
type: string
|
||||
sha256:
|
||||
type: string
|
||||
sha512:
|
||||
type: string
|
||||
required:
|
||||
- blocked
|
||||
- apikey
|
||||
@@ -1215,6 +1219,8 @@ definitions:
|
||||
type: boolean
|
||||
id:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
roles:
|
||||
items:
|
||||
type: string
|
||||
|
||||
@@ -116,10 +116,10 @@ func init() {
|
||||
gojsonschema.NewStringLoader(`{"type":"object","properties":{"default_groups":{"items":{"type":"string"},"type":"array"},"default_playbooks":{"items":{"type":"string"},"type":"array"},"default_template":{"type":"string"},"icon":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name","icon","default_template","default_playbooks"],"$id":"#/definitions/TicketTypeResponse"}`),
|
||||
gojsonschema.NewStringLoader(`{"type":"object","properties":{"artifacts":{"items":{"$ref":"#/definitions/Artifact"},"type":"array"},"comments":{"items":{"$ref":"#/definitions/Comment"},"type":"array"},"created":{"format":"date-time","type":"string"},"details":{"type":"object"},"files":{"items":{"$ref":"#/definitions/File"},"type":"array"},"id":{"format":"int64","type":"integer"},"logs":{"items":{"$ref":"#/definitions/LogEntry"},"type":"array"},"modified":{"format":"date-time","type":"string"},"name":{"type":"string"},"owner":{"type":"string"},"playbooks":{"type":"object","additionalProperties":{"$ref":"#/definitions/PlaybookResponse"}},"read":{"items":{"type":"string"},"type":"array"},"references":{"items":{"$ref":"#/definitions/Reference"},"type":"array"},"schema":{"type":"string"},"status":{"type":"string"},"tickets":{"items":{"$ref":"#/definitions/TicketSimpleResponse"},"type":"array"},"type":{"type":"string"},"write":{"items":{"type":"string"},"type":"array"}},"required":["id","name","type","status","created","modified","schema"],"$id":"#/definitions/TicketWithTickets"}`),
|
||||
gojsonschema.NewStringLoader(`{"type":"object","properties":{"color":{"title":"Color","type":"string","enum":["error","info","success","warning"]},"icon":{"title":"Icon (https://materialdesignicons.com)","type":"string"},"id":{"title":"ID","type":"string"},"name":{"title":"Name","type":"string"}},"required":["id","name","icon"],"$id":"#/definitions/Type"}`),
|
||||
gojsonschema.NewStringLoader(`{"type":"object","properties":{"apikey":{"type":"boolean"},"blocked":{"type":"boolean"},"roles":{"items":{"type":"string"},"type":"array"},"sha256":{"type":"string"}},"required":["blocked","apikey","roles"],"$id":"#/definitions/User"}`),
|
||||
gojsonschema.NewStringLoader(`{"type":"object","properties":{"apikey":{"type":"boolean"},"blocked":{"type":"boolean"},"roles":{"items":{"type":"string"},"type":"array"},"salt":{"type":"string"},"sha256":{"type":"string"},"sha512":{"type":"string"}},"required":["blocked","apikey","roles"],"$id":"#/definitions/User"}`),
|
||||
gojsonschema.NewStringLoader(`{"type":"object","properties":{"email":{"type":"string"},"image":{"type":"string"},"name":{"type":"string"},"timeformat":{"title":"Time Format (https://moment.github.io/luxon/docs/manual/formatting.html#table-of-tokens)","type":"string"}},"$id":"#/definitions/UserData"}`),
|
||||
gojsonschema.NewStringLoader(`{"type":"object","properties":{"email":{"type":"string"},"id":{"type":"string"},"image":{"type":"string"},"name":{"type":"string"},"timeformat":{"title":"Time Format (https://moment.github.io/luxon/docs/manual/formatting.html#table-of-tokens)","type":"string"}},"required":["id"],"$id":"#/definitions/UserDataResponse"}`),
|
||||
gojsonschema.NewStringLoader(`{"type":"object","properties":{"apikey":{"type":"boolean"},"blocked":{"type":"boolean"},"id":{"type":"string"},"roles":{"items":{"type":"string"},"type":"array"}},"required":["id","blocked","roles","apikey"],"$id":"#/definitions/UserForm"}`),
|
||||
gojsonschema.NewStringLoader(`{"type":"object","properties":{"apikey":{"type":"boolean"},"blocked":{"type":"boolean"},"id":{"type":"string"},"password":{"type":"string"},"roles":{"items":{"type":"string"},"type":"array"}},"required":["id","blocked","roles","apikey"],"$id":"#/definitions/UserForm"}`),
|
||||
gojsonschema.NewStringLoader(`{"type":"object","properties":{"apikey":{"type":"boolean"},"blocked":{"type":"boolean"},"id":{"type":"string"},"roles":{"items":{"type":"string"},"type":"array"}},"required":["id","blocked","roles","apikey"],"$id":"#/definitions/UserResponse"}`),
|
||||
gojsonschema.NewStringLoader(`{"type":"object","properties":{"aggregation":{"type":"string"},"filter":{"type":"string"},"name":{"type":"string"},"type":{"type":"string","enum":["bar","line","pie"]},"width":{"maximum":12,"type":"integer"}},"required":["name","type","aggregation","width"],"$id":"#/definitions/Widget"}`),
|
||||
)
|
||||
@@ -251,14 +251,14 @@ type DashboardResponse struct {
|
||||
}
|
||||
|
||||
type Enrichment struct {
|
||||
Created time.Time `json:"created"`
|
||||
Data map[string]any `json:"data"`
|
||||
Name string `json:"name"`
|
||||
Created time.Time `json:"created"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type EnrichmentForm struct {
|
||||
Data map[string]any `json:"data"`
|
||||
Name string `json:"name"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type File struct {
|
||||
@@ -267,39 +267,39 @@ type File struct {
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
Automation string `json:"automation"`
|
||||
Container *string `json:"container,omitempty"`
|
||||
Log *string `json:"log,omitempty"`
|
||||
Origin *Origin `json:"origin,omitempty"`
|
||||
Output map[string]any `json:"output,omitempty"`
|
||||
Payload any `json:"payload,omitempty"`
|
||||
Running bool `json:"running"`
|
||||
Status string `json:"status"`
|
||||
Automation string `json:"automation"`
|
||||
Container *string `json:"container,omitempty"`
|
||||
Log *string `json:"log,omitempty"`
|
||||
Origin *Origin `json:"origin,omitempty"`
|
||||
Output map[string]interface{} `json:"output,omitempty"`
|
||||
Payload interface{} `json:"payload,omitempty"`
|
||||
Running bool `json:"running"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type JobForm struct {
|
||||
Automation string `json:"automation"`
|
||||
Origin *Origin `json:"origin,omitempty"`
|
||||
Payload any `json:"payload,omitempty"`
|
||||
Automation string `json:"automation"`
|
||||
Origin *Origin `json:"origin,omitempty"`
|
||||
Payload interface{} `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type JobResponse struct {
|
||||
Automation string `json:"automation"`
|
||||
Container *string `json:"container,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Log *string `json:"log,omitempty"`
|
||||
Origin *Origin `json:"origin,omitempty"`
|
||||
Output map[string]any `json:"output,omitempty"`
|
||||
Payload any `json:"payload,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Automation string `json:"automation"`
|
||||
Container *string `json:"container,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Log *string `json:"log,omitempty"`
|
||||
Origin *Origin `json:"origin,omitempty"`
|
||||
Output map[string]interface{} `json:"output,omitempty"`
|
||||
Payload interface{} `json:"payload,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type JobUpdate struct {
|
||||
Container *string `json:"container,omitempty"`
|
||||
Log *string `json:"log,omitempty"`
|
||||
Output map[string]any `json:"output,omitempty"`
|
||||
Running bool `json:"running"`
|
||||
Status string `json:"status"`
|
||||
Container *string `json:"container,omitempty"`
|
||||
Log *string `json:"log,omitempty"`
|
||||
Output map[string]interface{} `json:"output,omitempty"`
|
||||
Running bool `json:"running"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type LogEntry struct {
|
||||
@@ -312,7 +312,7 @@ type LogEntry struct {
|
||||
|
||||
type Message struct {
|
||||
Context *Context `json:"context,omitempty"`
|
||||
Payload any `json:"payload,omitempty"`
|
||||
Payload interface{} `json:"payload,omitempty"`
|
||||
Secrets map[string]string `json:"secrets,omitempty"`
|
||||
}
|
||||
|
||||
@@ -385,18 +385,18 @@ type Statistics struct {
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
Automation *string `json:"automation,omitempty"`
|
||||
Closed *time.Time `json:"closed,omitempty"`
|
||||
Created time.Time `json:"created"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
Done bool `json:"done"`
|
||||
Join *bool `json:"join,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Next map[string]string `json:"next,omitempty"`
|
||||
Owner *string `json:"owner,omitempty"`
|
||||
Payload map[string]string `json:"payload,omitempty"`
|
||||
Schema map[string]any `json:"schema,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Automation *string `json:"automation,omitempty"`
|
||||
Closed *time.Time `json:"closed,omitempty"`
|
||||
Created time.Time `json:"created"`
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
Done bool `json:"done"`
|
||||
Join *bool `json:"join,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Next map[string]string `json:"next,omitempty"`
|
||||
Owner *string `json:"owner,omitempty"`
|
||||
Payload map[string]string `json:"payload,omitempty"`
|
||||
Schema map[string]interface{} `json:"schema,omitempty"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type TaskOrigin struct {
|
||||
@@ -406,20 +406,20 @@ type TaskOrigin struct {
|
||||
}
|
||||
|
||||
type TaskResponse struct {
|
||||
Active bool `json:"active"`
|
||||
Automation *string `json:"automation,omitempty"`
|
||||
Closed *time.Time `json:"closed,omitempty"`
|
||||
Created time.Time `json:"created"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
Done bool `json:"done"`
|
||||
Join *bool `json:"join,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Next map[string]string `json:"next,omitempty"`
|
||||
Order int64 `json:"order"`
|
||||
Owner *string `json:"owner,omitempty"`
|
||||
Payload map[string]string `json:"payload,omitempty"`
|
||||
Schema map[string]any `json:"schema,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Active bool `json:"active"`
|
||||
Automation *string `json:"automation,omitempty"`
|
||||
Closed *time.Time `json:"closed,omitempty"`
|
||||
Created time.Time `json:"created"`
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
Done bool `json:"done"`
|
||||
Join *bool `json:"join,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Next map[string]string `json:"next,omitempty"`
|
||||
Order int64 `json:"order"`
|
||||
Owner *string `json:"owner,omitempty"`
|
||||
Payload map[string]string `json:"payload,omitempty"`
|
||||
Schema map[string]interface{} `json:"schema,omitempty"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type TaskWithContext struct {
|
||||
@@ -432,28 +432,28 @@ type TaskWithContext struct {
|
||||
}
|
||||
|
||||
type Ticket struct {
|
||||
Artifacts []*Artifact `json:"artifacts,omitempty"`
|
||||
Comments []*Comment `json:"comments,omitempty"`
|
||||
Created time.Time `json:"created"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
Files []*File `json:"files,omitempty"`
|
||||
Modified time.Time `json:"modified"`
|
||||
Name string `json:"name"`
|
||||
Owner *string `json:"owner,omitempty"`
|
||||
Playbooks map[string]*Playbook `json:"playbooks,omitempty"`
|
||||
Read []string `json:"read,omitempty"`
|
||||
References []*Reference `json:"references,omitempty"`
|
||||
Schema string `json:"schema"`
|
||||
Status string `json:"status"`
|
||||
Type string `json:"type"`
|
||||
Write []string `json:"write,omitempty"`
|
||||
Artifacts []*Artifact `json:"artifacts,omitempty"`
|
||||
Comments []*Comment `json:"comments,omitempty"`
|
||||
Created time.Time `json:"created"`
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
Files []*File `json:"files,omitempty"`
|
||||
Modified time.Time `json:"modified"`
|
||||
Name string `json:"name"`
|
||||
Owner *string `json:"owner,omitempty"`
|
||||
Playbooks map[string]*Playbook `json:"playbooks,omitempty"`
|
||||
Read []string `json:"read,omitempty"`
|
||||
References []*Reference `json:"references,omitempty"`
|
||||
Schema string `json:"schema"`
|
||||
Status string `json:"status"`
|
||||
Type string `json:"type"`
|
||||
Write []string `json:"write,omitempty"`
|
||||
}
|
||||
|
||||
type TicketForm struct {
|
||||
Artifacts []*Artifact `json:"artifacts,omitempty"`
|
||||
Comments []*Comment `json:"comments,omitempty"`
|
||||
Created *time.Time `json:"created,omitempty"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
Files []*File `json:"files,omitempty"`
|
||||
ID *int64 `json:"id,omitempty"`
|
||||
Modified *time.Time `json:"modified,omitempty"`
|
||||
@@ -479,7 +479,7 @@ type TicketResponse struct {
|
||||
Artifacts []*Artifact `json:"artifacts,omitempty"`
|
||||
Comments []*Comment `json:"comments,omitempty"`
|
||||
Created time.Time `json:"created"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
Files []*File `json:"files,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
Modified time.Time `json:"modified"`
|
||||
@@ -495,22 +495,22 @@ type TicketResponse struct {
|
||||
}
|
||||
|
||||
type TicketSimpleResponse struct {
|
||||
Artifacts []*Artifact `json:"artifacts,omitempty"`
|
||||
Comments []*Comment `json:"comments,omitempty"`
|
||||
Created time.Time `json:"created"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
Files []*File `json:"files,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
Modified time.Time `json:"modified"`
|
||||
Name string `json:"name"`
|
||||
Owner *string `json:"owner,omitempty"`
|
||||
Playbooks map[string]*Playbook `json:"playbooks,omitempty"`
|
||||
Read []string `json:"read,omitempty"`
|
||||
References []*Reference `json:"references,omitempty"`
|
||||
Schema string `json:"schema"`
|
||||
Status string `json:"status"`
|
||||
Type string `json:"type"`
|
||||
Write []string `json:"write,omitempty"`
|
||||
Artifacts []*Artifact `json:"artifacts,omitempty"`
|
||||
Comments []*Comment `json:"comments,omitempty"`
|
||||
Created time.Time `json:"created"`
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
Files []*File `json:"files,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
Modified time.Time `json:"modified"`
|
||||
Name string `json:"name"`
|
||||
Owner *string `json:"owner,omitempty"`
|
||||
Playbooks map[string]*Playbook `json:"playbooks,omitempty"`
|
||||
Read []string `json:"read,omitempty"`
|
||||
References []*Reference `json:"references,omitempty"`
|
||||
Schema string `json:"schema"`
|
||||
Status string `json:"status"`
|
||||
Type string `json:"type"`
|
||||
Write []string `json:"write,omitempty"`
|
||||
}
|
||||
|
||||
type TicketTemplate struct {
|
||||
@@ -560,7 +560,7 @@ type TicketWithTickets struct {
|
||||
Artifacts []*Artifact `json:"artifacts,omitempty"`
|
||||
Comments []*Comment `json:"comments,omitempty"`
|
||||
Created time.Time `json:"created"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
Files []*File `json:"files,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
Logs []*LogEntry `json:"logs,omitempty"`
|
||||
@@ -588,7 +588,9 @@ type User struct {
|
||||
Apikey bool `json:"apikey"`
|
||||
Blocked bool `json:"blocked"`
|
||||
Roles []string `json:"roles"`
|
||||
Salt *string `json:"salt,omitempty"`
|
||||
Sha256 *string `json:"sha256,omitempty"`
|
||||
Sha512 *string `json:"sha512,omitempty"`
|
||||
}
|
||||
|
||||
type UserData struct {
|
||||
@@ -607,10 +609,11 @@ type UserDataResponse struct {
|
||||
}
|
||||
|
||||
type UserForm struct {
|
||||
Apikey bool `json:"apikey"`
|
||||
Blocked bool `json:"blocked"`
|
||||
ID string `json:"id"`
|
||||
Roles []string `json:"roles"`
|
||||
Apikey bool `json:"apikey"`
|
||||
Blocked bool `json:"blocked"`
|
||||
ID string `json:"id"`
|
||||
Password *string `json:"password,omitempty"`
|
||||
Roles []string `json:"roles"`
|
||||
}
|
||||
|
||||
type UserResponse struct {
|
||||
|
||||
3
go.cap
3
go.cap
@@ -2,6 +2,7 @@ github.com/SecurityBrewery/catalyst/cmd/catalyst (network)
|
||||
|
||||
github.com/RoaringBitmap/roaring (reflect, unsafe)
|
||||
github.com/SecurityBrewery/catalyst (execute, file, network)
|
||||
github.com/SecurityBrewery/catalyst/auth (network)
|
||||
github.com/SecurityBrewery/catalyst/database/busdb (network)
|
||||
github.com/SecurityBrewery/catalyst/generated/api (network)
|
||||
github.com/SecurityBrewery/catalyst/generated/caql/parser (reflect)
|
||||
@@ -77,7 +78,6 @@ github.com/docker/go-connections/sockets (file, network, syscall)
|
||||
github.com/docker/go-connections/tlsconfig (file)
|
||||
github.com/go-chi/chi (network)
|
||||
github.com/go-chi/chi/middleware (file, network)
|
||||
github.com/go-chi/cors (file, network)
|
||||
github.com/gobwas/ws (network, reflect, unsafe)
|
||||
github.com/gobwas/ws/wsutil (file, network)
|
||||
github.com/gogo/protobuf/proto (reflect, unsafe)
|
||||
@@ -99,6 +99,7 @@ golang.org/x/net/internal/socks (network)
|
||||
golang.org/x/net/proxy (file, network)
|
||||
golang.org/x/oauth2 (network)
|
||||
golang.org/x/oauth2/internal (file, network)
|
||||
golang.org/x/sys/cpu (file)
|
||||
golang.org/x/sys/internal/unsafeheader (unsafe)
|
||||
golang.org/x/sys/unix (syscall, unsafe)
|
||||
google.golang.org/genproto/googleapis/rpc/status (reflect)
|
||||
|
||||
4
go.mod
4
go.mod
@@ -12,9 +12,9 @@ require (
|
||||
github.com/coreos/go-oidc/v3 v3.2.0
|
||||
github.com/docker/docker v17.12.0-ce-rc1.0.20201201034508-7d75c1d40d88+incompatible
|
||||
github.com/go-chi/chi v1.5.4
|
||||
github.com/go-chi/cors v1.2.1
|
||||
github.com/gobwas/ws v1.1.0
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/gorilla/securecookie v1.1.1
|
||||
github.com/iancoleman/strcase v0.2.0
|
||||
github.com/icza/dyno v0.0.0-20210726202311-f1bafe5d9996
|
||||
github.com/imdario/mergo v0.3.13
|
||||
@@ -24,6 +24,7 @@ require (
|
||||
github.com/tidwall/sjson v1.2.4
|
||||
github.com/tus/tusd v1.9.0
|
||||
github.com/xeipuuv/gojsonschema v1.2.0
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2
|
||||
golang.org/x/exp v0.0.0-20220518171630-0b5c67f07fdf
|
||||
golang.org/x/oauth2 v0.0.0-20220524215830-622c5d57e401
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -76,7 +77,6 @@ require (
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||
go.etcd.io/bbolt v1.3.6 // indirect
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 // indirect
|
||||
golang.org/x/net v0.0.0-20220325170049-de3da57026de // indirect
|
||||
golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
|
||||
4
go.sum
4
go.sum
@@ -372,8 +372,6 @@ github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2H
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs=
|
||||
github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg=
|
||||
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
@@ -508,6 +506,8 @@ github.com/gorilla/handlers v0.0.0-20150720190736-60c7bfde3e33/go.mod h1:Qkdc/uu
|
||||
github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
|
||||
44
server.go
44
server.go
@@ -2,14 +2,13 @@ package catalyst
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
|
||||
"github.com/SecurityBrewery/catalyst/auth"
|
||||
"github.com/SecurityBrewery/catalyst/bus"
|
||||
"github.com/SecurityBrewery/catalyst/busservice"
|
||||
"github.com/SecurityBrewery/catalyst/database"
|
||||
@@ -21,7 +20,6 @@ import (
|
||||
"github.com/SecurityBrewery/catalyst/role"
|
||||
"github.com/SecurityBrewery/catalyst/service"
|
||||
"github.com/SecurityBrewery/catalyst/storage"
|
||||
"github.com/SecurityBrewery/catalyst/ui"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -30,11 +28,12 @@ type Config struct {
|
||||
Storage *storage.Config
|
||||
|
||||
Secret []byte
|
||||
Auth *AuthConfig
|
||||
Auth *auth.Config
|
||||
ExternalAddress string
|
||||
InternalAddress string
|
||||
InitialAPIKey string
|
||||
Network string
|
||||
Port int
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -47,12 +46,13 @@ type Server struct {
|
||||
|
||||
func New(hooks *hooks.Hooks, config *Config) (*Server, error) {
|
||||
ctx := context.Background()
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Second*30)
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Minute*10)
|
||||
defer cancel()
|
||||
|
||||
err := config.Auth.Load(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if config.Auth.OIDCAuthEnable {
|
||||
if err := config.Auth.Load(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
catalystStorage, err := storage.New(config.Storage)
|
||||
@@ -109,14 +109,16 @@ func New(hooks *hooks.Hooks, config *Config) (*Server, error) {
|
||||
}
|
||||
|
||||
func setupAPI(catalystService *service.Service, catalystStorage *storage.Storage, catalystDatabase *database.Database, dbConfig *database.Config, bus *bus.Bus, config *Config) (chi.Router, error) {
|
||||
middlewares := []func(next http.Handler) http.Handler{Authenticate(catalystDatabase, config.Auth), AuthorizeBlockedUser()}
|
||||
secureJar := auth.NewJar(config.Secret)
|
||||
|
||||
middlewares := []func(next http.Handler) http.Handler{
|
||||
auth.Authenticate(catalystDatabase, config.Auth, secureJar),
|
||||
auth.AuthorizeBlockedUser(),
|
||||
}
|
||||
|
||||
// create server
|
||||
apiServerMiddleware := []func(next http.Handler) http.Handler{cors.AllowAll().Handler}
|
||||
apiServerMiddleware = append(apiServerMiddleware, middlewares...)
|
||||
apiServer := api.NewServer(catalystService, AuthorizeRole, apiServerMiddleware...)
|
||||
|
||||
fileReadWrite := AuthorizeRole([]string{role.FileReadWrite.String()})
|
||||
apiServer := api.NewServer(catalystService, auth.AuthorizeRole, middlewares...)
|
||||
fileReadWrite := auth.AuthorizeRole([]string{role.FileReadWrite.String()})
|
||||
tudHandler := tusdUpload(catalystDatabase, bus, catalystStorage.S3(), config.ExternalAddress)
|
||||
apiServer.With(fileReadWrite).Head("/files/{ticketID}/tusd/{id}", tudHandler)
|
||||
apiServer.With(fileReadWrite).Patch("/files/{ticketID}/tusd/{id}", tudHandler)
|
||||
@@ -124,18 +126,18 @@ func setupAPI(catalystService *service.Service, catalystStorage *storage.Storage
|
||||
apiServer.With(fileReadWrite).Post("/files/{ticketID}/upload", upload(catalystDatabase, catalystStorage.S3(), catalystStorage.Uploader()))
|
||||
apiServer.With(fileReadWrite).Get("/files/{ticketID}/download/{key}", download(catalystStorage.Downloader()))
|
||||
|
||||
apiServer.With(AuthorizeRole([]string{role.BackupRead.String()})).Get("/backup/create", backupHandler(catalystStorage, dbConfig))
|
||||
apiServer.With(AuthorizeRole([]string{role.BackupRestore.String()})).Post("/backup/restore", restoreHandler(catalystStorage, catalystDatabase, dbConfig))
|
||||
apiServer.With(auth.AuthorizeRole([]string{role.BackupRead.String()})).Get("/backup/create", backupHandler(catalystStorage, dbConfig))
|
||||
apiServer.With(auth.AuthorizeRole([]string{role.BackupRestore.String()})).Post("/backup/restore", restoreHandler(catalystStorage, catalystDatabase, dbConfig))
|
||||
|
||||
server := chi.NewRouter()
|
||||
server.Use(middleware.RequestID, middleware.RealIP, middleware.Logger, middleware.Recoverer, cors.AllowAll().Handler)
|
||||
server.Use(middleware.RequestID, middleware.RealIP, middleware.Logger, middleware.Recoverer)
|
||||
server.Mount("/api", apiServer)
|
||||
|
||||
server.Get("/callback", callback(config.Auth))
|
||||
server.With(middlewares...).Handle("/wss", handleWebSocket(bus))
|
||||
server.Mount("/auth", auth.Server(config.Auth, catalystDatabase, secureJar))
|
||||
|
||||
fsys, _ := fs.Sub(ui.UI, "dist")
|
||||
server.With(middlewares...).NotFound(api.VueStatic(fsys))
|
||||
server.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/ui/", http.StatusFound)
|
||||
})
|
||||
|
||||
return server, nil
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func (s *Service) GetUserData(ctx context.Context, id string) (*model.UserDataRe
|
||||
}
|
||||
|
||||
func (s *Service) UpdateUserData(ctx context.Context, id string, data *model.UserData) (doc *model.UserDataResponse, err error) {
|
||||
defer s.publishRequest(ctx, err, "CreateUser", userDataResponseID(doc))
|
||||
defer s.publishRequest(ctx, err, "UpdateUserData", userDataResponseID(doc))
|
||||
|
||||
return s.database.UserDataUpdate(ctx, id, data)
|
||||
}
|
||||
|
||||
14
test/test.go
14
test/test.go
@@ -15,6 +15,7 @@ import (
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/SecurityBrewery/catalyst"
|
||||
"github.com/SecurityBrewery/catalyst/auth"
|
||||
"github.com/SecurityBrewery/catalyst/bus"
|
||||
"github.com/SecurityBrewery/catalyst/database"
|
||||
"github.com/SecurityBrewery/catalyst/database/busdb"
|
||||
@@ -46,8 +47,11 @@ func Config(ctx context.Context) (*catalyst.Config, error) {
|
||||
Password: "minio123",
|
||||
},
|
||||
Secret: []byte("4ef5b29539b70233dd40c02a1799d25079595565e05a193b09da2c3e60ada1cd"),
|
||||
Auth: &catalyst.AuthConfig{
|
||||
OIDCIssuer: "http://localhost:9002/auth/realms/catalyst",
|
||||
Auth: &auth.Config{
|
||||
SimpleAuthEnable: true,
|
||||
APIKeyAuthEnable: true,
|
||||
OIDCAuthEnable: true,
|
||||
OIDCIssuer: "http://localhost:9002/auth/realms/catalyst",
|
||||
OAuth2: &oauth2.Config{
|
||||
ClientID: "catalyst",
|
||||
ClientSecret: "13d4a081-7395-4f71-a911-bc098d8d3c45",
|
||||
@@ -61,12 +65,12 @@ func Config(ctx context.Context) (*catalyst.Config, error) {
|
||||
// AuthDefaultRoles: nil,
|
||||
},
|
||||
}
|
||||
err := config.Auth.Load(ctx)
|
||||
if err != nil {
|
||||
|
||||
if err := config.Auth.Load(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, err
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func Index(t *testing.T) (*index.Index, func(), error) {
|
||||
|
||||
@@ -2,31 +2,31 @@ describe('user', () => {
|
||||
it('open ticket', () => {
|
||||
cy.visit('/');
|
||||
|
||||
// login
|
||||
cy.get("#username").type("bob");
|
||||
cy.get("#password").type("bob");
|
||||
cy.get("#kc-login").click();
|
||||
if (Cypress.env('AUTH') === 'simple') {
|
||||
cy.login();
|
||||
} else if (Cypress.env('AUTH') === 'keycloak') {
|
||||
cy.get("#username").type("bob");
|
||||
cy.get("#password").type("bob");
|
||||
cy.get("#kc-login").click();
|
||||
}
|
||||
|
||||
cy.getCookie('user').should('exist');
|
||||
|
||||
cy.intercept('GET', '/api/userdata/demo', { fixture: 'userdata_demo.json' })
|
||||
cy.intercept('GET', '/api/users/demo', { fixture: 'user_demo.json' })
|
||||
|
||||
cy.origin('http://localhost', () => {
|
||||
cy.visit('/tickets');
|
||||
cy.visit('http://localhost/ui/tickets');
|
||||
|
||||
// clear caql
|
||||
cy.get("#app > div > main > div > div > div > div > header > div > div.v-input.v-input--hide-details.v-input--is-label-active.v-input--is-dirty.v-input--dense.theme--light.v-text-field.v-text-field--single-line.v-text-field--solo.v-text-field--solo-flat.v-text-field--is-booted.v-text-field--enclosed.v-text-field--placeholder > div > div > div:nth-child(2) > div > button")
|
||||
.click();
|
||||
// clear caql
|
||||
cy.get("#caqlbar > div > div > div > div > div:nth-child(2) > div > button").click();
|
||||
|
||||
// open ticket
|
||||
cy.get("#app > div > main > div > div > div > div > div > div.v-data-table__wrapper > table > tbody > tr:nth-child(1) > td > a")
|
||||
.click()
|
||||
// open ticket
|
||||
cy.contains("live zebra").click()
|
||||
|
||||
// assert url
|
||||
cy.url().should('eq', "http://localhost/tickets/8123")
|
||||
// assert url
|
||||
cy.url().should('eq', "http://localhost/ui/tickets/8123")
|
||||
|
||||
// assert title
|
||||
cy.get("#\\38 123 > div > div > div:nth-child(3) > div:nth-child(2) > div:nth-child(2) > div > div.col-lg-8.col-12 > h1")
|
||||
.should("have.text", " Incident #8123: live zebra ")
|
||||
})
|
||||
// assert title
|
||||
cy.get("h1").should("have.text", " Incident #8123: live zebra ")
|
||||
})
|
||||
})
|
||||
22
ui/cypress/plugins/index.js
Normal file
22
ui/cypress/plugins/index.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/// <reference types="cypress" />
|
||||
// ***********************************************************
|
||||
// This example plugins/index.js can be used to load plugins
|
||||
//
|
||||
// You can change the location of this file or turn off loading
|
||||
// the plugins file with the 'pluginsFile' configuration option.
|
||||
//
|
||||
// You can read more here:
|
||||
// https://on.cypress.io/plugins-guide
|
||||
// ***********************************************************
|
||||
|
||||
// This function is called when a project is opened or re-opened (e.g. due to
|
||||
// the project's config changing)
|
||||
|
||||
/**
|
||||
* @type {Cypress.PluginConfig}
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
module.exports = (on, config) => {
|
||||
// `on` is used to hook into various events Cypress emits
|
||||
// `config` is the resolved Cypress config
|
||||
}
|
||||
@@ -4,3 +4,20 @@
|
||||
// ***********************************************************
|
||||
|
||||
import './commands'
|
||||
|
||||
Cypress.Cookies.defaults({
|
||||
preserve: 'user',
|
||||
})
|
||||
|
||||
Cypress.on('uncaught:exception', (err, runnable) => {
|
||||
return false
|
||||
})
|
||||
|
||||
Cypress.Commands.add('login', (options = {}) => {
|
||||
// login
|
||||
cy.contains("Name").click({force: true});
|
||||
cy.get("#username").type("tom");
|
||||
cy.contains("Password").click({force: true});
|
||||
cy.get("#password").type("tom");
|
||||
cy.get("button").contains("Login").click();
|
||||
})
|
||||
|
||||
366
ui/src/App.vue
366
ui/src/App.vue
@@ -1,157 +1,212 @@
|
||||
<template>
|
||||
<v-app class="background">
|
||||
<v-navigation-drawer dark permanent :mini-variant="mini" :expand-on-hover="mini" app color="statusbar">
|
||||
<v-list>
|
||||
<v-list-item class="px-2" :to="{ name: 'Home' }">
|
||||
<v-list-item-avatar rounded="0">
|
||||
<v-img src="/static/flask_white.svg" :width="40"></v-img>
|
||||
</v-list-item-avatar>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title class="title">
|
||||
Catalyst
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<!--v-list dense nav>
|
||||
<v-list-item class="px-0" dense :to="{ name: 'Profile' }">
|
||||
<v-list-item-avatar>
|
||||
<v-img v-if="$store.state.userdata.image" :src="$store.state.userdata.image"></v-img>
|
||||
<v-icon v-else>mdi-account-circle</v-icon>
|
||||
</v-list-item-avatar>
|
||||
<div v-if="$store.state.user">
|
||||
{{ $store.state.userdata.name }}
|
||||
</div>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<v-divider></v-divider-->
|
||||
|
||||
<v-list nav dense>
|
||||
<v-list-item>
|
||||
<v-list-item-icon>
|
||||
<v-icon class="my-1">mdi-arrow-right-bold</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-title>
|
||||
<v-text-field
|
||||
placeholder="Goto"
|
||||
outlined
|
||||
dense
|
||||
hide-details
|
||||
v-on:keyup.enter="enter"
|
||||
clearable
|
||||
color="#fff"
|
||||
v-model="goto"></v-text-field>
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<v-divider></v-divider>
|
||||
|
||||
<AppLink :links="internal"></AppLink>
|
||||
|
||||
<v-list nav dense v-if="$store.state.settings.ticketTypes">
|
||||
<v-list-item
|
||||
v-for="customType in $store.state.settings.ticketTypes"
|
||||
:key="customType.id"
|
||||
link
|
||||
:class="{ 'v-list-item--active': ($route.params.type === customType.id) }"
|
||||
@click="openTicketList(customType.id)">
|
||||
<v-list-item-icon>
|
||||
<v-badge
|
||||
v-if="customType.id in counts && counts[customType.id] > 0"
|
||||
:content="counts[customType.id]"
|
||||
color="red"
|
||||
left
|
||||
offset-x="35"
|
||||
offset-y="8"
|
||||
bottom>
|
||||
<v-icon>{{ customType.icon }}</v-icon>
|
||||
</v-badge>
|
||||
<v-icon v-else>{{ customType.icon }}</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-title>{{ customType.name }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<AppLink :links="settings"></AppLink>
|
||||
|
||||
<template v-slot:append>
|
||||
|
||||
<v-list nav dense>
|
||||
<v-list-item class="version" dense style="min-height: 20px">
|
||||
<div>
|
||||
<v-app v-if="!authenticated" id="app" class="background-dark">
|
||||
<v-container class="login d-flex flex-column justify-center">
|
||||
<v-form ref="form" v-model="valid" @submit.prevent="login">
|
||||
<v-card class="pa-4">
|
||||
<div class="d-flex justify-center">
|
||||
<v-img src="/ui/flask.svg" height="100" width="100" class="flex-grow-0"></v-img>
|
||||
</div>
|
||||
<v-card-title class="text-center justify-center">
|
||||
Catalyst Login
|
||||
</v-card-title>
|
||||
<v-card-text v-if="hassimple" class="text-center">
|
||||
<v-text-field id="username" name="username" label="Name" v-model="username" :rules="[
|
||||
v => !!v || 'Name is required',
|
||||
]"></v-text-field>
|
||||
<v-text-field
|
||||
id="password"
|
||||
name="password"
|
||||
label="Password"
|
||||
:append-icon="show ? 'mdi-eye' : 'mdi-eye-off'"
|
||||
:type="show ? 'text' : 'password'"
|
||||
@click:append="show = !show"
|
||||
v-model="password"
|
||||
:rules="[
|
||||
v => !!v || 'Password is required',
|
||||
// v => (v && v.length > 8) || 'Password must be more than 8 characters',
|
||||
]"></v-text-field>
|
||||
</v-card-text>
|
||||
<v-card-actions class="justify-center">
|
||||
<v-btn v-if="hasoidc" text href="/auth/oidclogin">
|
||||
Login with OIDC
|
||||
</v-btn>
|
||||
<v-spacer v-if="hassimple"></v-spacer>
|
||||
<v-btn v-if="hassimple" type="submit" color="primary" elevation="0" :disabled="!valid">
|
||||
Login
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-form>
|
||||
</v-container>
|
||||
</v-app>
|
||||
<v-app v-else class="background">
|
||||
<v-navigation-drawer dark permanent :mini-variant="mini" :expand-on-hover="mini" app color="statusbar">
|
||||
<v-list>
|
||||
<v-list-item class="px-2" :to="{ name: 'Home' }">
|
||||
<v-list-item-avatar rounded="0">
|
||||
<v-img src="/ui/flask_white.svg" :width="40"></v-img>
|
||||
</v-list-item-avatar>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title style="text-align: center; opacity: 0.5;">
|
||||
{{ $store.state.settings.tier }} v{{ $store.state.settings.version }}
|
||||
<v-list-item-title class="title">
|
||||
Catalyst
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-list nav dense>
|
||||
|
||||
<v-list-item :to="{ name: 'API' }">
|
||||
<v-list-item>
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-share-variant</v-icon>
|
||||
<v-icon class="my-1">mdi-arrow-right-bold</v-icon>
|
||||
</v-list-item-icon>
|
||||
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>API Documentation</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
<v-list-item-title>
|
||||
<v-text-field
|
||||
placeholder="Goto"
|
||||
outlined
|
||||
dense
|
||||
hide-details
|
||||
v-on:keyup.enter="enter"
|
||||
clearable
|
||||
color="#fff"
|
||||
v-model="goto"></v-text-field>
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
</v-navigation-drawer>
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-app-bar app dense flat absolute color="transparent">
|
||||
<v-btn icon @click="mini = !mini">
|
||||
<v-icon color="primary">mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
<AppLink :links="internal"></AppLink>
|
||||
|
||||
<v-breadcrumbs :items="crumbs">
|
||||
<template v-slot:item="{ item }">
|
||||
<v-breadcrumbs-item
|
||||
:to="item.to"
|
||||
class="text-subtitle-2 crumb-item"
|
||||
:disabled="item.disabled"
|
||||
exact
|
||||
>
|
||||
{{ item.text }}
|
||||
</v-breadcrumbs-item>
|
||||
<v-list nav dense v-if="$store.state.settings.ticketTypes">
|
||||
<v-list-item
|
||||
v-for="customType in $store.state.settings.ticketTypes"
|
||||
:key="customType.id"
|
||||
link
|
||||
:class="{ 'v-list-item--active': ($route.params.type === customType.id) }"
|
||||
@click="openTicketList(customType.id)">
|
||||
<v-list-item-icon>
|
||||
<v-badge
|
||||
v-if="customType.id in counts && counts[customType.id] > 0"
|
||||
:content="counts[customType.id]"
|
||||
color="red"
|
||||
left
|
||||
offset-x="35"
|
||||
offset-y="8"
|
||||
bottom>
|
||||
<v-icon>{{ customType.icon }}</v-icon>
|
||||
</v-badge>
|
||||
<v-icon v-else>{{ customType.icon }}</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-title>{{ customType.name }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<AppLink :links="settings"></AppLink>
|
||||
|
||||
<template v-slot:append>
|
||||
|
||||
<v-list nav dense>
|
||||
<v-list-item class="version" dense style="min-height: 20px">
|
||||
<v-list-item-content>
|
||||
<v-list-item-title style="text-align: center; opacity: 0.5;">
|
||||
{{ $store.state.settings.tier }} v{{ $store.state.settings.version }}
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<v-divider></v-divider>
|
||||
<v-list nav dense>
|
||||
|
||||
<v-list-item :to="{ name: 'API' }">
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-share-variant</v-icon>
|
||||
</v-list-item-icon>
|
||||
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>API Documentation</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
</v-breadcrumbs>
|
||||
</v-navigation-drawer>
|
||||
<v-app-bar app dense flat absolute color="transparent">
|
||||
<v-btn icon @click="mini = !mini">
|
||||
<v-icon id="toggle_menu" color="primary">mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
<v-breadcrumbs :items="crumbs">
|
||||
<template v-slot:item="{ item }">
|
||||
<v-breadcrumbs-item
|
||||
:to="item.to"
|
||||
class="text-subtitle-2 crumb-item"
|
||||
:disabled="item.disabled"
|
||||
exact
|
||||
>
|
||||
{{ item.text }}
|
||||
</v-breadcrumbs-item>
|
||||
</template>
|
||||
</v-breadcrumbs>
|
||||
|
||||
<v-btn :to="{ name: 'Profile' }" icon>
|
||||
<v-avatar v-if="$store.state.userdata.image" size="32">
|
||||
<v-img :src="$store.state.userdata.image"></v-img>
|
||||
</v-avatar>
|
||||
<v-icon v-else>mdi-account-circle</v-icon>
|
||||
</v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
</v-app-bar>
|
||||
<router-view></router-view>
|
||||
<v-snackbar v-model="snackbar" :color="$store.state.alert.type" :timeout="$store.state.alert.type === 'error' ? -1 : 5000" outlined>
|
||||
<b style="display: block">{{ $store.state.alert.name | capitalize }}</b>
|
||||
{{ $store.state.alert.detail }}
|
||||
<template v-slot:action="{ attrs }">
|
||||
<v-btn text v-bind="attrs" @click="snackbar = false">Close</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</v-app>
|
||||
<v-menu left bottom offset-y>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-btn
|
||||
icon
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
>
|
||||
<v-avatar v-if="$store.state.userdata.image" size="32">
|
||||
<v-img :src="$store.state.userdata.image"></v-img>
|
||||
</v-avatar>
|
||||
<v-icon v-else>mdi-account-circle</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-list>
|
||||
<v-list-item :to="{ name: 'Profile' }">
|
||||
<v-list-item-title>Account</v-list-item-title>
|
||||
<v-list-item-icon><v-icon>mdi-account-circle</v-icon></v-list-item-icon>
|
||||
</v-list-item>
|
||||
<v-list-item @click="logout">
|
||||
<v-list-item-title>Logout</v-list-item-title>
|
||||
<v-list-item-icon><v-icon>mdi-logout</v-icon></v-list-item-icon>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
</v-app-bar>
|
||||
<router-view></router-view>
|
||||
<v-snackbar v-model="snackbar" :color="$store.state.alert.type" :timeout="$store.state.alert.type === 'error' ? -1 : 5000" outlined>
|
||||
<b style="display: block">{{ $store.state.alert.name | capitalize }}</b>
|
||||
{{ $store.state.alert.detail }}
|
||||
<template v-slot:action="{ attrs }">
|
||||
<v-btn text v-bind="attrs" @click="snackbar = false">Close</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</v-app>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from "vue";
|
||||
import AppLink from "./components/AppLink.vue";
|
||||
import router from "vue-router";
|
||||
import {API} from "@/services/api";
|
||||
|
||||
export default Vue.extend({
|
||||
name: "App",
|
||||
components: {AppLink},
|
||||
data: () => ({
|
||||
show: false,
|
||||
hassimple: false,
|
||||
hasoidc: false,
|
||||
username: "",
|
||||
password: "",
|
||||
valid: true,
|
||||
authenticated: false,
|
||||
settings: [
|
||||
{ icon: "mdi-format-list-bulleted-type", name: "Ticket Types", to: "TicketTypeList", role: "engineer:tickettype:write" },
|
||||
{ icon: "mdi-file-hidden", name: "Templates", to: "TemplateList", role: "analyst:template:read" },
|
||||
@@ -229,12 +284,56 @@ export default Vue.extend({
|
||||
return this.lodash.includes(this.$store.state.user.roles, s);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
login: function () {
|
||||
this.axios.post(
|
||||
"/auth/login",
|
||||
{username: this.username, password: this.password},
|
||||
).then((response) => {
|
||||
console.log(response.data);
|
||||
if (!this.lodash.isObject(response.data)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.$store.dispatch("getUser");
|
||||
this.$store.dispatch("getUserData");
|
||||
this.$store.dispatch("getSettings");
|
||||
|
||||
this.authenticated = true;
|
||||
}).catch(() => {
|
||||
this.valid = false;
|
||||
})
|
||||
},
|
||||
logout: function () {
|
||||
this.axios.post("/auth/logout").then(() => {
|
||||
this.authenticated = false;
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch("getUser");
|
||||
this.$store.dispatch("getUserData");
|
||||
this.$store.dispatch("getSettings");
|
||||
this.axios.get("/auth/config").then((response) => {
|
||||
this.hassimple = response.data.simple;
|
||||
this.hasoidc = response.data.oidc;
|
||||
|
||||
API.currentUser().then((response) => {
|
||||
if (!this.lodash.isObject(response.data)) {
|
||||
if (!this.hassimple && this.hasoidc) {
|
||||
window.location.href = "/auth/oidclogin";
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.authenticated = true;
|
||||
|
||||
this.$store.dispatch("getUser");
|
||||
this.$store.dispatch("getUserData");
|
||||
this.$store.dispatch("getSettings");
|
||||
})
|
||||
}).catch(() => {
|
||||
this.hassimple = false;
|
||||
this.hasoidc = false;
|
||||
})
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -244,6 +343,15 @@ export default Vue.extend({
|
||||
background-color: #f5f5f5 !important;
|
||||
}
|
||||
|
||||
.background-dark {
|
||||
background-color: #212121 !important;
|
||||
}
|
||||
|
||||
.login {
|
||||
height: 100%;
|
||||
max-width: 400px !important;
|
||||
}
|
||||
|
||||
.v-app-bar.v-toolbar--dense .v-toolbar__content {
|
||||
border-bottom: 1px solid #e0e0e0 !important;
|
||||
}
|
||||
|
||||
@@ -2186,12 +2186,24 @@ export interface User {
|
||||
* @memberof User
|
||||
*/
|
||||
'roles': Array<string>;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof User
|
||||
*/
|
||||
'salt'?: string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof User
|
||||
*/
|
||||
'sha256'?: string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof User
|
||||
*/
|
||||
'sha512'?: string;
|
||||
}
|
||||
/**
|
||||
*
|
||||
@@ -2285,6 +2297,12 @@ export interface UserForm {
|
||||
* @memberof UserForm
|
||||
*/
|
||||
'id': string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof UserForm
|
||||
*/
|
||||
'password'?: string;
|
||||
/**
|
||||
*
|
||||
* @type {Array<string>}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
</v-row>
|
||||
|
||||
<v-toolbar
|
||||
id="caqlbar"
|
||||
rounded
|
||||
filled
|
||||
dense
|
||||
|
||||
@@ -275,6 +275,7 @@ const routes: Array<RouteConfig> = [
|
||||
|
||||
const router = new VueRouter({
|
||||
mode: 'history',
|
||||
base: 'ui',
|
||||
routes,
|
||||
});
|
||||
|
||||
|
||||
@@ -12,17 +12,43 @@
|
||||
</div>
|
||||
<div v-else class="fill-height d-flex flex-column pa-8">
|
||||
<div v-if="$route.params.id === 'new'">
|
||||
<h2>Create new API key</h2>
|
||||
<h2>
|
||||
Create a new
|
||||
<span v-if="user.apikey">API Key</span>
|
||||
<span v-else>User</span>
|
||||
</h2>
|
||||
<v-form>
|
||||
<v-text-field label="ID" v-model="user.id" hide-details></v-text-field>
|
||||
<v-btn-toggle v-model="user.apikey" mandatory dense>
|
||||
<v-btn :value="false">User</v-btn>
|
||||
<v-btn :value="true">API Key</v-btn>
|
||||
</v-btn-toggle>
|
||||
<v-text-field label="ID" v-model="user.id" class="mb-2" :rules="[
|
||||
v => !!v || 'ID is required',
|
||||
v => (v && v.length < 254) || 'ID must be between 1 and 254 characters',
|
||||
v => /^[a-z\d\-]+$/.test(v) || 'Only characters a-z, 0-9 and - are allowed',
|
||||
// v => /^[A-Za-z0-9_\-\:@\(\)\+,=;\$!\*'%]+$/.test(v) || 'Only characters A-Z, a-z, 0-9, _, -, :, ., @, (, ), +, ,, =, ;, $, !, *, \', % are allowed',
|
||||
]"></v-text-field>
|
||||
<v-text-field
|
||||
v-if="!user.apikey"
|
||||
label="Password"
|
||||
v-model="user.password"
|
||||
:append-icon="show ? 'mdi-eye' : 'mdi-eye-off'"
|
||||
:type="show ? 'text' : 'password'"
|
||||
@click:append="show = !show"
|
||||
:rules="[
|
||||
v => !!v || 'Password is required',
|
||||
v => (v && v.length >= 8) || 'Password must be at least 8 characters',
|
||||
]"></v-text-field>
|
||||
<v-select multiple chips label="Roles" v-model="user.roles" :items="$store.state.settings.roles"></v-select>
|
||||
<v-btn @click="save" color="success" outlined>
|
||||
<v-icon>mdi-plus-thick</v-icon>
|
||||
Create API-Key
|
||||
Create
|
||||
<span v-if="user.apikey" class="ml-1">API Key</span>
|
||||
<span v-else class="ml-1">User</span>
|
||||
</v-btn>
|
||||
</v-form>
|
||||
<v-alert v-if="newUserResponse" color="warning" class="mt-4" dismissible>
|
||||
<b>New API-Secret:</b> {{ newUserResponse.secret }}<br>
|
||||
<b>New API secret:</b> {{ newUserResponse.secret }}<br>
|
||||
Make sure you save it - you won't be able to access it again.
|
||||
</v-alert>
|
||||
</div>
|
||||
@@ -32,9 +58,12 @@
|
||||
<span v-if="user.apikey">(API Key)</span>
|
||||
</h2>
|
||||
|
||||
<v-text-field v-if="!user.apikey" label="New Password (leave empty to keep)" v-model="user.password" hide-details class="mb-4"></v-text-field>
|
||||
<v-checkbox v-if="!user.apikey" label="Blocked" v-model="user.blocked" hide-details class="mb-4"></v-checkbox>
|
||||
|
||||
<v-select multiple chips v-if="!user.apikey" label="Roles" v-model="user.roles" :items="$store.state.settings.roles"></v-select>
|
||||
<div v-else>
|
||||
<v-chip v-for="role in user.roles" :key="role">{{ role }}</v-chip>
|
||||
<v-chip v-for="role in user.roles" :key="role" class="mr-1 mb-1">{{ role }}</v-chip>
|
||||
</div>
|
||||
|
||||
<v-btn v-if="!user.apikey" @click="save" color="success" outlined>
|
||||
@@ -49,11 +78,12 @@
|
||||
<script lang="ts">
|
||||
import Vue from "vue";
|
||||
|
||||
import { NewUserResponse, UserResponse } from "@/client";
|
||||
import {NewUserResponse, UserForm} from "@/client";
|
||||
import {API} from "@/services/api";
|
||||
|
||||
interface State {
|
||||
user?: UserResponse;
|
||||
show: boolean;
|
||||
user?: UserForm;
|
||||
newUserResponse?: NewUserResponse;
|
||||
}
|
||||
|
||||
@@ -61,6 +91,7 @@ export default Vue.extend({
|
||||
name: "User",
|
||||
components: {},
|
||||
data: (): State => ({
|
||||
show: false,
|
||||
user: undefined,
|
||||
newUserResponse: undefined
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
module.exports = {
|
||||
publicPath: "/static/",
|
||||
publicPath: "/ui/",
|
||||
transpileDependencies: ["vuetify", "@koumoul/vjsf"],
|
||||
pwa: {
|
||||
name: "Catalyst",
|
||||
|
||||
Reference in New Issue
Block a user