SpringSecurity簡明教學

2023-09-01 18:00:58

大致分為三部分:資料庫認證,自定義登入頁,自定義過濾器
SpringSecurity主要實現UserDetailsService來驗證登入的使用者資訊,和Security的設定類來對登入方式和資源進行限制。
案例包含利用資料庫進行登入驗證、URL存取限制、自定義登入頁和利用ajax方式登入、實現自定義過濾器對驗證碼進行驗證,完整程式碼在https://github.com/say-hey/springboot-security-concise

SprigSecurity介面

UserDetails

  • 介面:表示使用者資訊,賬號:密碼:是否過期:是否鎖定:證書是否過期:許可權集合
  • 實現類:User
    自定義類實現UserDetails介面,作為系統中的使用者類,這個類可以交給SpringSecurity使用
需要自定義的User類繼承UserDetails,然後實現方法,但在某些案例中也沒有繼承
同時在資料庫中新增相應欄位,如是否過期是否鎖定等
/**
 * 使用者表
 * 使用者表和角色表的對應關係,
 */

@Data
@Entity
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "user")
// 自定義的User可以實現 implements UserDetails 介面,需要完成方法如是否可用,是否鎖定,是否過期,角色集合等,同時在資料庫中新增這些欄位
// 實現這個方法可用於擴充套件,也可以不實現
public class User implements UserDetails{

    @Id
    // 主鍵自動增長
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id")
    Integer id;

    @Column(name = "username")
    String username;

    @Column(name = "password")
    String password;

    // 過期
    @Column(name = "isAccountNonExpired")
    Boolean isAccountNonExpired;
    // 鎖定
    @Column(name = "isAccountNonLocked")
    Boolean isAccountNonLocked;
    // 憑證
    @Column(name = "isCredentialsNonExpired")
    Boolean isCredentialsNonExpired;
    // 啟用
    @Column(name = "isEnabled")
    Boolean isEnabled;
    // 許可權
    // List<GrantedAuthority> authorities;


    /**
     * 多對多關係會在建立使用者和新角色時級聯新增,關聯表為user_role,當前物件在關聯表對應的外來鍵,和另一方在關聯表中對應的外來鍵
     * cascade:級聯操作,如儲存、刪除時級聯的行為
     * joinColumns:在關聯表中的外來鍵名
     * inverseJoinColumns:另一方在關聯表中的外來鍵名
     */
    @ManyToMany(targetEntity = Role.class, cascade = CascadeType.MERGE, fetch = FetchType.LAZY)
    @JoinTable(name = "user_role",
            joinColumns = {@JoinColumn(name = "u_id", referencedColumnName = "id")},
            inverseJoinColumns = {@JoinColumn(name = "r_id", referencedColumnName = "id")})
    List<Role> roles = new ArrayList<>();


    /**
     * 重寫toString()方法,否則在sout輸出時,會導致兩個物件的toString()相互呼叫,現在需要去掉一方的關聯欄位輸出
     * java.lang.StackOverflowError
     * @return
     */
    @Override
    public String toString() {
        return "User{" +
                "id=" + id +
                ", username='" + username + '\'' +
                ", password='" + password + '\'' +
                ", isAccountNonExpired=" + isAccountNonExpired +
                ", isAccountNonLocked=" + isAccountNonLocked +
                ", isCredentialsNonExpired=" + isCredentialsNonExpired +
                ", isEnabled=" + isEnabled +
                ", roles=" + roles +
                '}';
    }


    // 實現UserDetails後的方法

    /**
     * 獲取許可權,這裡使用的是GrantedAuthority類,在UserDetailsService中出現,用於組裝角色許可權資訊
     *
     * roles: [Role{id=1, role='Cat'}, Role{id=2, role='Dog'}]
     * authorities: [ROLE_Dog, ROLE_Cat]
     *
     * @return
     */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {

        List<Role> roles = this.getRoles();
        Set<GrantedAuthority> authorities = new HashSet<>();
        for (Role role : roles) {
            authorities.add(new SimpleGrantedAuthority("ROLE_"+role.getRole()));
        }

        return authorities;
    }

    /**
     * 賬戶是否過期
     * @return
     */
    @Override
    public boolean isAccountNonExpired() {
        return false;
    }

