在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依赖:
<dependencies><!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-thymeleaf --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-thymeleaf</artifactId><version>2.5.0</version></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!-- MyBatis --><dependency><groupId>org.mybatis.spring.boot</groupId><artifactId>mybatis-spring-boot-starter</artifactId><version>1.3.1</version></dependency><!-- 通用mapper --><dependency><groupId>tk.mybatis</groupId><artifactId>mapper-spring-boot-starter</artifactId><version>2.0.2</version></dependency><!-- shiro-spring --><dependency><groupId>org.apache.shiro</groupId><artifactId>shiro-core</artifactId><version>1.8.0</version></dependency><dependency><groupId>org.apache.shiro</groupId><artifactId>shiro-spring</artifactId><version>1.8.0</version></dependency><!-- druid数据源驱动 --><dependency><groupId>com.alibaba</groupId><artifactId>druid-spring-boot-starter</artifactId><version>1.1.10</version></dependency><!-- oracle驱动 --><dependency><groupId>com.oracle.database.jdbc</groupId><artifactId>ojdbc8</artifactId><version>21.1.0.0</version></dependency><dependency><groupId>com.oracle.database.jdbc</groupId><artifactId>ucp</artifactId><version>21.1.0.0</version></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency></dependencies>
ShiroConfig
定义一个Shiro配置类,名称为ShiroConfig:
@Configurationpublic class ShiroConfig {@Beanpublic ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();// 设置securityManagershiroFilterFactoryBean.setSecurityManager(securityManager);// 登录的urlshiroFilterFactoryBean.setLoginUrl("/login");// 登录成功后跳转的urlshiroFilterFactoryBean.setSuccessUrl("/index");// 未授权urlshiroFilterFactoryBean.setUnauthorizedUrl("/403");LinkedHashMap<String, String> filterChainDefinitionMap = new LinkedHashMap<>();// 定义filterChain,静态资源不拦截filterChainDefinitionMap.put("/css/**", "anon");filterChainDefinitionMap.put("/js/**", "anon");filterChainDefinitionMap.put("/fonts/**", "anon");filterChainDefinitionMap.put("/img/**", "anon");// druid数据源监控页面不拦截filterChainDefinitionMap.put("/druid/**", "anon");// 配置退出过滤器,其中具体的退出代码Shiro已经替我们实现了filterChainDefinitionMap.put("/logout", "logout");filterChainDefinitionMap.put("/", "anon");// 除上以外所有url都必须认证通过才可以访问,未通过认证自动访问LoginUrlfilterChainDefinitionMap.put("/**", "authc");shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);return shiroFilterFactoryBean;}@Beanpublic SecurityManager securityManager(){// 配置SecurityManager,并注入shiroRealmDefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();securityManager.setRealm(shiroRealm());return securityManager;}@Beanpublic ShiroRealm shiroRealm(){// 配置Realm,需自己实现ShiroRealm shiroRealm = new ShiroRealm();return shiroRealm;}}
需要注意的是filterChain基于短路机制,即最先匹配原则,如:
/user/**=anon/user/aa=authc 永远不会执行
其中anon、authc等为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],认证,身份验证的意思,即登录时验证用户的合法性,比如验证用户名和密码。
public class ShiroRealm extends AuthorizingRealm {@Autowiredprivate UserMapper userMapper;/*** 获取用户角色和权限*/@Overrideprotected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principal) {return null;}/*** 登录认证*/@Overrideprotected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {// 获取用户输入的用户名和密码String userName = (String) token.getPrincipal();String password = new String((char[]) token.getCredentials());System.out.println("用户" + userName + "认证-----ShiroRealm.doGetAuthenticationInfo");// 通过用户名到数据库查询用户信息User user = userMapper.findByUserName(userName);if (user == null) {throw new UnknownAccountException("用户名或密码错误!");}if (!password.equals(user.getPassword())) {throw new IncorrectCredentialsException("用户名或密码错误!");}if (user.getStatus().equals("0")) {throw new LockedAccountException("账号已被锁定,请联系管理员!");}SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(user, password, getName());return info;}}
因为本节只讲述用户认证,所以doGetAuthorizationInfo()方法先不进行实现。
其中UnknownAccountException等异常为Shiro自带异常,Shiro具有丰富的运行时AuthenticationException层次结构,可以准确指出尝试失败的原因。你可以包装在一个try/catch块,并捕捉任何你希望的异常,并作出相应的反应。例如:
try {currentUser.login(token);} catch ( UnknownAccountException uae ) { ...} catch ( IncorrectCredentialsException ice ) { ...} catch ( LockedAccountException lae ) { ...} catch ( ExcessiveAttemptsException eae ) { ...} ... catch your own ...} catch ( AuthenticationException ae ) {//unexpected error?}
虽然我们可以准确的获取异常信息,并根据这些信息给用户提示具体错误,但最安全的做法是在登录失败时仅向用户显示通用错误提示信息,例如“用户名或密码错误”。这样可以防止数据库被恶意扫描。
在Realm中UserMapper为Dao层,标准的做法应该还有Service层,但这里为了方便就不再定义Service层了。接下来编写和数据库打交道的Dao层。
数据层
首先创建一张用户表,用于存储用户的基本信息(基于Oracle 11g):
-- ------------------------------ Table structure for T_USER-- ----------------------------CREATE TABLE "SCOTT"."T_USER" ("ID" NUMBER NOT NULL ,"USERNAME" VARCHAR2(20 BYTE) NOT NULL ,"PASSWD" VARCHAR2(128 BYTE) NOT NULL ,"CREATE_TIME" DATE NULL ,"STATUS" CHAR(1 BYTE) NOT NULL);COMMENT ON COLUMN "SCOTT"."T_USER"."USERNAME" IS '用户名';COMMENT ON COLUMN "SCOTT"."T_USER"."PASSWD" IS '密码';COMMENT ON COLUMN "SCOTT"."T_USER"."CREATE_TIME" IS '创建时间';COMMENT ON COLUMN "SCOTT"."T_USER"."STATUS" IS '是否有效 1:有效 0:锁定';-- ------------------------------ Records of T_USER-- ----------------------------INSERT INTO "SCOTT"."T_USER" VALUES ('2', 'test', '7a38c13ec5e9310aed731de58bbc4214', TO_DATE('2017-11-19 17:20:21', 'YYYY-MM-DD HH24:MI:SS'), '0');INSERT INTO "SCOTT"."T_USER" VALUES ('1', 'mrbird', '42ee25d1e43e9f57119a00d0a39e5250', TO_DATE('2017-11-19 10:52:48', 'YYYY-MM-DD HH24:MI:SS'), '1');-- ------------------------------ Primary Key structure for table T_USER-- ----------------------------ALTER TABLE "SCOTT"."T_USER" ADD PRIMARY KEY ("ID");
数据源的配置参考Spring Boot中使用MyBatis。
库表对应的实体类:
public class User implements Serializable{private static final long serialVersionUID = -5440372534300871944L;private Integer id;private String userName;private String password;private Date createTime;private String status;// get,set略}
定义接口UserMapper:
@Mapperpublic interface UserMapper {User findByUserName(String userName);}
xml实现:
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"><mapper namespace="com.springboot.dao.UserMapper"><resultMap type="com.springboot.pojo.User" id="User"><id column="id" property="id" javaType="java.lang.Integer" jdbcType="NUMERIC"/><id column="username" property="userName" javaType="java.lang.String" jdbcType="VARCHAR"/><id column="passwd" property="password" javaType="java.lang.String" jdbcType="VARCHAR"/><id column="create_time" property="createTime" javaType="java.util.Date" jdbcType="DATE"/><id column="status" property="status" javaType="java.lang.String" jdbcType="VARCHAR"/></resultMap><select id="findByUserName" resultMap="User">select * from t_user where username = #{userName}</select></mapper>
数据层准备完了,接下来编写login.html和index.html页面。
页面准备
编写登录页面login.html:
<!DOCTYPE html><html xmlns:th="http://www.thymeleaf.org"><head><meta charset="UTF-8"><title>登录</title><link rel="stylesheet" th:href="@{/css/login.css}" type="text/css"><script th:src="@{/js/jquery-1.11.1.min.js}"></script></head><body><div class="login-page"><div class="form"><input type="text" placeholder="用户名" name="username" required="required"/><input type="password" placeholder="密码" name="password" required="required"/><button onclick="login()">登录</button></div></div></body><script th:inline="javascript">var ctx = [[@{/}]];function login() {var username = $("input[name='username']").val();var password = $("input[name='password']").val();$.ajax({type: "post",url: ctx + "login",data: {"username": username,"password": password},dataType: "json",success: function (r) {if (r.code == 0) {location.href = ctx + 'index';} else {alert(r.msg);}}});}</script></html>
主页index.html:
<!DOCTYPE html><html xmlns:th="http://www.thymeleaf.org"><head><meta charset="UTF-8"><title>首页</title></head><body><p>你好![[${user.userName}]]</p><a th:href="@{/logout}">注销</a></body></html>
页面准备完毕,接下来编写LoginController。
Controller
LoginController代码如下:
@Controllerpublic class LoginController {@GetMapping("/login")public String login() {return "login";}@PostMapping("/login")@ResponseBodypublic ResponseBo login(String username, String password) {// 密码MD5加密password = MD5Utils.encrypt(username, password);UsernamePasswordToken token = new UsernamePasswordToken(username, password);// 获取Subject对象Subject subject = SecurityUtils.getSubject();try {subject.login(token);return ResponseBo.ok();} catch (UnknownAccountException e) {return ResponseBo.error(e.getMessage());} catch (IncorrectCredentialsException e) {return ResponseBo.error(e.getMessage());} catch (LockedAccountException e) {return ResponseBo.error(e.getMessage());} catch (AuthenticationException e) {return ResponseBo.error("认证失败!");}}@RequestMapping("/")public String redirectIndex() {return "redirect:/index";}@RequestMapping("/index")public String index(Model model) {// 登录成后,即可通过Subject获取登录的用户信息User user = (User) SecurityUtils.getSubject().getPrincipal();model.addAttribute("user", user);return "index";}}
登录成功后,根据之前在ShiroConfig中的配置shiroFilterFactoryBean.setSuccessUrl("/index"),页面会自动访问/index路径。
测试
最终项目目录如下图所示:

启动项目,分别访问:
- http://localhost:8080/web/
- http://localhost:8080/web/index
- http://localhost:8080/web/aaaaaaa
- http://localhost:8080/web
可发现页面都被重定向到 http://localhost:8080/web/login:

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

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

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

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