git上完整的项目:https://github.com/yyrely/security-demo
好久没有写博客了,最近在写项目的时候,经常要去判断当前登录用户的权限再决定他是否是否可以操作这个逻辑。整个项目下来,这样的操作不要太多,一直重复的写一个逻辑真的很没有营养。因为Spring Security也是Spring全家桶中的一员,有空就决定研究一下了。话不多说,开始整合吧。
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-securityartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>mysqlgroupId>
<artifactId>mysql-connector-javaartifactId>
<version>5.1.41version>
<scope>runtimescope>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-jdbcartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
<dependency>
<groupId>org.apache.commonsgroupId>
<artifactId>commons-pool2artifactId>
dependency>
<dependency>
<groupId>org.mybatis.spring.bootgroupId>
<artifactId>mybatis-spring-boot-starterartifactId>
<version>1.3.2version>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
<scope>providedscope>
dependency>
dao层用的是mybatis
前后端分类,登录为无状态,使用redis对token进行存储
Entity
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Users {
private Long id;
private String username;
private String password;
private String role;
}
Controller
@RestController
@RequestMapping("/users")
public class UsersController {
private final UsersService usersService;
public UsersController(UsersService usersService) {
this.usersService = usersService;
}
@GetMapping("/test/{username}")
public Object test(@PathVariable("username") String username) {
Users user = usersService.findUserByUsername(username);
return "hello world" + user.getUsername();
}
}
Service
@Service
public class UsersServiceImpl implements UsersService {
private final UsersMapper usersMapper;
public UsersServiceImpl(UsersMapper usersMapper) {
this.usersMapper = usersMapper;
}
@Override
public Users findUserByUsername(String username) {
return usersMapper.findUserByUsername(username);
}
}
Mapper
@Mapper
public interface UsersMapper {
/**
* 根据用户名获取信息
* @param username
* @return
*/
Users findUserByUsername(String username);
}
Mapper.xml
<mapper namespace="com.security.mapper.UsersMapper">
<select id="findUserByUsername" resultType="com.security.entity.Users">
select *
from user
where username = #{username}
select>
mapper>
表结构
CREATE TABLE `user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`username` varchar(30) NOT NULL COMMENT '姓名',
`password` varchar(100) NOT NULL COMMENT '密码',
`role` varchar(30) NOT NULL COMMENT '角色',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8;
结果
首个hello world就完成啦,继续整合和权限有关的部分
对于权限框架的了解,是分为两步的,一步是授权,一步是鉴权
继承UsernamePasswordAuthenticationFilter,重写他的attemptAuthentication()方法
authenticationManager在整个项目中只能存在一个,是在配置类中传入的,后面会说到
super.setFilterProcessesUrl("/users/login");可设置登录使用的Url,在配置类中设置无效(求大神解答),经过测试只有在构造器中设置才生效。猜想是过滤器覆盖的原因
public class TokenAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
//Redis模板
private StringRedisTemplate redisTemplate;
//spring自带的Jackson
private ObjectMapper objectMapper;
private AuthenticationManager authenticationManager;
public TokenAuthenticationFilter(AuthenticationManager authenticationManager, StringRedisTemplate redisTemplate, ObjectMapper objectMapper) {
this.authenticationManager = authenticationManager;
this.objectMapper = objectMapper;
this.redisTemplate = redisTemplate;
super.setFilterProcessesUrl("/users/login");
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
try {
//从body中取出,参数是在请求体中使用json形式传入的
Users users = objectMapper.readValue(request.getInputStream(), Users.class);
//模仿UsernamePasswordAuthenticationFilter的方式,将用户名密码进行比较
return authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(users.getUsername(), users.getPassword()));
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
}
在鉴权的过程中会调用UserDetailsService接口的loadUserByUsername(String username)方法
我们使用自己的类来实现该接口,用户信息可从数据库中取出
@Service
public class UserDetailServiceImpl implements UserDetailsService {
private final UsersMapper usersMapper;
public UserDetailServiceImpl(UsersMapper usersMapper) {
this.usersMapper = usersMapper;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Users users = usersMapper.findUserByUsername(username);
return new SUser(users);
}
}
返回的结果是UserDetails类型的,这也需要我们自己来实现
public class SUser implements UserDetails {
private String username;
private String password;
private Collection<? extends GrantedAuthority> authorities;
public SUser(Users users) {
this.username = users.getUsername();
this.password = users.getPassword();
this.authorities = Collections.singleton(new SimpleGrantedAuthority(users.getRole()));
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
// 账号是否未过期,默认是false,记得要改一下
@Override
public boolean isAccountNonExpired() {
return true;
}
// 账号是否未锁定,默认是false,记得也要改一下
@Override
public boolean isAccountNonLocked() {
return true;
}
// 账号凭证是否未过期,默认是false,记得还要改一下
@Override
public boolean isCredentialsNonExpired() {
return true;
}
// 这个有点抽象不会翻译,默认也是false,记得改一下
@Override
public boolean isEnabled() {
return true;
}
}
这样一来自己定义授权逻辑就结束了
授权成功,授权失败又应该如何呢
继承UsernamePasswordAuthenticationFilter的方法还可以实现successfulAuthentication(),unsuccessfulAuthentication()两个方法来进行操作
授权成功:authResult.getPrincipal()的对象就是我们自己实现UserDetails接口的类,将信息取出存入redis中,存了token信息和username信息两条是用来注销时删除记录用的。
(记录一个问题:本来redis中存的是一整个SUser对象的json串,jackson在序列化的时候是可以的,反序列化就会因为SUser对象的private Collection extends GrantedAuthority> authorities;属性而失败,有大神有解吗?所以最后退而求次,存了用户名)
授权失败:返回json提示信息
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
SUser user = (SUser) authResult.getPrincipal();
String uuid = UUID.randomUUID().toString();
redisTemplate.opsForValue().set("SECURITY_TOKEN : " + uuid, user.getUsername());
redisTemplate.opsForValue().set("SECURITY_USERNAME : " + user.getUsername(), uuid);
redisTemplate.expire("SECURITY_TOKEN : " + uuid, 60, TimeUnit.MINUTES);
redisTemplate.expire("SECURITY_USERNAME : " + user.getUsername(), 60, TimeUnit.MINUTES);
response.setStatus(200);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().print("{\n" +
" \"code\": 1,\n" +
" \"data\": {\n" +
" \"username\": \""+user.getUsername()+"\",\n" +
" \"token\": \""+uuid+"\"\n" +
" }\n" +
"}");
response.flushBuffer();
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
response.setStatus(400);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().print("{\"code\":\"400\", \n \"msg\":\"用户名或密码不正确,登陆失败\"}");
response.flushBuffer();
}
继承OncePerRequestFilter ,改过滤器会过滤每一个请求
上面因为redis中只存了用户名,所以在一个过滤器中还需要和数据库进行一次交互
@Component
public class TokenAuthorizationFilter extends OncePerRequestFilter {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private UserDetailServiceImpl userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
//从请求头中取出token
String token = request.getHeader("token");
if(!StringUtils.isEmpty(token)) {
if (SecurityContextHolder.getContext().getAuthentication() == null) {
//从redis中获取用户名
String username = redisTemplate.opsForValue().get("SECURITY_TOKEN : "+ token);
//从数据库中根据用户名获取用户
UserDetails sUser = userDetailsService.loadUserByUsername(username);
if (sUser != null) {
//解析并设置认证信息(具体实现不清楚)
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(sUser, null, sUser.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
}
chain.doFilter(request, response);
}
}
@Component
public class TokenAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.setStatus(200);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().print("{\"code\":\"405\", \n \"msg\":\"用户未登录\"}");
response.flushBuffer();
}
}
@Component
public class TokenAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException {
httpServletResponse.setStatus(403);
httpServletResponse.setContentType("application/json;charset=UTF-8");
httpServletResponse.getWriter().print("{\"code\":\"403\", \n \"msg\":\"权限不足\"}");
httpServletResponse.flushBuffer();
}
}
配置spring security的类
配置的东西比较多直接在代码的注释中进行解释了,贴代码
@Configuration
//开启spring security的注解,也可加在spring boot的main函数上,切记加上
@EnableWebSecurity
public class SpringSecurityConf extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailServiceImpl userDetailsService;
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ObjectMapper objectMapper;
/**
* 权限鉴定过滤器
*/
@Autowired
private TokenAuthorizationFilter authorizationFilter;
/**
* 未登录结果处理
*/
@Autowired
private TokenAuthenticationEntryPoint authenticationEntryPoint;
/**
* 权限不足结果处理
*/
@Autowired
private TokenAccessDeniedHandler accessDeniedHandler;
/**
* 用户注销成功处理器
*/
@Autowired
private TokenLogoutSuccessHandler logoutSuccessHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭session,不再使用
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.csrf().disable()
//未登录结果处理
.httpBasic().authenticationEntryPoint(authenticationEntryPoint)
.and()
//权限不足结果处理
.exceptionHandling().accessDeniedHandler(accessDeniedHandler)
.and()
//权限设置管理
.authorizeRequests()
//放行以下url
.antMatchers("/users/register","/users/test/*").permitAll()
//给对应的url设置权限(只有ADMIN才可以访问,除去ROLE_前缀,spring帮我们处理了)
//在数据库中用户的role字段是要加ROLE_的ROLE_ADMIN才可以匹配到这里的ADMIN
.antMatchers("/users/lala/**").hasRole("ADMIN")
//所有请求都需要授权(除了放行的)
.anyRequest().authenticated()
.and()
//设置登出url
.logout().logoutUrl("/users/logout")
//设置登出成功处理器(下面介绍)
.logoutSuccessHandler(logoutSuccessHandler)
.and()
//开启登录
.formLogin();
//在UsernamePasswordAuthenticationFilter之前增加鉴权过滤器(需要修改,下面注销时解释)
http.addFilterBefore(authorizationFilter, UsernamePasswordAuthenticationFilter.class);
//用自定义的授权过滤器覆盖UsernamePasswordAuthenticationFilter
http.addFilterAt(createTokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}
/**
* 登陆授权过滤器
* @return
* @throws Exception
*/
private TokenAuthenticationFilter createTokenAuthenticationFilter() throws Exception {
//authenticationManagerBean()是调用WebSecurityConfigurerAdapter中的,创建环境中唯一的authenticationManager
return new TokenAuthenticationFilter(authenticationManagerBean(), redisTemplate, objectMapper);
}
//密码加密器,在授权时,框架为我们解析用户名密码时,密码会通过加密器加密在进行比较
//将密码加密器交给spring管理,在注册时,密码也是需要加密的,再存入数据库中
//用户输入登录的密码用加密器加密,再与数据库中查询到的用户密码比较
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder(){
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
//设置使用自己实现的userDetailsService(loadUserByUsername)
.userDetailsService(userDetailsService)
//设置密码加密方式
.passwordEncoder(bCryptPasswordEncoder());
}
}
经过以上配置,授权,鉴权都已经实现了,我们来看看结果吧
登录成功
登录失败
用户未登录
用户权限不足
请求成功(我们设置的需要ADMIN权限)
以上结果都完美了,最后再看看登出吧
在配置类中开启登出并设置了登出成功的类TokenLogoutSuccessHandler,实现LogoutSuccessHandler
@Component
public class TokenLogoutSuccessHandler implements LogoutSuccessHandler {
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
SUser user = (SUser) authentication.getPrincipal();
String token = redisTemplate.opsForValue().get("SECURITY_USERNAME : " + user.getUsername());
redisTemplate.expire("SECURITY_USERNAME : " + user.getUsername(), 0, TimeUnit.MICROSECONDS);
redisTemplate.expire("SECURITY_TOKEN : " + token, 0, TimeUnit.MICROSECONDS);
response.setStatus(200);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().print("{\"code\":\"1\", \n \"msg\":\"用户退出成功\"}");
response.flushBuffer();
}
}
在请求localhost:8090//users/logout时,会进入这个方法,但是方法中的Authentication authentication参数为null,因为这个请求没有走鉴权的过滤器,下面的这个代码就没执行,自然authentication就为空,将误被认为是用户未登录而返回。
SecurityContextHolder.getContext().setAuthentication(authentication);
这个坑是怎么回事呢?一个继承了OncePerRequestFilter的过滤器居然没有过滤到这个请求?
一顿乱找,给我找到了,spring security过滤器链的加载顺序
Alias | Filter Class | Namespace Element or Attribute |
---|---|---|
CHANNEL_FILTER | ChannelProcessingFilter | http/intercept-url@requires-channel |
SECURITY_CONTEXT_FILTER | SecurityContextPersistenceFilter | http |
CONCURRENT_SESSION_FILTER | ConcurrentSessionFilter | session-management/concurrency-control |
CSRF_FILTER | CsrfFilter | http/csrf |
LOGOUT_FILTER | LogoutFilter | http/logout |
X509_FILTER | X509AuthenticationFilter | http/x509 |
PRE_AUTH_FILTER | AbstractPreAuthenticatedProcessingFilter Subclasses | N/A |
CAS_FILTER | CasAuthenticationFilter | N/A |
FORM_LOGIN_FILTER | UsernamePasswordAuthenticationFilter | http/form-login |
BASIC_AUTH_FILTER | BasicAuthenticationFilter | http/http-basic |
SERVLET_API_SUPPORT_FILTER | SecurityContextHolderAwareRequestFilter | http/@servlet-api-provision |
JAAS_API_SUPPORT_FILTER | JaasApiIntegrationFilter | http/@jaas-api-provision |
REMEMBER_ME_FILTER | RememberMeAuthenticationFilter | http/remember-me |
ANONYMOUS_FILTER | AnonymousAuthenticationFilter | http/anonymous |
SESSION_MANAGEMENT_FILTER | SessionManagementFilter | session-management |
EXCEPTION_TRANSLATION_FILTER | ExceptionTranslationFilter | http |
FILTER_SECURITY_INTERCEPTOR | FilterSecurityInterceptor | http |
SWITCH_USER_FILTER | SwitchUserFilter | N/A |
顺序是从上到下的
之前的配置,我们是将鉴权的过滤器放在UsernamePasswordAuthenticationFilter之前的
但是,从表中我们可以看到LogoutFilter远远比UsernamePasswordAuthenticationFilter先执行了
http.addFilterBefore(authorizationFilter, UsernamePasswordAuthenticationFilter.class);
这也就是为什么我们没有鉴权就直接登出了,所以配置要改一下,鉴权要在登出之前
放在第一个我觉得也是可以的
http.addFilterBefore(authorizationFilter, LogoutFilter.class);
这样也完美登出了,redis中的数据记录也在登出成功后删除
整个授权,鉴权,再登出的过程就实现了,有错希望有大神指出
参考文章一
参考文章二
参考文章三