mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2024-12-25 21:09:05 +01:00
commit
4e7ff6f9bc
@ -74,12 +74,10 @@ type ProgramConfig struct {
|
|||||||
|
|
||||||
// For LDAP Authentication and user synchronisation.
|
// 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!
|
// If 0 or empty, the session/token does not expire!
|
||||||
SessionMaxAge string `json:"session-max-age"`
|
SessionMaxAge string `json:"session-max-age"`
|
||||||
JwtMaxAge string `json:"jwt-max-age"`
|
|
||||||
|
|
||||||
// If both those options are not empty, use HTTPS using those certificates.
|
// If both those options are not empty, use HTTPS using those certificates.
|
||||||
HttpsCertFile string `json:"https-cert-file"`
|
HttpsCertFile string `json:"https-cert-file"`
|
||||||
@ -110,7 +108,6 @@ var programConfig ProgramConfig = ProgramConfig{
|
|||||||
DisableArchive: false,
|
DisableArchive: false,
|
||||||
LdapConfig: nil,
|
LdapConfig: nil,
|
||||||
SessionMaxAge: "168h",
|
SessionMaxAge: "168h",
|
||||||
JwtMaxAge: "0",
|
|
||||||
UiDefaults: map[string]interface{}{
|
UiDefaults: map[string]interface{}{
|
||||||
"analysis_view_histogramMetrics": []string{"flops_any", "mem_bw", "mem_used"},
|
"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"}},
|
"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
|
var authentication *auth.Authentication
|
||||||
if !programConfig.DisableAuthentication {
|
if !programConfig.DisableAuthentication {
|
||||||
authentication = &auth.Authentication{}
|
if authentication, err = auth.Init(db.DB, map[string]interface{}{
|
||||||
if d, err := time.ParseDuration(programConfig.SessionMaxAge); err != nil {
|
"ldap": programConfig.LdapConfig,
|
||||||
authentication.SessionMaxAge = d
|
"jwt": programConfig.JwtConfig,
|
||||||
}
|
}); err != nil {
|
||||||
if d, err := time.ParseDuration(programConfig.JwtMaxAge); err != nil {
|
|
||||||
authentication.JwtMaxAge = d
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := authentication.Init(db.DB, programConfig.LdapConfig); err != nil {
|
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if d, err := time.ParseDuration(programConfig.SessionMaxAge); err != nil {
|
||||||
|
authentication.SessionMaxAge = d
|
||||||
|
}
|
||||||
|
|
||||||
if flagNewUser != "" {
|
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)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -214,13 +217,18 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if flagSyncLDAP {
|
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.Fatal(err)
|
||||||
}
|
}
|
||||||
|
log.Info("LDAP sync successfull")
|
||||||
}
|
}
|
||||||
|
|
||||||
if flagGenJWT != "" {
|
if flagGenJWT != "" {
|
||||||
user, err := authentication.FetchUser(flagGenJWT)
|
user, err := authentication.GetUser(flagGenJWT)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
@ -229,7 +237,7 @@ func main() {
|
|||||||
log.Warn("that user does not have the API role")
|
log.Warn("that user does not have the API role")
|
||||||
}
|
}
|
||||||
|
|
||||||
jwt, err := authentication.ProvideJWT(user)
|
jwt, err := authentication.JwtAuth.ProvideJWT(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -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.
|
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.
|
* `SESSION_KEY`: Some random bytes used as secret for cookie-based sessions.
|
||||||
* `LDAP_ADMIN_PASSWORD`: The LDAP admin user password (optional).
|
* `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.
|
* `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
51
configs/README_TOKENS.md
Normal 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>"
|
||||||
|
```
|
@ -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 {
|
if err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
|
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
jwt, err := api.Authentication.ProvideJWT(user)
|
jwt, err := api.Authentication.JwtAuth.ProvideJWT(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
|
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
|
||||||
return
|
return
|
||||||
@ -527,7 +527,12 @@ func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -556,9 +561,7 @@ func (api *RestApi) getUsers(rw http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
users, err := api.Authentication.FetchUsers(
|
users, err := api.Authentication.ListUsers(r.URL.Query().Get("not-just-user") == "true")
|
||||||
r.URL.Query().Get("via-ldap") == "true",
|
|
||||||
r.URL.Query().Get("not-just-user") == "true")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
@ -2,45 +2,40 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/ed25519"
|
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"database/sql"
|
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/graph/model"
|
|
||||||
"github.com/ClusterCockpit/cc-backend/pkg/log"
|
"github.com/ClusterCockpit/cc-backend/pkg/log"
|
||||||
sq "github.com/Masterminds/squirrel"
|
|
||||||
"github.com/golang-jwt/jwt/v4"
|
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
"github.com/jmoiron/sqlx"
|
"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 (
|
const (
|
||||||
RoleAdmin string = "admin"
|
RoleAdmin string = "admin"
|
||||||
RoleApi string = "api"
|
RoleApi string = "api"
|
||||||
RoleUser string = "user"
|
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 {
|
func (u *User) HasRole(role string) bool {
|
||||||
for _, r := range u.Roles {
|
for _, r := range u.Roles {
|
||||||
if r == role {
|
if r == role {
|
||||||
@ -50,6 +45,22 @@ func (u *User) HasRole(role string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetUser(ctx context.Context) *User {
|
||||||
|
x := ctx.Value(ContextUserKey)
|
||||||
|
if x == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
type ContextKey string
|
||||||
|
|
||||||
const ContextUserKey ContextKey = "user"
|
const ContextUserKey ContextKey = "user"
|
||||||
@ -57,29 +68,27 @@ const ContextUserKey ContextKey = "user"
|
|||||||
type Authentication struct {
|
type Authentication struct {
|
||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
sessionStore *sessions.CookieStore
|
sessionStore *sessions.CookieStore
|
||||||
jwtPublicKey ed25519.PublicKey
|
|
||||||
jwtPrivateKey ed25519.PrivateKey
|
|
||||||
|
|
||||||
ldapConfig *LdapConfig
|
|
||||||
ldapSyncUserPassword string
|
|
||||||
|
|
||||||
// If zero, tokens/sessions do not expire.
|
|
||||||
SessionMaxAge time.Duration
|
SessionMaxAge time.Duration
|
||||||
JwtMaxAge time.Duration
|
|
||||||
|
authenticators []Authenticator
|
||||||
|
LdapAuth *LdapAutnenticator
|
||||||
|
JwtAuth *JWTAuthenticator
|
||||||
|
LocalAuth *LocalAuthenticator
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *Authentication) Init(db *sqlx.DB, ldapConfig *LdapConfig) error {
|
func Init(db *sqlx.DB, configs map[string]interface{}) (*Authentication, error) {
|
||||||
|
auth := &Authentication{}
|
||||||
auth.db = db
|
auth.db = db
|
||||||
_, err := db.Exec(`
|
_, err := db.Exec(`
|
||||||
CREATE TABLE IF NOT EXISTS user (
|
CREATE TABLE IF NOT EXISTS user (
|
||||||
username varchar(255) PRIMARY KEY NOT NULL,
|
username varchar(255) PRIMARY KEY NOT NULL,
|
||||||
password varchar(255) DEFAULT NULL,
|
password varchar(255) DEFAULT NULL,
|
||||||
ldap tinyint NOT NULL DEFAULT 0,
|
ldap tinyint NOT NULL DEFAULT 0, /* col called "ldap" for historic reasons, fills the "AuthSource" */
|
||||||
name varchar(255) DEFAULT NULL,
|
name varchar(255) DEFAULT NULL,
|
||||||
roles varchar(255) NOT NULL DEFAULT "[]",
|
roles varchar(255) NOT NULL DEFAULT "[]",
|
||||||
email varchar(255) DEFAULT NULL);`)
|
email varchar(255) DEFAULT NULL);`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
sessKey := os.Getenv("SESSION_KEY")
|
sessKey := os.Getenv("SESSION_KEY")
|
||||||
@ -87,223 +96,79 @@ func (auth *Authentication) Init(db *sqlx.DB, ldapConfig *LdapConfig) error {
|
|||||||
log.Warn("environment variable 'SESSION_KEY' not set (will use non-persistent random key)")
|
log.Warn("environment variable 'SESSION_KEY' not set (will use non-persistent random key)")
|
||||||
bytes := make([]byte, 32)
|
bytes := make([]byte, 32)
|
||||||
if _, err := rand.Read(bytes); err != nil {
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
auth.sessionStore = sessions.NewCookieStore(bytes)
|
auth.sessionStore = sessions.NewCookieStore(bytes)
|
||||||
} else {
|
} else {
|
||||||
bytes, err := base64.StdEncoding.DecodeString(sessKey)
|
bytes, err := base64.StdEncoding.DecodeString(sessKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
auth.sessionStore = sessions.NewCookieStore(bytes)
|
auth.sessionStore = sessions.NewCookieStore(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
pubKey, privKey := os.Getenv("JWT_PUBLIC_KEY"), os.Getenv("JWT_PRIVATE_KEY")
|
auth.LocalAuth = &LocalAuthenticator{}
|
||||||
if pubKey == "" || privKey == "" {
|
if err := auth.LocalAuth.Init(auth, nil); err != nil {
|
||||||
log.Warn("environment variables 'JWT_PUBLIC_KEY' or 'JWT_PRIVATE_KEY' not set (token based authentication will not work)")
|
return nil, err
|
||||||
} else {
|
|
||||||
bytes, err := base64.StdEncoding.DecodeString(pubKey)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
auth.jwtPublicKey = ed25519.PublicKey(bytes)
|
auth.authenticators = append(auth.authenticators, auth.LocalAuth)
|
||||||
bytes, err = base64.StdEncoding.DecodeString(privKey)
|
|
||||||
if err != nil {
|
auth.JwtAuth = &JWTAuthenticator{}
|
||||||
return err
|
if err := auth.JwtAuth.Init(auth, configs["jwt"]); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
auth.jwtPrivateKey = ed25519.PrivateKey(bytes)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ldapConfig != nil {
|
return auth, 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) AuthViaSession(rw http.ResponseWriter, r *http.Request) (*User, error) {
|
||||||
func (auth *Authentication) AddUser(arg string) error {
|
session, err := auth.sessionStore.Get(r, "session")
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
users := make([]*User, 0)
|
if session.IsNew {
|
||||||
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, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, err
|
username, _ := session.Values["username"].(string)
|
||||||
}
|
roles, _ := session.Values["roles"].([]string)
|
||||||
|
return &User{
|
||||||
user.Name = name.String
|
Username: username,
|
||||||
user.Email = email.String
|
Roles: roles,
|
||||||
return user, nil
|
AuthSource: -1,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle a POST request that should log the user in, starting a new session.
|
// 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 {
|
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) {
|
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||||
username, password := r.FormValue("username"), r.FormValue("password")
|
var err error
|
||||||
user, err := auth.FetchUser(username)
|
username := r.FormValue("username")
|
||||||
if err == nil && user.ViaLdap && auth.ldapConfig != nil {
|
user := (*User)(nil)
|
||||||
err = auth.loginViaLdap(user, password)
|
if username != "" {
|
||||||
} else if err == nil && !user.ViaLdap && user.Password != "" {
|
if user, _ = auth.GetUser(username); err != nil {
|
||||||
if e := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); e != nil {
|
log.Warnf("login of unkown user %#v", username)
|
||||||
err = fmt.Errorf("user '%s' provided the wrong password (%s)", username, e.Error())
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
err = errors.New("could not authenticate user")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, authenticator := range auth.authenticators {
|
||||||
|
if !authenticator.CanLogin(user, rw, r) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err = authenticator.Login(user, rw, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("login of user %#v failed: %s", username, err.Error())
|
log.Warnf("login failed: %s", err.Error())
|
||||||
onfailure(rw, r, err)
|
onfailure(rw, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -329,126 +194,39 @@ func (auth *Authentication) Login(onsuccess http.Handler, onfailure func(rw http
|
|||||||
log.Infof("login successfull: user: %#v (roles: %v)", user.Username, user.Roles)
|
log.Infof("login successfull: user: %#v (roles: %v)", user.Username, user.Roles)
|
||||||
ctx := context.WithValue(r.Context(), ContextUserKey, user)
|
ctx := context.WithValue(r.Context(), ContextUserKey, user)
|
||||||
onsuccess.ServeHTTP(rw, r.WithContext(ctx))
|
onsuccess.ServeHTTP(rw, r.WithContext(ctx))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Warn("login failed: no authenticator applied")
|
||||||
|
onfailure(rw, r, err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
// Authenticate the user and put a User object in the
|
||||||
// context of the request. If authentication fails,
|
// context of the request. If authentication fails,
|
||||||
// do not continue but send client to the login screen.
|
// 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 {
|
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) {
|
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||||
user, err := auth.authViaToken(r)
|
for _, authenticator := range auth.authenticators {
|
||||||
|
user, err := authenticator.Auth(rw, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("authentication failed: %s", err.Error())
|
log.Warnf("authentication failed: %s", err.Error())
|
||||||
http.Error(rw, err.Error(), http.StatusUnauthorized)
|
http.Error(rw, err.Error(), http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if user != nil {
|
if user == nil {
|
||||||
// Successfull authentication using a token
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), ContextUserKey, user)
|
ctx := context.WithValue(r.Context(), ContextUserKey, user)
|
||||||
onsuccess.ServeHTTP(rw, r.WithContext(ctx))
|
onsuccess.ServeHTTP(rw, r.WithContext(ctx))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
session, err := auth.sessionStore.Get(r, "session")
|
log.Warnf("authentication failed: %s", "no authenticator applied")
|
||||||
if err != nil {
|
// http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||||
// sessionStore.Get will return a new session if no current one is attached to this request.
|
onfailure(rw, r, errors.New("unauthorized (login first or use a token)"))
|
||||||
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 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return x.(*User)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clears the session cookie
|
// Clears the session cookie
|
||||||
|
1
internal/auth/auth_test.go
Normal file
1
internal/auth/auth_test.go
Normal file
@ -0,0 +1 @@
|
|||||||
|
package auth
|
185
internal/auth/jwt.go
Normal file
185
internal/auth/jwt.go
Normal 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)
|
||||||
|
}
|
@ -2,6 +2,7 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -20,14 +21,25 @@ type LdapConfig struct {
|
|||||||
SyncDelOldUsers bool `json:"sync_del_old_users"`
|
SyncDelOldUsers bool `json:"sync_del_old_users"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (auth *Authentication) initLdap() error {
|
type LdapAutnenticator struct {
|
||||||
auth.ldapSyncUserPassword = os.Getenv("LDAP_ADMIN_PASSWORD")
|
auth *Authentication
|
||||||
if auth.ldapSyncUserPassword == "" {
|
config *LdapConfig
|
||||||
log.Warn("environment variable 'LDAP_ADMIN_PASSWORD' not set (ldap sync or authentication will not work)")
|
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 != "" {
|
if la.config.SyncInterval != "" {
|
||||||
interval, err := time.ParseDuration(auth.ldapConfig.SyncInterval)
|
interval, err := time.ParseDuration(la.config.SyncInterval)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -40,7 +52,7 @@ func (auth *Authentication) initLdap() error {
|
|||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
for t := range ticker.C {
|
for t := range ticker.C {
|
||||||
log.Printf("LDAP sync started at %s", t.Format(time.RFC3339))
|
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.Errorf("LDAP sync failed: %s", err.Error())
|
||||||
}
|
}
|
||||||
log.Print("LDAP sync done")
|
log.Print("LDAP sync done")
|
||||||
@ -51,53 +63,36 @@ func (auth *Authentication) initLdap() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Add a connection pool or something like
|
func (la *LdapAutnenticator) CanLogin(user *User, rw http.ResponseWriter, r *http.Request) bool {
|
||||||
// that so that connections can be reused/cached.
|
return user != nil && user.AuthSource == AuthViaLDAP
|
||||||
func (auth *Authentication) getLdapConnection(admin bool) (*ldap.Conn, error) {
|
|
||||||
conn, err := ldap.DialURL(auth.ldapConfig.Url)
|
|
||||||
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 {
|
func (la *LdapAutnenticator) Login(user *User, rw http.ResponseWriter, r *http.Request) (*User, error) {
|
||||||
l, err := auth.getLdapConnection(false)
|
l, err := la.getLdapConnection(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer l.Close()
|
defer l.Close()
|
||||||
|
|
||||||
userDn := strings.Replace(auth.ldapConfig.UserBind, "{username}", user.Username, -1)
|
userDn := strings.Replace(la.config.UserBind, "{username}", user.Username, -1)
|
||||||
if err := l.Bind(userDn, password); err != nil {
|
if err := l.Bind(userDn, r.FormValue("password")); err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
user.ViaLdap = true
|
return user, nil
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete users where user.ldap is 1 and that do not show up in the ldap search results.
|
func (la *LdapAutnenticator) Auth(rw http.ResponseWriter, r *http.Request) (*User, error) {
|
||||||
// Add users to the users table that are new in the ldap search results.
|
return la.auth.AuthViaSession(rw, r)
|
||||||
func (auth *Authentication) SyncWithLDAP(deleteOldUsers bool) error {
|
}
|
||||||
if auth.ldapConfig == nil {
|
|
||||||
return errors.New("ldap not enabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
func (la *LdapAutnenticator) Sync() error {
|
||||||
const IN_DB int = 1
|
const IN_DB int = 1
|
||||||
const IN_LDAP int = 2
|
const IN_LDAP int = 2
|
||||||
const IN_BOTH int = 3
|
const IN_BOTH int = 3
|
||||||
|
|
||||||
users := map[string]int{}
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -111,15 +106,15 @@ func (auth *Authentication) SyncWithLDAP(deleteOldUsers bool) error {
|
|||||||
users[username] = IN_DB
|
users[username] = IN_DB
|
||||||
}
|
}
|
||||||
|
|
||||||
l, err := auth.getLdapConnection(true)
|
l, err := la.getLdapConnection(true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer l.Close()
|
defer l.Close()
|
||||||
|
|
||||||
ldapResults, err := l.Search(ldap.NewSearchRequest(
|
ldapResults, err := l.Search(ldap.NewSearchRequest(
|
||||||
auth.ldapConfig.UserBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
la.config.UserBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
||||||
auth.ldapConfig.UserFilter, []string{"dn", "uid", "gecos"}, nil))
|
la.config.UserFilter, []string{"dn", "uid", "gecos"}, nil))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -141,15 +136,15 @@ func (auth *Authentication) SyncWithLDAP(deleteOldUsers bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for username, where := range users {
|
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)
|
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
|
return err
|
||||||
}
|
}
|
||||||
} else if where == IN_LDAP {
|
} else if where == IN_LDAP {
|
||||||
name := newnames[username]
|
name := newnames[username]
|
||||||
log.Debugf("ldap-sync: add %#v (name: %#v, roles: [user], ldap: true)", username, name)
|
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 {
|
username, 1, name, "[\""+RoleUser+"\"]"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -158,3 +153,21 @@ func (auth *Authentication) SyncWithLDAP(deleteOldUsers bool) error {
|
|||||||
|
|
||||||
return nil
|
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
35
internal/auth/local.go
Normal 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
138
internal/auth/users.go
Normal 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
|
||||||
|
}
|
@ -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>"
|
|
||||||
```
|
|
Loading…
Reference in New Issue
Block a user