Merge auth-refactoring (Fixes #27 and #24)

This commit is contained in:
Lou Knauer 2022-07-25 09:37:21 +02:00
commit 4e7ff6f9bc
12 changed files with 696 additions and 529 deletions

View File

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

View File

@ -55,7 +55,8 @@ Some of the `ui-defaults` values can be appended by `:<clustername>` 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.

51
configs/README_TOKENS.md Normal file
View File

@ -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 <username>:api:<password> --no-server
```
2. Issue token for user:
```
$ ./cc-backend --jwt <username> --no-server
```
3. Use issued token token on client side:
```
$ curl -X GET "<API ENDPOINT>" -H "accept: application/json" -H "Content-Type: application/json" -H "Authorization: Bearer <JWT TOKEN>"
```

View File

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

View File

@ -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: "<username>:[admin|api|]:<password>"
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) {

View File

@ -0,0 +1 @@
package auth

185
internal/auth/jwt.go Normal file
View File

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

View File

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

35
internal/auth/local.go Normal file
View File

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

138
internal/auth/users.go Normal file
View File

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

View File

@ -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 <username>:api:<Password> --no-server
```
2. Issue token for user:
```
$ ./cc-backend -jwt <username> -no-server
```
3. Use issued token token on client side:
```
$ curl -X GET "<API ENDPOINT>" -H "accept: application/json" -H "Content-Type: application/json" -H "Authorization: Bearer <JWT TOKEN>"
```