寫在前面
開發Web應用,對頁面的安全控制通常是必須的。比如:對於沒有訪問權限的用戶需要轉到登錄表單頁面。要實現訪問控制的方法多種多樣,可以通過Aop、攔截器實現,也可以通過框架實現,例如:Apache Shiro、Spring Security。我們這裏要講的Spring Security 就是一個Spring生態中關於安全方面的框架。它能夠爲基於Spring的企業應用系統提供聲明式的安全訪問控制解決方案。
默認認證用戶名密碼
項目pom.xml添加spring-boot-starter-security依賴
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
重啓你的應用。再次打開頁面,你講看到一個登錄頁面
既然跳到了登錄頁面,那麼這個時候我們就會想,這個登錄的用戶名以及密碼是什麼呢?讓我們來從SpringBoot源碼尋找一下。你搜一下輸出日誌,會看到下面一段輸出:
這段日誌是UserDetailsServiceAutoConfiguration類裏面的如下方法輸出的:
通過上面的這個類,我們可以看出,是SecurityProperties這個Bean管理了用戶名和密碼。在SecurityProperties裏面的一個內部靜態類User類裏面,管理了默認的認證的用戶名與密碼。代碼如下
@ConfigurationProperties(
prefix = "spring.security"
)
public class SecurityProperties {
public static final int BASIC_AUTH_ORDER = 2147483642;
public static final int IGNORED_ORDER = -2147483648;
public static final int DEFAULT_FILTER_ORDER = -100;
private final SecurityProperties.Filter filter = new SecurityProperties.Filter();
private SecurityProperties.User user = new SecurityProperties.User();
public SecurityProperties() {
}
public SecurityProperties.User getUser() {
return this.user;
}
public SecurityProperties.Filter getFilter() {
return this.filter;
}
public static class User {
private String name = "user";
private String password = UUID.randomUUID().toString();
private List<String> roles = new ArrayList();
private boolean passwordGenerated = true;
public User() {
}
public String getName() {
return this.name;
}
public void setName(String name) {
this.name = name;
}
public String getPassword() {
return this.password;
}
public void setPassword(String password) {
if (StringUtils.hasLength(password)) {
this.passwordGenerated = false;
this.password = password;
}
}
public List<String> getRoles() {
return this.roles;
}
public void setRoles(List<String> roles) {
this.roles = new ArrayList(roles);
}
public boolean isPasswordGenerated() {
return this.passwordGenerated;
}
}
public static class Filter {
private int order = -100;
private Set<DispatcherType> dispatcherTypes;
public Filter() {
this.dispatcherTypes = new HashSet(Arrays.asList(DispatcherType.ASYNC, DispatcherType.ERROR, DispatcherType.REQUEST));
}
public int getOrder() {
return this.order;
}
public void setOrder(int order) {
this.order = order;
}
public Set<DispatcherType> getDispatcherTypes() {
return this.dispatcherTypes;
}
public void setDispatcherTypes(Set<DispatcherType> dispatcherTypes) {
this.dispatcherTypes = dispatcherTypes;
}
}
}
綜上所述,security默認的用戶名是user, 默認密碼是應用啓動的時候,通過UUID算法隨機生成的,默認的role是"USER"。當然,如果我們想簡單改一下這個用戶名密碼,可以在application.properties
配置你的用戶名密碼,例如
當然這只是一個初級的配置,更復雜的配置,可以分不用角色,在控制範圍上,能夠攔截到方法級別的權限控制。
內存用戶名密碼認證
在上面的內容,我們什麼都沒做,就添加了spring-boot-starter-security依賴,整個應用就有了默認的認證安全機制。下面,我們來定製用戶名密碼。寫一個繼承了 WebSecurityConfigurerAdapter的配置類,具體內容如下
import org.springframework.context.annotation.Configuration;
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;
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
super.configure(http);
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.passwordEncoder(new BCryptPasswordEncoder())
.withUser("admin")
.password(new BCryptPasswordEncoder().encode("1234567"))
.roles("USER");
}
}
這裏對上面的代碼進行簡要說明:
-
Spring security 5.0中新增了多種加密方式,也改變了默認的密碼格式。需要修改一下configure中的代碼,我們要將前端傳過來的密碼進行某種方式加密,Spring Security 官方推薦的是使用
bcrypt
加密方式。inMemoryAuthentication().passwordEncoder(new BCryptPasswordEncoder())
,這相當於登陸時用BCrypt加密方式對用戶密碼進行處理。以前的".password("123")
" 變成了 “.password(new BCryptPasswordEncoder().encode("123"))
”,這相當於對內存中的密碼進行Bcrypt編碼加密。如果比對時一致,說明密碼正確,才允許登陸。 -
通過
@EnableWebSecurity
註解開啓Spring Security的功能。使用@EnableGlobalMethodSecurity(prePostEnabled = true)
這個註解,可以開啓security的註解,我們可以在需要控制權限的方法上面使用@PreAuthorize
,@PreFilter
這些註解。 -
繼承 WebSecurityConfigurerAdapter 類,並重寫它的方法來設置一些web安全的細節。我們結合
@EnableWebSecurity
註解和繼承WebSecurityConfigurerAdapter,來給我們的系統加上基於web的安全機制。 -
在
configure(HttpSecurity http)
方法裏面,我們進入到源碼中,就會看到默認的認證代碼是:
從方法名我們基本可以看懂這些方法的功能。上面的那個默認的登錄頁面,就是SpringBoot默認的用戶名密碼認證的login頁面。我們使用SpringBoot默認的配置super.configure(http)
,它通過 authorizeRequests()
定義哪些URL需要被保護、哪些不需要被保護。默認配置是所有訪問頁面都需要認證,纔可以訪問。
-
通過
formLogin()
定義當需要用戶登錄時候,轉到的登錄頁面。 -
configureGlobal(AuthenticationManagerBuilder auth)
方法,在內存中創建了一個用戶,該用戶的名稱爲root,密碼爲root,用戶角色爲USER。這個默認的登錄頁面是怎麼冒出來的呢?是的,SpringBoot內置的,SpringBoot甚至給我們做好了一個極簡的登錄頁面。這個登錄頁面是通過Filter實現的。具體的實現類是org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter
。同時,這個DefaultLoginPageGeneratingFilter也是SpringBoot的默認內置的Filter。
輸入用戶名,密碼,點擊Login。不過,我們發現,SpringBoot應用的啓動日誌還是打印瞭如下一段:
但實際上,已經使用了我們定製的用戶名密碼了。如果我們要配置多個用戶,多個角色,可參考使用如下示例的代碼:
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.passwordEncoder(new BCryptPasswordEncoder())
.withUser("admin")
.password(new BCryptPasswordEncoder().encode("1234567"))
.roles("USER")
.and()
.withUser("admin1")
.password(new BCryptPasswordEncoder().encode("123"))
.roles("ADMIN", "USER");
}
角色權限控制
當我們的系統功能模塊當需求發展到一定程度時,會不同的用戶,不同角色使用我們的系統。這樣就要求我們的系統可以做到,能夠對不同的系統功能模塊,開放給對應的擁有其訪問權限的用戶使用。Spring Security提供了Spring EL表達式,允許我們在定義URL路徑訪問(@RequestMapping)的方法上面添加註解,來控制訪問權限。在標註訪問權限時,根據對應的表達式返回結果,控制訪問權限:
true,表示有權限
fasle,表示無權限
Spring Security可用表達式對象的基類是SecurityExpressionRoot。
public abstract class SecurityExpressionRoot implements SecurityExpressionOperations {
protected final Authentication authentication;
private AuthenticationTrustResolver trustResolver;
private RoleHierarchy roleHierarchy;
private Set<String> roles;
private String defaultRolePrefix = "ROLE_";
public final boolean permitAll = true;
public final boolean denyAll = false;
private PermissionEvaluator permissionEvaluator;
public final String read = "read";
public final String write = "write";
public final String create = "create";
public final String delete = "delete";
public final String admin = "administration";
public SecurityExpressionRoot(Authentication authentication) {
if (authentication == null) {
throw new IllegalArgumentException("Authentication object cannot be null");
} else {
this.authentication = authentication;
}
}
public final boolean hasAuthority(String authority) {
return this.hasAnyAuthority(authority);
}
public final boolean hasAnyAuthority(String... authorities) {
return this.hasAnyAuthorityName((String)null, authorities);
}
public final boolean hasRole(String role) {
return this.hasAnyRole(role);
}
public final boolean hasAnyRole(String... roles) {
return this.hasAnyAuthorityName(this.defaultRolePrefix, roles);
}
private boolean hasAnyAuthorityName(String prefix, String... roles) {
Set<String> roleSet = this.getAuthoritySet();
String[] var4 = roles;
int var5 = roles.length;
for(int var6 = 0; var6 < var5; ++var6) {
String role = var4[var6];
String defaultedRole = getRoleWithDefaultPrefix(prefix, role);
if (roleSet.contains(defaultedRole)) {
return true;
}
}
return false;
}
public final Authentication getAuthentication() {
return this.authentication;
}
public final boolean permitAll() {
return true;
}
public final boolean denyAll() {
return false;
}
public final boolean isAnonymous() {
return this.trustResolver.isAnonymous(this.authentication);
}
public final boolean isAuthenticated() {
return !this.isAnonymous();
}
public final boolean isRememberMe() {
return this.trustResolver.isRememberMe(this.authentication);
}
public final boolean isFullyAuthenticated() {
return !this.trustResolver.isAnonymous(this.authentication) && !this.trustResolver.isRememberMe(this.authentication);
}
public Object getPrincipal() {
return this.authentication.getPrincipal();
}
public void setTrustResolver(AuthenticationTrustResolver trustResolver) {
this.trustResolver = trustResolver;
}
public void setRoleHierarchy(RoleHierarchy roleHierarchy) {
this.roleHierarchy = roleHierarchy;
}
public void setDefaultRolePrefix(String defaultRolePrefix) {
this.defaultRolePrefix = defaultRolePrefix;
}
private Set<String> getAuthoritySet() {
if (this.roles == null) {
Collection<? extends GrantedAuthority> userAuthorities = this.authentication.getAuthorities();
if (this.roleHierarchy != null) {
userAuthorities = this.roleHierarchy.getReachableGrantedAuthorities(userAuthorities);
}
this.roles = AuthorityUtils.authorityListToSet(userAuthorities);
}
return this.roles;
}
public boolean hasPermission(Object target, Object permission) {
return this.permissionEvaluator.hasPermission(this.authentication, target, permission);
}
public boolean hasPermission(Object targetId, String targetType, Object permission) {
return this.permissionEvaluator.hasPermission(this.authentication, (Serializable)targetId, targetType, permission);
}
public void setPermissionEvaluator(PermissionEvaluator permissionEvaluator) {
this.permissionEvaluator = permissionEvaluator;
}
private static String getRoleWithDefaultPrefix(String defaultRolePrefix, String role) {
if (role == null) {
return role;
} else if (defaultRolePrefix != null && defaultRolePrefix.length() != 0) {
return role.startsWith(defaultRolePrefix) ? role : defaultRolePrefix + role;
} else {
return role;
}
}
}
通過閱讀源碼,我們可以更加深刻的理解其EL寫法,並在寫代碼的時候正確的使用。變量defaultRolePrefix硬編碼約定了role的前綴是"ROLE_"。同時,我們可以看出hasRole跟hasAnyRole是一樣的。hasAnyRole是調用的hasAnyAuthorityName(defaultRolePrefix, roles)
。所以,我們在學習一個框架或者一門技術的時候,最準確的就是源碼。通過源碼,我們可以更好更深入的理解技術的本質。
SecurityExpressionRoot爲我們提供的使用Spring EL表達式總結如下:
表達式 | 描述 |
---|---|
hasRole([role]) | 當前用戶是否擁有指定角色。 |
hasAnyRole([role1,role2]) | 多個角色是一個以逗號進行分隔的字符串。如果當前用戶擁有指定角色中的任意一個則返回true。 |
hasAuthority([auth]) | 等同於hasRole |
hasAnyAuthority([auth1,auth2]) | 等同於hasAnyRole |
Principle | 代表當前用戶的principle對象 |
authentication | 直接從SecurityContext獲取的當前Authentication對象 |
permitAll | 總是返回true,表示允許所有的 |
denyAll | 總是返回false,表示拒絕所有的 |
isAnonymous() | 當前用戶是否是一個匿名用戶 |
isRememberMe() | 表示當前用戶是否是通過Remember-Me自動登錄的 |
isAuthenticated() | 表示當前用戶是否已經登錄認證成功了。 |
isFullyAuthenticated() | 如果當前用戶既不是一個匿名用戶,同時又不是通過Remember-Me自動登錄的,則返回true。 |
在Controller方法上添加@PreAuthorize這個註解,value="hasRole('ADMIN')")
是Spring-EL expression,當表達式值爲true,標識這個方法可以被調用。如果表達式值是false,標識此方法無權限訪問。
在Spring Security裏面獲取當前登錄認證通過的用戶信息
如果我們想要在前端頁面顯示當前登錄的用戶怎麼辦呢?在在Spring Security裏面怎樣獲取當前登錄認證通過的用戶信息?下面我們就來探討這個問題。其實很好辦。我們添加一個LoginFilter,默認攔截所有請求,把當前登錄的用戶放到系統session中即可。在Spring Security中,用戶信息保存在SecurityContextHolder中。Spring Security使用一個Authentication對象來持有所有系統的安全認證相關的信息。這個信息的內容格式如下:
{
"accountNonExpired":true,
"accountNonLocked":true,
"authorities":[{
"authority":"ROLE_ADMIN"
},{
"authority":"ROLE_USER"
}],
"credentialsNonExpired":true,
"enabled":true,
"username":"root"
}
這個Authentication對象信息其實就是User實體的信息,類似如下(當然,密碼沒放進來)。
public class User implements UserDetails, CredentialsContainer {
private String password;
private final String username;
private final Set<GrantedAuthority> authorities;
private final boolean accountNonExpired;
private final boolean accountNonLocked;
private final boolean credentialsNonExpired;
private final boolean enabled;
....
}
我們可以使用下面的代碼(Java)獲得當前身份驗證的用戶的名稱:
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (principal instanceof UserDetails) {
String username = ((UserDetails)principal).getUsername();
} else {
String username = principal.toString();
}
通過調用getContext()返回的對象是SecurityContext的實例對象,該實例對象保存在ThreadLocal線程本地存儲中。使用Spring Security框架,通常的認證機制都是返回UserDetails實例,通過如上這種方式,我們就可以拿到認證登錄的用戶信息。
用數據庫存儲用戶和角色,實現安全認證
很多時候,我們需要的是實現一個用數據庫存儲用戶和角色,實現系統的安全認證。爲了簡化講解,本例中在權限角色上,我們簡單設計兩個用戶角色:USER,ADMIN。我們設計頁面的權限如下:
- 首頁/ : 所有人可訪問
- 登錄頁 /login: 所有人可訪問
- 普通用戶權限頁 /httpapi, /httpsuite: 登錄後的用戶都可訪問
- 管理員權限頁 /httpreport : 僅管理員可訪問
- 無權限提醒頁: 當一個用戶訪問了其沒有權限的頁面,我們使用全局統一的異常處理頁面提示。
配置Spring Security
我們首先使用Spring Security幫我們做登錄、登出的處理,以及當用戶未登錄時只能訪問: http://localhost:8080/ 以及 http://localhost:8080/login 兩個頁面。同樣的,我們要寫一個繼承WebSecurityConfigurerAdapter的配置類:
import com.springboot.in.action.service.LightSwordUserDetailService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.core.userdetails.UserDetailsService;
/**
* Created by jack on 2017/4/27.
*/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
//使用@EnableGlobalMethodSecurity(prePostEnabled = true)
// 這個註解,可以開啓security的註解,我們可以在需要控制權限的方法上面使用@PreAuthorize,@PreFilter這些註解。
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
@Bean
public UserDetailsService userDetailsService() { //覆蓋寫userDetailsService方法 (1)
return new AdminUserDetailService();
}
/**
* If subclassed this will potentially override subclass configure(HttpSecurity)
*
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
//super.configure(http);
http.csrf().disable();
http.authorizeRequests()
.antMatchers("/").permitAll()
.antMatchers("/amchart/**",
"/bootstrap/**",
"/build/**",
"/css/**",
"/dist/**",
"/documentation/**",
"/fonts/**",
"/js/**",
"/pages/**",
"/plugins/**"
).permitAll() //默認不攔截靜態資源的url pattern (2)
.anyRequest().authenticated().and()
.formLogin().loginPage("/login")// 登錄url請求路徑 (3)
.defaultSuccessUrl("/httpapi").permitAll().and() // 登錄成功跳轉路徑url(4)
.logout().permitAll();
http.logout().logoutSuccessUrl("/"); // 退出默認跳轉頁面 (5)
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//AuthenticationManager使用我們的 Service來獲取用戶信息,Service可以自己寫,其實就是簡單的讀取數據庫的操作
auth.userDetailsService(()); // (6)
}
}
上面的代碼只做了基本的配置,其中:
- 覆蓋寫userDetailsService方法,具體的AdminUserDetailsService實現類,就是之前說的獲取用戶信息的service層類。
- 默認不攔截靜態資源的url pattern。我們也可以用下面的WebSecurity這個方式跳過靜態資源的認證。
public void configure(WebSecurity web) throws Exception {
web
.ignoring()
.antMatchers("/resourcesDir/**");
}
- 跳轉登錄頁面url請求路徑爲/login,我們需要定義一個Controller把路徑映射到login.html。
- 登錄成功後跳轉的路徑爲/httpapi
- 退出後跳轉到的url爲/
- 認證鑑權信息的Bean,採用我們自定義的從數據庫中獲取用戶信息的AdminUserDetailService類。
我們同樣使用@EnableGlobalMethodSecurity(prePostEnabled = true)
這個註解,開啓security的註解,這樣我們可以在需要控制權限的方法上面使用@PreAuthorize
,@PreFilter
這些註解。
用戶退出
我們在configure(HttpSecurity http)方法裏面定義了任何權限都允許退出,當然SpringBoot集成Security的默認退出請求是/logout
http.logout().logoutSuccessUrl("/"); // 退出默認跳轉頁面 (4)
配置錯誤處理頁面
訪問發生錯誤時,跳轉到系統統一異常處理頁面。我們首先添加一個GlobalExceptionHandlerAdvice
,使用@ControllerAdvice
註解:
import org.springframework.web.bind.annotation.{ControllerAdvice, ExceptionHandler}
import org.springframework.web.context.request.WebRequest
import org.springframework.web.servlet.ModelAndView
/**
* Created by jack on 2017/4/27.
*/
@ControllerAdvice
class GlobalExceptionHandlerAdvice {
@ExceptionHandler(value = Exception.class)//表示捕捉到所有的異常,你也可以捕捉一個你自定義的異常
public ModelAndView exception(Exception exception, WebRequest request){
ModelAndView modelAndView = new ModelAndView("/error");
modelAndView.addObject("errorMessage", exception.getMessage());
modelAndView.addObject("stackTrace", exception.getStackTrace());
return modelAndView;
}
}
其中,@ExceptionHandler(value = Exception.class),表示捕捉到所有的異常,這裏你也可以捕捉一個你自定義的異常。比如說,針對安全認證的Exception,我們可以單獨定義處理。此處不再贅述。