Spring Security 最佳實踐,看了必懂!

来源:https://www.cnblogs.com/javastack/archive/2022/08/09/16565959.html
-Advertisement-
Play Games

作者:清茶淡粥醬 鏈接:https://juejin.cn/post/7026734817853210661 Spring Security簡介 Spring Security 是一種高度自定義的安全框架,利用(基於)SpringIOC/DI和AOP功能,為系統提供了聲明式安全訪問控制功能,減少了為 ...


作者:清茶淡粥醬
鏈接:https://juejin.cn/post/7026734817853210661

Spring Security簡介

Spring Security 是一種高度自定義的安全框架,利用(基於)SpringIOC/DI和AOP功能,為系統提供了聲明式安全訪問控制功能,減少了為系統安全而編寫大量重覆代碼的工作

核心功能:認證和授權

Spring Security 認證流程

Spring Security 項目搭建

導入依賴

Spring Security已經被Spring boot進行集成,使用時直接引入啟動器即可

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

Spring Boot 基礎就不介紹了,推薦下這個實戰教程:

https://github.com/javastacks/spring-boot-best-practice

訪問頁面

導入spring-boot-starter-security啟動器後,Spring Security已經生效,預設攔截全部請求,如果用戶沒有登錄,跳轉到內置登錄頁面。

在瀏覽器輸入:http://localhost:8080/ 進入Spring Security內置登錄頁面

用戶名: user

密碼:項目啟動,列印在控制臺中

自定義用戶名和密碼

修改application.yml 文件

# 靜態用戶,一般只在內部網路認證中使用,如:內部伺服器1,訪問伺服器2
spring:
  security:
    user:
      name: test  # 通過配置文件,設置靜態用戶名
      password: test # 配置文件,設置靜態登錄密碼

UserDetailsService詳解

什麼也沒有配置的時候,賬號和密碼是由Spring Security定義生成的。而在實際項目中賬號和密碼都是從資料庫中查詢出來的。 所以我們要通過自定義邏輯控制認證邏輯。如果需要自定義邏輯時,只需要實現UserDetailsService介面

@Component
public class UserSecurity implements UserDetailsService {

    @Autowired
    private UserService userService;

    @Override
    public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {

        User user = userService.login(userName);
        System.out.println(user);
        if (null==user){
            throw new UsernameNotFoundException("用戶名錯誤");
        }
        org.springframework.security.core.userdetails.User result =
                new org.springframework.security.core.userdetails.User(
                        userName,user.getPassword(), AuthorityUtils.createAuthorityList()
                );
        return result;
    }

}

推薦一個 Spring Boot 基礎教程:

https://github.com/javastacks/spring-boot-best-practice

PasswordEncoder密碼解析器詳解

PasswordEncoder

PasswordEncoder 是SpringSecurity 的密碼解析器,用戶密碼校驗、加密 。 自定義登錄邏輯時要求必須給容器註入PaswordEncoder的bean對象

SpringSecurity 定義了很多實現介面PasswordEncoder 滿足我們密碼加密、密碼校驗 使用需求

自定義密碼解析器

  1. 編寫類,實現PasswordEncoder 介面
/**
 * 憑證匹配器,用於做認證流程的憑證校驗使用的類型
 * 其中有2個核心方法
 * 1. encode - 把明文密碼,加密成密文密碼
 * 2. matches - 校驗明文和密文是否匹配
 * */
public class MyMD5PasswordEncoder implements PasswordEncoder {

    /**
     * 加密
     * @param charSequence  明文字元串
     * @return
     */
    @Override
    public String encode(CharSequence charSequence) {
        try {
            MessageDigest digest = MessageDigest.getInstance("MD5");
            return toHexString(digest.digest(charSequence.toString().getBytes()));
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
            return "";
        }
    }

    /**
     * 密碼校驗
     * @param charSequence 明文,頁面收集密碼
     * @param s 密文 ,資料庫中存放密碼
     * @return
     */
    @Override
    public boolean matches(CharSequence charSequence, String s) {
        return s.equals(encode(charSequence));
    }

