Spring Boot 2.X(十八):集成 Spring Security-登陸認證和權限控制

前言

在企業項目開發中,對系統的安全和權限控制每每是必需的,常見的安全框架有 Spring Security、Apache Shiro 等。本文主要簡單介紹一下 Spring Security,再經過 Spring Boot 集成開一個簡單的示例。javascript

Spring Security

什麼是 Spring Security?

Spring Security 是一種基於 Spring AOP 和 Servlet 過濾器 Filter 的安全框架,它提供了全面的安全解決方案,提供在 Web 請求和方法調用級別的用戶鑑權和權限控制。css

Web 應用的安全性一般包括兩方面:用戶認證(Authentication)和用戶受權(Authorization)。html

用戶認證指的是驗證某個用戶是否爲系統合法用戶,也就是說用戶可否訪問該系統。用戶認證通常要求用戶提供用戶名和密碼,系統經過校驗用戶名和密碼來完成認證。前端

用戶受權指的是驗證某個用戶是否有權限執行某個操做。java

2.原理

Spring Security 功能的實現主要是靠一系列的過濾器鏈相互配合來完成的。如下是項目啓動時打印的默認安全過濾器鏈(集成5.2.0):mysql

[
    org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@5054e546,
    org.springframework.security.web.context.SecurityContextPersistenceFilter@7b0c69a6,
    org.springframework.security.web.header.HeaderWriterFilter@4fefa770,
    org.springframework.security.web.csrf.CsrfFilter@6346aba8,
    org.springframework.security.web.authentication.logout.LogoutFilter@677ac054,
    org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@51430781,
    org.springframework.security.web.savedrequest.RequestCacheAwareFilter@4203d678,
    org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@625e20e6,
    org.springframework.security.web.authentication.AnonymousAuthenticationFilter@19628fc2,
    org.springframework.security.web.session.SessionManagementFilter@471f8a70,
    org.springframework.security.web.access.ExceptionTranslationFilter@3e1eb569,
    org.springframework.security.web.access.intercept.FilterSecurityInterceptor@3089ab62
]
  • WebAsyncManagerIntegrationFilter
  • SecurityContextPersistenceFilter
  • HeaderWriterFilter
  • CsrfFilter
  • LogoutFilter
  • UsernamePasswordAuthenticationFilter
  • RequestCacheAwareFilter
  • SecurityContextHolderAwareRequestFilter
  • AnonymousAuthenticationFilter
  • SessionManagementFilter
  • ExceptionTranslationFilter
  • FilterSecurityInterceptor

詳細解讀能夠參考:http://www.javashuo.com/article/p-rgyffmcv-nn.htmljquery

3.核心組件

SecurityContextHolder

用於存儲應用程序安全上下文(Spring Context)的詳細信息,如當前操做的用戶對象信息、認證狀態、角色權限信息等。默認狀況下,SecurityContextHolder 會使用 ThreadLocal 來存儲這些信息,意味着安全上下文始終可用於同一執行線程中的方法。git

獲取有關當前用戶的信息

由於身份信息與線程是綁定的,因此能夠在程序的任何地方使用靜態方法獲取用戶信息。例如獲取當前通過身份驗證的用戶的名稱,代碼以下:github

Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (principal instanceof UserDetails) {
    String username = ((UserDetails)principal).getUsername();
} else {
    String username = principal.toString();
}

其中,getAuthentication() 返回認證信息,getPrincipal() 返回身份信息,UserDetails 是對用戶信息的封裝類。web

Authentication

認證信息接口,集成了 Principal 類。該接口中方法以下:

接口方法 功能說明
getAuthorities() 獲取權限信息列表,默認是 GrantedAuthority 接口的一些實現類,一般是表明權限信息的一系列字符串
getCredentials() 獲取用戶提交的密碼憑證,用戶輸入的密碼字符竄,在認證事後一般會被移除,用於保障安全
getDetails() 獲取用戶詳細信息,用於記錄 ip、sessionid、證書序列號等值
getPrincipal() 獲取用戶身份信息,大部分狀況下返回的是 UserDetails 接口的實現類,是框架中最經常使用的接口之一

AuthenticationManager

認證管理器,負責驗證。認證成功後,AuthenticationManager 返回一個填充了用戶認證信息(包括權限信息、身份信息、詳細信息等,但密碼一般會被移除)的 Authentication 實例。而後再將 Authentication 設置到 SecurityContextHolder 容器中。

