参考资料:Introduction to Apache Shiro

在Spring Boot中集成Shiro进行用户的认证过程主要可以归纳为以下三点:

1、定义一个ShiroConfig,然后配置SecurityManager Bean,SecurityManager为Shiro的安全管理器,管理着所有Subject;

2、在ShiroConfig中配置ShiroFilterFactoryBean,其为Shiro过滤器工厂类,依赖于SecurityManager;

3、自定义Realm实现,Realm包含doGetAuthorizationInfo()(授权)和doGetAuthenticationInfo()(认证)方法,因为本文只涉及用户认证,所以只实现doGetAuthenticationInfo()方法。

引入依赖

首先可根据文章《开启Spring Boot》搭建一个Spring Boot Web程序,然后引入Shiro、MyBatis、数据库和thymeleaf依赖:

  1. <dependencies>
  2. <!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-thymeleaf -->
  3. <dependency>
  4. <groupId>org.springframework.boot</groupId>
  5. <artifactId>spring-boot-starter-thymeleaf</artifactId>
  6. <version>2.5.0</version>
  7. </dependency>
  8. <dependency>
  9. <groupId>org.springframework.boot</groupId>
  10. <artifactId>spring-boot-starter-web</artifactId>
  11. </dependency>
  12. <!-- MyBatis -->
  13. <dependency>
  14. <groupId>org.mybatis.spring.boot</groupId>
  15. <artifactId>mybatis-spring-boot-starter</artifactId>
  16. <version>1.3.1</version>
  17. </dependency>
  18. <!-- 通用mapper -->
  19. <dependency>
  20. <groupId>tk.mybatis</groupId>
  21. <artifactId>mapper-spring-boot-starter</artifactId>
  22. <version>2.0.2</version>
  23. </dependency>
  24. <!-- shiro-spring -->
  25. <dependency>
  26. <groupId>org.apache.shiro</groupId>
  27. <artifactId>shiro-core</artifactId>
  28. <version>1.8.0</version>
  29. </dependency>
  30. <dependency>
  31. <groupId>org.apache.shiro</groupId>
  32. <artifactId>shiro-spring</artifactId>
  33. <version>1.8.0</version>
  34. </dependency>
  35. <!-- druid数据源驱动 -->
  36. <dependency>
  37. <groupId>com.alibaba</groupId>
  38. <artifactId>druid-spring-boot-starter</artifactId>
  39. <version>1.1.10</version>
  40. </dependency>
  41. <!-- oracle驱动 -->
  42. <dependency>
  43. <groupId>com.oracle.database.jdbc</groupId>
  44. <artifactId>ojdbc8</artifactId>
  45. <version>21.1.0.0</version>
  46. </dependency>
  47. <dependency>
  48. <groupId>com.oracle.database.jdbc</groupId>
  49. <artifactId>ucp</artifactId>
  50. <version>21.1.0.0</version>
  51. </dependency>
  52. <dependency>
  53. <groupId>org.projectlombok</groupId>
  54. <artifactId>lombok</artifactId>
  55. <optional>true</optional>
  56. </dependency>
  57. <dependency>
  58. <groupId>org.springframework.boot</groupId>
  59. <artifactId>spring-boot-starter-test</artifactId>
  60. <scope>test</scope>
  61. </dependency>
  62. </dependencies>

ShiroConfig

定义一个Shiro配置类,名称为ShiroConfig:

  1. @Configuration
  2. public class ShiroConfig {
  3. @Bean
  4. public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
  5. ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
  6. // 设置securityManager
  7. shiroFilterFactoryBean.setSecurityManager(securityManager);
  8. // 登录的url
  9. shiroFilterFactoryBean.setLoginUrl("/login");
  10. // 登录成功后跳转的url
  11. shiroFilterFactoryBean.setSuccessUrl("/index");
  12. // 未授权url
  13. shiroFilterFactoryBean.setUnauthorizedUrl("/403");
  14. LinkedHashMap<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
  15. // 定义filterChain,静态资源不拦截
  16. filterChainDefinitionMap.put("/css/**", "anon");
  17. filterChainDefinitionMap.put("/js/**", "anon");
  18. filterChainDefinitionMap.put("/fonts/**", "anon");
  19. filterChainDefinitionMap.put("/img/**", "anon");
  20. // druid数据源监控页面不拦截
  21. filterChainDefinitionMap.put("/druid/**", "anon");
  22. // 配置退出过滤器,其中具体的退出代码Shiro已经替我们实现了
  23. filterChainDefinitionMap.put("/logout", "logout");
  24. filterChainDefinitionMap.put("/", "anon");
  25. // 除上以外所有url都必须认证通过才可以访问,未通过认证自动访问LoginUrl
  26. filterChainDefinitionMap.put("/**", "authc");
  27. shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
  28. return shiroFilterFactoryBean;
  29. }
  30. @Bean
  31. public SecurityManager securityManager(){
  32. // 配置SecurityManager,并注入shiroRealm
  33. DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
  34. securityManager.setRealm(shiroRealm());
  35. return securityManager;
  36. }
  37. @Bean
  38. public ShiroRealm shiroRealm(){
  39. // 配置Realm,需自己实现
  40. ShiroRealm shiroRealm = new ShiroRealm();
  41. return shiroRealm;
  42. }
  43. }

