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 }