AuthenticationManager 接口是認證相關的核心接口,也是發起認證的入口。但它通常不直接認證,其經常使用實現類 ProviderManager 內部會維護一個 List<AuthenticationProvider> 列表,存放裏多種認證方式,默認狀況下,只須要經過一個 AuthenticationProvider 的認證,就可被認爲是登陸成功。

UserDetailsService

負責從特定的地方加載用戶信息,一般是經過JdbcDaoImpl從數據庫加載實現,也能夠經過內存映射InMemoryDaoImpl實現。

UserDetails

該接口表明了最詳細的用戶信息。該接口中方法以下:

接口方法 功能說明
getAuthorities() 獲取授予用戶的權限
getPassword() 獲取用戶正確的密碼,這個密碼在驗證時會和 Authentication 中的 getCredentials() 作比對
getUsername() 獲取用於驗證的用戶名
isAccountNonExpired() 指示用戶的賬戶是否已過時,沒法驗證過時的用戶
isAccountNonLocked() 指示用戶的帳號是否被鎖定,沒法驗證被鎖定的用戶
isCredentialsNonExpired() 指示用戶的憑據(密碼)是否已過時,沒法驗證憑證過時的用戶
isEnabled() 指示用戶是否被啓用,沒法驗證被禁用的用戶

Spring Security 實戰

1.系統設計

本文主要使用 Spring Security 來實現系統頁面的權限控制和安全認證,本示例不作詳細的數據增刪改查,sql 能夠在完整代碼裏下載,主要是基於數據庫對頁面 和 ajax 請求作權限控制。

1.1 技術棧

  • 編程語言:Java
  • 編程框架:Spring、Spring MVC、Spring Boot
  • ORM 框架:MyBatis
  • 視圖模板引擎:Thymeleaf
  • 安全框架:Spring Security(5.2.0)
  • 數據庫:MySQL
  • 前端:Layui、JQuery

1.2 功能設計

  1. 實現登陸、退出
  2. 實現菜單 url 跳轉的權限控制
  3. 實現按鈕 ajax 請求的權限控制
  4. 防止跨站請求僞造(CSRF)攻擊

1.3 數據庫層設計

t_user 用戶表

字段 類型 長度 是否爲空 說明
id int 8 主鍵,自增加
username varchar 20 用戶名
password varchar 255 密碼

t_role 角色表

字段 類型 長度 是否爲空 說明
id int 8 主鍵,自增加
role_name varchar 20 角色名稱

t_menu 菜單表

字段 類型 長度 是否爲空 說明
id int 8 主鍵,自增加
menu_name varchar 20 菜單名稱
menu_url varchar 50 菜單url(Controller 請求路徑)

t_user_roles 用戶權限表

字段 類型 長度 是否爲空 說明
id int 8 主鍵,自增加
user_id int 8 用戶表id
role_id int 8 角色表id

t_role_menus 權限菜單表

字段 類型 長度 是否爲空 說明
id int 8 主鍵,自增加
role_id int 8 角色表id
menu_id int 8 菜單表id

實體類這裏不詳細列了。

2.代碼實現

2.0 相關依賴

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

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
			<exclusions>
				<exclusion>
					<groupId>org.junit.vintage</groupId>
					<artifactId>junit-vintage-engine</artifactId>
				</exclusion>
			</exclusions>
		</dependency>
		
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-jdbc</artifactId>
		</dependency>

		<!-- 熱部署模塊 -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-devtools</artifactId>
			<optional>true</optional> <!-- 這個須要爲 true 熱部署纔有效 -->
		</dependency>
		
			<!-- mysql 數據庫驅動. -->
		<dependency>
			<groupId>mysql</groupId>
			<artifactId>mysql-connector-java</artifactId>
			<scope>runtime</scope>
		</dependency>

		<!-- mybaits -->
		<dependency>
			<groupId>org.mybatis.spring.boot</groupId>
			<artifactId>mybatis-spring-boot-starter</artifactId>
			<version>2.1.0</version>
		</dependency>
		
		<!-- thymeleaf -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-thymeleaf</artifactId>
		</dependency>
		
		<!-- alibaba fastjson -->
		<dependency>
			<groupId>com.alibaba</groupId>
			<artifactId>fastjson</artifactId>
			<version>1.2.47</version>
		</dependency>
		<!-- spring security -->
		<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
	</dependencies>

2.1 繼承 WebSecurityConfigurerAdapter 自定義 Spring Security 配置