需要注意的是filterChain基于短路机制,即最先匹配原则,如:

  1. /user/**=anon
  2. /user/aa=authc 永远不会执行

其中anonauthc等为Shiro为我们实现的过滤器,具体如下表所示:

Filter Name Class Description
anon org.apache.shiro.web.filter.authc.AnonymousFilter 匿名拦截器,即不需要登录即可访问;一般用于静态资源过滤;示例/static/**=anon
authc org.apache.shiro.web.filter.authc.FormAuthenticationFilter 基于表单的拦截器;如/**=authc
,如果没有登录会跳到相应的登录页面登录
authcBasic org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter Basic HTTP身份验证拦截器
logout org.apache.shiro.web.filter.authc.LogoutFilter 退出拦截器,主要属性:redirectUrl:退出成功后重定向的地址(/),示例/logout=logout
noSessionCreation org.apache.shiro.web.filter.session.NoSessionCreationFilter 不创建会话拦截器,调用subject.getSession(false)
不会有什么问题,但是如果subject.getSession(true)
将抛出DisabledSessionException
异常
perms org.apache.shiro.web.filter.authz.PermissionsAuthorizationFilter 权限授权拦截器,验证用户是否拥有所有权限;属性和roles一样;示例/user/**=perms["user:create"]
port org.apache.shiro.web.filter.authz.PortFilter 端口拦截器,主要属性port(80)
:可以通过的端口;示例/test= port[80]
,如果用户访问该页面是非80,将自动将请求端口改为80并重定向到该80端口,其他路径/参数等都一样
rest org.apache.shiro.web.filter.authz.HttpMethodPermissionFilter rest风格拦截器,自动根据请求方法构建权限字符串;示例/users=rest[user]
,会自动拼出user:read,user:create,user:update,user:delete权限字符串进行权限匹配(所有都得匹配,isPermittedAll)
roles org.apache.shiro.web.filter.authz.RolesAuthorizationFilter 角色授权拦截器,验证用户是否拥有所有角色;示例/admin/**=roles[admin]
ssl org.apache.shiro.web.filter.authz.SslFilter SSL拦截器,只有请求协议是https才能通过;否则自动跳转会https端口443;其他和port拦截器一样;
user org.apache.shiro.web.filter.authc.UserFilter 用户拦截器,用户已经身份验证/记住我登录的都可;示例/**=user

配置完ShiroConfig后,接下来对Realm进行实现,然后注入到SecurityManager中。

Realm

自定义Realm实现只需继承AuthorizingRealm类,然后实现doGetAuthorizationInfo()doGetAuthenticationInfo()方法即可。这两个方法名乍看有点像,authorization发音[ˌɔ:θəraɪˈzeɪʃn],为授权,批准的意思,即获取用户的角色和权限等信息;authentication发音[ɔ:ˌθentɪ’keɪʃn],认证,身份验证的意思,即登录时验证用户的合法性,比如验证用户名和密码。

  1. public class ShiroRealm extends AuthorizingRealm {
  2. @Autowired
  3. private UserMapper userMapper;
  4. /**
  5. * 获取用户角色和权限
  6. */
  7. @Override
  8. protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principal) {
  9. return null;
  10. }
  11. /**
  12. * 登录认证
  13. */
  14. @Override
  15. protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
  16. // 获取用户输入的用户名和密码
  17. String userName = (String) token.getPrincipal();
  18. String password = new String((char[]) token.getCredentials());
  19. System.out.println("用户" + userName + "认证-----ShiroRealm.doGetAuthenticationInfo");
  20. // 通过用户名到数据库查询用户信息
  21. User user = userMapper.findByUserName(userName);
  22. if (user == null) {
  23. throw new UnknownAccountException("用户名或密码错误!");
  24. }
  25. if (!password.equals(user.getPassword())) {
  26. throw new IncorrectCredentialsException("用户名或密码错误!");
  27. }
  28. if (user.getStatus().equals("0")) {
  29. throw new LockedAccountException("账号已被锁定,请联系管理员!");
  30. }
  31. SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(user, password, getName());
  32. return info;
  33. }
  34. }

