阅读 1108

Shiro-认证

0) 前言

上一篇[shiro-初体验]中讲解了Shiro的简单用法, 实现了URL是否需要登录访问, 当未登录访问URL时自动跳转至登录页.

本篇主要讲解在Shiro如何实现登录处理. 先简单说一下Shiro的登录处理流程.

Shiro的登录处理是在authc过滤器中. authc会判断如果是登录请求会单独处理, 因此登录请求必须要配置成authc.

其中, 登录请求包括两个:

  • 访问登录: 进入登录页面
  • 提交登录: 登录页点击登录按钮发出的请求

Shiro判断是否是登录请求时认这两个登录请求必须是同一个地址, 并且GET为访问登录页, POST为提交登录

// 登录请求(包括访问登录页和提交登录)
if (isLoginRequest(request, response)) {
    // 提交登录
    if (isLoginSubmission(request, response)) {
        // 提交登录, 执行Shiro的登录逻辑
        return executeLogin(request, response);
    } else {
        // 访问登录请求, 继续执行进入控制器
        return true;
    }
}
复制代码

上一篇中, 我们访问登录页的请求为/login.jsp, 直接访问登录JSP, 如果我们在用POST访问JSP显然是不合理的使用JSP了.

因此, 我们将登录请求修改为/login, 在控制器中对GETPOST进行处理. 当修改了登录请求地址时需要在Shiro配置一下

// Shiro核心配置
shiroFilter(ShiroFilterFactoryBean) {
    // 登录URL(包括请求登录页和提交登录)
    // 自定义的登录URL必须单独设置
    loginUrl = "/login"
    
    // ....
}
复制代码

相应的,在控制器中也增加两个方法分别处理登录请求和提交登录

// 处理请求登录页面
@GetMapping("/login")
public String toLogin() {
    return "/login";
}
 
// 处理提交登录
@PostMapping("/login")
public String login() {
    System.out.println("处理提交登录");
    return "/success";
}
复制代码

登录页面: login.jsp

<form action="/login" method="POST">
	<input type="text" name="username" placeholder="用户名" value="" />
	<input type="password" name="password" placeholder="密码" value="" />
	<input type="submit" value="立即登录" />
</form>
复制代码

完成上述操作后启动项目, 访问/page/a时, 由于未登录Shiro会重定向至/login, 在登录页面输入用户名和密码, 点击立即登录按钮后会以POST方式提交至/login, Shiro就会处理本次登录请求了.

  • 用户名和密码的name必须为username和password (Shiro会从Request中取这两个参数名的值作为用户名和密码)
  • 请求必须是POST, 请求地址必须和Shiro配置文件中的loginUrl保持一致.

那么, 问题来了, Shiro怎么知道输入的用户名和密码是否正确呢?

答案一定是不知道, 因此, 需要我们对用户名和密码进行验证后将结果告诉Shiro. 那么如何实现自定义验证呢?

1) 自定义Realm

Shiro对Realm的定义: 一个可以访问系统安全相关信息(例如用户, 角色, 权限等)的组件. 通俗的说, 就是在Realm实现写查询用户, 角色, 权限等系统安全相关的数据的方法.

用户信息一般会保存在数据库中, 我们可以在Realm中通过登录页面传递的用户名去数据库查询用户, 将结果返回给Shiro.

然而Shiro并不知道用户名和密码是否正确, 所以提供了Realm组件, 让我们在Realm中查询用户相关信息并返回, Shiro根据Realm返回结果判断是否登录成功.

举个例子

你在相亲的时候要请女生吃饭, 你也不知道每次相亲的女生喜欢吃什么. 但针对每个菜系都你准备好了相应的餐厅. 聪明的你准备了一个小盒子, 相亲时让女生把想吃的写好放到盒子里面, 然后你根据盒子里面的内容到事先准备好的餐厅去吃饭. 至于女生是用铅笔写的, 还是钢笔写的你根本不会关心, 你只关心女生想吃什么.