/**
prePostEnabled :決定Spring Security的前註解是否可用 [@PreAuthorize,@PostAuthorize,..]
secureEnabled : 決定是否Spring Security的保障註解 [@Secured] 是否可用
jsr250Enabled :決定 JSR-250 annotations 註解[@RolesAllowed..] 是否可用.
 */
@Configurable
@EnableWebSecurity
//開啓 Spring Security 方法級安全註解 @EnableGlobalMethodSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true,securedEnabled = true,jsr250Enabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter{

	@Autowired
	private CustomAccessDeniedHandler customAccessDeniedHandler;
	@Autowired
	private UserDetailsService userDetailsService;
	
	/**
	 * 靜態資源設置
	 */
	@Override
	public void configure(WebSecurity webSecurity) {
		//不攔截靜態資源,全部用戶都可訪問的資源
		webSecurity.ignoring().antMatchers(
				"/",
				"/css/**",
				"/js/**",
				"/images/**",
				"/layui/**"
				);
	}
	/**
	 * http請求設置
	 */
	@Override
	public void configure(HttpSecurity http) throws Exception {
		//http.csrf().disable(); //註釋就是使用 csrf 功能		
		http.headers().frameOptions().disable();//解決 in a frame because it set 'X-Frame-Options' to 'DENY' 問題			
		//http.anonymous().disable();
		http.authorizeRequests()
			.antMatchers("/login/**","/initUserData")//不攔截登陸相關方法		
			.permitAll()		
			//.antMatchers("/user").hasRole("ADMIN")  // user接口只有ADMIN角色的能夠訪問
//			.anyRequest()
//			.authenticated()// 任何還沒有匹配的URL只須要驗證用戶便可訪問
			.anyRequest()
			.access("@rbacPermission.hasPermission(request, authentication)")//根據帳號權限訪問			
			.and()
			.formLogin()
			.loginPage("/")
			.loginPage("/login")   //登陸請求頁
			.loginProcessingUrl("/login")  //登陸POST請求路徑
			.usernameParameter("username") //登陸用戶名參數
			.passwordParameter("password") //登陸密碼參數
			.defaultSuccessUrl("/main")   //默認登陸成功頁面
			.and()
			.exceptionHandling()
			.accessDeniedHandler(customAccessDeniedHandler) //無權限處理器
			.and()
			.logout()
			.logoutSuccessUrl("/login?logout");  //退出登陸成功URL
			
	}
	/**
	 * 自定義獲取用戶信息接口
	 */
	@Override
	public void configure(AuthenticationManagerBuilder auth) throws Exception {
		auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
	}
	
	/**
     * 密碼加密算法
     * @return
     */
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
 
    }
}

2.2 自定義實現 UserDetails 接口,擴展屬性

public class UserEntity implements UserDetails {

	/**
	 * 
	 */
	private static final long serialVersionUID = -9005214545793249372L;

	private Long id;// 用戶id
	private String username;// 用戶名
	private String password;// 密碼
	private List<Role> userRoles;// 用戶權限集合
	private List<Menu> roleMenus;// 角色菜單集合

	private Collection<? extends GrantedAuthority> authorities;
	public UserEntity() {
		
	}
	
	public UserEntity(String username, String password, Collection<? extends GrantedAuthority> authorities,
			List<Menu> roleMenus) {
		this.username = username;
		this.password = password;
		this.authorities = authorities;
		this.roleMenus = roleMenus;
	}

	public Long getId() {
		return id;
	}

	public void setId(Long id) {
		this.id = id;
	}

	public String getUsername() {
		return username;
	}

	public void setUsername(String username) {
		this.username = username;
	}

	public String getPassword() {
		return password;
	}

	public void setPassword(String password) {
		this.password = password;
	}

	public List<Role> getUserRoles() {
		return userRoles;
	}

	public void setUserRoles(List<Role> userRoles) {
		this.userRoles = userRoles;
	}

	public List<Menu> getRoleMenus() {
		return roleMenus;
	}

	public void setRoleMenus(List<Menu> roleMenus) {
		this.roleMenus = roleMenus;
	}

	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		return this.authorities;
	}

	@Override
	public boolean isAccountNonExpired() {
		return true;
	}

	@Override
	public boolean isAccountNonLocked() {
		return true;
	}

	@Override
	public boolean isCredentialsNonExpired() {
		return true;
	}

	@Override
	public boolean isEnabled() {
		return true;
	}

}

2.3 自定義實現 UserDetailsService 接口

/**
 * 獲取用戶相關信息
 * @author charlie
 *
 */
