簡介
Spring Security,這是一種基於 Spring AOP 和 Servlet 過濾器的安全框架。它提供全面的安全性解決方案,同時在 Web 請求級和方法呼叫級處理身份確認和授權。
工作流程
從網上找了一張Spring Security 的工作流程圖,如下。
圖中標記的MyXXX,就是我們專案中需要配置的。快速上手
建表
表結構
建表語句DROP TABLE IF EXISTS `user`;
DROP TABLE IF EXISTS `role`;
DROP TABLE IF EXISTS `user_role`;
DROP TABLE IF EXISTS `role_permission`;
DROP TABLE IF EXISTS `permission`;
CREATE TABLE `user` (
`id` bigint(11) NOT NULL AUTO_INCREMENT,
`username` varchar(255) NOT NULL,
`password` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
);
CREATE TABLE `role` (
`id` bigint(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
);
CREATE TABLE `user_role` (
`user_id` bigint(11) NOT NULL,
`role_id` bigint(11) NOT NULL
);
CREATE TABLE `role_permission` (
`role_id` bigint(11) NOT NULL,
`permission_id` bigint(11) NOT NULL
);
CREATE TABLE `permission` (
`id` bigint(11) NOT NULL AUTO_INCREMENT,
`url` varchar(255) NOT NULL,
`name` varchar(255) NOT NULL,
`description` varchar(255) NULL,
`pid` bigint(11) NOT NULL,
PRIMARY KEY (`id`)
);
INSERT INTO user (id, username, password) VALUES (1,'user','e10adc3949ba59abbe56e057f20f883e');
INSERT INTO user (id, username , password) VALUES (2,'admin','e10adc3949ba59abbe56e057f20f883e');
INSERT INTO role (id, name) VALUES (1,'USER');
INSERT INTO role (id, name) VALUES (2,'ADMIN');
INSERT INTO permission (id, url, name, pid) VALUES (1,'/user/common','common',0);
INSERT INTO permission (id, url, name, pid) VALUES (2,'/user/admin','admin',0);
INSERT INTO user_role (user_id, role_id) VALUES (1, 1);
INSERT INTO user_role (user_id, role_id) VALUES (2, 1);
INSERT INTO user_role (user_id, role_id) VALUES (2, 2);
INSERT INTO role_permission (role_id, permission_id) VALUES (1, 1);
INSERT INTO role_permission (role_id, permission_id) VALUES (2, 1);
INSERT INTO role_permission (role_id, permission_id) VALUES (2, 2);
複製程式碼
pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-security4</artifactId>
</dependency>
複製程式碼
application.yml
spring:
thymeleaf:
mode: HTML5
encoding: UTF-8
cache: false
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/spring-security?useUnicode=true&characterEncoding=utf-8&useSSL=false
username: root
password: root
複製程式碼
User
public class User implements UserDetails , Serializable {
private Long id;
private String username;
private String password;
private List<Role> authorities;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
@Override
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
@Override
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
@Override
public List<Role> getAuthorities() {
return authorities;
}
public void setAuthorities(List<Role> authorities) {
this.authorities = 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;
}
}
複製程式碼
上面的 User 類實現了 UserDetails 介面,該介面是實現Spring Security 認證資訊的核心介面。其中 getUsername 方法為 UserDetails 介面 的方法,這個方法返回 username,也可以是其他的使用者資訊,例如手機號、郵箱等。getAuthorities() 方法返回的是該使用者設定的許可權資訊,在本例項中,模擬從資料庫取出使用者的所有角色資訊,許可權資訊也可以是使用者的其他資訊,不一定是角色資訊。另外需要讀取密碼,最後幾個方法一般情況下都返回 true,也可以根據自己的需求進行業務判斷。
Role
public class Role implements GrantedAuthority {
private Long id;
private String name;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@Override
public String getAuthority() {
return name;
}
}
複製程式碼
Role 類實現了 GrantedAuthority 介面,並重寫 getAuthority() 方法。許可權點可以為任何字串,不一定是非要用角色名。
所有的Authentication實現類都儲存了一個GrantedAuthority列表,其表示使用者所具有的許可權。GrantedAuthority是通過AuthenticationManager設定到Authentication物件中的,然後AccessDecisionManager將從Authentication中獲取使用者所具有的GrantedAuthority來鑑定使用者是否具有訪問對應資源的許可權。
MyUserDetailsService
@Service
public class MyUserDetailsService implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Autowired
private RoleMapper roleMapper;
@Override
public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
//查資料庫
User user = userMapper.loadUserByUsername( userName );
if (null != user) {
List<Role> roles = roleMapper.getRolesByUserId( user.getId() );
user.setAuthorities( roles );
}
return user;
}
}
複製程式碼
Service 層需要實現 UserDetailsService 介面,該介面是根據使用者名稱獲取該使用者的所有資訊, 包括使用者資訊和許可權點。
MyInvocationSecurityMetadataSourceService
@Component
public class MyInvocationSecurityMetadataSourceService implements FilterInvocationSecurityMetadataSource {
@Autowired
private PermissionMapper permissionMapper;
/**
* 每一個資源所需要的角色 Collection<ConfigAttribute>決策器會用到
*/
private static HashMap<String, Collection<ConfigAttribute>> map =null;
/**
* 返回請求的資源需要的角色
*/
@Override
public Collection<ConfigAttribute> getAttributes(Object o) throws IllegalArgumentException {
if (null == map) {
loadResourceDefine();
}
//object 中包含使用者請求的request 資訊
HttpServletRequest request = ((FilterInvocation) o).getHttpRequest();
for (Iterator<String> it = map.keySet().iterator() ; it.hasNext();) {
String url = it.next();
if (new AntPathRequestMatcher( url ).matches( request )) {
return map.get( url );
}
}
return null;
}
@Override
public Collection<ConfigAttribute> getAllConfigAttributes() {
return null;
}
@Override
public boolean supports(Class<?> aClass) {
return true;
}
/**
* 初始化 所有資源 對應的角色
*/
public void loadResourceDefine() {
map = new HashMap<>(16);
//許可權資源 和 角色對應的表 也就是 角色許可權 中間表
List<RolePermisson> rolePermissons = permissionMapper.getRolePermissions();
//某個資源 可以被哪些角色訪問
for (RolePermisson rolePermisson : rolePermissons) {
String url = rolePermisson.getUrl();
String roleName = rolePermisson.getRoleName();
ConfigAttribute role = new SecurityConfig(roleName);
if(map.containsKey(url)){
map.get(url).add(role);
}else{
List<ConfigAttribute> list = new ArrayList<>();
list.add( role );
map.put( url , list );
}
}
}
}
複製程式碼
MyInvocationSecurityMetadataSourceService 類實現了 FilterInvocationSecurityMetadataSource,FilterInvocationSecurityMetadataSource 的作用是用來儲存請求與許可權的對應關係。
FilterInvocationSecurityMetadataSource介面有3個方法:
- boolean supports(Class<?> clazz):指示該類是否能夠為指定的方法呼叫或Web請求提供ConfigAttributes。
- Collection getAllConfigAttributes():Spring容器啟動時自動呼叫, 一般把所有請求與許可權的對應關係也要在這個方法裡初始化, 儲存在一個屬性變數裡。
- Collection getAttributes(Object object):當接收到一個http請求時, filterSecurityInterceptor會呼叫的方法. 引數object是一個包含url資訊的HttpServletRequest例項. 這個方法要返回請求該url所需要的所有許可權集合。
MyAccessDecisionManager
/**
* 決策器
*/
@Component
public class MyAccessDecisionManager implements AccessDecisionManager {
private final static Logger logger = LoggerFactory.getLogger(MyAccessDecisionManager.class);
/**
* 通過傳遞的引數來決定使用者是否有訪問對應受保護物件的許可權
*
* @param authentication 包含了當前的使用者資訊,包括擁有的許可權。這裡的許可權來源就是前面登入時UserDetailsService中設定的authorities。
* @param object 就是FilterInvocation物件,可以得到request等web資源
* @param configAttributes configAttributes是本次訪問需要的許可權
*/
@Override
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
if (null == configAttributes || 0 >= configAttributes.size()) {
return;
} else {
String needRole;
for(Iterator<ConfigAttribute> iter = configAttributes.iterator(); iter.hasNext(); ) {
needRole = iter.next().getAttribute();
for(GrantedAuthority ga : authentication.getAuthorities()) {
if(needRole.trim().equals(ga.getAuthority().trim())) {
return;
}
}
}
throw new AccessDeniedException("當前訪問沒有許可權");
}
}
/**
* 表示此AccessDecisionManager是否能夠處理傳遞的ConfigAttribute呈現的授權請求
*/
@Override
public boolean supports(ConfigAttribute configAttribute) {
return true;
}
/**
* 表示當前AccessDecisionManager實現是否能夠為指定的安全物件(方法呼叫或Web請求)提供訪問控制決策
*/
@Override
public boolean supports(Class<?> aClass) {
return true;
}
}
複製程式碼
MyAccessDecisionManager 類實現了AccessDecisionManager介面,AccessDecisionManager是由AbstractSecurityInterceptor呼叫的,它負責鑑定使用者是否有訪問對應資源(方法或URL)的許可權。
MyFilterSecurityInterceptor
@Component
public class MyFilterSecurityInterceptor extends AbstractSecurityInterceptor implements Filter {
@Autowired
private FilterInvocationSecurityMetadataSource securityMetadataSource;
@Autowired
public void setMyAccessDecisionManager(MyAccessDecisionManager myAccessDecisionManager) {
super.setAccessDecisionManager(myAccessDecisionManager);
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
FilterInvocation fi = new FilterInvocation(servletRequest, servletResponse, filterChain);
invoke(fi);
}
public void invoke(FilterInvocation fi) throws IOException, ServletException {
InterceptorStatusToken token = super.beforeInvocation(fi);
try {
//執行下一個攔截器
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
} finally {
super.afterInvocation(token, null);
}
}
@Override
public Class<?> getSecureObjectClass() {
return FilterInvocation.class;
}
@Override
public SecurityMetadataSource obtainSecurityMetadataSource() {
return this.securityMetadataSource;
}
}
複製程式碼
每種受支援的安全物件型別(方法呼叫或Web請求)都有自己的攔截器類,它是AbstractSecurityInterceptor的子類,AbstractSecurityInterceptor 是一個實現了對受保護物件的訪問進行攔截的抽象類。
AbstractSecurityInterceptor的機制可以分為幾個步驟:
-
- 查詢與當前請求關聯的“配置屬性(簡單的理解就是許可權)”
-
- 將 安全物件(方法呼叫或Web請求)、當前身份驗證、配置屬性 提交給決策器(AccessDecisionManager)
-
- (可選)更改呼叫所根據的身份驗證
-
- 允許繼續進行安全物件呼叫(假設授予了訪問權)
-
- 在呼叫返回之後,如果配置了AfterInvocationManager。如果呼叫引發異常,則不會呼叫AfterInvocationManager。
AbstractSecurityInterceptor中的方法說明:
- beforeInvocation()方法實現了對訪問受保護物件的許可權校驗,內部用到了AccessDecisionManager和AuthenticationManager;
- finallyInvocation()方法用於實現受保護物件請求完畢後的一些清理工作,主要是如果在beforeInvocation()中改變了SecurityContext,則在finallyInvocation()中需要將其恢復為原來的SecurityContext,該方法的呼叫應當包含在子類請求受保護資源時的finally語句塊中。
- afterInvocation()方法實現了對返回結果的處理,在注入了AfterInvocationManager的情況下預設會呼叫其decide()方法。
瞭解了AbstractSecurityInterceptor,就應該明白了,我們自定義MyFilterSecurityInterceptor就是想使用我們之前自定義的 AccessDecisionManager 和 securityMetadataSource。
SecurityConfig
@EnableWebSecurity註解以及WebSecurityConfigurerAdapter一起配合提供基於web的security。自定義類 繼承了WebSecurityConfigurerAdapter來重寫了一些方法來指定一些特定的Web安全設定。
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private MyUserDetailsService userService;
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
//校驗使用者
auth.userDetailsService( userService ).passwordEncoder( new PasswordEncoder() {
//對密碼進行加密
@Override
public String encode(CharSequence charSequence) {
System.out.println(charSequence.toString());
return DigestUtils.md5DigestAsHex(charSequence.toString().getBytes());
}
//對密碼進行判斷匹配
@Override
public boolean matches(CharSequence charSequence, String s) {
String encode = DigestUtils.md5DigestAsHex(charSequence.toString().getBytes());
boolean res = s.equals( encode );
return res;
}
} );
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/","index","/login","/login-error","/401","/css/**","/js/**").permitAll()
.anyRequest().authenticated()
.and()
.formLogin().loginPage( "/login" ).failureUrl( "/login-error" )
.and()
.exceptionHandling().accessDeniedPage( "/401" );
http.logout().logoutSuccessUrl( "/" );
}
}
複製程式碼
MainController
@Controller
public class MainController {
@RequestMapping("/")
public String root() {
return "redirect:/index";
}
@RequestMapping("/index")
public String index() {
return "index";
}
@RequestMapping("/login")
public String login() {
return "login";
}
@RequestMapping("/login-error")
public String loginError(Model model) {
model.addAttribute( "loginError" , true);
return "login";
}
@GetMapping("/401")
public String accessDenied() {
return "401";
}
@GetMapping("/user/common")
public String common() {
return "user/common";
}
@GetMapping("/user/admin")
public String admin() {
return "user/admin";
}
}
複製程式碼
頁面
login.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>登入</title>
</head>
<body>
<h1>Login page</h1>
<p th:if="${loginError}" class="error">使用者名稱或密碼錯誤</p>
<form th:action="@{/login}" method="post">
<label for="username">使用者名稱</label>:
<input type="text" id="username" name="username" autofocus="autofocus" />
<br/>
<label for="password">密 碼</label>:
<input type="password" id="password" name="password" />
<br/>
<input type="submit" value="登入" />
</form>
<p><a href="/index" th:href="@{/index}"></a></p>
</body>
</html>
複製程式碼
index.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<head>
<meta charset="UTF-8">
<title>首頁</title>
</head>
<body>
<h2>page list</h2>
<a href="/user/common">common page</a>
<br/>
<a href="/user/admin">admin page</a>
<br/>
<form th:action="@{/logout}" method="post">
<input type="submit" class="btn btn-primary" value="登出"/>
</form>
</body>
</html>
複製程式碼
admin.html
<!DOCTYPE html>
<head>
<meta charset="UTF-8">
<title>admin page</title>
</head>
<body>
success admin page!!!
</body>
</html>
複製程式碼
common.html
<!DOCTYPE html>
<head>
<meta charset="UTF-8">
<title>common page</title>
</head>
<body>
success common page!!!
</body>
</html>
複製程式碼
401.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>401 page</title>
</head>
<body>
<div>
<div>
<h2>許可權不夠</h2>
<p>拒絕訪問!</p>
</div>
</div>
</body>
</html>
複製程式碼
最後執行專案,可以分別用 user、admin 賬號 去測試認證和授權是否正確。
參考
《深入理解Spring Cloud與微服務構建》