diff --git a/.gitignore b/.gitignore index c43bddc..03bb765 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,5 @@ go.work.sum bin/** .claude + +certs/* \ No newline at end of file diff --git a/handlers/handler.go b/handlers/handler.go index d11c177..41ed31d 100644 --- a/handlers/handler.go +++ b/handlers/handler.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "log/slog" + "math" "net/http" "strconv" "strings" @@ -907,9 +908,132 @@ func filetimeToUnixTime(filetimeStr string) *time.Time { val -= windowsToUnixEpochDiff // Convert from 100-nanosecond intervals to nanoseconds - nanoseconds := int64(val * 100) + nanoseconds, _ := convertUint64ToInt64(val * 100) // Convert to UTC Go time.Time t := time.Unix(0, nanoseconds).UTC() return &t } + +// ChangeUserPassword changes a user's password in Active Directory. +// If username is omitted in the request body, the authenticated user's own password is changed. +// Providing a username requires elevated AD privileges (admin reset). +func (h *Handler) ChangeUserPassword(w http.ResponseWriter, r *http.Request) { + sess := middleware.GetSessionFromContext(r.Context()) + if sess == nil { + writeJSON(w, http.StatusUnauthorized, models.APIResponse{ + Success: false, + Error: "Session not found", + }) + return + } + + var req models.ChangeUserPasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, models.APIResponse{ + Success: false, + Error: "Invalid request body", + }) + return + } + + if req.NewPassword == "" { + writeJSON(w, http.StatusBadRequest, models.APIResponse{ + Success: false, + Error: "New password is required", + }) + return + } + + // Determine target user DN: use session's own DN unless an explicit username is provided + var userDN string + if req.Username == "" { + userDN = sess.UserDN + } else { + var err error + userDN, err = h.findUserDN(sess.Conn, req.Username) + if err != nil { + writeJSON(w, http.StatusNotFound, models.APIResponse{ + Success: false, + Error: fmt.Sprintf("User not found: %v", err), + }) + return + } + } + + // Active Directory requires passwords encoded as UTF-16LE wrapped in double quotes + // and set via the unicodePwd attribute over a secure (TLS/LDAPS) connection. + encodePassword := func(password string) []byte { + quoted := `"` + password + `"` + utf16 := make([]byte, len(quoted)*2) + for i, c := range quoted { + utf16[i*2] = byte(c) + utf16[i*2+1] = 0 + } + return utf16 + } + + modifyReq := ldap.NewModifyRequest(userDN, nil) + + if req.OldPassword != "" { + // Change password: delete old, add new (requires user to know current password) + modifyReq.Delete("unicodePwd", []string{string(encodePassword(req.OldPassword))}) + modifyReq.Add("unicodePwd", []string{string(encodePassword(req.NewPassword))}) + } else { + // Admin reset: replace password directly (requires elevated privileges) + modifyReq.Replace("unicodePwd", []string{string(encodePassword(req.NewPassword))}) + } + + if err := sess.Conn.Modify(modifyReq); err != nil { + logLDAPError("ChangeUserPassword", err, map[string]string{ + "userDN": userDN, + }) + + if strings.Contains(err.Error(), "Insufficient Access") || + strings.Contains(err.Error(), "LDAP Result Code 50") { + writeJSON(w, http.StatusForbidden, models.APIResponse{ + Success: false, + Error: "Permission denied: insufficient rights to change password", + }) + return + } + + if strings.Contains(err.Error(), "Constraint Violation") || + strings.Contains(err.Error(), "LDAP Result Code 19") { + writeJSON(w, http.StatusBadRequest, models.APIResponse{ + Success: false, + Error: "Password does not meet complexity requirements", + }) + return + } + + if strings.Contains(err.Error(), "Invalid Credentials") || + strings.Contains(err.Error(), "LDAP Result Code 49") { + writeJSON(w, http.StatusUnauthorized, models.APIResponse{ + Success: false, + Error: "Current password is incorrect", + }) + return + } + + writeJSON(w, http.StatusInternalServerError, models.APIResponse{ + Success: false, + Error: fmt.Sprintf("Failed to change password: %v", err), + }) + return + } + + slog.Info("Password changed successfully", "userDN", userDN) + + writeJSON(w, http.StatusOK, models.APIResponse{ + Success: true, + Message: "Password changed successfully", + }) +} + +func convertUint64ToInt64(u uint64) (int64, error) { + if u > math.MaxInt64 { + return 0, fmt.Errorf("uint64 value %d is too large to fit into int64", u) + } + return int64(u), nil +} diff --git a/main.go b/main.go index 0bdd9e8..b97e673 100644 --- a/main.go +++ b/main.go @@ -171,6 +171,7 @@ func setupRouter(handler *handlers.Handler, sessionMgr *session.Manager, cfg *co protected.HandleFunc("/users/me", handler.GetCurrentUser).Methods(http.MethodGet) protected.HandleFunc("/users/{username}", handler.GetUser).Methods(http.MethodGet) protected.HandleFunc("/users", handler.EditUser).Methods(http.MethodPut, http.MethodPatch) + protected.HandleFunc("/users/change-password", handler.ChangeUserPassword).Methods(http.MethodPost) // Group routes protected.HandleFunc("/groups", handler.GetAllGroups).Methods(http.MethodGet) diff --git a/models/models.go b/models/models.go index 3604f5e..5c6b7d8 100644 --- a/models/models.go +++ b/models/models.go @@ -153,3 +153,10 @@ type SessionInfo struct { CreatedAt time.Time `json:"createdAt"` ExpiresAt time.Time `json:"expiresAt"` } + +// ChangeUserPasswordRequest represents a password change request +type ChangeUserPasswordRequest struct { + Username string `json:"username,omitempty"` + OldPassword string `json:"oldPassword"` + NewPassword string `json:"newPassword"` +}