SpringBoot 整合 SpringSecurity + MySQL + JWT 附原始碼,廢話不多直接盤

VipSoft發表於2023-05-04

SpringBoot 整合 SpringSecurity + MySQL + JWT 無太多理論,直接盤
一般用於Web管理系統
可以先看 SpringBoot SpringSecurity 基於記憶體的使用介紹
本文介紹如何整合 SpringSecurity + MySQL + JWT

資料結構

資料庫指令碼:https://gitee.com/VipSoft/VipBoot/blob/develop/vipsoft-security/sql/Security.sql
image
常規許可權管理資料結構設計,三張常規表:使用者、角色、選單,透過使用者和角色的關係,角色和選單(許可權)的關係,實現使用者和選單(按鈕)的訪問控制權

使用者登入

  1. SecurityConfig 中新增登入介面匿名訪問配置

    .antMatchers("/auth/login", "/captchaImage").anonymous()
    BCryptPasswordEncoder 密碼加密方式

  2. POST 登入介面 /auth/login

    呼叫 AuthorizationController.login 使用者登入介面
    做入參、圖形驗證碼等驗證。

  3. 實現 UserDetailsService 介面

    根據使用者名稱,去資料庫獲取使用者資訊、許可權獲取等

  4. 密碼驗證

    AuthorizationService.login
    呼叫 authenticationManager.authenticate(authenticationToken) 看密碼是否正確
    可以在此集合 Redis 做失敗次數邏輯處理

  5. 透過JWT 生成 Token

    呼叫 jwtUtil.generateToken(userId) 生成Token令牌
    將 使用者資訊放入 Redis
    剔除其它已登入的使用者(如果需要)

  6. 返回Map物件給前端

介面許可權認證

  1. 獲取request.getHeader中的token資訊

    AuthenticationTokenFilter.doFilterInternal
    解析 Token 中的使用者ID 去 Redis 快取中獲取使用者資訊
    將資訊賦到 SecurityContextHolder.getContext().setAuthentication(authenticationToken) 中,供許可權驗證獲取使用者資訊使用, SecurityContextHolder使用了ThreadLocal機制來儲存每個使用者的安全上下文

  2. 介面許可權配置

    UserController 類的方法上,加了 @PreAuthorize("@ps.hasAnyPermi('system:user:list')") 用來做許可權控制

  3. 訪問許可權控制

    PermissionService.hasAnyPermi 判斷,使用者所擁有的許可權,是否包含 @PreAuthorize("@ps.hasAnyPermi('system:user:list')") 中配置的許可權,包含則有權訪問

使用者登入程式碼

image

SecurityConfig

package com.vipsoft.web.config;

import com.vipsoft.web.security.AuthenticationEntryPointImpl;
import com.vipsoft.web.security.AuthenticationTokenFilter;
import com.vipsoft.web.security.LogoutSuccessHandlerImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    /**
     * 自定義使用者認證邏輯
     */
    @Autowired
    private UserDetailsService userDetailsService;

    /**
     * 認證失敗處理類
     */
    @Autowired
    private AuthenticationEntryPointImpl unauthorizedHandler;


    /**
     * 退出處理類
     */
    @Autowired
    private LogoutSuccessHandlerImpl logoutSuccessHandler;
    /**
     * token認證過濾器
     */
    @Autowired
    private AuthenticationTokenFilter authenticationTokenFilter;



    /**
     * 解決 無法直接注入 AuthenticationManager
     *
     * @return
     * @throws Exception
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    /**
     * 強雜湊雜湊加密實現
     * 必須 Bean 的形式例項化,否則會報 :Encoded password does not look like BCrypt
     */
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder()
    {
        return new BCryptPasswordEncoder();
    }

    /**
     * 配置使用者身份的configure()方法
     *
     * @param auth
     * @throws Exception
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
    }

    /**
     * 配置使用者許可權的configure()方法
     *
     * @param httpSecurity
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                // 禁用 CSRF,因為不使用session
                .csrf().disable()
                // 認證失敗處理類
                .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
                // 基於token,所以不需要session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                // 過濾請求
                .authorizeRequests()
                // 對於登入login 驗證碼captchaImage 允許匿名訪問
                .antMatchers("/auth/login", "/captchaImage").anonymous()
                .antMatchers(
                        HttpMethod.GET,
                        "/*.html",
                        "/**/*.html",
                        "/**/*.css",
                        "/**/*.js",
                        "/webSocket/**"
                ).permitAll()
                // swagger 文件
                .antMatchers("/swagger-ui.html").permitAll()
                .antMatchers("/swagger-resources/**").permitAll()
                .antMatchers("/webjars/**").permitAll()
                .antMatchers("/*/api-docs").permitAll()
                .antMatchers("/druid/**").permitAll()
                // 放行OPTIONS請求
                .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                // 所有請求都需要認證
                .anyRequest().authenticated()
                //        .and().apply(this.securityConfigurerAdapter());

                .and()
                //設定跨域, 如果不設定, 即使配置了filter, 也不會生效
                .cors()
                .and()
                .headers().frameOptions().disable();
        httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
        // 新增JWT filter
        httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

AuthenticationController.login