    /**
     * 賬戶是否鎖定
     * @return
     */
    @Override
    public boolean isAccountNonLocked() {
        return false;
    }

    /**
     * 憑證是否過期
     * @return
     */
    @Override
    public boolean isCredentialsNonExpired() {
        return false;
    }

    /**
     * 是否啟用
     * @return
     */
    @Override
    public boolean isEnabled() {
        return false;
    }
}

UserDetailsService

  • 介面:獲取使用者資訊,得到UserDetails物件,一般專案要自定義類實現這個介面,從資料庫中獲取資料
  • 實現一個方法:loadUserByUsername()根據使用者名稱,獲取使用者資訊(使用者名稱稱,密碼,角色集合,是否可用等)
  • 實現類:UserDetailsManager介面{InMemoryUserDetailsManager,JdbcUserDetailsManager)基於記憶體和資料庫
具體實現檢視下一小節

資料庫認證

  1. SpringSecurity中有一個UserDetail介面,高度抽象使用者資訊類,它返回一個User類,和自定義user內容相似,包括username,password,authorities(角色、許可權,繼承GrantedAuthority)集合

  2. 其中,角色和許可權內容表達不同,角色:admin許可權:ROLE_ADMIN

  3. 實現介面UserDetailService介面,完成loadUserByUsername方法,返回User

  4. SpringSecurity在登入時會自動呼叫方法,去資料庫中查詢出資料並驗證

@Service
public class SecurityUserDetailsServiceImpl implements UserDetailsService {    


	@Transactional
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findUserByUsername(username);
        if(user == null){
            throw new UsernameNotFoundException("使用者 " + username + " 登入失敗,使用者名稱不存在!");
        }
        // System.out.println("登入使用者:" + ((Role)user.getRoles()).getRole());


        // 方式一:新增許可權
        List<Role> roles = user.getRoles();
        Set<GrantedAuthority> authorities = new HashSet<>();
        for (Role role : roles) {
            authorities.add(new SimpleGrantedAuthority("ROLE_"+role.getRole()));
        }
        // 方法二:在自定義的User實現UserDetails後,利用上方方式實現getAuthorities()方法,直接返回
        Collection<? extends GrantedAuthority> authorities1 = user.getAuthorities();

        // 許可權和角色在字首上不同,許可權會自動加上字首ROLE_,roles()方法點進去就是GrantedAuthority
        // GrantedAuthority : ROLE_admin
        // Role : admin

        return org.springframework.security.core.userdetails.User
                .withUsername(user.getUsername())
                .password(user.getPassword())
                .authorities(authorities)
                .build();
    }
}
對於role和authorities:
roles: [Role{id=1, role='Cat'}, Role{id=2, role='Dog'}]
authorities: [ROLE_Dog, ROLE_Cat]

URL許可權

實現資料庫認證之後,設定URL許可權,就可以在網頁進行許可權控制

方式一:舊方式,使用預設登入頁,在實現SecurityConfigurerAdapter類的cofnigure(HttpSecurity)方法中設定

// 實現SecurityConfigurerAdapter類

	public void configure(HttpSecurity http){
		http.authorizeHttpRequests()
			.requestMatchers("/home").hasRole("USER")
			.requestMatchers("/home/l1/**").hasRole("Dog")
			.requestMatchers("/home/l2/**").hasRole("Cat")
			.and()
			.formLogin();
	}
	// 「formLogin()」已棄用並標記為刪除

方式二:在SecurityConfig設定類中注入設定HttpSecurity

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .authorizeHttpRequests(auth->{

                    // 設定url許可權,注意所有許可權的設定順序
                    auth.requestMatchers("/home").permitAll();
                    auth.requestMatchers("/home/l0").hasRole("USER");
                    auth.requestMatchers("/home/l1/**").hasRole("Dog");
                    auth.requestMatchers("/home/l2/**").hasRole("Cat");
                    auth.anyRequest().authenticated();
                })
                .build();
    }

請求連結

    <h2>Welcome Home</h2>
    <!--  gn cheems  -->
    <a href="/home/l0">a dog/cat</a><br>
    <a href="/home/l1">a dog</a><br>
    <a href="/home/l2">a cat</a><br>

Controller

@RestController
public class HomeController {