     /**
     * @param tmp 轉16進位位元組數組
     * @return 飯回16進位字元串
     */
    private String toHexString(byte [] tmp){
        StringBuilder builder = new StringBuilder();
        for (byte b :tmp){
            String s = Integer.toHexString(b & 0xFF);
            if (s.length()==1){
                builder.append("0");
            }
            builder.append(s);
        }

        return builder.toString();

    }
}

2.在配置類中指定自定義密碼憑證匹配器

/**
  * 加密
  * @return 加密對象
  * 如需使用自定義密碼憑證匹配器 返回自定義加密對象
  * 例如: return new MD5PasswordEncoder(); 
  */
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder(); //Spring Security 自帶
}

登錄配置

方式一 轉發

http.formLogin()
    .usernameParameter("name") // 設置請求參數中,用戶名參數名稱。 預設username
    .passwordParameter("pswd") // 設置請求參數中,密碼參數名稱。 預設password
    .loginPage("/toLogin") // 當用戶未登錄的時候,跳轉的登錄頁面地址是什麼? 預設 /login
    .loginProcessingUrl("/login") // 用戶登錄邏輯請求地址是什麼。 預設是 /login
    .failureForwardUrl("/failure"); // 登錄失敗後,請求轉發的位置。Security請求轉發使用Post請求。預設轉發到: loginPage?error
    .successForwardUrl("/toMain"); // 用戶登錄成功後,請求轉發到的位置。Security請求轉發使用POST請求。

方式二 :重定向

http.formLogin()
    .usernameParameter("name") // 設置請求參數中,用戶名參數名稱。 預設username
    .passwordParameter("pswd") // 設置請求參數中,密碼參數名稱。 預設password
    .loginPage("/toLogin") // 當用戶未登錄的時候,跳轉的登錄頁面地址是什麼? 預設 /login
    .loginProcessingUrl("/login") // 用戶登錄邏輯請求地址是什麼。 預設是 /login
	.defaultSuccessUrl("/toMain",true); //用戶登錄成功後,響應重定向到的位置。 GET請求。必須配置絕對地址。
	 .failureUrl("/failure"); // 登錄失敗後,重定向的位置。

方式三:自定義登錄處理器

自定義登錄失敗邏輯處理器

/*自定義登錄失敗處理器*/
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
    private  String url;
    private boolean isRedirect;


    public MyAuthenticationFailureHandler(String url, boolean isRedirect) {
        this.url = url;
        this.isRedirect = isRedirect;
    }

    @Override
    public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        if (isRedirect){
            httpServletResponse.sendRedirect(url);
        }else {
            httpServletRequest.getRequestDispatcher(url).forward(httpServletRequest,httpServletResponse);
        }
    }

//get set 方法 省略

自定義登錄成功邏輯處理器

/**
 * 自定義登錄成功後處理器
 * 轉發重定向,有代碼邏輯實現
 * */
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    private String url;
    private boolean isRedirect;

    public MyAuthenticationSuccessHandler(String url, boolean isRedirect) {
        this.url = url;
        this.isRedirect = isRedirect;
    }

    /**
     * @param request 請求對象 request.getRequestDispatcher.forward()
     * @param response 響應對象 response.sendRedirect()
     * @param authentication 用戶認證成功後的對象。其中報換用戶名許可權結合,內容是
     *                       自定義UserDetailsService
     * */
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        if (isRedirect){
            response.sendRedirect(url);
        }else {
            request.getRequestDispatcher(url).forward(request,response);
        }
    }

//get set 方法 省略   
http.formLogin()
    .usernameParameter("name") // 設置請求參數中,用戶名參數名稱。 預設username
    .passwordParameter("pswd") // 設置請求參數中,密碼參數名稱。 預設password
    .loginPage("/toLogin") // 當用戶未登錄的時候,跳轉的登錄頁面地址是什麼? 預設 /login
    .loginProcessingUrl("/login") // 用戶登錄邏輯請求地址是什麼。 預設是 /login

