github.com/pelicanplatform/pelican@v1.0.5/web_ui/authentication.go (about)

     1  /***************************************************************
     2   *
     3   * Copyright (C) 2023, Pelican Project, Morgridge Institute for Research
     4   *
     5   * Licensed under the Apache License, Version 2.0 (the "License"); you
     6   * may not use this file except in compliance with the License.  You may
     7   * obtain a copy of the License at
     8   *
     9   *    http://www.apache.org/licenses/LICENSE-2.0
    10   *
    11   * Unless required by applicable law or agreed to in writing, software
    12   * distributed under the License is distributed on an "AS IS" BASIS,
    13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    14   * See the License for the specific language governing permissions and
    15   * limitations under the License.
    16   *
    17   ***************************************************************/
    18  
    19  package web_ui
    20  
    21  import (
    22  	"bufio"
    23  	"crypto/ecdsa"
    24  	"net/http"
    25  	"os"
    26  	"path"
    27  	"strings"
    28  	"time"
    29  
    30  	"github.com/gin-gonic/gin"
    31  	"github.com/lestrrat-go/jwx/v2/jwa"
    32  	"github.com/lestrrat-go/jwx/v2/jwt"
    33  	"github.com/pelicanplatform/pelican/config"
    34  	"github.com/pelicanplatform/pelican/param"
    35  	"github.com/pkg/errors"
    36  	log "github.com/sirupsen/logrus"
    37  	"github.com/tg123/go-htpasswd"
    38  	"go.uber.org/atomic"
    39  )
    40  
    41  type (
    42  	Login struct {
    43  		User     string `form:"user"`
    44  		Password string `form:"password"`
    45  	}
    46  
    47  	InitLogin struct {
    48  		Code string `form:"code"`
    49  	}
    50  
    51  	PasswordReset struct {
    52  		Password string `form:"password"`
    53  	}
    54  )
    55  
    56  var (
    57  	authDB       atomic.Pointer[htpasswd.File]
    58  	currentCode  atomic.Pointer[string]
    59  	previousCode atomic.Pointer[string]
    60  )
    61  
    62  // Periodically re-read the htpasswd file used for password-based authentication
    63  func periodicAuthDBReload() {
    64  	for {
    65  		time.Sleep(30 * time.Second)
    66  		log.Debug("Reloading the auth database")
    67  		_ = doReload()
    68  	}
    69  }
    70  
    71  func configureAuthDB() error {
    72  	fileName := param.Server_UIPasswordFile.GetString()
    73  	if fileName == "" {
    74  		return errors.New("Location of password file not set")
    75  	}
    76  	fp, err := os.Open(fileName)
    77  	if err != nil {
    78  		return err
    79  	}
    80  	defer fp.Close()
    81  	scanner := bufio.NewScanner(fp)
    82  	scanner.Split(bufio.ScanLines)
    83  	hasAdmin := false
    84  	for scanner.Scan() {
    85  		user := strings.Split(scanner.Text(), ":")[0]
    86  		if user == "admin" {
    87  			hasAdmin = true
    88  			break
    89  		}
    90  	}
    91  	if !hasAdmin {
    92  		return errors.New("AuthDB does not have 'admin' user")
    93  	}
    94  
    95  	auth, err := htpasswd.New(fileName, []htpasswd.PasswdParser{htpasswd.AcceptBcrypt}, nil)
    96  	if err != nil {
    97  		return err
    98  	}
    99  	authDB.Store(auth)
   100  
   101  	return nil
   102  }
   103  
   104  // Get the "subjuect" claim from the JWT that "login" cookie stores,
   105  // where subject is set to be the username. Return empty string if no "login" cookie is present
   106  func getUser(ctx *gin.Context) (string, error) {
   107  	token, err := ctx.Cookie("login")
   108  	if err != nil {
   109  		return "", nil
   110  	}
   111  	if token == "" {
   112  		return "", errors.New("Login cookie is empty")
   113  	}
   114  	key, err := config.GetIssuerPrivateJWK()
   115  	if err != nil {
   116  		return "", err
   117  	}
   118  	var raw ecdsa.PrivateKey
   119  	if err = key.Raw(&raw); err != nil {
   120  		return "", errors.New("Failed to extract cookie signing key")
   121  	}
   122  	parsed, err := jwt.Parse([]byte(token), jwt.WithKey(jwa.ES256, raw.PublicKey))
   123  	if err != nil {
   124  		return "", err
   125  	}
   126  	if err = jwt.Validate(parsed); err != nil {
   127  		return "", err
   128  	}
   129  	return parsed.Subject(), nil
   130  }
   131  
   132  // Create a JWT and set the "login" cookie to store that JWT
   133  func setLoginCookie(ctx *gin.Context, user string) {
   134  	key, err := config.GetIssuerPrivateJWK()
   135  	if err != nil {
   136  		log.Errorln("Failure when loading the cookie signing key:", err)
   137  		ctx.JSON(500, gin.H{"error": "Unable to create login cookies"})
   138  		return
   139  	}
   140  
   141  	now := time.Now()
   142  	tok, err := jwt.NewBuilder().
   143  		// The value of the "scope" claim is a JSON string containing a space-separated
   144  		// list of scopes associated with the token
   145  		Claim("scope", "monitoring.query monitoring.scrape").
   146  		Issuer(param.Server_ExternalWebUrl.GetString()).
   147  		IssuedAt(now).
   148  		Expiration(now.Add(30 * time.Minute)).
   149  		NotBefore(now).
   150  		Subject(user).
   151  		Build()
   152  	if err != nil {
   153  		ctx.JSON(500, gin.H{"error": "Failed to build token"})
   154  		return
   155  	}
   156  	log.Debugf("Type of *key: %T\n", key)
   157  	var raw ecdsa.PrivateKey
   158  	if err = key.Raw(&raw); err != nil {
   159  		ctx.JSON(500, gin.H{"error": "Unable to sign login cookie"})
   160  		return
   161  	}
   162  	signed, err := jwt.Sign(tok, jwt.WithKey(jwa.ES256, raw))
   163  	if err != nil {
   164  		log.Errorln("Failure when signing the login cookie:", err)
   165  		ctx.JSON(500, gin.H{"error": "Unable to sign login cookie"})
   166  		return
   167  	}
   168  
   169  	ctx.SetCookie("login", string(signed), 30*60, "/api/v1.0",
   170  		ctx.Request.URL.Host, true, true)
   171  	// Explicitly set Cookie for /metrics endpoint as they are in different paths
   172  	ctx.SetCookie("login", string(signed), 30*60, "/metrics",
   173  		ctx.Request.URL.Host, true, true)
   174  	// Explicitly set Cookie for /view endpoint as they are in different paths
   175  	ctx.SetCookie("login", string(signed), 30*60, "/view",
   176  		ctx.Request.URL.Host, true, true)
   177  	ctx.SetSameSite(http.SameSiteStrictMode)
   178  }
   179  
   180  // Check if user is authenticated by checking if the "login" cookie is present and set the user identity to ctx
   181  func authHandler(ctx *gin.Context) {
   182  	user, err := getUser(ctx)
   183  	if err != nil || user == "" {
   184  		log.Errorln("Invalid user cookie or unable to parse user cookie:", err)
   185  		ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authentication required to perform this operation"})
   186  	} else {
   187  		ctx.Set("User", user)
   188  		ctx.Next()
   189  	}
   190  }
   191  
   192  // Handle regular username/password based login
   193  func loginHandler(ctx *gin.Context) {
   194  	db := authDB.Load()
   195  	if db == nil {
   196  		newPath := path.Join(ctx.Request.URL.Path, "..", "initLogin")
   197  		initUrl := ctx.Request.URL
   198  		initUrl.Path = newPath
   199  		ctx.Redirect(307, initUrl.String())
   200  		return
   201  	}
   202  
   203  	login := Login{}
   204  	if ctx.ShouldBind(&login) != nil {
   205  		ctx.JSON(400, gin.H{"error": "Missing user/password in form data"})
   206  		return
   207  	}
   208  	if !db.Match(login.User, login.Password) {
   209  		ctx.JSON(401, gin.H{"error": "Login failed"})
   210  		return
   211  	}
   212  
   213  	setLoginCookie(ctx, login.User)
   214  	ctx.JSON(200, gin.H{"msg": "Success"})
   215  }
   216  
   217  // Handle initial code-based login for admin
   218  func initLoginHandler(ctx *gin.Context) {
   219  	db := authDB.Load()
   220  	if db != nil {
   221  		ctx.JSON(400, gin.H{"error": "Authentication is already initialized"})
   222  		return
   223  	}
   224  	curCode := currentCode.Load()
   225  	if curCode == nil {
   226  		ctx.JSON(400, gin.H{"error": "Code-based login is not available"})
   227  		return
   228  	}
   229  	prevCode := previousCode.Load()
   230  
   231  	code := InitLogin{}
   232  	if ctx.ShouldBind(&code) != nil {
   233  		ctx.JSON(400, gin.H{"error": "Login code not provided"})
   234  		return
   235  	}
   236  
   237  	if code.Code != *curCode && (prevCode == nil || code.Code != *prevCode) {
   238  		ctx.JSON(401, gin.H{"error": "Invalid login code"})
   239  		return
   240  	}
   241  
   242  	setLoginCookie(ctx, "admin")
   243  }
   244  
   245  // Handle reset password
   246  func resetLoginHandler(ctx *gin.Context) {
   247  	passwordReset := PasswordReset{}
   248  	if ctx.ShouldBind(&passwordReset) != nil {
   249  		ctx.JSON(400, gin.H{"error": "Invalid password reset request"})
   250  		return
   251  	}
   252  
   253  	user := ctx.GetString("User")
   254  
   255  	if err := WritePasswordEntry(user, passwordReset.Password); err != nil {
   256  		log.Errorf("Password reset for user %s failed: %s", user, err)
   257  		ctx.JSON(500, gin.H{"error": "Failed to reset password"})
   258  	} else {
   259  		log.Infof("Password reset for user %s was successful", user)
   260  		ctx.JSON(200, gin.H{"msg": "Success"})
   261  	}
   262  	if err := configureAuthDB(); err != nil {
   263  		log.Errorln("Error in reloading authDB:", err)
   264  	}
   265  }
   266  
   267  // Configure the authentication endpoints for the server web UI
   268  func configureAuthEndpoints(router *gin.Engine) error {
   269  	if router == nil {
   270  		return errors.New("Web engine configuration passed a nil pointer")
   271  	}
   272  
   273  	if err := configureAuthDB(); err != nil {
   274  		log.Infoln("Authorization not configured (non-fatal):", err)
   275  	}
   276  
   277  	group := router.Group("/api/v1.0/auth")
   278  	group.POST("/login", loginHandler)
   279  	group.POST("/initLogin", initLoginHandler)
   280  	group.POST("/resetLogin", authHandler, resetLoginHandler)
   281  	group.GET("/whoami", func(ctx *gin.Context) {
   282  		if user, err := getUser(ctx); err != nil || user == "" {
   283  			ctx.JSON(200, gin.H{"authenticated": false})
   284  		} else {
   285  			ctx.JSON(200, gin.H{"authenticated": true, "user": user})
   286  		}
   287  	})
   288  	group.GET("/loginInitialized", func(ctx *gin.Context) {
   289  		db := authDB.Load()
   290  		if db == nil {
   291  			ctx.JSON(200, gin.H{"initialized": false})
   292  		} else {
   293  			ctx.JSON(200, gin.H{"initialized": true})
   294  		}
   295  	})
   296  
   297  	go periodicAuthDBReload()
   298  
   299  	return nil
   300  }