Add simple auth (#186)

This commit is contained in:
Jonas Plum
2022-06-13 18:13:31 +02:00
committed by GitHub
parent 4883646f39
commit 9f1041d7ef
43 changed files with 1304 additions and 622 deletions

View File

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

View File

@@ -101,6 +101,7 @@ linters-settings:
- go-driver.Cursor
- go-driver.Collection
- go-driver.Database
- go-driver.Client
- chi.Router
issues:
exclude-rules:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,7 @@
</v-row>
<v-toolbar
id="caqlbar"
rounded
filled
dense

View File

@@ -275,6 +275,7 @@ const routes: Array<RouteConfig> = [
const router = new VueRouter({
mode: 'history',
base: 'ui',
routes,
});

View File

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

View File

@@ -1,5 +1,5 @@
module.exports = {
publicPath: "/static/",
publicPath: "/ui/",
transpileDependencies: ["vuetify", "@koumoul/vjsf"],
pwa: {
name: "Catalyst",