    @GetMapping("/home/l0")
    public String l0(){
        return "you is a dog/cat";
    }

    @GetMapping("/home/l1")
    public String l1(){
        return "you is a dog";
    }

    @GetMapping("/home/l2")
    public String l2(){
        return "you is a cat";
    }
}

自定義登入頁

檢視過濾器類UsernamePasswordAuthenticationFilter,裡面設定了預設的登入頁的資訊,只要規則匹配就會自動驗證登入資訊

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
    public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
    private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login", "POST");
    private String usernameParameter = "username";
    private String passwordParameter = "password";
    private boolean postOnly = true;
    // ...
}

自定義登入頁的標籤也要用username,password屬性

<!-- 這裡表單傳送的請求是post,在SecurityConfig.loginProcessingUrl和indexController.login自定義的登入頁是get/login,表單請求可以更改名字,避免混淆-->
<form th:action="@{/login}" method="post">
    <div>
        <input type="text" name="username" placeholder="Username"/>
    </div>
    <div>
        <input type="password" name="password" placeholder="Password"/>
    </div>
    <input type="submit" value="Log in" />
</form>

設定security,注入HttpSecurity引數

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .authorizeHttpRequests(auth->{

                    // 設定url許可權,注意所有許可權的設定順序
                    auth.requestMatchers("/home").permitAll();
                    auth.requestMatchers("/home/l0").hasRole("USER");
                    auth.requestMatchers("/home/l1/**").hasRole("Dog");
                    auth.requestMatchers("/home/l2/**").hasRole("Cat");
                    auth.anyRequest().authenticated();
                })
                .formLogin(conf->{
                    // 自定義表單登入頁,這個是網頁
                    // https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/form.html
                    conf.loginPage("/login");
                    // 表單登入請求,這個是url請求
                    conf.loginProcessingUrl("/login");
                    // 預設登入成功頁
                    conf.defaultSuccessUrl("/home");
                    // 登入相關請求不需要認證
                    conf.permitAll();
                })
                .logout(conf->{
                    // 登出請求
                    conf.logoutUrl("/logout");
                    conf.logoutSuccessUrl("/login");
                    conf.permitAll();
                })
                // 使用自定義的userDetails認證過程,
                // .userDetailsService(null)
                .csrf(AbstractHttpConfigurer::disable)// 關閉跨站請求偽造保護功能
                .build();
    }

AJAX登入

  1. 前後端分離,使用ajax登入,傳遞json資料,使用者傳送請求,spring security接受資料並驗證,然後返回json給使用者
  2. 還可以在security中設定成功和失敗的處理器
    登入頁
    <script type="text/javascript" src="/js/jquery-3.7.0.min.js"></script>
    <script type="text/javascript">
        $(function (){
            $("#btnLogin").click(function () {
                console.log("ajax")
                var uname = $("#username").val();
                var pwd = $("#password").val();
                $.ajax({
                    url:"/login",
                    type:"POST",
                    data:{
                        "username":uname,
                        "password":pwd
                    },
                    dataType:"json",
                    success:function (res) {
                        alert(res.status +":"+res.msg)
                    }
                })
            })
        })
    </script>
    
    
	<div>
        使用Ajax登入,json傳遞資料<br>
        使用者名稱:<input type="text" id="username"><br>
        密&nbsp;碼:<input type="password" id="password"><br>
        <button id="btnLogin">登入</button><br>
    </div>

在security設定類中通過靜態資源認證

        // 靜態資源
        auth.requestMatchers("/js/**").permitAll();

自定義處理器

認證處理器,自定義請求認證成功或失敗後的動作

/**
 * security登入認證成功處理器
 */
@Component
public class SecurityAuthSuccessHandler implements AuthenticationSuccessHandler {
    /**
     * 驗證成功後執行
     * @param request 請求物件
     * @param response 響應物件
     * @param authentication security驗證成功後的封裝物件,包括使用者的資訊
     * @throws IOException
     * @throws ServletException
     */
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        // 登入的使用者驗證成功後執行
        response.setContentType("text/json;charset=utf-8");
        PrintWriter writer = response.getWriter();
        writer.println("{\"msg\":\"登入成功!\"}");
        writer.flush();
        writer.close();
    }
}
/**
 * security登入認證失敗處理器
 */