上例中的你相当于Shiro, 准备好各种餐厅相当于实现了各种登录的逻辑, 小盒子就相当于Realm, 女生写的纸条相当于实现了一个Realm, 纸条上的内容相当于查询到的用户信息. 至于是用铅笔还是钢笔写则相当于用户信息获取方式(数据库,文件或其他).

Shiro只关心返回的结果, 不会关心Realm查询用户信息的实现过程. 下面我们来实现一个Realm

// 自定义查询用户信息的Realm
public class UserRealm extends AuthenticatingRealm {
 
    // 获取用户信息的方法
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        // 登录用户名
        // Shiro会将提交登录传入的用户名和密码封装到UsernamePasswordToken中
        String username = ((UsernamePasswordToken) token).getUsername();
 
        // 根据用户名从数据库或其他存储中查询用户信息
        // 模拟数据库查询, 返回用户信息
        User dbUser = getUser(username);
 
        // 用户不存在,当返回null时Shiro会认为用信息不存在
        if (dbUser == null) {
            return null;
        }
 
        // 将查询到用户信息返回给Shiro
        // 参数1: Shiro会将该参数作为当前登录用户的信息保存,随时可取
        // 参数2: 当前用户的密码,Shiro使用该参数和提交登录传递的密码进行判断
        // 参数3: Realm名称,暂不处理
        return new SimpleAuthenticationInfo(dbUser, dbUser.getPassword(), "");
    }
 
}
复制代码
  • 继承AuthenticatingRealm并实现获取用户信息的doGetAuthenticationInfo方法
  • 根据用户名查询到用户信息时返回Shiro需要的AuthenticationInfo对象(内置多种返回对象,稍后介绍)
  • 未查询到用户时返回null, 返回结果为null时Shiro会按照用户不存在进行处理, 本次登录失败
  • 密码是否争取判断不在该方法中进行,Shiro会根据返回结果进行判断,密码正确时登录成功. 错误时本次登录失败
  • Shiro不关心获取用户信息的方式, 无论是数据库查询还是文件查询,或是第三方接口,只要按照格式返回即可.
  • Shiro会将返回结果第一个参数对象保存,登录成功后可通过Shiro的方法获取登录用户的相关信息(例获取登录用户ID等)

本例未连接数据库, 模拟代码:

// 模拟根据用户名在数据库查询用户信息
private User getUser(String username) {
    // 使用"atd681"作为登录密码才能查到信息
    if (!"atd681".equals(username)) {
        return null;
    }
 
    User dbUser = new User();
    dbUser.setUserId(1L);
    dbUser.setUsername(username);
    dbUser.setPassword("123");
 
    return dbUser;
}
复制代码
  • 有效登录用户名:atd681, 密码:123, 用户ID:1
  • 其余用户名登录失败

2) 配置Realm

自定义Realm后需要告知Shiro哪个Realm是查询用户信息的, 即将Realm配置到Shiro中

// 安全管理器
securityManager(DefaultWebSecurityManager) { 
    realm = ref("userRealm") 
}
 
// 定义Realm
userRealm(UserRealm)
复制代码
  • 在Shiro配置文件中定义Realm
  • 将Realm配置到安全管理器securityManager中

启动项目, 访问/page/a, 未登录时Shiro重定向至登录页面. 输入atd681/123即可登录成功并跳转/page/a

3) 配置默认成功页

当登录成功后, Shiro会重定向到成功页面

  • 当访问其他页面(/page/a)跳转至登录时, 登录成功会跳转至目标页面(/page/a)
  • 直接访问登录页(无目标页), 登录成功后跳转至默认成功页

Shiro默认成功页为/, 可自定义默认成功页

// Shiro核心配置
shiroFilter(ShiroFilterFactoryBean) {
    // 默认登录成功后跳转的页面地址
    successUrl = "/index"
 
    // 其他配置...
}
复制代码

4) 处理登录失败

登录成功后并没有执行到控制器中的处理POST登录的方法. 输入atd681以外的账号或输入错误密码会导致登录失败, 却会执行控制器中的处理POST登录的方法. 为什么呢???