public Map<String, Object> login(SysUser user) {
    String username = user.getUserName();
    String password = user.getPassword();
    Authentication authentication;
    try {
        //該方法會去呼叫UserDetailsServiceImpl.loadUserByUsername
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
        authentication = authenticationManager.authenticate(authenticationToken);
    } catch (AuthenticationException ex) {
        Long incr = 3L; // Redis 實現
        if (incr > 5) {
            logger.error("{} 賬戶連續{}次登入失敗,賬戶被鎖定30分鐘", username, incr);
            throw new LockedException("密碼連續輸入錯誤次數過多,賬戶已被鎖定!");
        }
        throw new BadCredentialsException("您輸入的使用者名稱、密碼或驗證碼不正確,為保證賬戶安全,連續5次輸入錯誤,系統將鎖定您的賬戶30分鐘,當前剩餘:" + (PASSOWRD_MAX_ERROR_COUNT - incr) + "次", ex);
    }

    SecurityContextHolder.getContext().setAuthentication(authentication);
    LoginUser loginUser = (LoginUser) authentication.getPrincipal();

    String userId = loginUser.getUser().getUserId().toString();
    // 生成令牌
    String token = jwtUtil.generateToken(userId);
    Map<String, Object> resultMap = new HashMap();
    resultMap.put("AccessToken", token);
    resultMap.put("UserId", userId);

    // Redis 儲存上線資訊
    // UserAgent userAgent
    // 踢掉已登入使用者

    return resultMap;
}

UserDetailsServiceImpl


@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    Logger logger = LoggerFactory.getLogger(this.getClass());

    @Autowired
    private ISysUserService userService;

    @Autowired
    private ISysMenuService menuService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        SysUser user = userService.selectUserByUserName(username);
        if (user == null) {
            logger.info("登入使用者:{} 不存在.", username);
            throw new UsernameNotFoundException("登入使用者:" + username + " 不存在");
        } else if ("1".equals(user.getDelFlag())) {
            logger.info("登入使用者:{} 已被刪除.", username);
            throw new CustomException("對不起,您的賬號:" + username + " 已被刪除");
        } else if ("1".equals(user.getStatus())) {
            logger.info("登入使用者:{} 已被停用.", username);
            throw new CustomException("對不起,您的賬號:" + username + " 已停用");
        }

        Set<String> perms = new HashSet<>();
        // 管理員擁有所有許可權
        if (user.isAdmin()) {
            perms.add("*:*:*");
        } else {
            perms.addAll(menuService.selectMenuPermsByUserId(user.getUserId()));
        }
        return new LoginUser(user, perms);
    }
}

介面許可權認證程式碼

image
image

AuthenticationTokenFilter

@Component
public class AuthenticationTokenFilter extends OncePerRequestFilter {
    @Autowired
    private JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        LoginUser loginUser = jwtUtil.getLoginUser(request);
        if (loginUser != null && SecurityUtils.getAuthentication() == null) {
            jwtUtil.verifyToken(loginUser);
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
            authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            //SecurityContextHolder使用了ThreadLocal機制來儲存每個使用者的安全上下文,確保PermissionService判斷許可權時可以獲得當前LoginUser資訊
            SecurityUtils.setAuthentication(authenticationToken);
        }
        chain.doFilter(request, response);
    }
}

定義許可權驗證類 PermissionService

package com.vipsoft.web.security;

import cn.hutool.core.util.StrUtil;
import com.vipsoft.web.utils.SecurityUtils;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;

import java.util.Arrays;
import java.util.Set;

/**
 * 自定義許可權實現
 */
@Service("ps")
public class PermissionService {
    /**
     * 所有許可權標識
     */
    private static final String ALL_PERMISSION = "*:*:*";

    /**
     * 管理員角色許可權標識
     */
    private static final String SUPER_ADMIN = "admin";

    private static final String ROLE_DELIMETER = ",";

    private static final String PERMISSION_DELIMETER = ",";


    /**
     * 對使用者請求的介面進行驗證,看介面所需要的許可權,當前使用者是否包括
     *
     * @param permissions 以 PERMISSION_NAMES_DELIMETER 為分隔符的許可權列表,如:system:user:add,system:user:edit
     * @return 使用者是否具有以下任意一個許可權
     */
    public boolean hasAnyPermi(String permissions) {
        if (StrUtil.isEmpty(permissions)) {
            return false;
        }
        LoginUser loginUser = SecurityUtils.getCurrentUser(); //去SecurityContextHolder.getContext()中獲取登入使用者資訊
        if (loginUser == null || CollectionUtils.isEmpty(loginUser.getPermissions())) {
            return false;
        }
        Set<String> authorities = loginUser.getPermissions();
        String[] perms = permissions.split(PERMISSION_DELIMETER);
        boolean hasPerms = Arrays.stream(perms).anyMatch(authorities::contains);
        //是Admin許可權 或者 擁有介面所需許可權時
        return permissions.contains(ALL_PERMISSION) || hasPerms;
    }
}

image
image

詳細程式碼見:https://gitee.com/VipSoft/VipBoot/tree/develop/vipsoft-security
原始碼摘自:若依後臺管理系統

相關文章