前后端分离项目与传统的一体式项目有所不同,用户安全认证的方式不太一样,前后端分离项目不能像一体式项目那样使用session认证,所以一般使用token认证,具体原理很简单,客户端每次请求服务器的时候都会带上token密钥,服务器识别认证token后,即可识别身份并响应数据。废话不多说,直接上代码。
主要代码:
一、JwtUtil
Jwt即java web token,是比较成熟的token方案,JwtUtil里面有生成token的方法、校验token是否有效是否过期的方法、以及通过token获取解析值的方法,这里我们可以设置token的有效时间。
@Component
public class JwtUtil {
/**
* JWT认证过期时间 EXPIRE_TIME 分钟
*/
private static final long EXPIRE_TIME = 30*1000;
/**
* 校验token是否正确
*
* @param token 密钥
* @param secret 用户的密码
* @return 是否正确
*/
public static boolean verify(String token, String username, String secret) {
try {
//根据密码生成JWT效验器
Algorithm algorithm = Algorithm.HMAC256(secret);
JWTVerifier verifier = JWT.require(algorithm)
.withClaim("username", username)
.build();
//效验TOKEN
DecodedJWT jwt = verifier.verify(token);
System.out.println("登录认证成功!");
return true;
} catch (Exception exception) {
System.out.println("JwtUtil登录认证失败!");
return false;
}
}
/**
* 获得token中的信息无需secret解密也能获得
*
* @return token中包含的用户名
*/
public static String getUsername(String token) {
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim("username").asString();
} catch (JWTDecodeException e) {
return null;
}
}
/**
* 生成token签名EXPIRE_TIME 分钟后过期
*
* @param username 用户名(电话号码)
* @param secret 用户的密码
* @return 加密的token
*/
public static String sign(String username, String secret) {
long nowTime = System.currentTimeMillis();
Date date = new Date(nowTime + EXPIRE_TIME);
Algorithm algorithm = Algorithm.HMAC256(secret);
// 附带username信息
return JWT.create()
.withClaim("username", username)
.withClaim("currentTimeMillis", String.valueOf(nowTime))
.withExpiresAt(date)
.sign(algorithm);
}
public static String getClaim(String token, String claim) {
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim(claim).asString();
} catch (JWTDecodeException e) {
return null;
}
}
}
二、shiroConfig
shiroConfig基本变动不大,需要注意以下几点:1.需要设置关闭shiro自带的session;2.需要设置我们自己的身份认证过滤器MyFilter类(后面会介绍),关于MyFilter类也是前后端分离使用token认证的核心,不在使用shiro自带的authc过滤器(传统项目session校验使用的就是authc过滤器)了;3.realm不要设置缓存,传统一体式项目我们使用的用户名和密码校验,因为一个用户的用户名和密码不会经常改变,所以我们可以设置缓存,改变密码后需要更新缓存,而前后端分离项目使用的是经常改变的token,所以认证不能设置缓存,而权限可以单独设置@cache缓存,在后面代码UserServiceImpl中有所体现。
@Configuration
public class shiroConfig {
//创建shirofilter,负责拦截所有请求
@Bean
public ShiroFilterFactoryBean getShiroFilterFactoryBean(DefaultWebSecurityManager defaultWebSecurityManager){
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(defaultWebSecurityManager);
Map<String, Filter> filters = new HashMap<>();
filters.put("auth", new MyFilter());
shiroFilterFactoryBean.setFilters(filters);
//配置系统受限资源
//配置系统公共资源
Map<String,String> map = new HashMap<>();
//顺序无关
map.put("/user/login","anon");
map.put("/**","auth");
shiroFilterFactoryBean.setFilterChainDefinitionMap(map);
return shiroFilterFactoryBean;
}
//创建shiro安全管理器
@Bean
public DefaultWebSecurityManager getDefaultWebSecurityManager(Realm realm){
DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
//关闭shiro自带的session
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
defaultWebSecurityManager.setSubjectDAO(subjectDAO);
//配置realm
defaultWebSecurityManager.setRealm(realm);
return defaultWebSecurityManager;
}
//创建自定义realm
@Bean
public Realm getRealm(){
CustomerRealm customerRealm = new CustomerRealm();
//设置缓存管理器
customerRealm.setCachingEnabled(false);
customerRealm.setAuthenticationCachingEnabled(true);
customerRealm.setAuthenticationCacheName("AuthenticationCache");
customerRealm.setAuthorizationCachingEnabled(true);
customerRealm.setAuthorizationCacheName("AuthorizationCache");
return customerRealm;
}
}
三、编写自己的token来继承UsernamePasswordToken
由于我们使用shiro认证的时候已经不在是使用username和password,而是通过Jwt用username和password来生成的token进行认证,所以我们需要自己来写UsernamePasswordToken,以便subject.login(token)
public class JwtToken extends UsernamePasswordToken {
private String token;
public JwtToken(String token) {
this.token = token;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}
四、MyFilter
我们编写自己的Filter需要继承BasicHttpAuthenticationFilter(shiro的身份认证过滤器),我们需要重写executeLogin和isAccessAllowed,在executeLogin中,通过ServletRequest直接获取token,生成JwtToken,来进行getSubject(request, response).login(jwtToken)操作,实际上就是subject.login(token),最终会调用自定义realm中的doGetAuthenticationInfo方法。
我们在Filter中如果认证失败,可以直接设置返回需要的内容response.getWriter().print(…);
@Component
public class MyFilter extends BasicHttpAuthenticationFilter {
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String token = httpServletRequest.getHeader("Token");
JwtToken jwtToken = new JwtToken(token);
// 提交给realm进行登入,如果错误他会抛出异常并被捕获
try {
getSubject(request, response).login(jwtToken);
// 如果没有抛出异常则代表登入成功,返回true
return true;
} catch (AuthenticationException e) {
response.setCharacterEncoding("utf-8");
response.getWriter().print("error");
return false;
}
}
/**
* 执行登录认证
*
* @param request
* @param response
* @param mappedValue
* @return
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
try {
return executeLogin(request, response);
// return true;有一篇博客这里直接返回true是不正确的,在这里我特别指出一下
} catch (Exception e) {
System.out.println("JwtFilter过滤认证失败!");
return false;
}
}
/**
* 对跨域提供支持
* @param request
* @param response
* @return
* @throws Exception
*/
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
// 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态
if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
httpServletResponse.setStatus(HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
}
五、CustomerRealm
自定义Realm,doGetAuthenticationInfo中,核心是我们先通过token获取username,通过username再查询数据库来获取password,最后调用JwtUtil.verify(token, username, user.getPassword()方法,来判断token是否匹配是否过期
public class CustomerRealm extends AuthorizingRealm {
@Autowired
@Lazy
private UserService userService;
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JwtToken;
}
//授权
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
//shiro中Principal存储的是字符串
// String primaryPrincipal = (String) principalCollection.getPrimaryPrincipal();
// User user = userService.getUserByPrincipal(primaryPrincipal);
//也可以直接存储user对象,以便在程序中直接获取使用,但是AuthenticationInfo在存储的时候也需要做响应的处理
String username = JwtUtil.getUsername(principalCollection.toString());
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
List<String> pers = userService.getPers();
for (String per : pers) {
authorizationInfo.addStringPermission(per);
}
return authorizationInfo;
}
//认证
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
String token = (String) authenticationToken.getPrincipal();
// 解密获得username,用于和数据库进行对比
String username = null;
try {
//这里工具类没有处理空指针等异常这里处理一下(这里处理科学一些)
username = JwtUtil.getUsername(token);
} catch (Exception e) {
throw new AuthenticationException("heard的token拼写错误或者值为空");
}
if (username == null) {
System.out.println("token无效(空''或者null都不行!)");
throw new AuthenticationException("token无效");
}
User user = userService.getUserByUsername(username);
if (user == null) {
System.out.println("用户不存在!)");
throw new AuthenticationException("用户不存在!");
}
if (!JwtUtil.verify(token, username, user.getPassword())) {
System.out.println("用户名或密码错误(token无效或者与登录者不匹配)!");
throw new AuthenticationException("用户名或密码错误(token无效或者与登录者不匹配)!");
}
return new SimpleAuthenticationInfo(token,token,this.getName());
}
}
六、UserServiceImpl
由于我们的realm不能设置认证缓存,所以我们可以单独的设置获取权限的缓存
@Service("userService")
public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper;
@Override
public User getUserByUsername(String username) {
return userMapper.getUserByUsername(username);
}
//获取权限,这里我为了简单测试,没有写参数
@Override
@Cacheable(value = "cache")
public List<String> getPers() {
System.out.println("请求了。。。。。。。。。。。。。");
return userMapper.getPers();
}
}
七、UserController
"/login"方法为公开方法,客户端登录后服务器返回token响应,其他方法为测试方法,都会被自定义Filter拦截进行认证。
@Controller
@RequestMapping("/user")
public class UserController {
@Autowired
private UserService userService;
@RequestMapping("/login")
@ResponseBody
public String login(User user, HttpServletResponse response) throws Exception {
String tokenStr = JwtUtil.sign(user.getUsername(), user.getPassword());
JwtToken token = new JwtToken(tokenStr);
Subject subject = SecurityUtils.getSubject();
try {
subject.login(token);
System.out.println("认证成功");
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.setHeader("token", tokenStr);
}catch (Exception e){
e.printStackTrace();
System.out.println("认证失败");
}
return "success";
}
@RequestMapping("/test")
@ResponseBody
@RequiresPermissions("user:add:*")
public String test() {
return "success";
}
}
后续改进:
一、安全性问题
这里我们使用了token进行登录认证,由于每次客户端请求服务器的时候都需要带上token,所以存在一定的风险被窃取,这样黑客极有可能拿着token来窃取服务器数据或者攻击,所以为了安全,token的过期时间往往设置的比较短,但是这样也带来一个问题,就是用户每过一段时间就需要重新登录认证,显然这样是不够友好的。
解决办法:服务器在生成token的时候也会生成一个refresh-token,并把refresh-token保存在redis中,并把这两个token都发送给客户端,客户端将token和refresh-token保存的浏览器内存里,在请求数据的时候只带上token,服务器检测token有效后还比对 Token 中的时间戳与缓存中的 RefreshToken 时间戳是否一致,一致后才能请求到数据。当发现token过期的时候,客户端会使用refresh-token来向服务器请求刷新token,服务器会生成新的token和refresh-token,刷新redis中的refresh-token,并把两个新的token发送给客户端,这样一来,refresh-token只在第一次和刷新token的时候才会进行传输,就降低了被窃取的风险,黑客即便是拿到了token,但如果没有拿到refresh-token,短时间内的数据丢失和破坏也是在有限范围。同时通过设置refresh-token,还会带来另一个好处,就是我们可以通过控制服务器redis中的refresh-token来间接控制jwt的token认证。、
二、用户登出的问题
当我们使用refresh-token的时候,我们可以通过控制redis中的refresh-token来控制用户是否登出(通过删除refresh-token来实现),如果我们一些简单的项目没有使用refresh-token怎么办呢?
解决办法:我们可以设置token的黑名单,比如登出的时候把token添加到黑名单中,并在我们自定义的Filter中的进行检测请求token是否在黑名单中,如果在黑名单中,就报错,提示登录失败。