登錄相關配置類

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private  UserSecurity userSecurity;
    @Autowired
    private PersistentTokenRepository persistentTokenRepository;


    /**
     * 加密
     * @return 加密對象
     * 如需使用自定義加密邏輯 返回自定義加密對象
     * return new MD5PasswordEncoder(); return new SimplePasswordEncoder();
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(); //Spring Security 自帶
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 配置登錄請求相關內容。
        http.formLogin()
            .loginPage("/toLogin") // 當用戶未登錄的時候,跳轉的登錄頁面地址是什麼? 預設 /login
            .usernameParameter("name") // 設置請求參數中,用戶名參數名稱。 預設username
            .passwordParameter("pswd") // 設置請求參數中,密碼參數名稱。 預設password
            .loginProcessingUrl("/login") //設置登錄 提交表單數據訪問請求地址
            .defaultSuccessUrl("/toMain")   
            .failureUrl("/toLogin");
        	//.successForwardUrl("/toMain")
       		//.failureForwardUrl("/toLogin");
            //.successHandler(new LoginSuccessHandler("/toMain", true)) //自定義登錄成功處理器
                //.failureHandler(new LoginErrorHandler("/toLogin", true));

        http.authorizeRequests()
            //.antMatchers("/toLogin").anonymous() //只能匿名用戶訪問
            .antMatchers("/toLogin", "/register", "/login", "/favicon.ico").permitAll() // /toLogin請求地址,可以隨便訪問。
            .antMatchers("/**/*.js").permitAll() // 授予所有目錄下的所有.js文件可訪問許可權
            .regexMatchers(".*[.]css").permitAll() // 授予所有目錄下的所有.css文件可訪問許可權
            .anyRequest().authenticated(); // 任意的請求,都必須認證後才能訪問。


        // 配置退出登錄
        http.logout()
                .invalidateHttpSession(true) // 回收HttpSession對象。退出之前調用HttpSession.invalidate() 預設 true
                .clearAuthentication(true) // 退出之前,清空Security記錄的用戶登錄標記。 預設 true
                // .addLogoutHandler() // 增加退出處理器。
                .logoutSuccessUrl("/") // 配置退出後,進入的請求地址。 預設是loginPage?logout
                .logoutUrl("/logout"); // 配置退出登錄的路徑地址。和頁面請求地址一致即可。

        // 關閉CSRF安全協議。
        // 關閉是為了保證完整流程的可用。
        http.csrf().disable();
    }


   @Bean
   public PersistentTokenRepository persistentTokenRepository(DataSource dataSource){
        JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
        jdbcTokenRepository.setDataSource(dataSource);
        //jdbcTokenRepository.setCreateTableOnStartup(true);
        return jdbcTokenRepository;
    }

}

角色許可權

hasAuthority(String) 判斷角色是否具有特定許可權

http.authorizeRequests().antMatchers("/main1.html").hasAuthority("admin")

hasAnyAuthority(String ...) 如果用戶具備給定許可權中某一個,就允許訪問

http.authorizeRequests().antMatchers("/admin/read").hasAnyAuthority("xxx","xxx") 

hasRole(String) 如果用戶具備給定角色就允許訪問。否則出現403

//請求地址為/admin/read的請求,必須登錄用戶擁有'管理員'角色才可訪問
http.authorizeRequests().antMatchers("/admin/read").hasRole("管理員") 

hasAnyRole(String ...) 如果用戶具備給定角色的任意一個,就允許被訪問

//用戶擁有角色是管理員 或 訪客 可以訪問 /guest/read
http.authorizeRequests().antMatchers("/guest/read").hasAnyRole("管理員", "訪客")

hasIpAddress(String) 請求是指定的IP就運行訪問

//ip 是127.0.0.1 的請求 可以訪問/ip
http.authorizeRequests().antMatchers("/ip").hasIpAddress("127.0.0.1")

403 許可權不足頁面處理

1.編寫類實現介面AccessDeniedHandler

/**
 * @describe  403 許可權不足
 * @author: AnyWhere
 * @date 2021/4/18 20:57
 */
