技術背景
到目前為止,我們使用的權限認證框架是 Shiro,雖然 Shiro 也足夠好用並且簡單,但對於 Spring 官方主推的安全框架 Spring Security,用戶群也是甚大的,所以我們這里把當前的代碼切分出一個 shiro-cloud 分支,作為 Shiro + Spring Cloud 技術的分支代碼,dev 和 master 分支將替換為 Spring Security + Spring Cloud 的技術棧,並在后續計划中集成 Spring Security OAuth2 實現單點登錄功能。
代碼實現
Maven依賴
移除shiro依賴,添加Spring Scurity和JWT依賴包,jwt目前的最新版本是0.9.1。
<!-- spring security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- jwt -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>${jwt.version}</version>
</dependency>
權限注解
替換Shiro的權限注解為Spring Security的權限注解。
格式如下:
@PreAuthorize("hasAuthority('sys:menu:view')")
SysMenuController.java
package com.louis.kitty.admin.controller;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.louis.kitty.admin.model.SysMenu;
import com.louis.kitty.admin.sevice.SysMenuService;
import com.louis.kitty.core.http.HttpResult;
/**
* 菜單控制器
* @author Louis
* @date Oct 29, 2018
*/
@RestController
@RequestMapping("menu")
public class SysMenuController {
@Autowired
private SysMenuService sysMenuService;
@PreAuthorize("hasAuthority('sys:menu:add') AND hasAuthority('sys:menu:edit')")
@PostMapping(value="/save")
public HttpResult save(@RequestBody SysMenu record) {
return HttpResult.ok(sysMenuService.save(record));
}
@PreAuthorize("hasAuthority('sys:menu:delete')")
@PostMapping(value="/delete")
public HttpResult delete(@RequestBody List<SysMenu> records) {
return HttpResult.ok(sysMenuService.delete(records));
}
@PreAuthorize("hasAuthority('sys:menu:view')")
@GetMapping(value="/findNavTree")
public HttpResult findNavTree(@RequestParam String userName) {
return HttpResult.ok(sysMenuService.findTree(userName, 1));
}
@PreAuthorize("hasAuthority('sys:menu:view')")
@GetMapping(value="/findMenuTree")
public HttpResult findMenuTree() {
return HttpResult.ok(sysMenuService.findTree(null, 0));
}
}
Spring Security注解默認是關閉的,可以通過在配置類添加以下注解開啟。
@EnableGlobalMethodSecurity(prePostEnabled = true)
安全配置
添加安全配置類, 繼承 WebSecurityConfigurerAdapter,配置URL驗證策略和相關過濾器以及自定義的登錄驗證組件。
WebSecurityConfig.java
package com.louis.kitty.admin.config;
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.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler;
import com.louis.kitty.admin.security.JwtAuthenticationFilter;
import com.louis.kitty.admin.security.JwtAuthenticationProvider;
/**
* Spring Security Config
* @author Louis
* @date Nov 20, 2018
*/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
// 使用自定義身份驗證組件
auth.authenticationProvider(new JwtAuthenticationProvider(userDetailsService));
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// 禁用 csrf, 由於使用的是JWT,我們這里不需要csrf
http.cors().and().csrf().disable()
.authorizeRequests()
// 跨域預檢請求
.antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
// web jars
.antMatchers("/webjars/**").permitAll()
// 查看SQL監控(druid)
.antMatchers("/druid/**").permitAll()
// 首頁和登錄頁面
.antMatchers("/").permitAll()
.antMatchers("/login").permitAll()
// swagger
.antMatchers("/swagger-ui.html").permitAll()
.antMatchers("/swagger-resources").permitAll()
.antMatchers("/v2/api-docs").permitAll()
.antMatchers("/webjars/springfox-swagger-ui/**").permitAll()
// 驗證碼
.antMatchers("/captcha.jpg**").permitAll()
// 服務監控
.antMatchers("/actuator/**").permitAll()
// 其他所有請求需要身份認證
.anyRequest().authenticated();
// 退出登錄處理器
http.logout().logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler());
// 登錄認證過濾器
http.addFilterBefore(new JwtAuthenticationFilter(authenticationManager()), UsernamePasswordAuthenticationFilter.class);
}
@Bean
@Override
public AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}
}
登錄驗證組件
繼承 DaoAuthenticationProvider, 實現自定義的登錄驗證組件,覆寫密碼驗證邏輯。
JwtAuthenticationProvider.java
package com.louis.kitty.admin.security;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import com.louis.kitty.admin.util.PasswordEncoder;
/**
* 身份驗證提供者
* @author Louis
* @date Nov 20, 2018
*/
public class JwtAuthenticationProvider extends DaoAuthenticationProvider {
public JwtAuthenticationProvider(UserDetailsService userDetailsService) {
setUserDetailsService(userDetailsService);
}
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
if (authentication.getCredentials() == null) {
logger.debug("Authentication failed: no credentials provided");
throw new BadCredentialsException(messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
String presentedPassword = authentication.getCredentials().toString();
String salt = ((JwtUserDetails) userDetails).getSalt();
// 覆寫密碼驗證邏輯
if (!new PasswordEncoder(salt).matches(userDetails.getPassword(), presentedPassword)) {
logger.debug("Authentication failed: password does not match stored value");
throw new BadCredentialsException(messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
}
}
用戶認證信息查詢組件
實現 UserDetailsService 接口,定義用戶認證信息查詢組件,用於獲取認證所需的用戶信息和授權信息。
UserDetailsServiceImpl.java
package com.louis.kitty.admin.security;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import com.louis.kitty.admin.model.SysUser;
import com.louis.kitty.admin.sevice.SysUserService;
/**
* 用戶登錄認證信息查詢
* @author Louis
* @date Nov 20, 2018
*/
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private SysUserService sysUserService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SysUser user = sysUserService.findByName(username);
if (user == null) {
throw new UsernameNotFoundException("該用戶不存在");
}
// 用戶權限列表,根據用戶擁有的權限標識與如 @PreAuthorize("hasAuthority('sys:menu:view')") 標注的接口對比,決定是否可以調用接口
Set<String> permissions = sysUserService.findPermissions(user.getName());
List<GrantedAuthority> grantedAuthorities = permissions.stream().map(GrantedAuthorityImpl::new).collect(Collectors.toList());
return new JwtUserDetails(user.getName(), user.getPassword(), user.getSalt(), grantedAuthorities);
}
}
用戶認證信息封裝
上面 UserDetailsService 查詢的信息需要封裝到實現 UserDetails 接口的封裝對象里。
JwtUserDetails.java
package com.louis.kitty.admin.security;
import java.util.Collection;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import com.fasterxml.jackson.annotation.JsonIgnore;
/**
* 安全用戶模型
* @author Louis
* @date Nov 20, 2018
*/
public class JwtUserDetails implements UserDetails {
private static final long serialVersionUID = 1L;
private String username;
private String password;
private String salt;
private Collection<? extends GrantedAuthority> authorities;
JwtUserDetails(String username, String password, String salt, Collection<? extends GrantedAuthority> authorities) {
this.username = username;
this.password = password;
this.salt = salt;
this.authorities = authorities;
}
@Override
public String getUsername() {
return username;
}
@JsonIgnore
@Override
public String getPassword() {
return password;
}
public String getSalt() {
return salt;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@JsonIgnore
@Override
public boolean isAccountNonExpired() {
return true;
}
@JsonIgnore
@Override
public boolean isAccountNonLocked() {
return true;
}
@JsonIgnore
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@JsonIgnore
@Override
public boolean isEnabled() {
return true;
}
}
登錄接口
因為我們沒有使用內置的 formLogin 登錄處理過濾器,所以需要調用登錄認證流程,修改登錄接口,加入系統登錄認證調用。
SysLoginController.java
/**
* 登錄接口
*/
@PostMapping(value = "/login")
public HttpResult login(@RequestBody LoginBean loginBean, HttpServletRequest request) throws IOException {
String username = loginBean.getAccount();
String password = loginBean.getPassword();
String captcha = loginBean.getCaptcha();...
// 系統登錄認證
JwtAuthenticatioToken token = SecurityUtils.login(request, username, password, authenticationManager);
return HttpResult.ok(token);
}
Spring Security 的登錄認證過程是通過調用 AuthenticationManager 的 authenticate(token) 方法實現的。
登錄流程中主要是返回一個認證好的 Authentication 對象,然后保存到上下文供后續進行授權的時候使用。
登錄認證成功之后,會利用JWT生成 token 返回給客戶端,后續的訪問都需要攜帶此 token 來進行認證。
SecurityUtils.java
/**
* 系統登錄認證
* @param request
* @param username
* @param password
* @param authenticationManager
* @return
*/
public static JwtAuthenticatioToken login(HttpServletRequest request, String username, String password, AuthenticationManager authenticationManager) {
JwtAuthenticatioToken token = new JwtAuthenticatioToken(username, password);
token.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// 執行登錄認證過程
Authentication authentication = authenticationManager.authenticate(token);
// 認證成功存儲認證信息到上下文
SecurityContextHolder.getContext().setAuthentication(authentication);
// 生成令牌並返回給客戶端
token.setToken(JwtTokenUtils.generateToken(authentication));
return token;
}
令牌生成器
令牌生成器主要是利用JWT生成所需的令牌,部分代碼如下。
JwtTokenUtils.java
/**
* JWT工具類
* @author Louis
* @date Nov 20, 2018
*/
public class JwtTokenUtils implements Serializable {
/**
* 生成令牌
* @param userDetails 用戶
* @return 令牌
*/
public static String generateToken(Authentication authentication) {
Map<String, Object> claims = new HashMap<>(3);
claims.put(USERNAME, SecurityUtils.getUsername(authentication));
claims.put(CREATED, new Date());
claims.put(AUTHORITIES, authentication.getAuthorities());
return generateToken(claims);
}
/**
* 從數據聲明生成令牌
* @param claims 數據聲明
* @return 令牌
*/
private static String generateToken(Map<String, Object> claims) {
Date expirationDate = new Date(System.currentTimeMillis() + EXPIRE_TIME);
return Jwts.builder().setClaims(claims).setExpiration(expirationDate).signWith(SignatureAlgorithm.HS512, SECRET).compact();
}
}
登錄認證過濾器
登錄認證過濾器繼承 BasicAuthenticationFilter,在訪問任何URL的時候會被此過濾器攔截,通過調用 SecurityUtils.checkAuthentication(request) 檢查登錄狀態。
JwtAuthenticationFilter.java
package com.louis.kitty.admin.security;
import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import com.louis.kitty.admin.util.SecurityUtils;
/**
* 登錄認證過濾器
* @author Louis
* @date Nov 20, 2018
*/
public class JwtAuthenticationFilter extends BasicAuthenticationFilter {
@Autowired
public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
// 獲取token, 並檢查登錄狀態
SecurityUtils.checkAuthentication(request);
chain.doFilter(request, response);
}
}
登錄認證檢查
登錄驗證檢查是通過 SecurityUtils.checkAuthentication(request) 來完成的。
SecurityUtils.java
/**
* 獲取令牌進行認證
* @param request
*/
public static void checkAuthentication(HttpServletRequest request) {
// 獲取令牌並根據令牌獲取登錄認證信息
Authentication authentication = JwtTokenUtils.getAuthenticationeFromToken(request);
// 設置登錄認證信息到上下文
SecurityContextHolder.getContext().setAuthentication(authentication);
}
上面的登錄驗證是通過 JwtTokenUtils.getAuthenticationeFromToken(request),來驗證令牌並返回登錄信息的。
JwtTokenUtils.java
/**
* 根據請求令牌獲取登錄認證信息
* @param token 令牌
* @return 用戶名
*/
public static Authentication getAuthenticationeFromToken(HttpServletRequest request) {
Authentication authentication = null;
// 獲取請求攜帶的令牌
String token = JwtTokenUtils.getToken(request);
if(token != null) {
// 請求令牌不能為空
if(SecurityUtils.getAuthentication() == null) {
// 上下文中Authentication為空
Claims claims = getClaimsFromToken(token);
if(claims == null) {
return null;
}
String username = claims.getSubject();
if(username == null) {
return null;
}
if(isTokenExpired(token)) {
return null;
}
Object authors = claims.get(AUTHORITIES);
List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
if (authors != null && authors instanceof List) {
for (Object object : (List) authors) {
authorities.add(new GrantedAuthorityImpl((String) ((Map) object).get("authority")));
}
}
authentication = new JwtAuthenticatioToken(username, null, authorities, token);
} else {
if(validateToken(token, SecurityUtils.getUsername())) {
// 如果上下文中Authentication非空,且請求令牌合法,直接返回當前登錄認證信息
authentication = SecurityUtils.getAuthentication();
}
}
}
return authentication;
}
清除Shiro配置
清除掉 config 包下的 ShiroConfig 配置類。
清除 oautho2 包下有關 Shiro 的相關代碼。
清除掉 sys_token 表和相關操作代碼。