@Component
public class SecurityAuthFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        // 登入的使用者驗證失敗後執行
        response.setContentType("text/json;charset=utf-8");
        PrintWriter writer = response.getWriter();
        writer.println("{\"msg\":\"登入失敗(使用者名稱或密碼錯誤)!\"}");
        writer.flush();
        writer.close();
    }
}

在security設定類中通過靜態資源認證

         // 靜態資源
        auth.requestMatchers("/js/**").permitAll();

注意,使用了handler處理器,就不要設定預設登入頁,否則不起作用

        // 使用handler類
        conf.successHandler(successHandler);
        conf.failureHandler(failureHandler);
        // 預設登入成功頁,使用了handler,就不要使用預設登入頁,否則handler不起作用
        // conf.defaultSuccessUrl("/home");

使用JSON格式

在用ajax的過程中使用json傳遞資料
建立vo類物件,傳遞資料

@Data
public class Result {
    // 0成功 1失敗
    Integer code;
    // 200 成功 500失敗
    Integer status;
    // 訊息
    String msg;
}

處理器

@Component
public class SecurityAuthSuccessHandler implements AuthenticationSuccessHandler {
    /**
     * 驗證成功後執行
     * @param request 請求物件
     * @param response 響應物件
     * @param authentication security驗證成功後的封裝物件,包括使用者的資訊
     * @throws IOException
     * @throws ServletException
     */
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        // 登入的使用者驗證成功後執行
        response.setContentType("text/json;charset=utf-8");

        Result result = new Result();
        result.setCode(0);
        result.setStatus(200);
        result.setMsg("登入成功");
        // 使用jsckson
        ObjectMapper mapper = new ObjectMapper();
        ServletOutputStream outputStream = response.getOutputStream();
        mapper.writeValue(outputStream, result);

        outputStream.flush();
        outputStream.close();

        // PrintWriter writer = response.getWriter();
        // writer.println("{\"msg\":\"登入成功!\"}");
        // writer.flush();
        // writer.close();
    }
}

驗證碼

在使用者名稱和密碼下方新增驗證碼輸入,在controller中生成驗證碼圖片,然後響應給網頁

/**
 * 生成驗證碼響應到頁面
 */
@Controller
@RequestMapping("/captcha")
public class ChptchaController {

    // 生成驗證碼的屬性
    // 寬度
    private int width = 120;
    // 高度
    private int height = 30;
    // 內容在圖片中的起始位置
    private int drawY = 20;
    // 文字的間隔
    private int space = 15;
    // 驗證碼文字個數
    private int charCount = 6;
    // 驗證碼內容陣列 注意數位0和字母O容易混淆,最好註釋掉
    private String chars[] = {"A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P"
            ,"Q","R","S","T","U","V","W","X","Y","Z","0","1","2","3","4","5","6","7","8","9"};

    /**
     * 繪製一個圖片,將圖片響應給請求
     * @param request
     * @param response
     */
    @GetMapping("/code")
    public void makeCaptchaCode(HttpServletRequest request, HttpServletResponse response) throws IOException {

        // 建立一個背景透明的圖片,圖片格式使用rgb表示顏色,畫布
        BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
        // 獲取畫筆
        Graphics graphics = image.getGraphics();
        // 設定畫筆顏色 白色
        graphics.setColor(Color.white);
        // 把畫布塗成白色 fillRect(矩形的起始x,矩形的起始y,矩形的寬度,矩形的高度)
        graphics.fillRect(0, 0, width, height);

        // 畫內容
        // 建立字型
        Font font = new Font("宋體", Font.BOLD, 18);
        // 畫筆設定字型和顏色
        graphics.setFont(font);
        graphics.setColor(Color.black);
        // 獲取隨機值
        int ran = 0;
        int len = chars.length;
        StringBuffer stringBuffer = new StringBuffer();
        for(int i = 0; i < charCount; i++){
            ran = new Random().nextInt(len);
            // 儲存隨機值
            stringBuffer.append(chars[ran]);
            // 設定隨機顏色
            graphics.setColor(randomColor());
            // 畫的內容,間隔,起始
            graphics.drawString(chars[ran], (i+1)*space, drawY);
        }
        // 繪製干擾線
        for(int i = 0; i < 4; i++){
            graphics.setColor(randomColor());
            int line[] = randomLine();
            graphics.drawLine(line[0], line[1], line[2], line[3]);
        }

        // 生成的驗證碼存到session
        request.getSession().setAttribute("code", stringBuffer.toString());
        System.out.println("captcha: " + stringBuffer.toString());

        // 設定響應沒有快取
        response.setHeader("Pragma", "no-cache");
        response.setHeader("Cache-Control", "no-cache");
        response.setDateHeader("Expires", 0);
        // 設定響應格式
        response.setContentType("image/png");

        // 輸出影象 w(輸出的影象,影象格式,輸出到哪)
        ServletOutputStream outputStream = response.getOutputStream();
        ImageIO.write(image, "png", outputStream);
        outputStream.flush();
        outputStream.close();
    }