@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) 
            throws IOException, ServletException {

        response.setStatus(HttpServletResponse.SC_OK);

        response.setContentType("text/html;charset=UTF-8");

        response.getWriter().write(
                "<html>" +
                        "<body>" +
                        "<div style='width:800px;text-align:center;margin:auto;font-size:24px'>" +
                        "許可權不足,請聯繫管理員" +
                        "</div>" +
                        "</body>" +
                        "</html>"

        );

        response.getWriter().flush();//刷新緩衝區
    }
}

2.配置類中配置exceptionHandling

// 配置403訪問錯誤處理器。
http.exceptionHandling().accessDeniedHandler(myAccessDeniedHandler);/

RememberMe(記住我)

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    //配置記住密碼
    http.rememberMe()
        .rememberMeParameter("remember-me") // 修改請求參數名。 預設是remember-me
        .tokenValiditySeconds(14*24*60*60) // 設置記住我有效時間。單位是秒。預設是14天
        .rememberMeCookieName("remember-me") // 修改remember me的cookie名稱。預設是remember-me
        .tokenRepository(persistentTokenRepository) // 配置用戶登錄標記的持久化工具對象。
        .userDetailsService(userSecurity); // 配置自定義的UserDetailsService介面實現類對象

  }
  @Bean
  public PersistentTokenRepository persistentTokenRepository(DataSource dataSource){
     JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
     jdbcTokenRepository.setDataSource(dataSource);
     //jdbcTokenRepository.setCreateTableOnStartup(true);
     return jdbcTokenRepository;
  }
}   

Spring Security 註解

@Secured

角色校驗 ,請求到來訪問控制單元方法時必須包含XX角色才能訪問

角色必須添加ROLE_首碼

  @Secured({"ROLE_管理員","ROLE_訪客"})
  @RequestMapping("/toMain")
  public String toMain(){
      return "main";
  }

使用註解@Secured需要在配置類中添加註解 使@Secured註解生效

@EnableGlobalMethodSecurity(securedEnabled = true)

@PreAuthorize

許可權檢驗,請求到來訪問控制單元之前必須包含xx許可權才能訪問,控制單元方法執行前進行角色校驗

   /**
     * [ROLE_管理員, admin:read, admin:write, all:login, all:logout, all:error, all:toMain]
     * @PreAuthorize   角色 、許可權 校驗 方法執行前進行角色校驗
     *
     *  hasAnyAuthority() 
     *  hasAuthority()
     *
     *  hasPermission()
     *
     *
     *  hasRole()   
     *  hasAnyRole()
     * */

    @PreAuthorize("hasAnyRole('ROLE_管理員','ROLE_訪客')")
    @RequestMapping("/toMain")
    @PreAuthorize("hasAuthority('admin:write')")
    public String toMain(){
        return "main";
    }

使用@PreAuthorize@PostAuthorize 需要在配置類中配置註解@EnableGlobalMethodSecurity 才能生效

@EnableGlobalMethodSecurity(prePostEnabled = true)

@PostAuthorize

許可權檢驗,請求到來訪問控制單元之後必須包含xx許可權才能訪問 ,控制單元方法執行完後進行角色校驗

   /**
     * [ROLE_管理員, admin:read, admin:write, all:login, all:logout, all:error, all:toMain]
     * @PostAuthorize  角色 、許可權 校驗 方法執行後進行角色校驗
     *
     *  hasAnyAuthority()
     *  hasAuthority()
     *  hasPermission()
     *  hasRole()
     *  hasAnyRole()
     * */

    @PostAuthorize("hasRole('ROLE_管理員')")
    @RequestMapping("/toMain")
    @PreAuthorize("hasAuthority('admin:write')")
    public String toMain(){
        return "main";
    }

Spring Security 整合Thymeleaf 進行許可權校驗

<dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

<dependency>
     <groupId>org.thymeleaf.extras</groupId>
     <artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>

Spring Security中CSRF

什麼是CSRF?

