diff --git a/change.go b/change.go index 36b2b6e..a179658 100644 --- a/change.go +++ b/change.go @@ -1,12 +1,29 @@ 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") @@ -118,3 +135,118 @@ func changePassword(w http.ResponseWriter, r *http.Request) { 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"}) +} diff --git a/change_test.go b/change_test.go index 89dd24f..40f8276 100644 --- a/change_test.go +++ b/change_test.go @@ -1,6 +1,9 @@ package main import ( + "fmt" + "net/http" + "net/http/httptest" "strings" "testing" ) @@ -31,3 +34,129 @@ func TestCheckPasswdConstraint(t *testing.T) { }) } } + +// TestChangePasswordAPI exercises the request-validation paths of the JSON +// password change API that return before any LDAP connection is attempted. +func TestChangePasswordAPI(t *testing.T) { + const secret = "s3cr3t-token" + + // Restore the global secret after the test. + orig := changeAPISecret + t.Cleanup(func() { changeAPISecret = orig }) + + tests := []struct { + name string + secret string // value of changeAPISecret for this case + authHeader string // Authorization header ("" means none) + body string + wantStatus int + }{ + { + name: "API disabled", + secret: "", + authHeader: "Bearer " + secret, + body: `{"username":"alice","old_password":"OldPass12345","new_password":"NewPass12345"}`, + wantStatus: http.StatusServiceUnavailable, + }, + { + name: "missing token", + secret: secret, + authHeader: "", + body: `{"username":"alice","old_password":"OldPass12345","new_password":"NewPass12345"}`, + wantStatus: http.StatusUnauthorized, + }, + { + name: "wrong token", + secret: secret, + authHeader: "Bearer nope", + body: `{"username":"alice","old_password":"OldPass12345","new_password":"NewPass12345"}`, + wantStatus: http.StatusUnauthorized, + }, + { + name: "malformed authorization header", + secret: secret, + authHeader: secret, + body: `{"username":"alice","old_password":"OldPass12345","new_password":"NewPass12345"}`, + wantStatus: http.StatusUnauthorized, + }, + { + name: "invalid JSON", + secret: secret, + authHeader: "Bearer " + secret, + body: `{not json`, + wantStatus: http.StatusBadRequest, + }, + { + name: "missing username", + secret: secret, + authHeader: "Bearer " + secret, + body: `{"old_password":"OldPass12345","new_password":"NewPass12345"}`, + wantStatus: http.StatusBadRequest, + }, + { + name: "missing passwords", + secret: secret, + authHeader: "Bearer " + secret, + body: `{"username":"alice"}`, + wantStatus: http.StatusBadRequest, + }, + { + name: "weak new password", + secret: secret, + authHeader: "Bearer " + secret, + body: `{"username":"alice","old_password":"OldPass12345","new_password":"short"}`, + wantStatus: http.StatusNotAcceptable, + }, + } + + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + changeAPISecret = tt.secret + + req := httptest.NewRequest(http.MethodPost, "/api/v1/password", strings.NewReader(tt.body)) + if tt.authHeader != "" { + req.Header.Set("Authorization", tt.authHeader) + } + // Unique remote address per case so the shared rate limiter never trips. + req.RemoteAddr = fmt.Sprintf("192.0.2.%d:1234", i+1) + + w := httptest.NewRecorder() + changePasswordAPI(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("status = %d, want %d (body: %s)", w.Code, tt.wantStatus, w.Body.String()) + } + }) + } +} + +func TestCheckChangeAPIAuthorization(t *testing.T) { + orig := changeAPISecret + t.Cleanup(func() { changeAPISecret = orig }) + changeAPISecret = "s3cr3t-token" + + tests := []struct { + name string + header string + want bool + }{ + {"valid", "Bearer s3cr3t-token", true}, + {"case-insensitive scheme", "bearer s3cr3t-token", true}, + {"wrong token", "Bearer wrong", false}, + {"missing", "", false}, + {"no scheme", "s3cr3t-token", false}, + {"wrong scheme", "Basic s3cr3t-token", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/v1/password", nil) + if tt.header != "" { + req.Header.Set("Authorization", tt.header) + } + if got := checkChangeAPIAuthorization(req); got != tt.want { + t.Errorf("checkChangeAPIAuthorization(%q) = %v, want %v", tt.header, got, tt.want) + } + }) + } +} diff --git a/main.go b/main.go index 1f2020a..4c3cf6d 100644 --- a/main.go +++ b/main.go @@ -241,6 +241,7 @@ func main() { http.HandleFunc(fmt.Sprintf("GET %s/style.css", baseURL), serveStyleCSS) http.HandleFunc(fmt.Sprintf("GET %s/altcha-challenge", baseURL), serveAltchaChallenge) http.HandleFunc(fmt.Sprintf("%s/{$}", baseURL), changePassword) + http.HandleFunc(fmt.Sprintf("POST %s/api/v1/password", baseURL), changePasswordAPI) http.HandleFunc(fmt.Sprintf("POST %s/api/v1/aliases", baseURL), addyAliasAPI) http.HandleFunc(fmt.Sprintf("DELETE %s/api/v1/aliases/{alias}", baseURL), addyAliasAPIDelete) http.HandleFunc(fmt.Sprintf("%s/auth", baseURL), httpBasicAuth)