Shiro的登录逻辑:

  • 访问登录页面时, Shiro不处理, 进入控制器
  • 登录成功后, 直接重定向至成功页面(不进入控制器)
  • 登录失败时, 进入控制器处理, 由控制器决定登录失败页面

登录失败时, Shiro用异常表示失败原因, 并将失败原因保存在Request中, key为shiroLoginFailure, Shiro登录逻辑中会抛出如下异常:

  • 用户不存在: org.apache.shiro.authc.UnknownAccountException
  • 密码不正确: org.apache.shiro.authc.IncorrectCredentialsException

同时内置了如下异常, 方便用户自行验证时抛出:

  • 无效的用户: org.apache.shiro.authc.DisabledAccountException
  • 锁定的用户: org.apache.shiro.authc.LockedAccountException
  • 失败数过多: org.apache.shiro.authc.ExcessiveAttemptsException
  • 用户已登录: org.apache.shiro.authc.ConcurrentAccessException

登录失败时可以根据异常在页面中显示相应的错误提示信息, 本例登录失败时返回登录页并显示错误信息

<!-- 有登录错误信息时,根据异常显示对应的提示信息 -->
<c:if test="${shiroLoginFailure != null}">
	<c:if test="${shiroLoginFailure == 'org.apache.shiro.authc.UnknownAccountException'}">用户不存在</c:if>
	<c:if test="${shiroLoginFailure == 'org.apache.shiro.authc.IncorrectCredentialsException'}">密码不正确</c:if>
</c:if>
<!-- 无登录错误时 -->
<c:if test="${shiroLoginFailure == null}">你访问的页面需要先进行登录</c:if>
 
<form action="/login" method="post">
	<input type="text" name="username" placeholder="用户名" value="" />
	<input type="password" name="password" placeholder="密码" value="" />
	<input type="submit" value="立即登录" />
</form>
复制代码

5) 登出

配置登出URL使用logout过滤器即可. Shiro登出后默认重定向至登录页.

// Shiro核心配置
shiroFilter(ShiroFilterFactoryBean) {
    // 配置URL规则
    // 有请求访问时Shiro会根据此规则找到对应的过滤器处理
    filterChainDefinitionMap = [
        "/page/n" : "anon", // /page/n不需要登录即可访问
        "/logout" : "logout", // 登出使用logout过滤器
        "/**": "authc" // 其余所有页面需要认证(authc为认证过滤器)
    ]
 
    // 其他配置 ....   
}
复制代码

如登出后自定义重定向页面, 需要在配置文件中手动定义logout过滤器(未定义时Shiro会通过Spring自动加载)

// 手动定义Logout过滤器
// 未定义时Shiro会通过Spring自动加载
logout(LogoutFilter){
    redirectUrl = "/logout_success.jsp"
}
复制代码

同时, 必须配置logout_success.jsp不需要登录也可以访问(anon), 如果不配置, 登出后进入logout_success.jsp不需要时会被Shiro拦截(此时未登录)并重定向至登录(登录成功后会重定向至logout_success.jsp)

"/logout_success.jsp" : "anon", // 登出成功页不需要认证
复制代码

6) 获取登录用户信息

1) 自定义Realm中提到获取的登录用户信息在登录成功后会被Shiro保存. Shiro提供了可以获取登录用户信息的方法.

@RequestMapping("/page/a")
public String toPageA(ModelMap map) {
    // Shiro提供的获取当前登录用户信息的静态方法
    // 用户信息对象为在Realm中保存的对象
    User user = (User) SecurityUtils.getSubject().getPrincipal();
    // 获取用户ID,用户名
    map.put("userId", user.getUserId());
    map.put("userName", user.getUsername());
 
    return "/page_a";
}
复制代码

获取到的用户对象必须和在Realm中返回SimpleAuthenticationInfo对象中第一个参数一致

7) 示例代码

至此, 基于Shiro认证的示例配置完成.