CSRF(Cross-site request forgery)跨站請求偽造,也被稱為“One Click Attack” 或者Session Riding。通過偽造用戶請求訪問受信任站點的非法請求訪問。

跨域:只要網路協議,ip地址,埠中任何一個不相同就是跨域請求。

客戶端與服務進行交互時,由於http協議本身是無狀態協議,所以引入了cookie進行記錄客戶端身份。在cookie中會存放session id用來識別客戶端身份的。在跨域的情況下,session id可能被第三方惡意劫持,通過這個session id向服務端發起請求時,服務端會認為這個請求是合法的,可能發生很多意想不到的事情。

通俗解釋:

CSRF就是別的網站非法獲取我們網站Cookie值,我們項目伺服器是無法區分到底是不是我們的客戶端,只有請求中有Cookie,認為是自己的客戶端,所以這個時候就出現了CSRF。

近期熱文推薦:

1.1,000+ 道 Java面試題及答案整理(2022最新版)

2.勁爆!Java 協程要來了。。。

3.Spring Boot 2.x 教程,太全了!

4.別再寫滿屏的爆爆爆炸類了,試試裝飾器模式,這才是優雅的方式!!

5.《Java開發手冊(嵩山版)》最新發佈,速速下載!

覺得不錯,別忘了隨手點贊+轉發哦!


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • @Autowired註解是spring用來支持依賴註入的核心利器之一,但是我們或多或少都會遇到required a single bean, but 2 were found(2可能是其他數字)的問題,接下來我們從源碼的角度去看為什麼會出現這個問題,以及這個問題的解法是什麼? 首先我們寫一個demo ...
  • 2、ElasticSearch高級搜索 Elasticsearch提供了基於JSON的DSL(Domain Specific Language)來定義查詢。常見的查詢類型如下所示 ①、查詢所有 查詢出所有數據,一般測試用;例如 match_all 如下圖所示 ②、全文檢索(full text)查詢 ...
  • Java多線程基礎入門 參考:b站-狂神-多線程詳解 練習與演示代碼見gitee:https://gitee.com/yuhaozhee/java-learning-record ...
  • 1.ObjectPostProcessor 使用 前面介紹了 ObjectPostProcessor的基本概念。相信讀者已經明白,所有的過濾器都由對應的配置類來負責創建,配置類在將過濾器創建成功之後,會調用父類的postProcess方法,該 方法最終會調用到CompositeObjectPostP ...
  • 0. 前言 寫完這篇文章後發現自己對於 DP 的優化一竅不通,所以補了補 DP 的一些優化,寫篇 blog 總結一下。 1. 單調隊列/單調棧優化 1.2 演算法介紹 這應該算是最基礎的 DP 優化方法了。 顧名思義,單調隊列/單調棧優化 DP 就是保持容器內元素的單調性,以達成減少冗餘狀態的目的。 ...
  • 目錄 一.簡介 二.效果演示 三.源碼下載 四.猜你喜歡 零基礎 OpenGL (ES) 學習路線推薦 : OpenGL (ES) 學習目錄 >> OpenGL ES 基礎 零基礎 OpenGL (ES) 學習路線推薦 : OpenGL (ES) 學習目錄 >> OpenGL ES 轉場 零基礎 O ...
  • 前言 在Python中 in 操作符可以用於判斷某個元素是否存在於當前對象中,而對於不同的Python對象,使用 in 操作符的處理效率是不一樣的。 今天我們主要針對 4 種不同的Python數據類型進行學習:list列表、tuple元組、set集合、dict字典。 測試過程 我們用於測試的 4 種 ...
  • 多商戶商城系統,也稱為B2B2C(BBC)平臺電商模式多商家商城系統。可以快速幫助企業搭建類似拼多多/京東/天貓/淘寶的綜合商城。 多商戶商城系統支持商家入駐加盟,同時滿足平臺自營、旗艦店等多種經營方式。平臺可以通過收取商家入駐費,訂單交易服務費,提現手續費,簡訊通道費等多手段方式,實現整體盈利。 ...
