package main import ( "crypto/subtle" "encoding/json" "errors" "flag" "log" "net/http" "os" "strings" "unicode" ) // changeAPISecret is the shared Bearer token protecting the JSON password change // API (POST /api/v1/password). If empty, the API is disabled. var changeAPISecret string func init() { if val, ok := os.LookupEnv("CHANGE_API_SECRET"); ok { changeAPISecret = val } flag.StringVar(&changeAPISecret, "change-api-secret", changeAPISecret, "Bearer token protecting the JSON password change API") } func checkPasswdConstraint(password string) error { if len(password) < 12 { return errors.New("too short, please choose a password at least 12 characters long") } if len(password) > 128 { return errors.New("too long, please choose a password at most 128 characters long") } var hasUpper, hasLower, hasDigit bool for _, r := range password { switch { case unicode.IsUpper(r): hasUpper = true case unicode.IsLower(r): hasLower = true case unicode.IsDigit(r): hasDigit = true } } if !hasUpper || !hasLower || !hasDigit { return errors.New("password must contain at least one uppercase letter, one lowercase letter, and one digit") } return nil } func changePassword(w http.ResponseWriter, r *http.Request) { if r.Method == "POST" && !changeLimiter.Allow(remoteIP(r)) { csrfToken, _ := setCSRFToken(w) displayTmplError(w, http.StatusTooManyRequests, "change.html", map[string]any{"error": "Too many requests. Please try again later.", "csrf_token": csrfToken}) return } if r.Method != "POST" { csrfToken, err := setCSRFToken(w) if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } displayTmpl(w, "change.html", map[string]any{"csrf_token": csrfToken}) return } if !validateCSRF(r) { csrfToken, _ := setCSRFToken(w) displayTmplError(w, http.StatusForbidden, "change.html", map[string]any{"error": "Invalid or missing CSRF token. Please try again.", "csrf_token": csrfToken}) return } if !validateAltcha(r) { csrfToken, _ := setCSRFToken(w) displayTmplError(w, http.StatusForbidden, "change.html", map[string]any{"error": "Invalid or missing altcha response. Please try again.", "csrf_token": csrfToken}) return } renderError := func(status int, msg string) { csrfToken, _ := setCSRFToken(w) displayTmplError(w, status, "change.html", map[string]any{"error": msg, "csrf_token": csrfToken}) } // Check the two new passwords are identical if r.PostFormValue("newpassword") != r.PostFormValue("new2password") { renderError(http.StatusNotAcceptable, "New passwords are not identical. Please retry.") return } if len(r.PostFormValue("login")) == 0 { renderError(http.StatusNotAcceptable, "Please provide a valid login") return } if err := checkPasswdConstraint(r.PostFormValue("newpassword")); err != nil { renderError(http.StatusNotAcceptable, "The password you chose doesn't respect all constraints: "+err.Error()) return } conn, err := myLDAP.Connect() if err != nil || conn == nil { log.Println(err) renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.") return } defer conn.Close() if err := conn.ServiceBind(); err != nil { log.Println(err) renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.") return } dn, err := conn.SearchDN(r.PostFormValue("login"), true) if err != nil { log.Println(err) // User not found: perform a dummy bind to prevent username enumeration via timing. conn.Bind("cn=dummy,"+myLDAP.BaseDN, r.PostFormValue("password")) renderError(http.StatusUnauthorized, "Invalid login or password.") return } if err := conn.Bind(dn, r.PostFormValue("password")); err != nil { log.Println(err) renderError(http.StatusUnauthorized, "Invalid login or password.") return } if err := conn.ChangePassword(dn, r.PostFormValue("newpassword")); err != nil { log.Println(err) renderError(http.StatusInternalServerError, "Unable to process your request. Please try again later.") return } displayMsg(w, "Password successfully changed!", http.StatusOK) } // changePasswordAPIRequest is the JSON body accepted by changePasswordAPI. // The field names match the default payload_mapping targets of the alps // "password" plugin. type changePasswordAPIRequest struct { Username string `json:"username"` OldPassword string `json:"old_password"` NewPassword string `json:"new_password"` } // checkChangeAPIAuthorization validates the Bearer token of an incoming request // against the configured shared secret in constant time. func checkChangeAPIAuthorization(r *http.Request) bool { if changeAPISecret == "" { return false } fields := strings.Fields(r.Header.Get("Authorization")) if len(fields) != 2 || !strings.EqualFold(fields[0], "Bearer") { return false } return subtle.ConstantTimeCompare([]byte(fields[1]), []byte(changeAPISecret)) == 1 } func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(payload) } func writeJSONError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } // changePasswordAPI changes a user's password through a JSON API protected by a // Bearer token. Unlike the HTML form flow it is meant to be called by a trusted // backend (e.g. the alps "password" plugin), but it still verifies the user's // current password via an LDAP bind before applying the change. func changePasswordAPI(w http.ResponseWriter, r *http.Request) { if !changeLimiter.Allow(remoteIP(r)) { writeJSONError(w, http.StatusTooManyRequests, "Too many requests. Please try again later.") return } if changeAPISecret == "" { writeJSONError(w, http.StatusServiceUnavailable, "Password change API is not configured.") return } if !checkChangeAPIAuthorization(r) { writeJSONError(w, http.StatusUnauthorized, "Invalid or missing Bearer token.") return } var req changePasswordAPIRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONError(w, http.StatusBadRequest, "Invalid JSON body.") return } if req.Username == "" { writeJSONError(w, http.StatusBadRequest, "Please provide a valid username.") return } if req.OldPassword == "" || req.NewPassword == "" { writeJSONError(w, http.StatusBadRequest, "Old and new passwords are required.") return } if err := checkPasswdConstraint(req.NewPassword); err != nil { writeJSONError(w, http.StatusNotAcceptable, "The password you chose doesn't respect all constraints: "+err.Error()) return } internalErr := func(err error) { log.Println(err) writeJSONError(w, http.StatusInternalServerError, "Unable to process your request. Please try again later.") } conn, err := myLDAP.Connect() if err != nil || conn == nil { internalErr(err) return } defer conn.Close() if err := conn.ServiceBind(); err != nil { internalErr(err) return } dn, err := conn.SearchDN(req.Username, true) if err != nil { log.Println(err) // User not found: perform a dummy bind to prevent username enumeration via timing. conn.Bind("cn=dummy,"+myLDAP.BaseDN, req.OldPassword) writeJSONError(w, http.StatusUnauthorized, "Invalid login or password.") return } if err := conn.Bind(dn, req.OldPassword); err != nil { log.Println(err) writeJSONError(w, http.StatusUnauthorized, "Invalid login or password.") return } if err := conn.ChangePassword(dn, req.NewPassword); err != nil { internalErr(err) return } log.Printf("Password changed via API for %s", dn) writeJSON(w, http.StatusOK, map[string]string{"message": "Password successfully changed"}) }