mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2024-11-14 18:57:25 +01:00
474 lines
13 KiB
Go
474 lines
13 KiB
Go
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/graph/model"
|
|
"github.com/ClusterCockpit/cc-backend/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"
|
|
)
|
|
|
|
func (u *User) HasRole(role string) bool {
|
|
for _, r := range u.Roles {
|
|
if r == role {
|
|
return true
|
|
}
|
|
}
|
|
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 {
|
|
return nil
|
|
}
|
|
|
|
return x.(*User)
|
|
}
|
|
|
|
// Clears the session cookie
|
|
func (auth *Authentication) Logout(onsuccess http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
session, err := auth.sessionStore.Get(r, "session")
|
|
if err != nil {
|
|
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if !session.IsNew {
|
|
session.Options.MaxAge = -1
|
|
if err := auth.sessionStore.Save(r, rw, session); err != nil {
|
|
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
onsuccess.ServeHTTP(rw, r)
|
|
})
|
|
}
|