一周排行
    -Advertisement-
    Play Games
  • 概述:在C#中,++i和i++都是自增運算符,其中++i先增加值再返回,而i++先返回值再增加。應用場景根據需求選擇,首碼適合先增後用,尾碼適合先用後增。詳細示例提供清晰的代碼演示這兩者的操作時機和實際應用。 在C#中,++i 和 i++ 都是自增運算符,但它們在操作上有細微的差異,主要體現在操作的 ...
  • 上次發佈了:Taurus.MVC 性能壓力測試(ap 壓測 和 linux 下wrk 壓測):.NET Core 版本,今天計劃準備壓測一下 .NET 版本,來測試並記錄一下 Taurus.MVC 框架在 .NET 版本的性能,以便後續持續優化改進。 為了方便對比,本文章的電腦環境和測試思路,儘量和... ...
  • .NET WebAPI作為一種構建RESTful服務的強大工具,為開發者提供了便捷的方式來定義、處理HTTP請求並返迴響應。在設計API介面時,正確地接收和解析客戶端發送的數據至關重要。.NET WebAPI提供了一系列特性,如[FromRoute]、[FromQuery]和[FromBody],用 ...
  • 原因:我之所以想做這個項目,是因為在之前查找關於C#/WPF相關資料時,我發現講解圖像濾鏡的資源非常稀缺。此外,我註意到許多現有的開源庫主要基於CPU進行圖像渲染。這種方式在處理大量圖像時,會導致CPU的渲染負擔過重。因此,我將在下文中介紹如何通過GPU渲染來有效實現圖像的各種濾鏡效果。 生成的效果 ...
  • 引言 上一章我們介紹了在xUnit單元測試中用xUnit.DependencyInject來使用依賴註入,上一章我們的Sample.Repository倉儲層有一個批量註入的介面沒有做單元測試,今天用這個示例來演示一下如何用Bogus創建模擬數據 ,和 EFCore 的種子數據生成 Bogus 的優 ...
  • 一、前言 在自己的項目中,涉及到實時心率曲線的繪製,項目上的曲線繪製,一般很難找到能直接用的第三方庫,而且有些還是定製化的功能,所以還是自己繪製比較方便。很多人一聽到自己畫就害怕,感覺很難,今天就分享一個完整的實時心率數據繪製心率曲線圖的例子;之前的博客也分享給DrawingVisual繪製曲線的方 ...
  • 如果你在自定義的 Main 方法中直接使用 App 類並啟動應用程式,但發現 App.xaml 中定義的資源沒有被正確載入,那麼問題可能在於如何正確配置 App.xaml 與你的 App 類的交互。 確保 App.xaml 文件中的 x:Class 屬性正確指向你的 App 類。這樣,當你創建 Ap ...
  • 一:背景 1. 講故事 上個月有個朋友在微信上找到我,說他們的軟體在客戶那邊隔幾天就要崩潰一次,一直都沒有找到原因,讓我幫忙看下怎麼回事,確實工控類的軟體環境複雜難搞,朋友手上有一個崩潰的dump,剛好丟給我來分析一下。 二:WinDbg分析 1. 程式為什麼會崩潰 windbg 有一個厲害之處在於 ...
  • 前言 .NET生態中有許多依賴註入容器。在大多數情況下,微軟提供的內置容器在易用性和性能方面都非常優秀。外加ASP.NET Core預設使用內置容器,使用很方便。 但是筆者在使用中一直有一個頭疼的問題:服務工廠無法提供請求的服務類型相關的信息。這在一般情況下並沒有影響,但是內置容器支持註冊開放泛型服 ...
  • 一、前言 在項目開發過程中,DataGrid是經常使用到的一個數據展示控制項,而通常表格的最後一列是作為操作列存在,比如會有編輯、刪除等功能按鈕。但WPF的原始DataGrid中,預設只支持固定左側列,這跟大家習慣性操作列放最後不符,今天就來介紹一種簡單的方式實現固定右側列。(這裡的實現方式參考的大佬 ...