唠嗑部分
在项目开发中,权限认证是很重要的,尤其是一些管理类的系统,对于权限要求更为严格,那么在Java开发中,常用的权限框架有哪些呢?
推荐的有两种,Shiro 与 SpringSecurity,当然也可以结合切面自己实现
Shiro是Apache开源的一款权限框架,比较轻量级,简单容易学,但是不能在其中注入Spring中的容器Bean
SpringSecurity是Spring生态中的一个组件,比较重量级,它也整合了OAuth2协议,对于Spring框架来说,更推荐SpringSecurity
今天我们就来分享一下如何整合SpringSecurity进行认证与授权,顺便实现一下token的无感知续期
SpringSecurity进行认证与授权是SpringSecurity框架进行处理,我们就不必多说,按照步骤进行编码就OK了
token的无感知续期我们来说一下思路:
1、用户在登录成功后,由服务器下发token,有效期30分钟。
2、客户端拿到token之后,请求其余需要认证的接口时,再请求头携带token访问。
3、服务器编写过滤器,对请求头中的token进行验证,判断用户登录是否有效,于此同时,判断token有效期是否即将过期,如果即将过期,重新颁发token,如果已过期,返回401未认证状态码。
上面说到判断token有效期是否即将过期,说明一下哈
oauth2中是颁发了两个token,一个access_token(访问token),一个refresh_token(刷新token),刷新token的有效期是访问token的2倍,如果访问token过期,就拿刷新token重新申请访问token
我们只有一个token,逻辑是判断token的有效期,如果有效期小于15分钟,就刷新token,这样的话既可以实现toekn的无感知刷新
实际的token是比较长的一段字符串,标准的jwt token包括头部、载荷、签名,格式如下
eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIxIiwiaWQiOiIxIiwiYXV0aGVudGljYXRpb25zIjpbImFkbWluIl0sInVzZXJuYW1lIjoiYWRtaW4iLCJpYXQiOjE2ODg2MjI3MjMsImV4cCI6MTY4ODYyNDUyM30.xPRul6ePE1bwSe70rbo0-jPFUxU9O9MPQf9gliZ18X8
接口设计说明:
1、用户认证处理器
接口地址:/auth/login
请求方式:POST
请求头:content-type: application/json;charset=utf-8
接口功能说明:进行用户认证,颁发token,实际的token比较长,我们是生成了一个字符串充当token,实际token存在于redis中
接口参数:
{
"password": "",
"username": ""
}
出参说明:
tokenInfo:token信息,包括token的失效时间、token值、用户信息等等
user:用户信息
{
"code": 200,
"data": {
"tokenInfo": {
"expirationTime": "2023-07-06 12:25:42",
"token": "3f49e86b93c743f2865a4446a7a85398",
"user": {
"authentications": [
"ROLE_user"
],
"id": "2",
"username": "user"
}
},
"user": {
"roles": [
"ROLE_user"
],
"userId": "2",
"userStatus": 1,
"userType": 0,
"username": "admin"
}
},
"msg": "登陆成功"
}
2、用户令牌检查处理器
接口地址:/auth/checkToken
请求方式:POST
请求头: X-Access-Token
接口功能说明:token令牌检查
出参说明:
status: 检查结果
token:token信息
{
"code": 200,
"data": {
"status": true,
"token": {
"expirationTime": "2023-07-06 12:25:42",
"token": "3f49e86b93c743f2865a4446a7a85398",
"user": {
"authentications": [
"ROLE_user"
],
"id": "2",
"username": "user"
}
}
},
"msg": "操作成功"
}
3、测试接口
/admin/common/test:只有admin角色才能访问
/common/test:任何角色都可以访问
言归正传
1、相关SQL脚本
create database `springsecurity_case` character set 'utf8mb4';
use `springsecurity_case`;
create table t_user(
user_id varchar(50) primary key comment '用户id',
username varchar(50) not null comment '用户名',
password varchar(100) not null comment '密码',
role varchar(50) not null comment '角色',
user_status tinyint(1) default 1 comment '用户状态'
);
insert into t_user values ('1', 'admin', '$2a$10$wmUXgiTZzc3ux3h3UiuxWumeDYbt8uaZmmPw6utx9GyyuGEDSTNJy', 'admin', 1),
('2', 'user', '$2a$10$wmUXgiTZzc3ux3h3UiuxWumeDYbt8uaZmmPw6utx9GyyuGEDSTNJy', 'user', 1);
2、创建项目&导入依赖
<!-- ... 常规化的依赖省略了-->
<!-- redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- springSecurity-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
3、UserDetailService实现类的编写,认证的主逻辑
/*
* @Project:springboot-springsecurity-case
* @Author:cxs
* @Motto:放下杂念,只为迎接明天更好的自己
* */
@Service
@Slf4j
public class UserDetailServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
if (!StringUtils.hasLength(username)) {
throw new UsernameNotFoundException("用户名岂能为空!");
}
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("username", username);
User user = userMapper.selectOne(queryWrapper);
if (ObjectUtils.isEmpty(user)) {
throw new UsernameNotFoundException("用户名不存在");
}
if (user.getUserStatus().equals(2)) {
throw new LockedException("账户已被锁定,认证失败");
}
GrantedAuthority grantedAuthority = new SimpleGrantedAuthority("ROLE_" + user.getRole());
Collection<GrantedAuthority> grantedAuthorities = new ArrayList<>();
grantedAuthorities.add(grantedAuthority);
return new AuthUser(user.getUserId(), username, user.getPassword(), grantedAuthorities);
}
}
4、Token过滤器的编写
/*
* @Project:springboot-springsecurity-case
* @Author:cxs
* @Motto:放下杂念,只为迎接明天更好的自己
* */
@Component
public class TokenVerificationFilter extends OncePerRequestFilter {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private CommonConfig commonConfig;
@Autowired
private RedisUtil redisUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 获取url,如果在核心配置文件中配置了白名单,则跳过验证
String requestURI = request.getRequestURI();
if (ignore(requestURI)) {
filterChain.doFilter(request, response);
return;
}
// 获取X-Access-Token
String header = request.getHeader(CommonContent.TOKEN);
if (!StringUtils.hasLength(header)) {
filterChain.doFilter(request, response);
return;
}
Token token = null;
List<String> strings = null;
try {
// 根据X-Access-Token去redis查询真实token
String tokenStr = redisUtil.getString(redisUtil.getCacheKey(CachePrefixContent.TOKEN_PREFIX, header.trim()));
if (!StringUtils.hasLength(tokenStr)) {
response(response, BaseResult.error().setCode(ResponseStateConstant.NO_LOGIN).setMsg("用户认证信息已过期"));
return;
}
if (jwtUtil.validTokenIssued(tokenStr)) {
response(response, BaseResult.error().setCode(ResponseStateConstant.NO_LOGIN).setMsg("用户认证信息已过期"));
return;
}
// 校验信息是否正确,省略
token = jwtUtil.parseToken(tokenStr);
strings = token.getUser().getAuthentications();
Authentication context = new UsernamePasswordAuthenticationToken(
token.getUser().getUsername(),
token.getUser().getUsername(),
AuthorityUtils.createAuthorityList(strings.toArray(new String[0]))
);
SecurityContextHolder.getContext().setAuthentication(context);
Long expire = redisUtil.getExpire(redisUtil.getCacheKey(CachePrefixContent.TOKEN_PREFIX, header));
// 有效期小于15分钟,续时
if (expire <= 900L) {
redisUtil.set(redisUtil.getCacheKey(CachePrefixContent.TOKEN_PREFIX, header), jwtUtil.generateToken(token.getUser()), commonConfig.getValidityTime(), TimeUnit.MINUTES);
}
filterChain.doFilter(request, response);
} catch (Exception e) {
if (e instanceof SignatureException) {
BaseResult error = BaseResult.error();
error.setMsg(CurrencyErrorEnum.UNAUTHORIZED.getMsg());
error.setCode(CurrencyErrorEnum.UNAUTHORIZED.getCode());
response(response, error);
} else if (e instanceof ExpiredJwtException) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
BaseResult error = BaseResult.error();
error.setMsg(CurrencyErrorEnum.UNAUTHORIZED_BE_OVERDUE.getMsg());
error.setCode(CurrencyErrorEnum.UNAUTHORIZED_BE_OVERDUE.getCode());
response(response, error);
} else if (e instanceof JwtException) {
BaseResult error = BaseResult.error();
error.setMsg(CurrencyErrorEnum.UNAUTHORIZED.getMsg());
error.setCode(CurrencyErrorEnum.UNAUTHORIZED.getCode());
response(response, error);
} else {
throw e;
}
}
}
// ...
}
5、Security核心配置文件编写
/*
* @Project:springboot-springsecurity-case
* @Author:cxs
* @Motto:放下杂念,只为迎接明天更好的自己
* */
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
/*
创建加密编码器
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Autowired
private TokenVerificationFilter tokenVerificationFilter;
@Autowired
private AccessForbiddenHandler forbiddenHandler;
@Autowired
private AuthenticationHandler authenticationHandler;
@Autowired
private CommonConfig commonConfig;
@Override
protected void configure(HttpSecurity http) throws Exception {
// 请求授权管理
http.authorizeRequests()
// 其他的请求都需要授权
.antMatchers(commonConfig.getIgnoreUrl()).permitAll()
.antMatchers("/common/**").hasAnyRole("admin", "user")
.antMatchers("/admin/**").hasRole("admin")
.anyRequest().authenticated()
.and()
.exceptionHandling()
.accessDeniedHandler(forbiddenHandler)
.authenticationEntryPoint(authenticationHandler)
.and()
.csrf().disable()
// 整合token校验过滤器
.addFilterBefore(tokenVerificationFilter, UsernamePasswordAuthenticationFilter.class)
// 禁用springsecurity的本地存储,做无状态登录
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
/**
* 注入认证管理器
* @return
* @throws Exception
*/
@Bean
public AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}
}
6、登录认证逻辑
这一块有两个逻辑
1、首先如果用户带着X-Access-Token进行登录,首先判断是否有效,如果有效的话,直接将之前的token回传给用户
2、如果失效、或者用户未带X-Access-Token,进行登录逻辑
public void login(UserLoginDTO dto, HttpServletRequest request, HttpServletResponse response, BaseResult result) {
// 判断用户是否携带X-Access-Token
String accessTokenKey = request.getHeader(CommonContent.TOKEN);
if (StringUtils.hasLength(accessTokenKey)) {
String tokenStr = redisUtil.getString(redisUtil.getCacheKey(CachePrefixContent.TOKEN_PREFIX, accessTokenKey.trim()));
Token token = null;
try {
token = jwtUtil.parseToken(tokenStr);
} catch (Exception e) {
log.info("用户登录:用户已有token校验失败");
}
// 进行token验证
if (!ObjectUtils.isEmpty(token)) {
String generateToken = jwtUtil.generateToken(token.getUser());
redisUtil.set(redisUtil.getCacheKey(CachePrefixContent.TOKEN_PREFIX, accessTokenKey), generateToken, commonConfig.getValidityTime(), TimeUnit.MINUTES);
Token parseToken = jwtUtil.parseToken(generateToken);
if (!ObjectUtils.isEmpty(parseToken)) parseToken.setToken(accessTokenKey);
UserLoginVO vo = new UserLoginVO();
vo.setTokenInfo(parseToken);
UserVO userVO = new UserVO();
User userInfo = userMapper.selectById(parseToken.getUser().getId());
BeanUtils.copyProperties(userInfo, userVO);
userVO.setRoles(parseToken.getUser().getAuthentications());
vo.setUser(userVO);
result.setCode(ResponseStateConstant.OPERA_SUCCESS).setData(vo).setMsg("登陆成功");
return;
}
}
// 如果失效,或者未带X-Access-Token,进行登录逻辑
String password = dto.getPassword().trim();
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(dto.getUsername().trim(), password);
Authentication authenticate = null;
try {
authenticate = authenticationManager.authenticate(token);
} catch (AuthenticationException e) {
e.printStackTrace();
result.setCode(ResponseStateConstant.OPERA_FAIL).setMsg(e.getMessage());
}
if (authenticate != null) {
UserLoginVO vo = new UserLoginVO();
SecurityContextHolder.getContext().setAuthentication(authenticate);
Object principal = authenticate.getPrincipal();
AuthUser user = (AuthUser) principal;
List<String> auths = CollectionUtils.isEmpty(user.getAuthorities()) ? new ArrayList<>(0) :
user.getAuthorities().stream().map(a -> a.getAuthority()).collect(Collectors.toList());
// 用户登陆成功,生成token
String tokenStr = IdUtil.simpleUUID();
String generateToken = jwtUtil.generateToken(UserSubject.builder()
.id(user.getId())
.username(user.getUsername())
.authentications(auths).build());
redisUtil.set(redisUtil.getCacheKey(CachePrefixContent.TOKEN_PREFIX, tokenStr), generateToken, commonConfig.getValidityTime(), TimeUnit.MINUTES);
Token parseToken = jwtUtil.parseToken(generateToken);
if (!ObjectUtils.isEmpty(parseToken)) parseToken.setToken(tokenStr);
vo.setTokenInfo(parseToken);
UserVO userVO = new UserVO();
User userInfo = userMapper.selectById(user.getId());
BeanUtils.copyProperties(userInfo, userVO);
userVO.setRoles(auths);
vo.setUser(userVO);
result.setCode(ResponseStateConstant.OPERA_SUCCESS).setData(vo).setMsg("登陆成功");
}
}
7、测试
使用user角色登录
使用token分别访问测试接口
使用admin角色登录
使用token分别访问测试接口
token令牌检查接口测试
查看真实token,在redis存储
结语
1、完结撒花,制作不易,一键三连再走吧,您的支持永远是我最大的动力!
2、完整笔记与案例代码在群文件,如有需要,请自行获取!