    /**
     * 生成隨機顏色
     * @return
     */
    public Color randomColor(){
        Random random = new Random();
        int r = random.nextInt(255);
        int g = random.nextInt(255);
        int b = random.nextInt(255);
        return new Color(r, g, b);
    }

    /**
     * 生成干擾線的隨機起始點
     * @return
     */
    public int[] randomLine(){
        Random random = new Random();
        int x1 = random.nextInt(width/2);
        int y1 = random.nextInt(height);
        int x2 = random.nextInt(width);
        int y2 = random.nextInt(height);
        return new int[]{x1, y1, x2, y2};
    }
}

通過驗證

                    // 驗證碼
                    auth.requestMatchers("/captcha/**").permitAll();

在前端頁面新增驗證碼

    <script type="text/javascript" src="/js/jquery-3.7.0.min.js"></script>
    <script type="text/javascript">
        $(function (){
            $("#btnLogin").click(function () {
                console.log("ajax")
                var uname = $("#username").val();
                var pwd = $("#password").val();
                // 使用者輸入驗證碼
                var textcode = $("#textcode").val();

                $.ajax({
                    url:"/login",
                    type:"POST",
                    data:{
                        "username":uname,
                        "password":pwd,
                        "code":textcode
                    },
                    dataType:"json",
                    success:function (res) {
                        alert(res.status +":"+res.msg)
                    }
                })
            })
        })

        function changeCode(){
            var url = "/captcha/code?t=" + new Date();
            $("#imageCode").attr("src", url);
        }
    </script>
    
    // ...
    
    <div>
        使用Ajax登入,json傳遞資料<br>
        使用者名稱:<input type="text" id="username"><br>
        密&nbsp;碼:<input type="password" id="password"><br>
        驗證碼:<input type="text" id="textcode">
        <img src="/captcha/code" id="imageCode"/>
        <a href="javascript:void(0)" onclick="changeCode()">重新獲取</a><br>
        <button id="btnLogin">登入</button><br>
    </div>

異常

驗證碼例外處理,在過濾器處理驗證碼之前

/**
 * 驗證碼例外處理,在過濾器處理驗證碼之前
 */
public class VerificationException extends AuthenticationException {

    public VerificationException(){
        super("驗證碼錯誤,請重新輸入!");
    }
}

過濾器

概述

Security中有很多過濾器,例如表單登入驗證使用的UsernamePasswordAuthenticationFilter,而驗證碼在表單登入驗證之前使用,所以需要自定義一個過濾器,然後放入整個過濾器鏈中,並且在UsernamePasswordAuthenticationFilter之前

自定義過濾器

使用OncePerRequestFilter,一次性過濾器,出現異常呼叫handler處理器

/**
 * 驗證碼過濾器,使用在UsernamePasswordAuthenticationFilter之前
 */
public class VerificationFilter extends OncePerRequestFilter {

    // 登入失敗的handler,在過濾器丟擲異常時使用
    private SecurityAuthFailureHandler failureHandler = new SecurityAuthFailureHandler();

