Shiro&JWT实现用户登录和权限管理

开始之前我们先了解两个概念

认证(authentication)

在程序中认证要做的事情主要是搞明白访问者是谁,他有没有在我们系统中注册过?他是不是我们系统中的用户?如果是,那么这个用户有没有被我们加入黑名单!这是认证要做的事情。举个例子:进一个旅游景区首先要买票吧,要是你手里没有票在检票口会被检票大叔/大妈直接拦住,不让你进去,如果你手里有票,大叔还会看一眼你票是不是今天买的,有没有过期啊……如果你有票并且一切正常,那么你进可以进去了。这就是一个认证的过程!

授权(authorization)

授权的作用主要是验证你有没有访问某项资源的权利!我们都知道在MySQL数据库中,root用户可以随便增删改查各个库的数据,但是普通用户可能就只有查看到权利,而没有删除和修改的权利。在程序中也一样,有一个很常见的需求:用户A可以访问和并修改一个页面,对于用户B可能就只有访问这个页面的权利,而不能修改!


shiro和jwt的简单介绍

jwt:JSON Web Token,由请求头、请求体和签名3部分组成,它主要用来认证。
shiro是一个轻量级的安全框架,可以使用注解快速的实现权限管理。他主要用来授权。


实现认证和授权的整体思路

客户端访问服务端,服务端对请求进行认证,主要包括用户名和密码是否正确,如果认证成功会给客户端颁发一个凭证token,后面客户端再访问服务端的时候都要携带这个token,如果不携带或者token被篡改,都会认证失败!如果token认证成功,客户端访问资源,那么此时服务端还会去认证该用户是否有访问此资源的权利!如果此用户没有访问这个资源的权利,同样会访问失败!

数据库表设计

权限的认证和校验采用RBAC模型,总共有5张表,分别为user、user_role、role、role_permission和permission,
其中user_role和role_permission为中间表,user表和role表示一对多的关系,
role表和permission表也是一对多的关系。


代码实现

首先是登录的代码,即是用来认证的代码,当用户认证成功后会返回一个token给客户端。认证失败客户端将不能获取token!

登录

@RestController
@Slf4j
public class LoginController {
    @Resource
    private UserService userService;

    @PostMapping("/login")
    public ResponseBean login(@RequestBody JSONObject requestJson) {
        log.info("用户登录");
        String username = requestJson.getString("username");
        String password = requestJson.getString("password");

        if (StringUtils.isBlank(username) || StringUtils.isBlank(password)) {
            throw new RuntimeException("用户名和密码不可以为空!");
        }

        // 从数据库中根据用户名查找该用户信息
        UserDTO userDTO = userService.getByUseName(username);

        // 获取盐
        String salt = userDTO.getSalt();
        // 原密码加密(通过username + salt作为盐)
        String encodedPassword = ShiroKit.md5(password, username + salt);
        if (null != userDTO && userDTO.getPassword().equals(encodedPassword)) {
            return new ResponseBean(200, "Login success", JWTUtil.sign(username, encodedPassword));
        } else {
            throw new UnauthorizedException("用户名或者密码错误!");
        }
    }

}

JWT生成token(附带验证的一些工具类)

public class JWTUtil {
    final static Logger logger = LogManager.getLogger(JWTUtil.class);

    /**
     * 过期时间30分钟
     */
    private static final long EXPIRE_TIME = 30 * 60 * 1000;

    /**
     * 校验token是否正确
     *
     * @param token  密钥
     * @param secret 用户的密码
     * @return 是否正确
     */
    public static boolean verify(String token, String username, String secret) {
        try {
            Algorithm algorithm = Algorithm.HMAC256(secret);
            JWTVerifier verifier = JWT.require(algorithm)
                    .withClaim("username", username)
                    .build();
            verifier.verify(token);
            return true;
        } catch (Exception exception) {
            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;
        }
    }