因为本节只讲述用户认证,所以doGetAuthorizationInfo()方法先不进行实现。

其中UnknownAccountException等异常为Shiro自带异常,Shiro具有丰富的运行时AuthenticationException层次结构,可以准确指出尝试失败的原因。你可以包装在一个try/catch块,并捕捉任何你希望的异常,并作出相应的反应。例如:

  1. try {
  2. currentUser.login(token);
  3. } catch ( UnknownAccountException uae ) { ...
  4. } catch ( IncorrectCredentialsException ice ) { ...
  5. } catch ( LockedAccountException lae ) { ...
  6. } catch ( ExcessiveAttemptsException eae ) { ...
  7. } ... catch your own ...
  8. } catch ( AuthenticationException ae ) {
  9. //unexpected error?
  10. }

虽然我们可以准确的获取异常信息,并根据这些信息给用户提示具体错误,但最安全的做法是在登录失败时仅向用户显示通用错误提示信息,例如“用户名或密码错误”。这样可以防止数据库被恶意扫描。

在Realm中UserMapper为Dao层,标准的做法应该还有Service层,但这里为了方便就不再定义Service层了。接下来编写和数据库打交道的Dao层。

数据层

首先创建一张用户表,用于存储用户的基本信息(基于Oracle 11g):

  1. -- ----------------------------
  2. -- Table structure for T_USER
  3. -- ----------------------------
  4. CREATE TABLE "SCOTT"."T_USER" (
  5. "ID" NUMBER NOT NULL ,
  6. "USERNAME" VARCHAR2(20 BYTE) NOT NULL ,
  7. "PASSWD" VARCHAR2(128 BYTE) NOT NULL ,
  8. "CREATE_TIME" DATE NULL ,
  9. "STATUS" CHAR(1 BYTE) NOT NULL
  10. );
  11. COMMENT ON COLUMN "SCOTT"."T_USER"."USERNAME" IS '用户名';
  12. COMMENT ON COLUMN "SCOTT"."T_USER"."PASSWD" IS '密码';
  13. COMMENT ON COLUMN "SCOTT"."T_USER"."CREATE_TIME" IS '创建时间';
  14. COMMENT ON COLUMN "SCOTT"."T_USER"."STATUS" IS '是否有效 1:有效 0:锁定';
  15. -- ----------------------------
  16. -- Records of T_USER
  17. -- ----------------------------
  18. INSERT INTO "SCOTT"."T_USER" VALUES ('2', 'test', '7a38c13ec5e9310aed731de58bbc4214', TO_DATE('2017-11-19 17:20:21', 'YYYY-MM-DD HH24:MI:SS'), '0');
  19. INSERT INTO "SCOTT"."T_USER" VALUES ('1', 'mrbird', '42ee25d1e43e9f57119a00d0a39e5250', TO_DATE('2017-11-19 10:52:48', 'YYYY-MM-DD HH24:MI:SS'), '1');
  20. -- ----------------------------
  21. -- Primary Key structure for table T_USER
  22. -- ----------------------------
  23. ALTER TABLE "SCOTT"."T_USER" ADD PRIMARY KEY ("ID");

数据源的配置参考Spring Boot中使用MyBatis

库表对应的实体类:

  1. public class User implements Serializable{
  2. private static final long serialVersionUID = -5440372534300871944L;
  3. private Integer id;
  4. private String userName;
  5. private String password;
  6. private Date createTime;
  7. private String status;
  8. // get,set略
  9. }

定义接口UserMapper:

  1. @Mapper
  2. public interface UserMapper {
  3. User findByUserName(String userName);
  4. }

xml实现:

  1. <?xml version="1.0" encoding="UTF-8"?>
  2. <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
  3. <mapper namespace="com.springboot.dao.UserMapper">
  4. <resultMap type="com.springboot.pojo.User" id="User">
  5. <id column="id" property="id" javaType="java.lang.Integer" jdbcType="NUMERIC"/>
  6. <id column="username" property="userName" javaType="java.lang.String" jdbcType="VARCHAR"/>
  7. <id column="passwd" property="password" javaType="java.lang.String" jdbcType="VARCHAR"/>
  8. <id column="create_time" property="createTime" javaType="java.util.Date" jdbcType="DATE"/>
  9. <id column="status" property="status" javaType="java.lang.String" jdbcType="VARCHAR"/>
  10. </resultMap>
  11. <select id="findByUserName" resultMap="User">
  12. select * from t_user where username = #{userName}
  13. </select>
  14. </mapper>