    /**
     * 驗證碼過濾器
     * @param request
     * @param response
     * @param filterChain
     * @throws ServletException
     * @throws IOException
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        // 缺少登入成功,錯誤提示有問題!!原因是設定了defaultSuccessUrl(),同樣的有failurehandler也不要設定預設的錯誤頁

        // 驗證碼只在登入的過程中才使用這個過濾器
        String requestURI = request.getRequestURI();
        // 如果登入頁和表單登入請求都使用/login,那麼此處要判斷是去登入頁(GET)還是表單登入請求(POST)
        String method = request.getMethod();
        if(!"/login".equals(requestURI) || "GET".equals(method)){
            // 不是登入操作,不經過這個過濾器
            filterChain.doFilter(request, response);
        }else{
            try{
                // 驗證驗證碼
                verificationCode(request);
                // 通過
                filterChain.doFilter(request, response);
            }catch (VerificationException e){
                // 驗證出現異常時,跳轉到表單登入失敗的處理器SecurityAuthFailureHandler中
                // 1.在filter中新增handler屬性,在這裡呼叫
                // 2.在SecurityAuthFailureHandler中修改,新增一個vo.Result屬性,然後判斷是正常的handler還是第三方異常跳轉過去的
                Result result = new Result();
                result.setCode(1);
                result.setStatus(501);
                result.setMsg("驗證碼錯誤,請重新輸入!!");
                failureHandler.setResult(result);
                failureHandler.onAuthenticationFailure(request, response, e);
            }

        }


    }

    private void verificationCode(HttpServletRequest request) throws VerificationException {
        // 獲取請求中的驗證碼Code
        String requestCode = request.getParameter("code");
        // 獲取session中的驗證碼Code
        String sessionCode = "";
        HttpSession session = request.getSession();
        Object code = session.getAttribute("code");
        if(code != null){
            sessionCode = (String) code;
        }

        System.out.println("Verificate Captcha: session:" + sessionCode + " |request:" + requestCode);

        // 一次性驗證碼,使用後銷燬
        if(!StringUtils.isEmpty(sessionCode)){
            // 能獲取到session中的驗證碼,說明已經在頁面生成了,現在就不能再用了
            session.removeAttribute("code");
        }

        // 判斷驗證碼code是否正確
        if(StringUtils.isEmpty(requestCode) || StringUtils.isEmpty(sessionCode) || !requestCode.equals(sessionCode)){
            // 驗證失敗
            throw new VerificationException();
        }
    }
}

修改handler處理器,判斷一下是否第三方呼叫(如驗證碼異常)

/**
 * security登入認證失敗處理器
 */
@Component
public class SecurityAuthFailureHandler implements AuthenticationFailureHandler {

    // 新增result屬性,可以讓第三方異常呼叫,展示異常資訊
    private Result result;
    public Result getResult() {
        return result;
    }
    public void setResult(Result result) {
        this.result = result;
    }

    /**
     * 驗證失敗後執行
     * @param request 請求物件
     * @param response 響應物件
     * @param exception security驗證失敗後的封裝物件,包括使用者的資訊
     * @throws IOException
     * @throws ServletException
     */
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        // 登入的使用者驗證失敗後執行
        response.setContentType("text/json;charset=utf-8");
        System.out.println("failure handler...");

        // 判斷是否自定義的result,還是第三方異常呼叫的result,第三方異常呼叫時,result已經有值了
        if(result == null){
            Result localResult = new Result();
            localResult.setCode(1);
            localResult.setStatus(500);
            localResult.setMsg("登入失敗(使用者名稱或密碼錯誤)!");
            result = localResult;
        }

        // 使用jsckson
        ObjectMapper mapper = new ObjectMapper();
        ServletOutputStream outputStream = response.getOutputStream();
        mapper.writeValue(outputStream, result);

        outputStream.flush();
        outputStream.close();
    }
}

Security設定過濾器,注意用了handler處理器,就不要設定預設登入頁

@EnableWebSecurity
@Configuration
public class SecurityConfig {


    // 驗證成功和失敗處理器
    @Autowired
    SecurityAuthSuccessHandler successHandler;
    @Autowired
    SecurityAuthFailureHandler failureHandler;

    /**
     * 密碼編碼器
     * @return
     */
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder(){
        return new BCryptPasswordEncoder();
    }