@Service
public class UserDetailServiceImpl implements UserDetailsService {
	private Logger log = LoggerFactory.getLogger(UserDetailServiceImpl.class);

	@Autowired
	private UserDao userDao;

	@Autowired
	private RoleDao roleDao;
	@Autowired
	private MenuDao menuDao;

	@Override
	public UserEntity loadUserByUsername(String username) throws UsernameNotFoundException {
		// 根據用戶名查找用戶
		UserEntity user = userDao.getUserByUsername(username);
		System.out.println(user);
		if (user != null) {
			System.out.println("UserDetailsService");
			//根據用戶id獲取用戶角色
			List<Role> roles = roleDao.getUserRoleByUserId(user.getId());
			// 填充權限
			Collection<SimpleGrantedAuthority> authorities = new HashSet<SimpleGrantedAuthority>();
			for (Role role : roles) {
				authorities.add(new SimpleGrantedAuthority(role.getRoleName()));
			}
			//填充權限菜單
			List<Menu> menus=menuDao.getRoleMenuByRoles(roles);
			return new UserEntity(username,user.getPassword(),authorities,menus);
		} else {
			System.out.println(username +" not found");
			throw new UsernameNotFoundException(username +" not found");
		}		
	}

}

2.4 自定義實現 URL 權限控制

/**
 * RBAC數據模型控制權限
 * @author charlie
 *
 */
@Component("rbacPermission")
public class RbacPermission{

	private AntPathMatcher antPathMatcher = new AntPathMatcher();

	public boolean hasPermission(HttpServletRequest request, Authentication authentication) {
		Object principal = authentication.getPrincipal();
		boolean hasPermission = false;
		if (principal instanceof UserEntity) {
			// 讀取用戶所擁有的權限菜單
			List<Menu> menus = ((UserEntity) principal).getRoleMenus();
			System.out.println(menus.size());
			for (Menu menu : menus) {
				if (antPathMatcher.match(menu.getMenuUrl(), request.getRequestURI())) {
					hasPermission = true;
					break;
				}
			}
		}
		return hasPermission;
	}
}

2.5 實現 AccessDeniedHandler

自定義處理無權請求

/**
 * 處理無權請求
 * @author charlie
 *
 */
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

	private Logger log = LoggerFactory.getLogger(CustomAccessDeniedHandler.class);

	@Override
	public void handle(HttpServletRequest request, HttpServletResponse response,
			AccessDeniedException accessDeniedException) throws IOException, ServletException {
		boolean isAjax = ControllerTools.isAjaxRequest(request);
		System.out.println("CustomAccessDeniedHandler handle");
		if (!response.isCommitted()) {
			if (isAjax) {
				String msg = accessDeniedException.getMessage();
				log.info("accessDeniedException.message:" + msg);
				String accessDenyMsg = "{\"code\":\"403\",\"msg\":\"沒有權限\"}";
				ControllerTools.print(response, accessDenyMsg);
			} else {
				request.setAttribute(WebAttributes.ACCESS_DENIED_403, accessDeniedException);
				response.setStatus(HttpStatus.FORBIDDEN.value());
				RequestDispatcher dispatcher = request.getRequestDispatcher("/403");
				dispatcher.forward(request, response);
			}
		}

	}

	public static class ControllerTools {
		public static boolean isAjaxRequest(HttpServletRequest request) {
			return "XMLHttpRequest".equals(request.getHeader("X-Requested-With"));
		}

		public static void print(HttpServletResponse response, String msg) throws IOException {
			response.setCharacterEncoding("UTF-8");
			response.setContentType("application/json; charset=utf-8");
			PrintWriter writer = response.getWriter();
			writer.write(msg);
			writer.flush();
			writer.close();
		}
	}

}

2.6 相關 Controller

登陸/退出跳轉

/**
 * 登陸/退出跳轉
 * @author charlie
 *
 */
@Controller
public class LoginController {
	@GetMapping("/login")
	public ModelAndView login(@RequestParam(value = "error", required = false) String error,
			@RequestParam(value = "logout", required = false) String logout) {
		ModelAndView mav = new ModelAndView();
		if (error != null) {
			mav.addObject("error", "用戶名或者密碼不正確");
		}
		if (logout != null) {
			mav.addObject("msg", "退出成功");
		}
		mav.setViewName("login");
		return mav;
	}
}

登陸成功跳轉

@Controller
public class MainController {

