上篇我们通过扫描注解自动生成了菜单,本篇我们通过Spring Security来加上权限验证。
接上篇 https://www.jianshu.com/p/ef5854cce4eb
Spring Security配置
Spring Security 是Spring提供的安全控制组件,它本身提供了很多功能,我们目前用不到,Spring Security支持通过配置来启用、禁用这些功能。本文打算实现基于Token的认证、授权模式,先对Spring Security进行配置。
Spring Security的详细使用可以在网上搜索相关资料。
@Configuration
@EnableWebSecurity
public class MVCWebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
public void configure(WebSecurity web) throws Exception {
super.configure(web);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.headers().frameOptions().disable();
http
.exceptionHandling()
//未登录时的handler
.authenticationEntryPoint(new UnauthenticatedEntryPoint())
//无权限时的handler
.accessDeniedHandler(new UnauthorizedAccessDeniedHandler())
.and()
//如果是微服务 需要禁用session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
//不关心跨域
.csrf().disable()
//我们自己实现匿名
.anonymous().disable()
//我们自己实现登录
.formLogin().disable()
//我们自己实现注销
.logout().disable()
//业务逻辑
.addFilterAt(new TokenAuthenticationFilter(), RememberMeAuthenticationFilter.class)
;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
super.configure(auth);
}
}
核心部分
登录和鉴权
我们先实现一个 Token Principal类,这个类最终也是 JSP的 userPrincipal
。
public class AuthorityAuthenticationToken extends AbstractAuthenticationToken {
private String principal;
public AuthorityAuthenticationToken(String principal, Collection extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
setAuthenticated(true);
}
public AuthorityAuthenticationToken(String principal) {
super(null);
this.principal = principal;
setAuthenticated(true);
}
@Override
public Object getCredentials() {
return null; //不支持密码
}
@Override
public Object getPrincipal() {
return principal;
}
}
然后声明两个接口,分别用于认证和鉴权。
public interface ITokenProvider extends Ordered {
String getToken(HttpServletRequest request);
}
public interface ITokenConverter extends Ordered {
AuthorityAuthenticationToken decodeToken(String token);
}
这两个接口继承了org.springframework.core.Ordered
接口,然后我们实现一个过滤器来进行登录。
public class TokenAuthenticationFilter implements Filter {
private ITokenProvider[] providers;
private ITokenConverter[] converters;
private AuthenticationDetailsSource authenticationDetailsSource = new WebAuthenticationDetailsSource();
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
private final AnonymousAuthenticationToken anonymousUser = new AnonymousAuthenticationToken(
UUID.randomUUID().toString(), "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")
);
private String getToken(HttpServletRequest request) {
if (providers == null) { //幂等的 所以没加锁
providers = SpringApplicationContextHolder.getInstance()
.getBeansOfType(ITokenProvider.class)
.values().stream()
.sorted(Comparator.comparingInt(ITokenProvider::getOrder)) //有序
.toArray(ITokenProvider[]::new);
}
for (ITokenProvider provider : providers) {
String token = provider.getToken(request);
if (StringUtils.hasText(token)) //取第一个成功的
return token;
}
return null;
}
private AuthorityAuthenticationToken decodeToken(String token) {
if (StringUtils.isEmpty(token))
return null;
if (converters == null) {
converters = SpringApplicationContextHolder.getInstance()
.getBeansOfType(ITokenConverter.class)
.values().stream()
.sorted(Comparator.comparingInt(ITokenConverter::getOrder)) //有序
.toArray(ITokenConverter[]::new);
}
for (ITokenConverter converter : converters) {
AuthorityAuthenticationToken u = converter.decodeToken(token);
if (u != null) //取第一个成功的
return u;
}
return null;
}
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
try {
HttpServletRequest request = (HttpServletRequest) req;
String token = getToken(request);
AuthorityAuthenticationToken authToken = null;
if (StringUtils.hasText(token)) {
authToken = decodeToken(token);
}
if (authToken != null) {
authToken.setDetails(authenticationDetailsSource.buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
} else {
SecurityContextHolder.getContext().setAuthentication(anonymousUser);
}
} catch (Exception e) {
SecurityContextHolder.getContext().setAuthentication(anonymousUser);
}
chain.doFilter(req, res);
}
@Override
public void destroy() {
}
}
这里允许 TokenProvider
和TokenConverter
同时存在多份,以便在同一个服务中支持多种授权方式。
权限校验
Spring Security的权限校验是投票机制的,默认实现了一票否决制(只要有一票不允许就不能访问)、一票允许值(与否决相对的,只要有一票允许就可以访问)、多票优胜。Spring Security默认采用的是一票允许值,我们这里采用一票否决制。
此外Spring Security的权限校验是基于注解的,我们希望能基于我们的菜单注解,所以需要在投票之前进行注解数据转换,在这里我们采用一种简单粗暴的办法,用一个Wrapper把投票器包起来,在Wrapper中进行数据转换。
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MVCMethodSecurityConfig extends GlobalMethodSecurityConfiguration {
@Override
protected AccessDecisionManager accessDecisionManager() {
AbstractAccessDecisionManager decisionManager = (AbstractAccessDecisionManager)super.accessDecisionManager();
//wrapper和一票否决 虽然其实只有一票
return new AccessDecisionManagerWrapper(new UnanimousBased(decisionManager.getDecisionVoters()));
}
}
Wrapper类
public class AccessDecisionManagerWrapper implements AccessDecisionManager {
private AbstractAccessDecisionManager wrapped;
private Logger logger = LoggerFactory.getLogger(AccessDecisionManagerWrapper.class);
private AuthorizeAttributeLoader loader = new AuthorizeAttributeLoader();
public AccessDecisionManagerWrapper(AbstractAccessDecisionManager wrapped) {
this.wrapped = wrapped;
}
private boolean isAnonymous(Authentication authentication) {
return (authentication instanceof AnonymousAuthenticationToken);
}
@Override
public void decide(Authentication authentication, Object object, Collection configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
if (authentication == null || !authentication.isAuthenticated()) { //啥信息都没有
wrapped.decide(authentication, object, configAttributes);
} else {
//注解数据转换
configAttributes = loader.loadAuthorizeAttribute(object, configAttributes);
wrapped.decide(authentication, object, configAttributes);
}
}
@Override
public boolean supports(ConfigAttribute attribute) {
return wrapped.supports(attribute);
}
@Override
public boolean supports(Class> clazz) {
return wrapped.supports(clazz);
}
}
注解转换部分,我们先声明一个IAuthorizeAttributeProvider
接口,以便扩展到其他功能。
public interface IAuthorizeAttributeProvider {
void loadAttribute(Class> aClass, Method method, AuthorizeAttributeContainer container);
}
public class AuthorizeAttributeLoader {
//理论上运行过程中 这个是不会变化的 缓存起来
private Map> attrCache = new ConcurrentHashMap<>();
private Class> getTargetClass(MethodInvocation mi) {
Object target = mi.getThis();
if (target != null) {
return target instanceof Class> ? (Class>) target
: AopProxyUtils.ultimateTargetClass(target);
}
return null;
}
public Collection loadAuthorizeAttribute(Object object, Collection configAttributes) {
if (object instanceof MethodInvocation) {
MethodInvocation mi = (MethodInvocation) object;
Collection result = attrCache.get(mi);
if (result == null) {
Method method = mi.getMethod();
AuthorizeAttributeContainer container = new AuthorizeAttributeContainer(configAttributes);
SpringApplicationContextHolder.getInstance()
.getBeansOfType(IAuthorizeAttributeProvider.class)
.forEach((k, v) -> v.loadAttribute(getTargetClass(mi), method, container));
result = new ArrayList<>(container.getAttributes());
attrCache.put(mi, result);
}
return result;
}
return configAttributes;
}
}
核心功能部分的操作都是幂等的,所以没有加锁,最后我们再实现未登录和权限校验失败的监听。
//这个类在MVCWebSecurityConfig注册的
public class UnauthenticatedEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
response.setStatus(HttpStatus.OK.value());
//未登录,跳转到/401
RequestDispatcher dispatcher = request.getRequestDispatcher(
"/401?redirect=" + response.encodeRedirectURL(request.getRequestURI())
);
dispatcher.forward(request, response);
}
//这个类在MVCWebSecurityConfig注册的
public class UnauthorizedAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
response.setStatus(HttpStatus.OK.value());
//没权限,跳转到403
RequestDispatcher dispatcher = request.getRequestDispatcher(
"/403?redirect=" + response.encodeRedirectURL(request.getRequestURI())
);
dispatcher.forward(request, response);
}
}
菜单部分
实现了核心部分后,我们基于核心部分扩展修改菜单部分。
注解
首先,在注解中增加权限码。
@Target({ ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@PreAuthorize("isAuthenticated()") //这个注解必须有,启用Spring Security的投票机制
public @interface MenuItem {
String label();
String icon();
String code(); //权限码
}
然后实现菜单注解到Spring Security的注解的转换。
@Component
public class MenuAuthorizeAttributeProvider implements IAuthorizeAttributeProvider {
private ExpressionBasedAnnotationAttributeFactory attributeFactory = ExpressionAttrFactoryManager.getFactory();
@Override
public void loadAttribute(Class> aClass, Method method, AuthorizeAttributeContainer container) {
MenuItem menuItem = method.getDeclaredAnnotation(MenuItem.class);
if (menuItem == null)
return;
String code = menuItem.code();
if ("*".equals(code))
return;
//实现一个扩展,管理员有所有菜单的权限
String exp;
if ("admin".equals(code)) {
exp = "hasAuthority('admin')";
} else {
//有菜单权限或是管理员
exp = "hasAuthority('" + code + "') || hasAuthority('admin')";
}
container.addAttribute(
attributeFactory.createPreInvocationAttribute(null, null, exp)
);
}
}
菜单服务
扩展菜单服务,能过滤当前用户能看到的菜单列表,由于菜单服务没有权限的信息,所以我们需要声明一个接口,由权限服务来实现。
public interface IUserAuthorityProvider {
Set getCurrentUserAuthorities();
}
//菜单服务的主要修改部分
public List getUserMenu() {
Set authorities = provider.getCurrentUserAuthorities();
if (authorities.isEmpty())
return Collections.emptyList();
List menu = getAllMenu();
if (authorities.contains("admin"))
return menu; //管理员能看到所有菜单
return menu.stream()
.filter(m -> authorities.contains(m.getCode()))
.collect(Collectors.toList());
}
测试
核心部分和菜单部分分别暴漏了3个接口,我们依次实现这3个接口。
登录验证
@Component
public class UrlParameterTokenProvider implements ITokenProvider {
@Override
public String getToken(HttpServletRequest request) {
//这里应该是从cookie或http头获取用户是否登录,token是否有效
//单纯测试,我们从URL获取
return request.getParameter("token");
}
@Override
public int getOrder() {
return 0;
}
}
鉴权
@Component
public class TestTokenDecoder implements ITokenConverter {
@Override
public AuthorityAuthenticationToken decodeToken(String token) {
if (StringUtils.isEmpty(token))
return null;
//这里是加载用户有哪些权限
List authorities = new ArrayList<>(2);
switch (token) {
case "1":
//菜单1的权限
authorities.add(new SimpleGrantedAuthority("test1"));
break;
case "2":
//菜单2的权限
authorities.add(new SimpleGrantedAuthority("test2"));
break;
case "admin":
authorities.add(new SimpleGrantedAuthority("admin"));
break;
}
return new AuthorityAuthenticationToken(token, authorities);
}
@Override
public int getOrder() {
return 0;
}
}
加载当前用户的权限
@Component
public class TestUserAuthoritiesProvider implements IUserAuthorityProvider {
@Override
public Set getCurrentUserAuthorities() {
//正常应该有业务提供这个数据,暂时从Principal获取
HttpServletRequest request = ServletHolder.getCurrentRequest();
Principal principal = request.getUserPrincipal();
if (principal == null) //未登录
return Collections.emptySet();
if (!(principal instanceof AbstractAuthenticationToken)) {
return Collections.emptySet();
}
Collection authorities = ((AbstractAuthenticationToken) principal).getAuthorities();
return authorities.stream().map(GrantedAuthority::getAuthority).collect(Collectors.toSet());
}
}
错误页面
public class ErrorController {
@RequestMapping("/401")
@ResponseBody
public String on401(String redirect) {
//应该显示登录界面
return "请登录后在访问";
}
@RequestMapping("/403")
@ResponseBody
public String on403(String redirect) {
return "您没有权限访问此功能";
}
}
完整代码见 https://github.com/giafei/spring-security-token