    /**
     *
     * 之前的做法有在configure(AuthenticationManagerBuilder)中設定auth.userDetailsService(myDetailsService).passwordEncoder(bcry)
     * 在configure(HttpSecurity)中設定http.authorizeHttpRequests()認證
     * 現在同樣使用HttpSecurity引數,HttpSecurity:具體的許可權控制規則設定
     * @param http
     * @return
     * @throws Exception
     */
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .authorizeHttpRequests(auth->{

                    // 設定url許可權,注意所有許可權的設定順序
                    auth.requestMatchers("/home").permitAll();
                    // 驗證碼
                    auth.requestMatchers("/captcha/**").permitAll();
                    // 靜態資源
                    auth.requestMatchers("/js/**").permitAll();
                    auth.requestMatchers("/home/l0").hasRole("USER");
                    auth.requestMatchers("/home/l1/**").hasRole("Dog");
                    auth.requestMatchers("/home/l2/**").hasRole("Cat");
                    auth.anyRequest().authenticated();
                })
                .formLogin(conf->{
                    // 自定義表單登入頁
                    // https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/form.html
                    conf.loginPage("/login");
                    // 表單登入請求
                    conf.loginProcessingUrl("/login");
                    // 登入成功處理器,取消defaultSuccessUrl預設登入成功頁可以看到效果,如登入失敗處理器類似
                    // conf.successHandler(authenticationSuccessHandler());
                    // 登入失敗處理器,但此處不能在表單上方顯示error資訊
                    // conf.failureHandler(authenticationFailureHandler());
                    // 使用handler類
                    conf.successHandler(successHandler);
                    conf.failureHandler(failureHandler);
                    // 預設登入成功頁,使用了handler,就不要使用預設登入頁,否則handler不起作用
                    // conf.defaultSuccessUrl("/home");
                    // 登入相關請求不需要認證
                    conf.permitAll();
                })
                .logout(conf->{
                    // 登出請求
                    conf.logoutUrl("/logout");
                    conf.logoutSuccessUrl("/login");
                    conf.permitAll();
                })
                // 使用自定義過濾器,並且
                .addFilterBefore(new VerificationFilter(), UsernamePasswordAuthenticationFilter.class)
                // 使用自定義的userDetails認證過程,
                // .userDetailsService(null)
                .csrf(AbstractHttpConfigurer::disable)// 關閉跨站請求偽造保護功能
                .build();
    }
}

html新增驗證碼

<!DOCTYPE html>

<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
<head>
    <title>Welcome</title>

    <script type="text/javascript" src="/js/jquery-3.7.0.min.js"></script>
    <script type="text/javascript">
        $(function (){
            $("#btnLogin").click(function () {
                console.log("ajax")
                alert("ajax")
                var uname = $("#username").val();
                var pwd = $("#password").val();
                // 使用者輸入驗證碼
                var textcode = $("#textcode").val();

                $.ajax({
                    url:"/login",
                    type:"POST",
                    // async: false,
                    data:{
                        "username":uname,
                        "password":pwd,
                        "code":textcode
                    },
                    dataType:"json",
                    success:function(res) {
                        console.log(res)
                        alert(res.status +":"+res.msg)
                    }
                })
            })
        })

        function changeCode(){
        	// 防止快取
            var url = "/captcha/code?t=" + new Date();
            $("#imageCode").attr("src", url);
        }
    </script>


</head>
<body>
<h1>Welcome Log In</h1>
<div th:if="${param.error}">
    Invalid username and password.</div>
<div th:if="${param.logout}">
    You have been logged out.</div>

<!-- 這裡表單傳送的請求是post,在SecurityConfig.loginProcessingUrl和indexController.login自定義的登入頁是get/login,表單請求可以更改名字,避免混淆-->
<form th:action="@{/login}" method="post">
    <div>
        <input type="text" name="username" placeholder="Username"/>
    </div>
    <div>
        <input type="password" name="password" placeholder="Password"/>
    </div>
    <input type="submit" value="Log in" />
</form>

<br>
    <div>
        使用Ajax登入,json傳遞資料<br>
        使用者名稱:<input type="text" id="username"><br>
        密&nbsp;碼:<input type="password" id="password"><br>
        驗證碼:<input type="text" id="textcode">
        <img src="/captcha/code" id="imageCode"/>
        <a href="javascript:void(0)" onclick="changeCode()">重新獲取</a><br>
        <button id="btnLogin">登入</button><br>
    </div>
</body>
</html>

完整程式碼在https://github.com/say-hey/springboot-security-concise