数据层准备完了,接下来编写login.html和index.html页面。

页面准备

编写登录页面login.html:

  1. <!DOCTYPE html>
  2. <html xmlns:th="http://www.thymeleaf.org">
  3. <head>
  4. <meta charset="UTF-8">
  5. <title>登录</title>
  6. <link rel="stylesheet" th:href="@{/css/login.css}" type="text/css">
  7. <script th:src="@{/js/jquery-1.11.1.min.js}"></script>
  8. </head>
  9. <body>
  10. <div class="login-page">
  11. <div class="form">
  12. <input type="text" placeholder="用户名" name="username" required="required"/>
  13. <input type="password" placeholder="密码" name="password" required="required"/>
  14. <button onclick="login()">登录</button>
  15. </div>
  16. </div>
  17. </body>
  18. <script th:inline="javascript">
  19. var ctx = [[@{/}]];
  20. function login() {
  21. var username = $("input[name='username']").val();
  22. var password = $("input[name='password']").val();
  23. $.ajax({
  24. type: "post",
  25. url: ctx + "login",
  26. data: {"username": username,"password": password},
  27. dataType: "json",
  28. success: function (r) {
  29. if (r.code == 0) {
  30. location.href = ctx + 'index';
  31. } else {
  32. alert(r.msg);
  33. }
  34. }
  35. });
  36. }
  37. </script>
  38. </html>

主页index.html:

  1. <!DOCTYPE html>
  2. <html xmlns:th="http://www.thymeleaf.org">
  3. <head>
  4. <meta charset="UTF-8">
  5. <title>首页</title>
  6. </head>
  7. <body>
  8. <p>你好![[${user.userName}]]</p>
  9. <a th:href="@{/logout}">注销</a>
  10. </body>
  11. </html>

页面准备完毕,接下来编写LoginController。

Controller

LoginController代码如下:

  1. @Controller
  2. public class LoginController {
  3. @GetMapping("/login")
  4. public String login() {
  5. return "login";
  6. }
  7. @PostMapping("/login")
  8. @ResponseBody
  9. public ResponseBo login(String username, String password) {
  10. // 密码MD5加密
  11. password = MD5Utils.encrypt(username, password);
  12. UsernamePasswordToken token = new UsernamePasswordToken(username, password);
  13. // 获取Subject对象
  14. Subject subject = SecurityUtils.getSubject();
  15. try {
  16. subject.login(token);
  17. return ResponseBo.ok();
  18. } catch (UnknownAccountException e) {
  19. return ResponseBo.error(e.getMessage());
  20. } catch (IncorrectCredentialsException e) {
  21. return ResponseBo.error(e.getMessage());
  22. } catch (LockedAccountException e) {
  23. return ResponseBo.error(e.getMessage());
  24. } catch (AuthenticationException e) {
  25. return ResponseBo.error("认证失败!");
  26. }
  27. }
  28. @RequestMapping("/")
  29. public String redirectIndex() {
  30. return "redirect:/index";
  31. }
  32. @RequestMapping("/index")
  33. public String index(Model model) {
  34. // 登录成后,即可通过Subject获取登录的用户信息
  35. User user = (User) SecurityUtils.getSubject().getPrincipal();
  36. model.addAttribute("user", user);
  37. return "index";
  38. }
  39. }

登录成功后,根据之前在ShiroConfig中的配置shiroFilterFactoryBean.setSuccessUrl("/index"),页面会自动访问/index路径。

测试

最终项目目录如下图所示:

Spring Boot Shiro用户认证 - 图1

启动项目,分别访问:

可发现页面都被重定向到 http://localhost:8080/web/login

Spring Boot Shiro用户认证 - 图2

当输入错误的用户信息时:

Spring Boot Shiro用户认证 - 图3

用test的账户登录(test账户的status为0,已被锁定):

Spring Boot Shiro用户认证 - 图4

当输入正确的用户名密码时候(密码可以用“123456”在代码中的MD5函数加密后存入数据库 ):

Spring Boot Shiro用户认证 - 图5

点击注销连接,根据ShiroConfig的配置filterChainDefinitionMap.put("/logout", "logout"),Shiro会自动帮我们注销用户信息,并重定向到/路径。