chldapasswd/change.go
Pierre-Olivier Mercier 37edbbb9b6
All checks were successful
continuous-integration/drone/push Build is passing
feat: add bearer-authenticated password change API
Add POST /api/v1/password endpoint accepting a JSON body
(username, old_password, new_password) protected by a shared
Bearer token (CHANGE_API_SECRET / -change-api-secret). It verifies
the current password via an LDAP bind before applying the change,
matching the alps "password" plugin bearer flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:00:42 +08:00

252 lines
7.6 KiB
Go

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"})
}