在线演示
演示地址:http://139.196.87.48:9002/kitty
用户名:admin 密码:admin
技术背景
到目前为止,我们使用的权限认证框架是 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 表和相关操作代码。