	@GetMapping("/main")
	public ModelAndView toMainPage() {
		//獲取登陸的用戶名
		Object principal= SecurityContextHolder.getContext().getAuthentication().getPrincipal();
		String username=null;
		if(principal instanceof UserDetails) {
			username=((UserDetails)principal).getUsername();
		}else {
			username=principal.toString();
		}
		ModelAndView mav = new ModelAndView();
		mav.setViewName("main");
		mav.addObject("username", username);
		return mav;
	}
	
}

用於不一樣權限頁面訪問測試

/**
 * 用於不一樣權限頁面訪問測試
 * @author charlie
 *
 */
@Controller
public class ResourceController {

	@GetMapping("/publicResource")
	public String toPublicResource() {
		return "resource/public";
	}
	
	@GetMapping("/vipResource")
	public String toVipResource() {
		return "resource/vip";
	}
}

用於不一樣權限ajax請求測試

/**
 * 用於不一樣權限ajax請求測試
 * @author charlie
 *
 */
@RestController
@RequestMapping("/test")
public class HttptestController {

	@PostMapping("/public")
	public JSONObject doPublicHandler(Long id) {
		JSONObject json = new JSONObject();
		json.put("code", 200);
		json.put("msg", "請求成功" + id);
		return json;
	}

	@PostMapping("/vip")
	public JSONObject doVipHandler(Long id) {
		JSONObject json = new JSONObject();
		json.put("code", 200);
		json.put("msg", "請求成功" + id);
		return json;
	}
}

2.7 相關 html 頁面

登陸頁面

<form class="layui-form" action="/login" method="post">
			<div class="layui-input-inline">
				<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
				<input type="text" name="username" required
					placeholder="用戶名" autocomplete="off" class="layui-input">
			</div>
			<div class="layui-input-inline">
				<input type="password" name="password" required  placeholder="密碼" autocomplete="off"
					class="layui-input">
			</div>
			<div class="layui-input-inline login-btn">
				<button id="btnLogin" lay-submit lay-filter="*" class="layui-btn">登陸</button>
			</div>
			<div class="form-message">
				<label th:text="${error}"></label>
				<label th:text="${msg}"></label>
			</div>
		</form>

<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/> 防止跨站請求僞造(CSRF)攻擊

退出系統

<form id="logoutForm" action="/logout" method="post"
								style="display: none;">
								<input type="hidden" th:name="${_csrf.parameterName}"
									th:value="${_csrf.token}">
							</form>
							<a
								href="javascript:document.getElementById('logoutForm').submit();">退出系統</a>

ajax 請求頁面

<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" id="hidCSRF">
<button class="layui-btn" id="btnPublic">公共權限請求按鈕</button>
<br>
<br>
<button class="layui-btn" id="btnVip">VIP權限請求按鈕</button>
<script type="text/javascript" th:src="@{/js/jquery-1.8.3.min.js}"></script>
<script type="text/javascript" th:src="@{/layui/layui.js}"></script>
<script type="text/javascript">
		layui.use('form', function() {
			var form = layui.form;
			$("#btnPublic").click(function(){
				$.ajax({
					url:"/test/public",
					type:"POST",
					data:{id:1},
					beforeSend:function(xhr){
						xhr.setRequestHeader('X-CSRF-TOKEN',$("#hidCSRF").val());	
					},
					success:function(res){
						alert(res.code+":"+res.msg);
				
					}	
				});
			});
			$("#btnVip").click(function(){
				$.ajax({
					url:"/test/vip",
					type:"POST",
					data:{id:2},
					beforeSend:function(xhr){
						xhr.setRequestHeader('X-CSRF-TOKEN',$("#hidCSRF").val());	
					},
					success:function(res){
						alert(res.code+":"+res.msg);
						
					}
				});
			});
		});
	</script>

2.8 測試

測試提供兩個帳號:user 和 admin (密碼與帳號同樣)

因爲 admin 做爲管理員權限,設置了所有的訪問權限,這裏只展現 user 的測試結果。

完整代碼

github

碼雲

非特殊說明,本文版權歸 朝霧輕寒 全部,轉載請註明出處.

原文標題:Spring Boot 2.X(十八):集成 Spring Security-登陸認證和權限控制

原文地址: https://www.zwqh.top/article/info/27

若是文章有不足的地方,歡迎提點,後續會完善。

若是文章對您有幫助,請給我點個贊,請掃碼關注下個人公衆號,文章持續更新中...

原文出處:https://www.cnblogs.com/zwqh/p/11934880.html

相關文章
相關標籤/搜索