diff --git a/cmd/cc-backend/main.go b/cmd/cc-backend/main.go index 2cc6926..84bc2cc 100644 --- a/cmd/cc-backend/main.go +++ b/cmd/cc-backend/main.go @@ -73,13 +73,11 @@ type ProgramConfig struct { DisableArchive bool `json:"disable-archive"` // For LDAP Authentication and user synchronisation. - LdapConfig *auth.LdapConfig `json:"ldap"` + LdapConfig *auth.LdapConfig `json:"ldap"` + JwtConfig *auth.JWTAuthConfig `json:"jwts"` - // Specifies for how long a session or JWT shall be valid - // as a string parsable by time.ParseDuration(). // If 0 or empty, the session/token does not expire! SessionMaxAge string `json:"session-max-age"` - JwtMaxAge string `json:"jwt-max-age"` // If both those options are not empty, use HTTPS using those certificates. HttpsCertFile string `json:"https-cert-file"` @@ -110,7 +108,6 @@ var programConfig ProgramConfig = ProgramConfig{ DisableArchive: false, LdapConfig: nil, SessionMaxAge: "168h", - JwtMaxAge: "0", UiDefaults: map[string]interface{}{ "analysis_view_histogramMetrics": []string{"flops_any", "mem_bw", "mem_used"}, "analysis_view_scatterPlotMetrics": [][]string{{"flops_any", "mem_bw"}, {"flops_any", "cpu_load"}, {"cpu_load", "mem_bw"}}, @@ -190,20 +187,26 @@ func main() { var authentication *auth.Authentication if !programConfig.DisableAuthentication { - authentication = &auth.Authentication{} - if d, err := time.ParseDuration(programConfig.SessionMaxAge); err != nil { - authentication.SessionMaxAge = d - } - if d, err := time.ParseDuration(programConfig.JwtMaxAge); err != nil { - authentication.JwtMaxAge = d - } - - if err := authentication.Init(db.DB, programConfig.LdapConfig); err != nil { + if authentication, err = auth.Init(db.DB, map[string]interface{}{ + "ldap": programConfig.LdapConfig, + "jwt": programConfig.JwtConfig, + }); err != nil { log.Fatal(err) } + if d, err := time.ParseDuration(programConfig.SessionMaxAge); err != nil { + authentication.SessionMaxAge = d + } + if flagNewUser != "" { - if err := authentication.AddUser(flagNewUser); err != nil { + parts := strings.SplitN(flagNewUser, ":", 3) + if len(parts) != 3 || len(parts[0]) == 0 { + log.Fatal("invalid argument format for user creation") + } + + if err := authentication.AddUser(&auth.User{ + Username: parts[0], Password: parts[2], Roles: strings.Split(parts[1], ","), + }); err != nil { log.Fatal(err) } } @@ -214,13 +217,18 @@ func main() { } if flagSyncLDAP { - if err := authentication.SyncWithLDAP(true); err != nil { + if authentication.LdapAuth == nil { + log.Fatal("cannot sync: LDAP authentication is not configured") + } + + if err := authentication.LdapAuth.Sync(); err != nil { log.Fatal(err) } + log.Info("LDAP sync successfull") } if flagGenJWT != "" { - user, err := authentication.FetchUser(flagGenJWT) + user, err := authentication.GetUser(flagGenJWT) if err != nil { log.Fatal(err) } @@ -229,7 +237,7 @@ func main() { log.Warn("that user does not have the API role") } - jwt, err := authentication.ProvideJWT(user) + jwt, err := authentication.JwtAuth.ProvideJWT(user) if err != nil { log.Fatal(err) } diff --git a/tools/gen-keypair.go b/cmd/gen-keypair/gen-keypair.go similarity index 100% rename from tools/gen-keypair.go rename to cmd/gen-keypair/gen-keypair.go diff --git a/configs/README.md b/configs/README.md index 050ea2c..c0d6d90 100644 --- a/configs/README.md +++ b/configs/README.md @@ -55,7 +55,8 @@ Some of the `ui-defaults` values can be appended by `:` in order to An example env file is found in this directory. Copy it to `.env` in the project root and adapt it for your needs. -* `JWT_PUBLIC_KEY` and `JWT_PRIVATE_KEY`: Base64 encoded Ed25519 keys used for JSON Web Token (JWT) authentication. You must generate your own keypair using `go run tools/gen-keypair.go`. Next to the `gen-keypair.go` utility, you will find a README with more information about how to use these keys and where to put tokens. +* `JWT_PUBLIC_KEY` and `JWT_PRIVATE_KEY`: Base64 encoded Ed25519 keys used for JSON Web Token (JWT) authentication. You can generate your own keypair using `go run ./cmd/gen-keypair/gen-keypair.go`. More information in [README_TOKENS.md](./README_TOKENS.md). * `SESSION_KEY`: Some random bytes used as secret for cookie-based sessions. * `LDAP_ADMIN_PASSWORD`: The LDAP admin user password (optional). +* `CROSS_LOGIN_JWT_HS512_KEY`: Used for token based logins via another authentication service. * `LOGLEVEL`: Can be `err`, `warn`, `info` or `debug` (optional, `debug` by default). Can be used to reduce logging. diff --git a/configs/README_TOKENS.md b/configs/README_TOKENS.md new file mode 100644 index 0000000..be8a912 --- /dev/null +++ b/configs/README_TOKENS.md @@ -0,0 +1,51 @@ +## Introduction + +ClusterCockpit uses JSON Web Tokens (JWT) for authorization of its APIs. +JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. +This information can be verified and trusted because it is digitally signed. +In ClusterCockpit JWTs are signed using a public/private key pair using ECDSA. +Because tokens are signed using public/private key pairs, the signature also certifies that only the party holding the private key is the one that signed it. +Expiration of the generated tokens as well as the max. length of a browser session can be configured in the `config.json` file described [here](./README.md). + +The [Ed25519](https://ed25519.cr.yp.to/) algorithm for signatures was used because it is compatible with other tools that require authentication, such as NATS.io, and because these elliptic-curve methods provide simillar security with smaller keys compared to something like RSA. They are sligthly more expensive to validate, but that effect is negligible. + +## JWT Payload + +You may view the payload of a JWT token at [https://jwt.io/#debugger-io](https://jwt.io/#debugger-io). +Currently ClusterCockpit sets the following claims: +* `iat`: Issued at claim. The “iat” claim is used to identify the the time at which the JWT was issued. This claim can be used to determine the age of the JWT. +* `sub`: Subject claim. Identifies the subject of the JWT, in our case this is the username. +* `roles`: An array of strings specifying the roles set for the subject. +* `exp`: Expiration date of the token (only if explicitly configured) + +It is important to know that JWTs are not encrypted, only signed. This means that outsiders cannot create new JWTs or modify existing ones, but they are able to read out the username. + +## Workflow + +1. Create a new ECDSA Public/private keypair: +``` +$ go build ./cmd/gen-keypair/ +$ ./gen-keypair +``` +2. Add keypair in your `.env` file. A template can be found in `./configs`. + +When a user logs in via the `/login` page using a browser, a session cookie (secured using the random bytes in the `SESSION_KEY` env. variable you shoud change as well) is used for all requests after the successfull login. The JWTs make it easier to use the APIs of ClusterCockpit using scripts or other external programs. The token is specified n the `Authorization` HTTP header using the [Bearer schema](https://datatracker.ietf.org/doc/html/rfc6750) (there is an example below). Tokens can be issued to users from the configuration view in the Web-UI or the command line. In order to use the token for API endpoints such as `/api/jobs/start_job/`, the user that executes it needs to have the `api` role. Regular users can only perform read-only queries and only look at data connected to jobs they started themselves. + +## cc-metric-store + +The [cc-metric-store](https://github.com/ClusterCockpit/cc-metric-store) also uses JWTs for authentication. As it does not issue new tokens, it does not need to kown the private key. The public key of the keypair that is used to generate the JWTs that grant access to the `cc-metric-store` can be specified in its `config.json`. When configuring the `metricDataRepository` object in the `cluster.json` file, you can put a token issued by ClusterCockpit itself. + +## Setup user and JWT token for REST API authorization + +1. Create user: +``` +$ ./cc-backend --add-user :api: --no-server +``` +2. Issue token for user: +``` +$ ./cc-backend --jwt --no-server +``` +3. Use issued token token on client side: +``` +$ curl -X GET "" -H "accept: application/json" -H "Content-Type: application/json" -H "Authorization: Bearer " +``` diff --git a/internal/api/rest.go b/internal/api/rest.go index b561b18..342d8b8 100644 --- a/internal/api/rest.go +++ b/internal/api/rest.go @@ -497,13 +497,13 @@ func (api *RestApi) getJWT(rw http.ResponseWriter, r *http.Request) { } } - user, err := api.Authentication.FetchUser(username) + user, err := api.Authentication.GetUser(username) if err != nil { http.Error(rw, err.Error(), http.StatusUnprocessableEntity) return } - jwt, err := api.Authentication.ProvideJWT(user) + jwt, err := api.Authentication.JwtAuth.ProvideJWT(user) if err != nil { http.Error(rw, err.Error(), http.StatusUnprocessableEntity) return @@ -527,7 +527,12 @@ func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) { return } - if err := api.Authentication.CreateUser(username, name, password, email, []string{role}); err != nil { + if err := api.Authentication.AddUser(&auth.User{ + Username: username, + Name: name, + Password: password, + Email: email, + Roles: []string{role}}); err != nil { http.Error(rw, err.Error(), http.StatusUnprocessableEntity) return } @@ -556,9 +561,7 @@ func (api *RestApi) getUsers(rw http.ResponseWriter, r *http.Request) { return } - users, err := api.Authentication.FetchUsers( - r.URL.Query().Get("via-ldap") == "true", - r.URL.Query().Get("not-just-user") == "true") + users, err := api.Authentication.ListUsers(r.URL.Query().Get("not-just-user") == "true") if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 3fa6f1d..8bb538f 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -2,45 +2,40 @@ package auth import ( "context" - "crypto/ed25519" "crypto/rand" - "database/sql" "encoding/base64" - "encoding/json" "errors" - "fmt" "net/http" "os" - "strings" "time" - "github.com/ClusterCockpit/cc-backend/internal/graph/model" "github.com/ClusterCockpit/cc-backend/pkg/log" - sq "github.com/Masterminds/squirrel" - "github.com/golang-jwt/jwt/v4" "github.com/gorilla/sessions" "github.com/jmoiron/sqlx" - "golang.org/x/crypto/bcrypt" ) -// Only Username and Roles will always be filled in when returned by `GetUser`. -// If Name and Email is needed as well, use auth.FetchUser(), which does a database -// query for all fields. -type User struct { - Username string `json:"username"` - Password string `json:"-"` - Name string `json:"name"` - Roles []string `json:"roles"` - ViaLdap bool `json:"via-ldap"` - Email string `json:"email"` -} - const ( RoleAdmin string = "admin" RoleApi string = "api" RoleUser string = "user" ) +const ( + AuthViaLocalPassword int8 = 0 + AuthViaLDAP int8 = 1 + AuthViaToken int8 = 2 +) + +type User struct { + Username string `json:"username"` + Password string `json:"-"` + Name string `json:"name"` + Roles []string `json:"roles"` + AuthSource int8 `json:"via"` + Email string `json:"email"` + Expiration time.Time +} + func (u *User) HasRole(role string) bool { for _, r := range u.Roles { if r == role { @@ -50,398 +45,6 @@ func (u *User) HasRole(role string) bool { return false } -type ContextKey string - -const ContextUserKey ContextKey = "user" - -type Authentication struct { - db *sqlx.DB - sessionStore *sessions.CookieStore - jwtPublicKey ed25519.PublicKey - jwtPrivateKey ed25519.PrivateKey - - ldapConfig *LdapConfig - ldapSyncUserPassword string - - // If zero, tokens/sessions do not expire. - SessionMaxAge time.Duration - JwtMaxAge time.Duration -} - -func (auth *Authentication) Init(db *sqlx.DB, ldapConfig *LdapConfig) error { - auth.db = db - _, err := db.Exec(` - CREATE TABLE IF NOT EXISTS user ( - username varchar(255) PRIMARY KEY NOT NULL, - password varchar(255) DEFAULT NULL, - ldap tinyint NOT NULL DEFAULT 0, - name varchar(255) DEFAULT NULL, - roles varchar(255) NOT NULL DEFAULT "[]", - email varchar(255) DEFAULT NULL);`) - if err != nil { - return err - } - - sessKey := os.Getenv("SESSION_KEY") - if sessKey == "" { - log.Warn("environment variable 'SESSION_KEY' not set (will use non-persistent random key)") - bytes := make([]byte, 32) - if _, err := rand.Read(bytes); err != nil { - return err - } - auth.sessionStore = sessions.NewCookieStore(bytes) - } else { - bytes, err := base64.StdEncoding.DecodeString(sessKey) - if err != nil { - return err - } - auth.sessionStore = sessions.NewCookieStore(bytes) - } - - pubKey, privKey := os.Getenv("JWT_PUBLIC_KEY"), os.Getenv("JWT_PRIVATE_KEY") - if pubKey == "" || privKey == "" { - log.Warn("environment variables 'JWT_PUBLIC_KEY' or 'JWT_PRIVATE_KEY' not set (token based authentication will not work)") - } else { - bytes, err := base64.StdEncoding.DecodeString(pubKey) - if err != nil { - return err - } - auth.jwtPublicKey = ed25519.PublicKey(bytes) - bytes, err = base64.StdEncoding.DecodeString(privKey) - if err != nil { - return err - } - auth.jwtPrivateKey = ed25519.PrivateKey(bytes) - } - - if ldapConfig != nil { - auth.ldapConfig = ldapConfig - if err := auth.initLdap(); err != nil { - return err - } - } - - return nil -} - -// arg must be formated like this: ":[admin|api|]:" -func (auth *Authentication) AddUser(arg string) error { - parts := strings.SplitN(arg, ":", 3) - if len(parts) != 3 || len(parts[0]) == 0 { - return errors.New("invalid argument format") - } - - roles := strings.Split(parts[1], ",") - return auth.CreateUser(parts[0], "", parts[2], "", roles) -} - -func (auth *Authentication) CreateUser(username, name, password, email string, roles []string) error { - for _, role := range roles { - if role != RoleAdmin && role != RoleApi && role != RoleUser { - return fmt.Errorf("invalid user role: %#v", role) - } - } - - if username == "" { - return errors.New("username should not be empty") - } - - if password != "" { - bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return err - } - password = string(bytes) - } - - rolesJson, _ := json.Marshal(roles) - cols := []string{"username", "password", "roles"} - vals := []interface{}{username, password, string(rolesJson)} - if name != "" { - cols = append(cols, "name") - vals = append(vals, name) - } - if email != "" { - cols = append(cols, "email") - vals = append(vals, email) - } - - if _, err := sq.Insert("user").Columns(cols...).Values(vals...).RunWith(auth.db).Exec(); err != nil { - return err - } - - log.Infof("new user %#v created (roles: %s)", username, roles) - return nil -} - -func (auth *Authentication) AddRole(ctx context.Context, username string, role string) error { - user, err := auth.FetchUser(username) - if err != nil { - return err - } - - if role != RoleAdmin && role != RoleApi && role != RoleUser { - return fmt.Errorf("invalid user role: %#v", role) - } - - for _, r := range user.Roles { - if r == role { - return fmt.Errorf("user %#v already has role %#v", username, role) - } - } - - roles, _ := json.Marshal(append(user.Roles, role)) - if _, err := sq.Update("user").Set("roles", roles).Where("user.username = ?", username).RunWith(auth.db).Exec(); err != nil { - return err - } - return nil -} - -func (auth *Authentication) DelUser(username string) error { - _, err := auth.db.Exec(`DELETE FROM user WHERE user.username = ?`, username) - return err -} - -func (auth *Authentication) FetchUsers(viaLdap, notJustUser bool) ([]*User, error) { - q := sq.Select("username", "name", "email", "roles").From("user") - if !viaLdap { - if notJustUser { - q = q.Where("ldap = 0 OR (roles != '[\"user\"]' AND roles != '[]')") - } else { - q = q.Where("ldap = 0") - } - } else { - if notJustUser { - q = q.Where("ldap = 1 OR (roles != '[\"user\"]' AND roles != '[]')") - } else { - q = q.Where("ldap = 1") - } - } - - rows, err := q.RunWith(auth.db).Query() - if err != nil { - return nil, err - } - - users := make([]*User, 0) - defer rows.Close() - for rows.Next() { - rawroles := "" - user := &User{} - var name, email sql.NullString - if err := rows.Scan(&user.Username, &name, &email, &rawroles); err != nil { - return nil, err - } - - if err := json.Unmarshal([]byte(rawroles), &user.Roles); err != nil { - return nil, err - } - - user.Name = name.String - user.Email = email.String - users = append(users, user) - } - return users, nil -} - -func (auth *Authentication) FetchUser(username string) (*User, error) { - user := &User{Username: username} - var hashedPassword, name, rawRoles, email sql.NullString - if err := sq.Select("password", "ldap", "name", "roles", "email").From("user"). - Where("user.username = ?", username).RunWith(auth.db). - QueryRow().Scan(&hashedPassword, &user.ViaLdap, &name, &rawRoles, &email); err != nil { - return nil, fmt.Errorf("user '%s' not found (%s)", username, err.Error()) - } - - user.Password = hashedPassword.String - user.Name = name.String - user.Email = email.String - if rawRoles.Valid { - if err := json.Unmarshal([]byte(rawRoles.String), &user.Roles); err != nil { - return nil, err - } - } - - return user, nil -} - -func FetchUser(ctx context.Context, db *sqlx.DB, username string) (*model.User, error) { - me := GetUser(ctx) - if me != nil && !me.HasRole(RoleAdmin) && me.Username != username { - return nil, errors.New("forbidden") - } - - user := &model.User{Username: username} - var name, email sql.NullString - if err := sq.Select("name", "email").From("user").Where("user.username = ?", username). - RunWith(db).QueryRow().Scan(&name, &email); err != nil { - if err == sql.ErrNoRows { - return nil, nil - } - - return nil, err - } - - user.Name = name.String - user.Email = email.String - return user, nil -} - -// Handle a POST request that should log the user in, starting a new session. -func (auth *Authentication) Login(onsuccess http.Handler, onfailure func(rw http.ResponseWriter, r *http.Request, loginErr error)) http.Handler { - return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - username, password := r.FormValue("username"), r.FormValue("password") - user, err := auth.FetchUser(username) - if err == nil && user.ViaLdap && auth.ldapConfig != nil { - err = auth.loginViaLdap(user, password) - } else if err == nil && !user.ViaLdap && user.Password != "" { - if e := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); e != nil { - err = fmt.Errorf("user '%s' provided the wrong password (%s)", username, e.Error()) - } - } else { - err = errors.New("could not authenticate user") - } - - if err != nil { - log.Warnf("login of user %#v failed: %s", username, err.Error()) - onfailure(rw, r, err) - return - } - - session, err := auth.sessionStore.New(r, "session") - if err != nil { - log.Errorf("session creation failed: %s", err.Error()) - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - if auth.SessionMaxAge != 0 { - session.Options.MaxAge = int(auth.SessionMaxAge.Seconds()) - } - session.Values["username"] = user.Username - session.Values["roles"] = user.Roles - if err := auth.sessionStore.Save(r, rw, session); err != nil { - log.Errorf("session save failed: %s", err.Error()) - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - log.Infof("login successfull: user: %#v (roles: %v)", user.Username, user.Roles) - ctx := context.WithValue(r.Context(), ContextUserKey, user) - onsuccess.ServeHTTP(rw, r.WithContext(ctx)) - }) -} - -var ErrTokenInvalid error = errors.New("invalid token") - -func (auth *Authentication) authViaToken(r *http.Request) (*User, error) { - if auth.jwtPublicKey == nil { - return nil, nil - } - - rawtoken := r.Header.Get("X-Auth-Token") - if rawtoken == "" { - rawtoken = r.Header.Get("Authorization") - prefix := "Bearer " - if !strings.HasPrefix(rawtoken, prefix) { - return nil, nil - } - rawtoken = rawtoken[len(prefix):] - } - - token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (interface{}, error) { - if t.Method != jwt.SigningMethodEdDSA { - return nil, errors.New("only Ed25519/EdDSA supported") - } - return auth.jwtPublicKey, nil - }) - if err != nil { - return nil, err - } - - if err := token.Claims.Valid(); err != nil { - return nil, err - } - - claims := token.Claims.(jwt.MapClaims) - sub, _ := claims["sub"].(string) - - var roles []string - if rawroles, ok := claims["roles"].([]interface{}); ok { - for _, rr := range rawroles { - if r, ok := rr.(string); ok { - roles = append(roles, r) - } - } - } - - // TODO: Check if sub is still a valid user! - return &User{ - Username: sub, - Roles: roles, - }, nil -} - -// Authenticate the user and put a User object in the -// context of the request. If authentication fails, -// do not continue but send client to the login screen. -func (auth *Authentication) Auth(onsuccess http.Handler, onfailure func(rw http.ResponseWriter, r *http.Request, authErr error)) http.Handler { - return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - user, err := auth.authViaToken(r) - if err != nil { - log.Warnf("authentication failed: %s", err.Error()) - http.Error(rw, err.Error(), http.StatusUnauthorized) - return - } - if user != nil { - // Successfull authentication using a token - ctx := context.WithValue(r.Context(), ContextUserKey, user) - onsuccess.ServeHTTP(rw, r.WithContext(ctx)) - return - } - - session, err := auth.sessionStore.Get(r, "session") - if err != nil { - // sessionStore.Get will return a new session if no current one is attached to this request. - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - - if session.IsNew { - log.Warn("authentication failed: no session or jwt found") - onfailure(rw, r, errors.New("no valid session or JWT provided")) - return - } - - username, _ := session.Values["username"].(string) - roles, _ := session.Values["roles"].([]string) - ctx := context.WithValue(r.Context(), ContextUserKey, &User{ - Username: username, - Roles: roles, - }) - onsuccess.ServeHTTP(rw, r.WithContext(ctx)) - }) -} - -// Generate a new JWT that can be used for authentication -func (auth *Authentication) ProvideJWT(user *User) (string, error) { - if auth.jwtPrivateKey == nil { - return "", errors.New("environment variable 'JWT_PRIVATE_KEY' not set") - } - - now := time.Now() - claims := jwt.MapClaims{ - "sub": user.Username, - "roles": user.Roles, - "iat": now.Unix(), - } - if auth.JwtMaxAge != 0 { - claims["exp"] = now.Add(auth.JwtMaxAge).Unix() - } - - return jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims).SignedString(auth.jwtPrivateKey) -} - func GetUser(ctx context.Context) *User { x := ctx.Value(ContextUserKey) if x == nil { @@ -451,6 +54,181 @@ func GetUser(ctx context.Context) *User { return x.(*User) } +type Authenticator interface { + Init(auth *Authentication, config interface{}) error + CanLogin(user *User, rw http.ResponseWriter, r *http.Request) bool + Login(user *User, rw http.ResponseWriter, r *http.Request) (*User, error) + Auth(rw http.ResponseWriter, r *http.Request) (*User, error) +} + +type ContextKey string + +const ContextUserKey ContextKey = "user" + +type Authentication struct { + db *sqlx.DB + sessionStore *sessions.CookieStore + SessionMaxAge time.Duration + + authenticators []Authenticator + LdapAuth *LdapAutnenticator + JwtAuth *JWTAuthenticator + LocalAuth *LocalAuthenticator +} + +func Init(db *sqlx.DB, configs map[string]interface{}) (*Authentication, error) { + auth := &Authentication{} + auth.db = db + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS user ( + username varchar(255) PRIMARY KEY NOT NULL, + password varchar(255) DEFAULT NULL, + ldap tinyint NOT NULL DEFAULT 0, /* col called "ldap" for historic reasons, fills the "AuthSource" */ + name varchar(255) DEFAULT NULL, + roles varchar(255) NOT NULL DEFAULT "[]", + email varchar(255) DEFAULT NULL);`) + if err != nil { + return nil, err + } + + sessKey := os.Getenv("SESSION_KEY") + if sessKey == "" { + log.Warn("environment variable 'SESSION_KEY' not set (will use non-persistent random key)") + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return nil, err + } + auth.sessionStore = sessions.NewCookieStore(bytes) + } else { + bytes, err := base64.StdEncoding.DecodeString(sessKey) + if err != nil { + return nil, err + } + auth.sessionStore = sessions.NewCookieStore(bytes) + } + + auth.LocalAuth = &LocalAuthenticator{} + if err := auth.LocalAuth.Init(auth, nil); err != nil { + return nil, err + } + auth.authenticators = append(auth.authenticators, auth.LocalAuth) + + auth.JwtAuth = &JWTAuthenticator{} + if err := auth.JwtAuth.Init(auth, configs["jwt"]); err != nil { + return nil, err + } + auth.authenticators = append(auth.authenticators, auth.JwtAuth) + + if config, ok := configs["ldap"]; ok { + auth.LdapAuth = &LdapAutnenticator{} + if err := auth.LdapAuth.Init(auth, config); err != nil { + return nil, err + } + auth.authenticators = append(auth.authenticators, auth.LdapAuth) + } + + return auth, nil +} + +func (auth *Authentication) AuthViaSession(rw http.ResponseWriter, r *http.Request) (*User, error) { + session, err := auth.sessionStore.Get(r, "session") + if err != nil { + return nil, err + } + + if session.IsNew { + return nil, nil + } + + username, _ := session.Values["username"].(string) + roles, _ := session.Values["roles"].([]string) + return &User{ + Username: username, + Roles: roles, + AuthSource: -1, + }, nil +} + +// Handle a POST request that should log the user in, starting a new session. +func (auth *Authentication) Login(onsuccess http.Handler, onfailure func(rw http.ResponseWriter, r *http.Request, loginErr error)) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + var err error + username := r.FormValue("username") + user := (*User)(nil) + if username != "" { + if user, _ = auth.GetUser(username); err != nil { + log.Warnf("login of unkown user %#v", username) + } + } + + for _, authenticator := range auth.authenticators { + if !authenticator.CanLogin(user, rw, r) { + continue + } + + user, err = authenticator.Login(user, rw, r) + if err != nil { + log.Warnf("login failed: %s", err.Error()) + onfailure(rw, r, err) + return + } + + session, err := auth.sessionStore.New(r, "session") + if err != nil { + log.Errorf("session creation failed: %s", err.Error()) + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + if auth.SessionMaxAge != 0 { + session.Options.MaxAge = int(auth.SessionMaxAge.Seconds()) + } + session.Values["username"] = user.Username + session.Values["roles"] = user.Roles + if err := auth.sessionStore.Save(r, rw, session); err != nil { + log.Errorf("session save failed: %s", err.Error()) + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + log.Infof("login successfull: user: %#v (roles: %v)", user.Username, user.Roles) + ctx := context.WithValue(r.Context(), ContextUserKey, user) + onsuccess.ServeHTTP(rw, r.WithContext(ctx)) + return + } + + log.Warn("login failed: no authenticator applied") + onfailure(rw, r, err) + }) +} + +// Authenticate the user and put a User object in the +// context of the request. If authentication fails, +// do not continue but send client to the login screen. +func (auth *Authentication) Auth(onsuccess http.Handler, onfailure func(rw http.ResponseWriter, r *http.Request, authErr error)) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + for _, authenticator := range auth.authenticators { + user, err := authenticator.Auth(rw, r) + if err != nil { + log.Warnf("authentication failed: %s", err.Error()) + http.Error(rw, err.Error(), http.StatusUnauthorized) + return + } + if user == nil { + continue + } + + ctx := context.WithValue(r.Context(), ContextUserKey, user) + onsuccess.ServeHTTP(rw, r.WithContext(ctx)) + return + } + + log.Warnf("authentication failed: %s", "no authenticator applied") + // http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + onfailure(rw, r, errors.New("unauthorized (login first or use a token)")) + }) +} + // Clears the session cookie func (auth *Authentication) Logout(onsuccess http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..8832b06 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1 @@ +package auth diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..3943c3d --- /dev/null +++ b/internal/auth/jwt.go @@ -0,0 +1,185 @@ +package auth + +import ( + "crypto/ed25519" + "encoding/base64" + "errors" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/ClusterCockpit/cc-backend/pkg/log" + "github.com/golang-jwt/jwt/v4" +) + +type JWTAuthConfig struct { + // Specifies for how long a session or JWT shall be valid + // as a string parsable by time.ParseDuration(). + MaxAge int64 `json:"max-age"` +} + +type JWTAuthenticator struct { + auth *Authentication + + publicKey ed25519.PublicKey + privateKey ed25519.PrivateKey + + loginTokenKey []byte // HS256 key + + config *JWTAuthConfig +} + +var _ Authenticator = (*JWTAuthenticator)(nil) + +func (ja *JWTAuthenticator) Init(auth *Authentication, conf interface{}) error { + ja.auth = auth + ja.config = conf.(*JWTAuthConfig) + + pubKey, privKey := os.Getenv("JWT_PUBLIC_KEY"), os.Getenv("JWT_PRIVATE_KEY") + if pubKey == "" || privKey == "" { + log.Warn("environment variables 'JWT_PUBLIC_KEY' or 'JWT_PRIVATE_KEY' not set (token based authentication will not work)") + } else { + bytes, err := base64.StdEncoding.DecodeString(pubKey) + if err != nil { + return err + } + ja.publicKey = ed25519.PublicKey(bytes) + bytes, err = base64.StdEncoding.DecodeString(privKey) + if err != nil { + return err + } + ja.privateKey = ed25519.PrivateKey(bytes) + } + + if pubKey = os.Getenv("CROSS_LOGIN_JWT_HS512_KEY"); pubKey != "" { + bytes, err := base64.StdEncoding.DecodeString(pubKey) + if err != nil { + return err + } + ja.loginTokenKey = bytes + } + + return nil +} + +func (ja *JWTAuthenticator) CanLogin(user *User, rw http.ResponseWriter, r *http.Request) bool { + return (user != nil && user.AuthSource == AuthViaToken) || r.Header.Get("Authorization") != "" +} + +func (ja *JWTAuthenticator) Login(user *User, rw http.ResponseWriter, r *http.Request) (*User, error) { + rawtoken := r.Header.Get("X-Auth-Token") + if rawtoken == "" { + rawtoken = r.Header.Get("Authorization") + rawtoken = strings.TrimPrefix("Bearer ", rawtoken) + } + + token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (interface{}, error) { + if t.Method == jwt.SigningMethodEdDSA { + return ja.publicKey, nil + } + if t.Method == jwt.SigningMethodHS256 || t.Method == jwt.SigningMethodHS512 { + return ja.loginTokenKey, nil + } + return nil, fmt.Errorf("unkown signing method for login token: %s (known: HS256, HS512, EdDSA)", t.Method.Alg()) + }) + if err != nil { + return nil, err + } + + if err := token.Claims.Valid(); err != nil { + return nil, err + } + + claims := token.Claims.(jwt.MapClaims) + sub, _ := claims["sub"].(string) + exp, _ := claims["exp"].(float64) + var roles []string + if rawroles, ok := claims["roles"].([]interface{}); ok { + for _, rr := range rawroles { + if r, ok := rr.(string); ok { + roles = append(roles, r) + } + } + } + + if user == nil { + user = &User{ + Username: sub, + Roles: roles, + AuthSource: AuthViaToken, + } + if err := ja.auth.AddUser(user); err != nil { + return nil, err + } + } + + user.Expiration = time.Unix(int64(exp), 0) + return user, nil +} + +func (ja *JWTAuthenticator) Auth(rw http.ResponseWriter, r *http.Request) (*User, error) { + rawtoken := r.Header.Get("X-Auth-Token") + if rawtoken == "" { + rawtoken = r.Header.Get("Authorization") + rawtoken = strings.TrimPrefix(rawtoken, "Bearer ") + } + + // Because a user can also log in via a token, the + // session cookie must be checked here as well: + if rawtoken == "" { + return ja.auth.AuthViaSession(rw, r) + } + + token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (interface{}, error) { + if t.Method != jwt.SigningMethodEdDSA { + return nil, errors.New("only Ed25519/EdDSA supported") + } + return ja.publicKey, nil + }) + if err != nil { + return nil, err + } + + if err := token.Claims.Valid(); err != nil { + return nil, err + } + + claims := token.Claims.(jwt.MapClaims) + sub, _ := claims["sub"].(string) + + var roles []string + if rawroles, ok := claims["roles"].([]interface{}); ok { + for _, rr := range rawroles { + if r, ok := rr.(string); ok { + roles = append(roles, r) + } + } + } + + return &User{ + Username: sub, + Roles: roles, + AuthSource: AuthViaToken, + }, nil +} + +// Generate a new JWT that can be used for authentication +func (ja *JWTAuthenticator) ProvideJWT(user *User) (string, error) { + if ja.privateKey == nil { + return "", errors.New("environment variable 'JWT_PRIVATE_KEY' not set") + } + + now := time.Now() + claims := jwt.MapClaims{ + "sub": user.Username, + "roles": user.Roles, + "iat": now.Unix(), + } + if ja.config != nil && ja.config.MaxAge != 0 { + claims["exp"] = now.Add(time.Duration(ja.config.MaxAge)).Unix() + } + + return jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims).SignedString(ja.privateKey) +} diff --git a/internal/auth/ldap.go b/internal/auth/ldap.go index 8d1fe18..6abaad6 100644 --- a/internal/auth/ldap.go +++ b/internal/auth/ldap.go @@ -2,6 +2,7 @@ package auth import ( "errors" + "net/http" "os" "strings" "time" @@ -20,14 +21,25 @@ type LdapConfig struct { SyncDelOldUsers bool `json:"sync_del_old_users"` } -func (auth *Authentication) initLdap() error { - auth.ldapSyncUserPassword = os.Getenv("LDAP_ADMIN_PASSWORD") - if auth.ldapSyncUserPassword == "" { - log.Warn("environment variable 'LDAP_ADMIN_PASSWORD' not set (ldap sync or authentication will not work)") +type LdapAutnenticator struct { + auth *Authentication + config *LdapConfig + syncPassword string +} + +var _ Authenticator = (*LdapAutnenticator)(nil) + +func (la *LdapAutnenticator) Init(auth *Authentication, conf interface{}) error { + la.auth = auth + la.config = conf.(*LdapConfig) + + la.syncPassword = os.Getenv("LDAP_ADMIN_PASSWORD") + if la.syncPassword == "" { + log.Warn("environment variable 'LDAP_ADMIN_PASSWORD' not set (ldap sync will not work)") } - if auth.ldapConfig.SyncInterval != "" { - interval, err := time.ParseDuration(auth.ldapConfig.SyncInterval) + if la.config.SyncInterval != "" { + interval, err := time.ParseDuration(la.config.SyncInterval) if err != nil { return err } @@ -40,7 +52,7 @@ func (auth *Authentication) initLdap() error { ticker := time.NewTicker(interval) for t := range ticker.C { log.Printf("LDAP sync started at %s", t.Format(time.RFC3339)) - if err := auth.SyncWithLDAP(auth.ldapConfig.SyncDelOldUsers); err != nil { + if err := la.Sync(); err != nil { log.Errorf("LDAP sync failed: %s", err.Error()) } log.Print("LDAP sync done") @@ -51,53 +63,36 @@ func (auth *Authentication) initLdap() error { return nil } -// TODO: Add a connection pool or something like -// that so that connections can be reused/cached. -func (auth *Authentication) getLdapConnection(admin bool) (*ldap.Conn, error) { - conn, err := ldap.DialURL(auth.ldapConfig.Url) +func (la *LdapAutnenticator) CanLogin(user *User, rw http.ResponseWriter, r *http.Request) bool { + return user != nil && user.AuthSource == AuthViaLDAP +} + +func (la *LdapAutnenticator) Login(user *User, rw http.ResponseWriter, r *http.Request) (*User, error) { + l, err := la.getLdapConnection(false) if err != nil { return nil, err } - - if admin { - if err := conn.Bind(auth.ldapConfig.SearchDN, auth.ldapSyncUserPassword); err != nil { - conn.Close() - return nil, err - } - } - - return conn, nil -} - -func (auth *Authentication) loginViaLdap(user *User, password string) error { - l, err := auth.getLdapConnection(false) - if err != nil { - return err - } defer l.Close() - userDn := strings.Replace(auth.ldapConfig.UserBind, "{username}", user.Username, -1) - if err := l.Bind(userDn, password); err != nil { - return err + userDn := strings.Replace(la.config.UserBind, "{username}", user.Username, -1) + if err := l.Bind(userDn, r.FormValue("password")); err != nil { + return nil, err } - user.ViaLdap = true - return nil + return user, nil } -// Delete users where user.ldap is 1 and that do not show up in the ldap search results. -// Add users to the users table that are new in the ldap search results. -func (auth *Authentication) SyncWithLDAP(deleteOldUsers bool) error { - if auth.ldapConfig == nil { - return errors.New("ldap not enabled") - } +func (la *LdapAutnenticator) Auth(rw http.ResponseWriter, r *http.Request) (*User, error) { + return la.auth.AuthViaSession(rw, r) +} +func (la *LdapAutnenticator) Sync() error { const IN_DB int = 1 const IN_LDAP int = 2 const IN_BOTH int = 3 users := map[string]int{} - rows, err := auth.db.Query(`SELECT username FROM user WHERE user.ldap = 1`) + rows, err := la.auth.db.Query(`SELECT username FROM user WHERE user.ldap = 1`) if err != nil { return err } @@ -111,15 +106,15 @@ func (auth *Authentication) SyncWithLDAP(deleteOldUsers bool) error { users[username] = IN_DB } - l, err := auth.getLdapConnection(true) + l, err := la.getLdapConnection(true) if err != nil { return err } defer l.Close() ldapResults, err := l.Search(ldap.NewSearchRequest( - auth.ldapConfig.UserBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - auth.ldapConfig.UserFilter, []string{"dn", "uid", "gecos"}, nil)) + la.config.UserBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + la.config.UserFilter, []string{"dn", "uid", "gecos"}, nil)) if err != nil { return err } @@ -141,15 +136,15 @@ func (auth *Authentication) SyncWithLDAP(deleteOldUsers bool) error { } for username, where := range users { - if where == IN_DB && deleteOldUsers { + if where == IN_DB && la.config.SyncDelOldUsers { log.Debugf("ldap-sync: remove %#v (does not show up in LDAP anymore)", username) - if _, err := auth.db.Exec(`DELETE FROM user WHERE user.username = ?`, username); err != nil { + if _, err := la.auth.db.Exec(`DELETE FROM user WHERE user.username = ?`, username); err != nil { return err } } else if where == IN_LDAP { name := newnames[username] log.Debugf("ldap-sync: add %#v (name: %#v, roles: [user], ldap: true)", username, name) - if _, err := auth.db.Exec(`INSERT INTO user (username, ldap, name, roles) VALUES (?, ?, ?, ?)`, + if _, err := la.auth.db.Exec(`INSERT INTO user (username, ldap, name, roles) VALUES (?, ?, ?, ?)`, username, 1, name, "[\""+RoleUser+"\"]"); err != nil { return err } @@ -158,3 +153,21 @@ func (auth *Authentication) SyncWithLDAP(deleteOldUsers bool) error { return nil } + +// TODO: Add a connection pool or something like +// that so that connections can be reused/cached. +func (la *LdapAutnenticator) getLdapConnection(admin bool) (*ldap.Conn, error) { + conn, err := ldap.DialURL(la.config.Url) + if err != nil { + return nil, err + } + + if admin { + if err := conn.Bind(la.config.SearchDN, la.syncPassword); err != nil { + conn.Close() + return nil, err + } + } + + return conn, nil +} diff --git a/internal/auth/local.go b/internal/auth/local.go new file mode 100644 index 0000000..280b394 --- /dev/null +++ b/internal/auth/local.go @@ -0,0 +1,35 @@ +package auth + +import ( + "fmt" + "net/http" + + "golang.org/x/crypto/bcrypt" +) + +type LocalAuthenticator struct { + auth *Authentication +} + +var _ Authenticator = (*LocalAuthenticator)(nil) + +func (la *LocalAuthenticator) Init(auth *Authentication, _ interface{}) error { + la.auth = auth + return nil +} + +func (la *LocalAuthenticator) CanLogin(user *User, rw http.ResponseWriter, r *http.Request) bool { + return user != nil && user.AuthSource == AuthViaLocalPassword +} + +func (la *LocalAuthenticator) Login(user *User, rw http.ResponseWriter, r *http.Request) (*User, error) { + if e := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(r.FormValue("password"))); e != nil { + return nil, fmt.Errorf("user '%s' provided the wrong password (%w)", user.Username, e) + } + + return user, nil +} + +func (la *LocalAuthenticator) Auth(rw http.ResponseWriter, r *http.Request) (*User, error) { + return la.auth.AuthViaSession(rw, r) +} diff --git a/internal/auth/users.go b/internal/auth/users.go new file mode 100644 index 0000000..f21bafa --- /dev/null +++ b/internal/auth/users.go @@ -0,0 +1,138 @@ +package auth + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + + "github.com/ClusterCockpit/cc-backend/internal/graph/model" + "github.com/ClusterCockpit/cc-backend/pkg/log" + sq "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" +) + +func (auth *Authentication) GetUser(username string) (*User, error) { + user := &User{Username: username} + var hashedPassword, name, rawRoles, email sql.NullString + if err := sq.Select("password", "ldap", "name", "roles", "email").From("user"). + Where("user.username = ?", username).RunWith(auth.db). + QueryRow().Scan(&hashedPassword, &user.AuthSource, &name, &rawRoles, &email); err != nil { + return nil, err + } + + user.Password = hashedPassword.String + user.Name = name.String + user.Email = email.String + if rawRoles.Valid { + if err := json.Unmarshal([]byte(rawRoles.String), &user.Roles); err != nil { + return nil, err + } + } + + return user, nil +} + +func (auth *Authentication) AddUser(user *User) error { + rolesJson, _ := json.Marshal(user.Roles) + cols := []string{"username", "password", "roles"} + vals := []interface{}{user.Username, user.Password, string(rolesJson)} + if user.Name != "" { + cols = append(cols, "name") + vals = append(vals, user.Name) + } + if user.Email != "" { + cols = append(cols, "email") + vals = append(vals, user.Email) + } + + if _, err := sq.Insert("user").Columns(cols...).Values(vals...).RunWith(auth.db).Exec(); err != nil { + return err + } + + log.Infof("new user %#v created (roles: %s, auth-source: %d)", user.Username, rolesJson, user.AuthSource) + return nil +} + +func (auth *Authentication) DelUser(username string) error { + _, err := auth.db.Exec(`DELETE FROM user WHERE user.username = ?`, username) + return err +} + +func (auth *Authentication) ListUsers(specialsOnly bool) ([]*User, error) { + q := sq.Select("username", "name", "email", "roles").From("user") + if specialsOnly { + q = q.Where("(roles != '[\"user\"]' AND roles != '[]')") + } + + rows, err := q.RunWith(auth.db).Query() + if err != nil { + return nil, err + } + + users := make([]*User, 0) + defer rows.Close() + for rows.Next() { + rawroles := "" + user := &User{} + var name, email sql.NullString + if err := rows.Scan(&user.Username, &name, &email, &rawroles); err != nil { + return nil, err + } + + if err := json.Unmarshal([]byte(rawroles), &user.Roles); err != nil { + return nil, err + } + + user.Name = name.String + user.Email = email.String + users = append(users, user) + } + return users, nil +} + +func (auth *Authentication) AddRole(ctx context.Context, username string, role string) error { + user, err := auth.GetUser(username) + if err != nil { + return err + } + + if role != RoleAdmin && role != RoleApi && role != RoleUser { + return fmt.Errorf("invalid user role: %#v", role) + } + + for _, r := range user.Roles { + if r == role { + return fmt.Errorf("user %#v already has role %#v", username, role) + } + } + + roles, _ := json.Marshal(append(user.Roles, role)) + if _, err := sq.Update("user").Set("roles", roles).Where("user.username = ?", username).RunWith(auth.db).Exec(); err != nil { + return err + } + return nil +} + +func FetchUser(ctx context.Context, db *sqlx.DB, username string) (*model.User, error) { + me := GetUser(ctx) + if me != nil && !me.HasRole(RoleAdmin) && me.Username != username { + return nil, errors.New("forbidden") + } + + user := &model.User{Username: username} + var name, email sql.NullString + if err := sq.Select("name", "email").From("user").Where("user.username = ?", username). + RunWith(db).QueryRow().Scan(&name, &email); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + + return nil, err + } + + user.Name = name.String + user.Email = email.String + return user, nil +} diff --git a/tools/README.md b/tools/README.md deleted file mode 100644 index bdb6367..0000000 --- a/tools/README.md +++ /dev/null @@ -1,46 +0,0 @@ -## Introduction - -ClusterCockpit uses JSON Web Tokens (JWT) for authorization of its APIs. -JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. -This information can be verified and trusted because it is digitally signed. -In ClusterCockpit JWTs are signed using a public/private key pair using ECDSA. -Because tokens are signed using public/private key pairs, the signature also certifies that only the party holding the private key is the one that signed it. -Currently JWT tokens in ClusterCockpit not yet expire. - -## JWT Payload - -You may view the payload of a JWT token at [https://jwt.io/#debugger-io](https://jwt.io/#debugger-io). -Currently ClusterCockpit sets the following claims: -* `iat`: Issued at claim. The “iat” claim is used to identify the the time at which the JWT was issued. This claim can be used to determine the age of the JWT. -* `sub`: Subject claim. Identifies the subject of the JWT, in our case this is the username. -* `roles`: An array of strings specifying the roles set for the subject. - -## Workflow - -1. Create a new ECDSA Public/private keypair: -``` -$ go build ./tools/gen-keypair.go -$ ./gen-keypair -``` -2. Add keypair in your `.env` file. A template can be found in `./configs`. - -There are two usage scenarios: -* The APIs are used during a browser session. In this case on login a JWT token is issued on login, that is used by the web frontend to authorize against the GraphQL and REST APIs. -* The REST API is used outside a browser session, e.g. by scripts. In this case you have to issue a token manually. This possible from within the configuration view or on the command line. It is recommended to issue a JWT token in this case for a special user that only has the `api` role. By using different users for different purposes a fine grained access control and access revocation management is possible. - -The token is commonly specified in the Authorization HTTP header using the Bearer schema. - -## Setup user and JWT token for REST API authorization - -1. Create user: -``` -$ ./cc-backend --add-user :api: --no-server -``` -2. Issue token for user: -``` -$ ./cc-backend -jwt -no-server -``` -3. Use issued token token on client side: -``` -$ curl -X GET "" -H "accept: application/json" -H "Content-Type: application/json" -H "Authorization: Bearer " -```