diff --git a/internal/auth-v2/auth.go b/internal/auth-v2/auth.go index 666b95c..76b44b5 100644 --- a/internal/auth-v2/auth.go +++ b/internal/auth-v2/auth.go @@ -1,8 +1,10 @@ package authv2 import ( + "database/sql" "encoding/json" "net/http" + "time" "github.com/ClusterCockpit/cc-backend/pkg/log" sq "github.com/Masterminds/squirrel" @@ -29,6 +31,7 @@ type User struct { Roles []string `json:"roles"` AuthSource int8 `json:"via"` Email string `json:"email"` + Expiration time.Time } func (u *User) HasRole(role string) bool { @@ -43,7 +46,7 @@ func (u *User) HasRole(role string) bool { type Authenticator interface { Init(auth *Authentication, config json.RawMessage) error CanLogin(user *User, rw http.ResponseWriter, r *http.Request) bool - Login(user *User, password string, rw http.ResponseWriter, r *http.Request) error + Login(user *User, password string, rw http.ResponseWriter, r *http.Request) (*User, error) Auth(rw http.ResponseWriter, r *http.Request) (*User, error) } @@ -75,7 +78,28 @@ func Init(db *sqlx.DB) (*Authentication, error) { return auth, nil } -func (auth *Authentication) RegisterUser(user *User) error { +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)} diff --git a/internal/auth-v2/jwt.go b/internal/auth-v2/jwt.go index 1e26b96..b84f984 100644 --- a/internal/auth-v2/jwt.go +++ b/internal/auth-v2/jwt.go @@ -2,12 +2,14 @@ package authv2 import ( "crypto/ed25519" + "database/sql" "encoding/base64" "encoding/json" "errors" "net/http" "os" "strings" + "time" "github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/golang-jwt/jwt/v4" @@ -17,6 +19,8 @@ type JWTAuthenticator struct { auth *Authentication publicKey ed25519.PublicKey privateKey ed25519.PrivateKey + + maxAge time.Duration } var _ Authenticator = (*JWTAuthenticator)(nil) @@ -47,19 +51,76 @@ func (ja *JWTAuthenticator) CanLogin(user *User, rw http.ResponseWriter, r *http return user.AuthSource == AuthViaToken || r.Header.Get("Authorization") != "" } -func (ja *JWTAuthenticator) Login(user *User, password string, rw http.ResponseWriter, r *http.Request) error { - return nil +func (ja *JWTAuthenticator) Login(_ *User, password string, 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 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) + 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) + } + } + } + + user, err := ja.auth.GetUser(sub) + if err != nil && err != sql.ErrNoRows { + return nil, err + } + + if err != nil && err == sql.ErrNoRows { + user = &User{ + Username: user.Username, + 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") - prefix := "Bearer " - if !strings.HasPrefix(rawtoken, prefix) { - return nil, nil + rawtoken = strings.TrimPrefix("Bearer ", rawtoken) + } + + // Because a user can also log in via a token, the + // session cookie must be checked here as well: + if rawtoken == "" { + user, err := ja.auth.AuthViaSession(rw, r) + if err != nil { + return nil, err } - rawtoken = rawtoken[len(prefix):] + + user.AuthSource = AuthViaToken + return user, nil } token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (interface{}, error) { @@ -88,10 +149,28 @@ func (ja *JWTAuthenticator) Auth(rw http.ResponseWriter, r *http.Request) (*User } } - // TODO: Check if sub is still a valid user! 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.maxAge != 0 { + claims["exp"] = now.Add(ja.maxAge).Unix() + } + + return jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims).SignedString(ja.privateKey) +} diff --git a/internal/auth-v2/ldap.go b/internal/auth-v2/ldap.go index e432ded..caa37d5 100644 --- a/internal/auth-v2/ldap.go +++ b/internal/auth-v2/ldap.go @@ -70,19 +70,19 @@ func (la *LdapAutnenticator) CanLogin(user *User, rw http.ResponseWriter, r *htt return user.AuthSource == AuthViaLDAP } -func (la *LdapAutnenticator) Login(user *User, password string, rw http.ResponseWriter, r *http.Request) error { +func (la *LdapAutnenticator) Login(user *User, password string, rw http.ResponseWriter, r *http.Request) (*User, error) { l, err := la.getLdapConnection(false) if err != nil { - return err + return nil, err } defer l.Close() userDn := strings.Replace(la.config.UserBind, "{username}", user.Username, -1) if err := l.Bind(userDn, password); err != nil { - return err + return nil, err } - return nil + return user, nil } func (la *LdapAutnenticator) Auth(rw http.ResponseWriter, r *http.Request) (*User, error) { diff --git a/internal/auth-v2/local.go b/internal/auth-v2/local.go index 2040beb..605f1b4 100644 --- a/internal/auth-v2/local.go +++ b/internal/auth-v2/local.go @@ -23,12 +23,12 @@ func (la *LocalAuthenticator) CanLogin(user *User, rw http.ResponseWriter, r *ht return user.AuthSource == AuthViaLocalPassword } -func (la *LocalAuthenticator) Login(user *User, password string, rw http.ResponseWriter, r *http.Request) error { +func (la *LocalAuthenticator) Login(user *User, password string, rw http.ResponseWriter, r *http.Request) (*User, error) { if e := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); e != nil { - return fmt.Errorf("user '%s' provided the wrong password (%s)", user.Username, e.Error()) + return nil, fmt.Errorf("user '%s' provided the wrong password (%w)", user.Username, e) } - return nil + return user, nil } func (la *LocalAuthenticator) Auth(rw http.ResponseWriter, r *http.Request) (*User, error) {