前言:接上篇,上篇主要講理論,下篇講實戰,結合代碼演示SpringSecurity,Shiro,Oauth,jwt token以及單點登錄等當下主流的登錄及權限管理.在技術上我是個喜新厭舊的渣男,全篇以截至2020年2月最新的Springboot及其它包版本爲例演示.
完整的項目我已上傳至GitHub,如有需要可以下載下來參考,地址:https://github.com/laohanjianshen/login-auth
1.SpringSecurity
新建一個Springboot工程,並引入Springsecurity依賴(爲了不浪費篇幅,Sringboot web jpa jdbc等包請自行添加)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
新建一個Controller類,並啓動你的Springboot項目,不出意外訪問任何URL時你將會看到:
說明SpringSecurity已經生效,下面正式進入研發階段...
項目整體包及結構如下圖所示:
雖然內容乍看上去有點多,而且比網上大多數教程複雜一點,其實並不多,只需要三個步驟即可實現,之所以多是因爲我用了更規範的寫法,更接近生產環境.
第一步:根據RBAC,我們首先需要創建User,Role,Permission這三個對象及其DAO層:
@Entity
@Data
public class User implements UserDetails, Serializable {
@Id @GeneratedValue
private long uid;//主鍵.
private String username;//用戶名.
private String password;//密碼.
//省略用戶的其它信息,如手機號,郵箱等...
//用戶 - 角色關係. 多對多./
@ManyToMany(fetch= FetchType.EAGER)//立即從數據庫中獲取.
@JoinTable(name="user_role",joinColumns= {@JoinColumn(name="uid")},inverseJoinColumns= {@JoinColumn(name="role_id")})
private List<Role> roles;
//當前用戶的角色列表
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return roles;
}
//賬號是否未過期
@Override
public boolean isAccountNonExpired() {
return true;
}
//賬號是否未被鎖定
@Override
public boolean isAccountNonLocked() {
return true;
}
//證書是否過期
@Override
public boolean isCredentialsNonExpired() {
return true;
}
//是否可用
@Override
public boolean isEnabled() {
return true;
}
}
@Entity
@Data
public class Role implements GrantedAuthority, Serializable {
@Id
@GeneratedValue
private long rid;
private String name;//角色名.
private String descprtion;//角色描述.
@Override
public String getAuthority() {
return name;
}
}
@Data
@Entity
public class Permission implements Serializable {
@Id @GeneratedValue
private long id;
private String name;//權限名稱.
private String description;//描述.
private String url;//地址.
private long pid;//父id.
//角色和權限的關係 多對多.
@ManyToMany(fetch= FetchType.EAGER)
@JoinTable(name="role_permission",joinColumns= {@JoinColumn(name="permission_id")},
inverseJoinColumns= {@JoinColumn(name="role_id")})
private List<Role> roles;
}
配置中加入JPA自動生成表策略 :
spring.jpa.generate-ddl=true
spring.jpa.hibernate.ddl-auto=update
然後運行項目,標準的5張表(還有一張是id序列表)就被創建好了,因爲是Demo,所以我選擇用JPA作爲ORM框架,增刪改查等都比較方便.
然後添加兩個用戶,普通用戶和管理員並分別初始化權限等信息:
INSERT INTO `permission` VALUES (1, '公共頁面訪問權限', 'common', 0, '/user/common');
INSERT INTO `permission` VALUES (2, '管理員頁面訪問權限', 'admin', 0, '/user/admin');
INSERT INTO `role` VALUES (1, '普通用戶', 'ordinary');
INSERT INTO `role` VALUES (2, '老闆', 'boss');
INSERT INTO `role_permission` VALUES (1, 1);
INSERT INTO `role_permission` VALUES (1, 2);
INSERT INTO `role_permission` VALUES (2, 2);
INSERT INTO `user` VALUES (1, 'e10adc3949ba59abbe56e057f20f883e', 'user');
INSERT INTO `user` VALUES (2, 'e10adc3949ba59abbe56e057f20f883e', 'admin');
INSERT INTO `user_role` VALUES (1, 1);
INSERT INTO `user_role` VALUES (2, 1);
INSERT INTO `user_role` VALUES (2, 2);
相關SQL我已放入項目中,可以直接用Navicat等工具導入也行.
DAO層請自行創建,比較簡單,就三個接口,這裏省略了,至此第一步就已完成.
第二步:創建登錄及鑑權的Service層(劃重點,這塊是整個Spring-security的核心)
①在上篇中我有提到,SpringSecurity用到的跟用戶相關的信息來源於UserDetailService,所以我們需要實現此接口
@Service
public class MyUserDetailService implements UserDetailsService {
@Resource
UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
return userRepository.findByUsername(s);
}
}
②自定義過濾器的元數據 ,這步的核心是getAttributes(Object o)方法,該方法需要返回當前請求所需要的用戶身份列表(roleNames).
@Service
public class MyInvocationSecurityMetadataSourceService implements FilterInvocationSecurityMetadataSource {
@Resource
private PermissionRepository permissionRepository;
/**
* 每一個資源所需要的角色 ,Collection<ConfigAttribute>決策器會用到,用Map作緩存,避免每次請求都去查庫
*/
private static HashMap<String, Collection<ConfigAttribute>> map = null;
/**
* 獲取決策器DecisionManager所需要的當前請求對應的role
* @param o
* @return
* @throws IllegalArgumentException
*/
@Override
public Collection<ConfigAttribute> getAttributes(Object o) throws IllegalArgumentException {
if (null == map) {
loadResourceDefine();
}
HttpServletRequest request = ((FilterInvocation) o).getHttpRequest();
for (Iterator<String> it = map.keySet().iterator(); it.hasNext(); ) {
String url = it.next();
if (new AntPathRequestMatcher(url).matches(request)) {
//這裏返回的就是當前請求的url所需要的roleNameList
return map.get(url);
}
}
return null;
}
@Override
public Collection<ConfigAttribute> getAllConfigAttributes() {
return null;
}
@Override
public boolean supports(Class<?> aClass) {
return true;
}
/**
* 將permission表中的url對應的權限通過role_permission表與role關聯並存入map
*/
private void loadResourceDefine() {
map = new HashMap<>(16);
List<Permission> permissions = permissionRepository.findAll();
for (Permission permission : permissions) {
String url = permission.getUrl();
StringBuilder sb = new StringBuilder();
permission.getRoles().forEach(r->{
sb.append(r.getName());
});
String name = sb.toString();
ConfigAttribute configAttribute = new SecurityConfig(name);
if (map.containsKey(url)) {
map.get(url).add(configAttribute);
} else {
List<ConfigAttribute> list = new ArrayList<>();
list.add(configAttribute);
map.put(url, list);
}
}
}
}
③覆蓋SpringSecurity的攔截器,用上面自定義的元數據:
@Component
public class MyFilterSecurityInterceptor extends FilterSecurityInterceptor {
@Autowired
private FilterInvocationSecurityMetadataSource securityMetadataSource;
@Autowired
public void setMyAccessDecisionManager(MyAccessDecisionManager myAccessDecisionManager) {
super.setAccessDecisionManager(myAccessDecisionManager);
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
FilterInvocation fi = new FilterInvocation(request, response, chain);
invoke(fi);
}
public void invoke(FilterInvocation fi) {
InterceptorStatusToken token = super.beforeInvocation(fi);
try {
fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
} catch (IOException e) {
e.printStackTrace();
} catch (ServletException e) {
e.printStackTrace();
} finally {
super.afterInvocation(token, null);
}
}
@Override
public SecurityMetadataSource obtainSecurityMetadataSource() {
return this.securityMetadataSource;
}
}
④ 自定義決策器,核心方法是decide(...),此方法用來判斷當前登錄用戶是否有權限訪問該資源.
@Component
public class MyAccessDecisionManager implements AccessDecisionManager {
@Override
public void decide(Authentication authentication, Object o, Collection<ConfigAttribute> collection) throws AccessDeniedException, InsufficientAuthenticationException {
if (CollectionUtils.isEmpty(collection)) {
return;
} else {
String needRole;
for (Iterator<ConfigAttribute> iter = collection.iterator(); iter.hasNext(); ) {
needRole = iter.next().getAttribute();
for (GrantedAuthority ga : authentication.getAuthorities()) {
if (needRole.contains(ga.getAuthority())) {
//當前請求所需角色列表包含當前登陸人的角色,允許訪問
return;
}
}
}
throw new AccessDeniedException("當前訪問沒有權限");
}
}
/**
* 表示此AccessDecisionManager是否能夠處理傳遞的ConfigAttribute呈現的授權請求
*/
@Override
public boolean supports(ConfigAttribute configAttribute) {
return true;
}
/**
* 表示當前AccessDecisionManager實現是否能夠爲指定的安全對象(方法調用或Web請求)提供訪問控制決策
*/
@Override
public boolean supports(Class<?> aClass) {
return true;
}
}
此方法中的:
authentication包含了當前用戶的相關信息
Object o其實就是FilterInvocation對象,可以通過它來獲取HttpServletRequest等...所以如果要簡寫的話可以把②③中的內容挪到此處,但不推薦,不規範,雖然可以減少代碼.
collection其實就是②中的請求url對應的roleNameList.
至此,步驟二完成.
第三步:全局配置
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private MyUserDetailService userDetailService;
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
//校驗用戶
auth.userDetailsService(userDetailService).passwordEncoder(new PasswordEncoder() {
//對密碼進行加密
@Override
public String encode(CharSequence charSequence) {
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()//其餘請求統統要走spring-security的攔截
.and()
.formLogin().loginPage("/login").failureUrl("/login-error")//登錄失敗
.and()
.exceptionHandling().accessDeniedPage("/401");//權限異常時的跳轉頁面
http.logout().logoutSuccessUrl("/");
}
}
configureGlobal中主要配置我們自定義的UserDetailService,以及對密碼的加密解密,可以看到,Spring-security對加密解密的支持非常友好,不需要你再去花大量筆墨去寫工具類.
configure方法主要配置一些攔截和跳轉信息
幾個靜態頁面我就不貼了,太浪費篇幅,有需要可以去Git拉取
現在我們可以測試一下:
@Controller
public class SecurityController {
@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";
}
}
啓動項目後測試符合預期:登錄user普通用戶賬號,訪問公共頁面被允許,受保護頁面被拒絕.登錄admin用戶則可不受限制.
至此,Spring-security的部分先告一段落.
2.Apache Shiro
shiro的配置和使用都比較簡單,爲了演示更簡單,我這裏省略從數據庫查詢的操作,用Map來模擬.
新建一個Springboot的子工程,先來看一下整體的結構:
第一步,引入shiro依賴,其它web,jpa等相關依賴請自行引入,完整代碼可以從本篇開頭那裏的Git倉拉取.
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.4.2</version>
</dependency>
第二步,創建User等類對象,因爲shiro也是RBAC的,比較簡單,我就不貼了.
第三步,創建自定義Reaml繼承自AuthorizingRealm,並覆寫doGetAuthorizationInfo方法和doGetAuthorticationInfo方法
public class CustomRealm extends AuthorizingRealm {
/**
* 處理授權
*
* @param principalCollection
* @return
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
String name = principalCollection.getPrimaryPrincipal().toString();
User user = getUserByName(name);
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
user.getRoles().forEach(role -> {
//添加角色
authorizationInfo.addRole(role.getRoleName());
//添加權限
role.getPermission().forEach(permission -> {
authorizationInfo.addStringPermission(permission.getPermissionName());
});
});
return authorizationInfo;
}
/**
* 處理認證
*
* @param authenticationToken
* @return
* @throws AuthenticationException
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
String name = authenticationToken.getPrincipal().toString();
User user = getUserByName(name);
return new SimpleAuthenticationInfo(name, user.getPassword(), getName());
}
private User getUserByName(String name) {
//模擬數據庫查詢
Permission permission1 = new Permission(1L, "common");
Permission permission2 = new Permission(2L, "private");
Set<Permission> permissionSet1 = new HashSet<>();
permissionSet1.add(permission1);
Set<Permission> permissionSet2 = new HashSet<>();
permissionSet2.add(permission1);
permissionSet2.add(permission2);
Role role1 = new Role(1L, "ordinary", permissionSet1);
Role role2 = new Role(2L, "admin", permissionSet2);
Set<Role> roleSet1 = new HashSet<>();
roleSet1.add(role1);
Set<Role> roleSet2 = new HashSet<>();
roleSet2.add(role1);
roleSet2.add(role2);
User user1 = new User(1L, "user", "123456", "abc", roleSet1);
User user2 = new User(2L, "admin", "123456", "def", roleSet2);
Map<String, User> map = new HashMap<>(3);
map.put(user1.getUsername(), user1);
map.put(user2.getUsername(), user2);
return map.get(name);
}
}
其中doGetAuthorizationInfo方法負責封裝權限信息,doGetAuthorticationInfo負責封裝認證(賬戶名,密碼等)信息
第四步,全局配置
@Configuration
public class ShiroConfig {
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
Map<String, String> map = new HashMap<>();
//登出
map.put("/logout", "logout");
//對所有用戶認證
map.put("/**", "authc");
//登錄
shiroFilterFactoryBean.setLoginUrl("/login");
//首頁
shiroFilterFactoryBean.setSuccessUrl("/index");
//錯誤頁面,認證不通過跳轉
shiroFilterFactoryBean.setUnauthorizedUrl("/error");
shiroFilterFactoryBean.setFilterChainDefinitionMap(map);
return shiroFilterFactoryBean;
}
//配置自定義的Realm
@Bean
public CustomRealm customRealm(){
return new CustomRealm();
}
@Bean
public SecurityManager securityManager(){
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(customRealm());
return securityManager;
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}
//解決spring aop的二次代理問題
@Bean
@ConditionalOnMissingBean
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator defaultAAP = new DefaultAdvisorAutoProxyCreator();
defaultAAP.setProxyTargetClass(true);
return defaultAAP;
}
}
配置類主要配置ShiroFilterFactoryBean,和自定義的Realm.
ShiroFilterFactoryBean負責配置默認的登錄登出以及首頁,錯誤頁面等信息.
自定義的Realm一定要設置給SecurityManager來處理,否則不生效.
編寫測試類
@Controller
public class LoginController {
@RequestMapping("/login")
@ResponseBody
public String login(User user) {
//添加用戶認證信息
Subject subject = SecurityUtils.getSubject();
UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(
user.getUsername(),
user.getPassword()
);
try {
//進行驗證,這裏可以捕獲異常,然後返回對應信息
subject.login(usernamePasswordToken);
} catch (AuthenticationException e) {
e.printStackTrace();
return "賬號或密碼錯誤!";
} catch (AuthorizationException e) {
e.printStackTrace();
return "沒有權限";
}
return "login success";
}
//註解驗角色和權限
@RequiresRoles("ordinary")
@RequiresPermissions("common")
@RequestMapping("/index")
@ResponseBody
public String index() {
return "index!";
}
@RequiresRoles("admin")
@RequiresPermissions("private")
@RequestMapping("/limit")
@ResponseBody
public String limit() {
return "limit!";
}
}
可以啓動項目後在瀏覽器輸入:
http://localhost:8080/login?username=user&password=123456
登錄普通用戶然後分別訪問index和limit接口,然後再登錄admin賬號,重複此流程並觀察,我已經測過了,結果符合預期.
至此就完成了整個shiro的演示,可以看出shiro在配置上要比springsecurity簡單很多,在springboot誕生前,相比之下的簡單程度更是不言而喻,但基礎功能上兩者不相上下,所以在早期項目中喜歡用shrio的開發者更多一些,現在這種局勢已經被逆轉,現在的主角是spring-security,所以我不想再浪費篇幅在shiro上.
在更多的場景裏,單點登錄和oauth纔是我們想要的.
關於Oauth如果想學習的極力推薦阮一峯老師的教程,真的太讚了:http://www.ruanyifeng.com/blog/2014/05/oauth_2_0.html
在上篇中我已經介紹了單點登錄和oauth的好處,這裏不再贅述,在微服務架構流行的今天,大部分上點規模的企業都會有自己的認證中心,也就是把傳統的登錄鑑權模塊單獨抽取出來,做成一個獨立的認證服務,該企業下的子應用可以直接去請求該服務,完成登錄和鑑權,具體的流程可以參照下圖(引自李衛民老師https://www.funtl.com/zh/spring-security-oauth2):
其中,客戶端就是我們具體的某個應用,甚至是瀏覽器,認證服務器就是本篇重點要講的負責登錄和鑑權的服務,資源服務器則是一些受保護的資源,也就是登錄後且具備某些權限纔可以訪問的資源.
先來看一下認證服務器的項目結構:
項目下載地址:https://github.com/laohanjianshen/spring-security-oauth2
大部分都是RBAC相關的內容,與前面講的無異,核心配置其實只有AuthorizationServerConfiguration和WebSecurityConfiguration.
AuthorizationServerConfiguration繼承並覆蓋AuthorizationServerConfigurerAdapter類中的configure方法,以此來告訴SpringSecurity,當前認證服務器要使用tokenstore來存放token,客戶端採用Jdbc方式.
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {
@Bean
@Primary
@ConfigurationProperties(prefix = "spring.datasource")
public DataSource dataSource() {
return DataSourceBuilder.create().build();
}
@Bean
public TokenStore tokenStore() {
return new JdbcTokenStore(dataSource());
}
@Bean
public ClientDetailsService jdbcClientDetailsService() {
return new JdbcClientDetailsService(dataSource());
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.tokenStore(tokenStore());
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
// 配置客戶端
clients.withClientDetails(jdbcClientDetailsService());
}
}
WebSecurityConfiguration繼承並覆寫WebSecurityConfigurerAdapter類中的configure方法,以此來告訴SpringSecurity默認的登錄及鑑權Servierce是UserDetailService,至於UserDetailService,是我們自己來實現的.
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsServiceImpl userDetailsService;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService);
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring()
.antMatchers("/oauth/check_token");
}
}
UserDetailServiceImpl類實現SpringSecurity定義的UserDetailsService接口,覆寫loadUserByUsername方法,通過用戶名從數據庫中查詢並封裝該用戶的賬號,密碼,權限等信息.
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private TbUserService tbUserService;
@Autowired
private TbPermissionService tbPermissionService;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
TbUser tbUser = tbUserService.getUserByName(s);
List<GrantedAuthority> grantedAuthorities = Lists.newArrayList();
if (Objects.nonNull(tbUser)) {
List<TbPermission> permissions = tbPermissionService.getPermissionListByUserId(tbUser.getId());
permissions.forEach(tbPermission -> {
GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(tbPermission.getEnname());
grantedAuthorities.add(grantedAuthority);
});
}
return new User(tbUser.getUsername(), tbUser.getPassword(), grantedAuthorities);
}
}
封裝好這些之後,具體的登錄和權限判定,SpringSecurity框架會幫我們去實現具體的過程,我們無需再操心後面的實現過程.
然後在資源服務器的配置文件中指定對應的認證服務器地址,就可以將認證服務器和資源服務器的聯繫建立起來.
security:
oauth2:
client:
client-id: client
client-secret: secret
access-token-uri: http://localhost:8080/oauth/token
user-authorization-uri: http://localhost:8080/oauth/authorize
resource:
token-info-uri: http://localhost:8080/oauth/check_token
然後分別啓動客戶端和服務端,然後進行測試:
首先直接訪問資源服務器,這時候系統提示我沒有登錄或沒有權限
然後訪問認證服務器進行授權:
授權完成後,會跳轉到一個backUrl,並攜帶一個code,通過此code我們可以申請到訪問資源服務器的token令牌
通過此code+clientId+client secret即可獲取到token
然後我們在訪問資源服務器時,攜帶該token就可以正確訪問資源了:
上面爲了演示和幫助理解,把部分步驟拆分開來了,在實際業務中,過程更爲簡化,完整的過程是:
①用戶請求資源服務器->②如果用戶未登錄或未授權->③跳轉至授權頁面->④授權成功後頒發令牌並攜帶該令牌跳轉至資源服務器->⑤資源服務器請求認證服務器判定該令牌是否有效->⑥有效即放行讓用戶訪問資源.
①~⑥中用戶可見的步驟只有①③④⑥,其它步驟都由後臺自動完成.
登錄和鑑權幾乎是每個系統必備的,但在實際開發中接觸的卻比較少,因爲大部分公司都有現成的輪子,所以關於登錄鑑權這塊平時開發的極少,所以特意拎出來再複習一遍.
最後特別感謝阮一峯老師和李衛民老師,能給予一些學習和參考的資料,收穫頗多.