    /**
     * 生成签名,30min后过期
     *
     * @param username 用户名
     * @param secret   用户的密码
     * @return 加密的token
     */
    public static String sign(String username, String secret) {
        try {
            Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
            //使用用户自己的密码充当加密密钥
            Algorithm algorithm = Algorithm.HMAC256(secret);
            // 附带username信息
            //  建造者模式
            String jwtString = JWT.create()
                    .withClaim("username", username)
                    .withExpiresAt(date)
                    .sign(algorithm);
            logger.debug(String.format("JWT:%s", jwtString));
            return jwtString;
        } catch (UnsupportedEncodingException e) {
            return null;
        }
    }
}

过滤器

将登录的请求排除之外,然后其他的请求,在请求之前都必须走认证过程:

public class JWTFilter extends BasicHttpAuthenticationFilter {

    private Logger LOGGER = LoggerFactory.getLogger(this.getClass());

    /**
     * 判断用户是否想要登入。
     * true:是要登录
     * 检测header里面是否包含Authorization字段即可
     */
    @Override
    protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
        HttpServletRequest req = (HttpServletRequest) request;
        String authorization = req.getHeader("Authorization");
        return authorization != null;
    }

    /**
     *
     */
    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response)  throws Exception {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String authorization = httpServletRequest.getHeader("Authorization");

        JWTToken token = new JWTToken(authorization);
        // 提交给realm进行登入,如果错误他会抛出异常并被捕获
        getSubject(request, response).login(token);
        // 如果没有抛出异常则代表登入成功,返回true
        return true;
    }

    /**
     * 这里我们详细说明下为什么最终返回的都是true,即允许访问
     * 例如我们提供一个地址 GET /article
     * 登入用户和游客看到的内容是不同的
     * 如果在这里返回了false,请求会被直接拦截,用户看不到任何东西
     * 所以我们在这里返回true,Controller中可以通过 subject.isAuthenticated() 来判断用户是否登入
     * 如果有些资源只有登入用户才能访问,我们只需要在方法上面加上 @RequiresAuthentication 注解即可
     * 但是这样做有一个缺点,就是不能够对GET,POST等请求进行分别过滤鉴权(因为我们重写了官方的方法),但实际上对应用影响不大
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        if (isLoginAttempt(request, response)) {
            try {
                executeLogin(request, response);
            } catch (Exception e) {
                response401(request, response);
            }
        }
        return true;
    }

    /**
     * 对跨域提供支持
     */
    @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);
    }

    /**
     * 将非法请求跳转到 /401
     */
    private void response401(ServletRequest req, ServletResponse resp) {
        try {
            HttpServletResponse httpServletResponse = (HttpServletResponse) resp;
            httpServletResponse.sendRedirect("/401");
        } catch (IOException e) {
            LOGGER.error(e.getMessage());
        }
    }
}

自定义MyRealm

自定义权限和身份认证逻辑:

@Configuration
public class MyRealm extends AuthorizingRealm {
    @Resource
    private UserService userService;


    /**
     * 必须重写此方法,不然Shiro会报错
     */
    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JWTToken;
    }

    /**
     * 只有当需要检测用户权限的时候才会调用此方法,例如checkRole,checkPermission之类的
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        String username = JWTUtil.getUsername(principals.toString());
        // 根据用户名获取用户角色 权限信息
        UserDTO user = userService.getByUseName(username);
        SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();

        // 获取角色
        List<String> roleNames = getRoleNameList(user.getRoleList());
        for (String roleName : roleNames) {
            simpleAuthorizationInfo.addRole(roleName);
        }

        // 获取权限
        List<String> permissions = getPermissionList(user.getPermissionList());
        simpleAuthorizationInfo.addStringPermissions(new HashSet<>(permissions));
        return simpleAuthorizationInfo;
    }

    /**
     * 获取权限
     *
     * @param permissionList
     * @return
     */
    private List<String> getPermissionList(List<Permission> permissionList) {
        List<String> permissions = new ArrayList<>(permissionList.size());
        for (Permission permission : permissionList) {
            if (StringUtils.isNotBlank(permission.getPerCode())) {
                permissions.add(permission.getPerCode());
            }
        }
        return permissions;
    }

    /**
     * 获取角色名称
     *
     * @param roleList
     * @return
     */
    private List<String> getRoleNameList(List<Role> roleList) {
        List<String> roleNames = new ArrayList<>(roleList.size());
        for (Role role : roleList) {
            roleNames.add(role.getName());
        }
        return roleNames;
    }

    /**
     * 默认使用此方法进行用户名正确与否验证,错误抛出异常即可。
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
        String token = (String) auth.getCredentials();
        // 解密获得username,用于和数据库进行对比
        String username = JWTUtil.getUsername(token);
        if (username == null) {
            throw new AuthenticationException("token invalid");
        }

        UserDTO userBean = userService.getByUseName(username);
        if (userBean == null) {
            throw new AuthenticationException("User didn't existed!");
        }

        if (!JWTUtil.verify(token, username, userBean.getPassword())) {
            throw new AuthenticationException("Username or password error");
        }

        return new SimpleAuthenticationInfo(token, token, "my_realm");
    }
}

MyRealm的解释

自定义Realm是实现权限的关键,MyRealm继承Shiro框架的AuthorizingRealm抽象类,然后重写doGetAuthorizationInfo方法,在这个方法中,我们先是从客户端携带的token中取出用户名,然后根据用户名在数据库中查出该用户所拥有的角色和角色所拥有的权限,然后分别将角色和权限放到SimpleAuthorizationInfo对象中。如下:

 // 获取角色
        List<String> roleNames = getRoleNameList(user.getRoleList());
        for (String roleName : roleNames) {
            simpleAuthorizationInfo.addRole(roleName);
        }

        // 获取权限
        List<String> permissions = getPermissionList(user.getPermissionList());
        simpleAuthorizationInfo.addStringPermissions(new HashSet<>(permissions));

Shiro注解

1.RequiresAuthentication:

  • 使用该注解标注的类,实例,方法在访问或调用时,当前Subject必须在当前session中已经过认证。

2.RequiresGuest:

  • 使用该注解标注的类,实例,方法在访问或调用时,当前Subject可以是“gust”身份,不需要经过认证或者在原先的session中存在记录。

3.RequiresPermissions:

  • 当前Subject需要拥有某些特定的权限时,才能执行被该注解标注的方法。如果当前Subject不具有这样的权限,则方法不会被执行。

4.RequiresRoles:

  • 当前Subject必须拥有所有指定的角色时,才能访问被该注解标注的方法。如果当天Subject不同时拥有所有指定角色,则方法不会执行还会抛出AuthorizationException异常。

5.RequiresUser:

  • 当前Subject必须是应用的用户,才能访问或调用被该注解标注的类,实例,方法。

我们常用的就是RequiresRoles和RequiresPermissions。
这些注解是有使用顺序的:

RequiresRoles-->RequiresPermissions-->RequiresAuthentication-->RequiresUser-->RequiresGuest
如果有个多个注解的话,前面的通过了会继续检查后面的,若不通过则直接返回

@RequiresRoles(value = {"admin","guest"},logical = Logical.OR)
   这个注解的作用是携带的token中的角色必须是admin、guest中的一个
   如果把logical = Logical.OR改成logical = Logical.AND,那么这个注解的作用就变成角色必须同时包含admin和guest了.
   
@RequiresPermissions(logical = Logical.OR, value = {"user:view", "user:edit"})
    和@RequiresRoles类似,权限的的值放在value中,值之间的关系用Logical.OR和Logical.AND控制

    

代码测试

登录

登入获取token

未携带token访问资源

未携带token访问资源

携带token访问

携带token访问成功

需要guest角色但却是admin的token

需要guest角色但却是admin的token

guest权限guest的token

guest权限guest的token

权限不足的情况

权限不足的情况

有权限访问

有权限访问

项目完整代码

GitHub项目完整代码


参考博文

发布了4 篇原创文章 · 获赞 4 · 访问量 103

猜你喜欢

转载自blog.csdn.net/qq_40585384/article/details/105345435