springboot整合shiro与jwt

jwt 介绍

JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed. JWTs can be signed using a secret (with the HMAC algorithm) or a public/private key pair using RSA or ECDSA.

硬翻译:JSON Web令牌(JWT)是一个开放标准(RFC 7519),它定义了一种紧凑而独立的方法,用于在各方之间安全地将信息作为JSON对象传输。由于此信息是经过数字签名的,因此可以被验证和信任。可以使用秘密(使用HMAC算法)或使用RSA或ECDSA的公用/专用密钥对对JWT进行签名。

Although JWTs can be encrypted to also provide secrecy between parties, we will focus on signed tokens. Signed tokens can verify the integrity of the claims contained within it, while encrypted tokens hide those claims from other parties. When tokens are signed using public/private key pairs, the signature also certifies that only the party holding the private key is the one that signed it.

硬翻译:尽管可以对JWT进行加密以提供双方之间的保密性,但我们将重点关注已签名的令牌。签名的令牌可以验证其中包含的声明的完整性,而加密的令牌则将这些声明隐藏在其他方的面前。当使用公钥/私钥对对令牌进行签名时,签名还证明只有持有私钥的一方才是对其进行签名的一方。

—— jwt 官网介绍

jwt 能做的事

  • Authorization: This is the most common scenario for using JWT. Once the user is logged in, each subsequent request will include the JWT, allowing the user to access routes, services, and resources that are permitted with that token. Single Sign On is a feature that widely uses JWT nowadays, because of its small overhead and its ability to be easily used across different domains.

    授权:这是使用JWT的最常见方案。一旦用户登录,每个后续请求将包括JWT,从而允许用户访问该令牌允许的路由,服务和资源。单一登录是当今广泛使用JWT的一项功能,因为它的开销很小并且可以在不同的域中轻松使用。

  • Information Exchange: JSON Web Tokens are a good way of securely transmitting information between parties. Because JWTs can be signed—for example, using public/private key pairs—you can be sure the senders are who they say they are. Additionally, as the signature is calculated using the header and the payload, you can also verify that the content hasn’t been tampered with.

    信息交换:JSON Web令牌是在各方之间安全传输信息的一种好方法。因为可以对JWT进行签名(例如,使用公钥/私钥对),所以您可以确保发件人是他们所说的人。此外,由于签名是使用标头和有效负载计算的,因此您还可以验证内容是否未被篡改。

jwt 组成结构

  • Header
  • Payload
  • Signature

整体看起来像:

1
xxxxx.yyyyy.zzzzz

header 通常由两部分组成:令牌的类型为JWT,以及所使用的签名算法,例如HMAC SHA256或RSA。

例如:

1
2
3
4
{
"alg": "HS256",
"typ": "JWT"
}

此JSON被Base64Url编码以形成JWT的第一部分。

payload 令牌的第二部分是有效负载,其中包含声明。声明是有关实体(通常是用户)和其他数据的声明。共有三种类型的索赔:注册,公共和私人索赔。

例如:

1
2
3
4
5
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}

此JSON被Base64Url编码以形成JWT的第二部分。

Signature 要创建签名部分,您必须获取编码的标头,编码的有效载荷,机密,标头中指定的算法,并对其进行签名。

例如,如果要使用HMAC SHA256算法,则将通过以下方式创建签名

1
2
3
4
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
**最后把他们连在一起看起来了像这样**

image-20201226101439507

java 中使用

官网 提供了多种 Java 的实现。具体不同实现之间的区别请参考这篇博客:https://andaily.com/blog/?p=956

image-20201226101958285

这里以 jose4j 为例 (官方代码:https://bitbucket.org/b_c/jose4j/wiki/JWT%20Examples)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
//
// JSON Web Token is a compact URL-safe means of representing claims/attributes to be transferred between two parties.
// This example demonstrates producing and consuming a signed JWT
//

// Generate an RSA key pair, which will be used for signing and verification of the JWT, wrapped in a JWK
RsaJsonWebKey rsaJsonWebKey = RsaJwkGenerator.generateJwk(2048);

// Give the JWK a Key ID (kid), which is just the polite thing to do
rsaJsonWebKey.setKeyId("k1");

// Create the Claims, which will be the content of the JWT
JwtClaims claims = new JwtClaims();
claims.setIssuer("Issuer"); // who creates the token and signs it
claims.setAudience("Audience"); // to whom the token is intended to be sent
claims.setExpirationTimeMinutesInTheFuture(10); // time when the token will expire (10 minutes from now)
claims.setGeneratedJwtId(); // a unique identifier for the token
claims.setIssuedAtToNow(); // when the token was issued/created (now)
claims.setNotBeforeMinutesInThePast(2); // time before which the token is not yet valid (2 minutes ago)
claims.setSubject("subject"); // the subject/principal is whom the token is about
claims.setClaim("email","mail@example.com"); // additional claims/attributes about the subject can be added
List<String> groups = Arrays.asList("group-one", "other-group", "group-three");
claims.setStringListClaim("groups", groups); // multi-valued claims work too and will end up as a JSON array

// A JWT is a JWS and/or a JWE with JSON claims as the payload.
// In this example it is a JWS so we create a JsonWebSignature object.
JsonWebSignature jws = new JsonWebSignature();

// The payload of the JWS is JSON content of the JWT Claims
jws.setPayload(claims.toJson());

// The JWT is signed using the private key
jws.setKey(rsaJsonWebKey.getPrivateKey());

// Set the Key ID (kid) header because it's just the polite thing to do.
// We only have one key in this example but a using a Key ID helps
// facilitate a smooth key rollover process
jws.setKeyIdHeaderValue(rsaJsonWebKey.getKeyId());

// Set the signature algorithm on the JWT/JWS that will integrity protect the claims
jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.RSA_USING_SHA256);

// Sign the JWS and produce the compact serialization or the complete JWT/JWS
// representation, which is a string consisting of three dot ('.') separated
// base64url-encoded parts in the form Header.Payload.Signature
// If you wanted to encrypt it, you can simply set this jwt as the payload
// of a JsonWebEncryption object and set the cty (Content Type) header to "jwt".
String jwt = jws.getCompactSerialization();


// Now you can do something with the JWT. Like send it to some other party
// over the clouds and through the interwebs.
System.out.println("JWT: " + jwt);


// Use JwtConsumerBuilder to construct an appropriate JwtConsumer, which will
// be used to validate and process the JWT.
// The specific validation requirements for a JWT are context dependent, however,
// it typically advisable to require a (reasonable) expiration time, a trusted issuer, and
// and audience that identifies your system as the intended recipient.
// If the JWT is encrypted too, you need only provide a decryption key or
// decryption key resolver to the builder.
JwtConsumer jwtConsumer = new JwtConsumerBuilder()
.setRequireExpirationTime() // the JWT must have an expiration time
.setAllowedClockSkewInSeconds(30) // allow some leeway in validating time based claims to account for clock skew
.setRequireSubject() // the JWT must have a subject claim
.setExpectedIssuer("Issuer") // whom the JWT needs to have been issued by
.setExpectedAudience("Audience") // to whom the JWT is intended for
.setVerificationKey(rsaJsonWebKey.getKey()) // verify the signature with the public key
.setJwsAlgorithmConstraints( // only allow the expected signature algorithm(s) in the given context
AlgorithmConstraints.ConstraintType.PERMIT, AlgorithmIdentifiers.RSA_USING_SHA256) // which is only RS256 here
.build(); // create the JwtConsumer instance

try
{
// Validate the JWT and process it to the Claims
JwtClaims jwtClaims = jwtConsumer.processToClaims(jwt);
System.out.println("JWT validation succeeded! " + jwtClaims);
}
catch (InvalidJwtException e)
{
// InvalidJwtException will be thrown, if the JWT failed processing or validation in anyway.
// Hopefully with meaningful explanations(s) about what went wrong.
System.out.println("Invalid JWT! " + e);

// Programmatic access to (some) specific reasons for JWT invalidity is also possible
// should you want different error handling behavior for certain conditions.

// Whether or not the JWT has expired being one common reason for invalidity
if (e.hasExpired())
{
System.out.println("JWT expired at " + e.getJwtContext().getJwtClaims().getExpirationTime());
}

// Or maybe the audience was invalid
if (e.hasErrorCode(ErrorCodes.AUDIENCE_INVALID))
{
System.out.println("JWT had wrong audience: " + e.getJwtContext().getJwtClaims().getAudience());
}
}
}

运行下 控制台输出为:

1
2
JWT: eyJraWQiOiJrMSIsImFsZyI6IlJTMjU2In0.eyJpc3MiOiJJc3N1ZXIiLCJhdWQiOiJBdWRpZW5jZSIsImV4cCI6MTYwOTAzMzExOSwianRpIjoiczlLa2hzMEZpOTU1QjNWaW0tZkV1dyIsImlhdCI6MTYwOTAzMjUxOSwibmJmIjoxNjA5MDMyMzk5LCJzdWIiOiJzdWJqZWN0IiwiZW1haWwiOiJtYWlsQGV4YW1wbGUuY29tIiwiZ3JvdXBzIjpbImdyb3VwLW9uZSIsIm90aGVyLWdyb3VwIiwiZ3JvdXAtdGhyZWUiXX0.ag7oSV0pW6z0N8YmDEfEoXyZWgycSLHsbMjP46dH0cbpamI5cW7jdYHAom-LPviIvTE8k5raWJktgYu0s5vxMzJON95vzQvsxzHVS0kSrKOUZVONRWn4M7mZa56gV9wVYFlcisF-hejNDS-vV7gJN5HEGaU8iiLyYFtSXEftSIj_kbKALCswWc1cRKfJOVnLhMy4xEM3VXevcsRWpOVtt_-h_C4wEzdRPvnuqMKUZwjVwiKV8VDbTnuQCPn9fQ8OThjSZ4lMhTNI2bgls0uglEeEOL5iFGW_orzSv0trW2FbENlwAi41RjIpi3_ga3wPQUPzi6-Pf6YrM5rVVZ3xKg
JWT validation succeeded! JWT Claims Set:{iss=Issuer, aud=Audience, exp=1609033119, jti=s9Kkhs0Fi955B3Vim-fEuw, iat=1609032519, nbf=1609032399, sub=subject, email=mail@example.com, groups=[group-one, other-group, group-three]}

springboot 集成 shiro

吐槽 :去 shiro 官网查看文档,没有看到 springboot 相关的内容,最接近的也只是 Java web app 的文档。然后百度了大量的博客。发现了 shiro 的启动器。然后去 maven 仓库查 shiro 的启动器。但是不知道怎么用。image-20201227121931090

难道别人家的 springboot 的例子配置都是自己看源码得出的么。然后继续百度。终于有人说到了。

image-20201227122120935

传统web方式(前后端不分离)
  • shiro pom 依赖

    1
    2
    3
    4
    5
    <dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring-boot-web-starter</artifactId>
    <version>1.7.0</version>
    </dependency>
  • application.properties

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    #启用/禁用 shiro starter 默认 true
    shiro.web.enabled=true

    #这里如果配置了 url 。
    # shiro 的认证 filter 会自动将 post /login 的请求视作登录 并从请求中获取参数 来执行 shiro 的 login 逻辑。
    # 且 shiro 会将 get /login 视作获取登录页面。当未认证的请求被拦截后 shiro 会重定向到 get /login.
    # 注意:如果没配置 loginUrl 那么 shiro 会默认把 get /login.jsp 路径当作登录页(即认证失败会往这里跳转让你去登录)
    # 相关源代码 参看 FormAuthenticationFilter.onAccessDenied() 方法。
    #shiro.loginUrl=/login

    #如果配置了此参数。shiro 的认证 filter 在处理成功登录后会跳转到这里的url。
    #注意:shiro 会记住上次登录的地址。如果有上次访问的地址 那么会重定向上次的地址。
    # 相关源代码 参看 FormAuthenticationFilter.onLoginSuccess()
    #shiro.successUrl=/success
  • ShiroConfig.class

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    package com.wzy.platform.config;

    import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
    import org.apache.shiro.realm.Realm;
    import org.apache.shiro.spring.web.config.DefaultShiroFilterChainDefinition;
    import org.apache.shiro.spring.web.config.ShiroFilterChainDefinition;
    import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
    import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    /**
    * @ClassName ShiroConfig
    * @Author wuzhiyong
    * @Date 2020/12/27 12:26
    * @Version 1.0
    **/
    @Configuration
    public class ShiroConfig {
    /**
    * 配置密码加密 规则
    * @return
    */
    @Bean
    public HashedCredentialsMatcher hashedCredentialsMatcher() {
    HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
    //md5加密1次
    hashedCredentialsMatcher.setHashAlgorithmName("md5");
    hashedCredentialsMatcher.setHashIterations(1);
    return hashedCredentialsMatcher;
    }

    @Bean
    public Realm realm() {
    ShiroCustomRealm customRealm = new ShiroCustomRealm();
    customRealm.setCredentialsMatcher(hashedCredentialsMatcher());
    return customRealm;
    }
    @Bean
    public DefaultWebSecurityManager securityManager() {
    DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
    securityManager.setRealm(realm());
    return securityManager;
    }

    @Bean
    public ShiroFilterChainDefinition shiroFilterChainDefinition() {
    DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();


    chainDefinition.addPathDefinition("/static/**", "anon");
    chainDefinition.addPathDefinition("/login", "anon");
    // 其他的权限这里我们都通过注解的方式来控制
    // logged in users with the 'admin' role
    // chainDefinition.addPathDefinition("/admin/**", "authc, roles[admin]");
    // // logged in users with the 'document:read' permission
    // chainDefinition.addPathDefinition("/docs/**", "authc, perms[document:read]");

    // chainDefinition.addPathDefinition("/logout", "authc");
    // chainDefinition.addPathDefinition("/**", "anon");
    return chainDefinition;
    }

    public static DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator() {
    DefaultAdvisorAutoProxyCreator creator = new DefaultAdvisorAutoProxyCreator();
    /**
    * setUsePrefix(false)用于解决一个奇怪的bug。在引入spring aop的情况下。
    * 在@Controller注解的类的方法中加入@RequiresRole注解,会导致该方法无法映射请求,导致返回404。
    * 加入这项配置能解决这个bug
    */
    creator.setUsePrefix(true);
    return creator;
    }
    }

  • ShiroCustomRealm.class

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    package com.wzy.platform.config;

    import com.wzy.platform.model.SysUser;
    import com.wzy.platform.service.SysPermissionService;
    import com.wzy.platform.service.SysRoleService;
    import com.wzy.platform.service.SysUserService;
    import org.apache.shiro.authc.*;
    import org.apache.shiro.authz.AuthorizationInfo;
    import org.apache.shiro.authz.SimpleAuthorizationInfo;
    import org.apache.shiro.realm.AuthorizingRealm;
    import org.apache.shiro.subject.PrincipalCollection;
    import org.apache.shiro.util.ByteSource;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.beans.factory.annotation.Autowired;

    import java.util.List;
    /**
    * @ClassName ShiroCustomRealm
    * @Author wuzhiyong
    * @Date 2020/12/28 8:40
    * @Version 1.0
    **/
    public class ShiroCustomRealm extends AuthorizingRealm {
    private static final Logger LOGGER = LoggerFactory.getLogger(ShiroCustomRealm.class);
    @Autowired
    private SysUserService sysUserService;
    @Autowired
    private SysPermissionService sysPermissionService;
    @Autowired
    private SysRoleService sysRoleService;

    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
    SysUser sysUser = (SysUser) principals.getPrimaryPrincipal();
    //查询 权限
    List<String> sysPermissions = sysPermissionService.selectPermissionByUserId(sysUser.getUserId());
    //查询 角色
    List<String> sysRoles = sysRoleService.selectRolesByUserId(sysUser.getUserId());
    SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
    info.addStringPermissions(sysPermissions);
    info.addRoles(sysRoles);
    LOGGER.info("doGetAuthorizationInfo");
    return info;
    }

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
    UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken;
    //通过 用户名 查询用户对象
    SysUser sysUser = sysUserService.findByUserName(token.getUsername());
    if (sysUser == null) {
    return null;
    }
    LOGGER.info("doGetAuthenticationInfo");
    return new SimpleAuthenticationInfo(sysUser, sysUser.getPassword().toCharArray(), ByteSource.Util.bytes(sysUser.getSalt()), getName());
    }
    }

  • LoginController.class

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    package com.wzy.platform.web.mvc;

    import org.apache.shiro.SecurityUtils;
    import org.apache.shiro.authc.DisabledAccountException;
    import org.apache.shiro.authc.IncorrectCredentialsException;
    import org.apache.shiro.authc.UnknownAccountException;
    import org.apache.shiro.authc.UsernamePasswordToken;
    import org.apache.shiro.authz.annotation.RequiresAuthentication;
    import org.apache.shiro.subject.Subject;
    import org.springframework.stereotype.Controller;
    import org.springframework.ui.Model;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.ResponseBody;
    /**
    * @ClassName LoginController
    * @Author wuzhiyong
    * @Date 2020/12/28 22:26
    * @Version 1.0
    **/
    @Controller
    public class LoginController {

    @GetMapping({"/","/index"})
    public String index(){
    return "index";
    }

    @GetMapping("/success")
    public String success(){
    return "success";
    }

    /**
    * get请求,登录页面跳转
    * @return
    */
    @GetMapping("/login")
    public String login(Model model) {
    //如果已经认证通过,直接跳转到首页
    if (SecurityUtils.getSubject().isAuthenticated()) {
    return "redirect:/index";
    }
    model.addAttribute("message","wuzhiyong");
    return "login";
    }

    /**
    * post表单提交,登录
    * @param username
    * @param password
    * @param model
    * @return
    */
    @PostMapping("/login")
    public Object login(String username, String password, Model model) {
    Subject user = SecurityUtils.getSubject();
    UsernamePasswordToken token = new UsernamePasswordToken(username, password);
    try {
    //shiro帮我们匹配密码什么的,我们只需要把东西传给它,它会根据我们在UserRealm里认证方法设置的来验证
    user.login(token);
    return "redirect:/success";
    } catch (UnknownAccountException e) {
    //账号不存在和下面密码错误一般都合并为一个账号或密码错误,这样可以增加暴力破解难度
    model.addAttribute("message", "账号不存在!");
    } catch (DisabledAccountException e) {
    model.addAttribute("message", "账号未启用!");
    } catch (IncorrectCredentialsException e) {
    model.addAttribute("message", "密码错误!");
    } catch (Throwable e) {
    model.addAttribute("message", "未知错误!");
    }
    return "success";
    }

    /**
    * 退出
    * @return
    */
    @RequiresAuthentication
    @GetMapping("/logout")
    public String logout() {
    SecurityUtils.getSubject().logout();
    return "redirect:/login";
    }
    }

三个简单页面:

  • index.html

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8">
    <title>index</title>
    </head>
    <body>
    index page
    </body>
    </html>
  • login.html

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    <!DOCTYPE html>
    <html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
    <head>
    <meta charset="UTF-8">
    <title>Title</title>
    </head>
    <body>
    <h1 th:text="${message}">${message}</h1>
    <form method="post" action="/login">
    用户名:<input name="username"><br>
    密码:<input name="password"><br>
    <input type="submit" value="登录">
    </form>
    </body>
    </html>
  • success.html

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8">
    <title>test success</title>
    </head>
    <body>
    test success!
    </body>
    </html>

权限注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
package com.wzy.platform.web.rest;

import com.wzy.platform.service.UserService;
import org.apache.shiro.authz.annotation.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @ClassName UserController
* @Author wuzhiyong
* @Date 2020/12/6 15:05
* @Version 1.0
**/
@RestController
@RequestMapping("/user")
public class UserController {

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

@Autowired
UserService userService;

@GetMapping("/test")
public Object testUser(){
return userService.list();
}

/**
* 需要 登录 才能访问的
* @return
*/
@RequiresAuthentication
@GetMapping("/requiresAuthentication")
public Object requiresAuthentication(){
return "RequiresAuthentication";
}

/**
* 游客访问的
* @return
*/
@RequiresGuest
@GetMapping("/requiresGuest")
public Object requiresGuest(){
return "RequiresGuest";
}

/**
* 需要 xx权限 才能访问的
* logical 参数 表示多个权限(角色)之间的逻辑关系,默认为 AND
* @return
*/
@RequiresPermissions("m@sys:role")
// @RequiresPermissions(value = "m@sys:role")
// @RequiresPermissions(value = {"m@sys:role","m@sys:usr"})
// @RequiresPermissions(value = {"m@sys:role","m@sys:usr"},logical = Logical.OR)
@GetMapping("/requiresPermissions")
public Object requiresPermissions(){
return "RequiresPermissions";
}

/**
* 需要 xx角色才能访问的
* logical 参数 表示多个权限(角色)之间的逻辑关系,默认为 AND
* @return
*/
@RequiresRoles("普通用户")
// @RequiresRoles(value = "普通用户")
// @RequiresRoles(value = {"普通用户","用户管理员"})
// @RequiresRoles(value = {"普通用户","用户管理员"},logical = Logical.OR)
@GetMapping("/requiresRoles")
public Object requiresRoles(){
return "RequiresRoles";
}

/**
* 需要 xx角色 和 xx权限 才能访问的
* Shiro的认证注解处理是有内定的处理顺序的,如果有多个注解的话,前面的通过了会继续检查后面的,
若不通过则直接返回,处理顺序依次为(与实际声明顺序无关):
* RequiresRoles
* RequiresPermissions
* RequiresAuthentication
* RequiresUser
* RequiresGuest
* @return
*/
@RequiresPermissions(value = {"m@sys:role","m@sys:usr"},logical = Logical.OR)
@RequiresRoles("普通用户")
@GetMapping("/requiresRolesPermissions")
public Object requiresRolesPermissions(){
return "RequiresRoles";
}

/**
* 需要 登录 或 ‘记住我’ 才能访问的
* @return
*/
@RequiresUser
@GetMapping("/requiresUser")
public Object requiresUser(){
return "RequiresUser";
}

}

用户与密码

id user_name full_name password salt
1 zhangsan 张三 86fb1b048301461bdc71d021d2af3f97 1
2 lisi 李四 c9351e5cf153923f052ebe1462cca93a 2
3 wangwu 王五 92262648696eae1b0a321cbd2b238bf2 3
4 user1 用户1 86fb1b048301461bdc71d021d2af3f97 4

密码可通过如下方法加密:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import org.apache.shiro.crypto.hash.SimpleHash;
import org.apache.shiro.util.ByteSource;
import org.springframework.boot.test.context.SpringBootTest;

class Test {

public static void main(String[] args) {
//参数1:md5 方式
//参数2:密码
//参数3:盐
//参数4:加密次数
SimpleHash simpleHash = new SimpleHash("md5","zhangsan".toCharArray(), ByteSource.Util.bytes("1"),1);
System.out.println(simpleHash.toHex());
}

}

优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package org.apache.shiro.web.util;	

public class WebUtils {
public static void redirectToSavedRequest(ServletRequest request, ServletResponse response, String fallbackUrl)
throws IOException {
String successUrl = null;
boolean contextRelative = true;
SavedRequest savedRequest = WebUtils.getAndClearSavedRequest(request);
if (savedRequest != null && savedRequest.getMethod().equalsIgnoreCase(AccessControlFilter.GET_METHOD)) {
successUrl = savedRequest.getRequestUrl();
contextRelative = false;
}

if (successUrl == null) {
successUrl = fallbackUrl;
}

if (successUrl == null) {
throw new IllegalStateException("Success URL not available via saved request or via the " +
"successUrlFallback method parameter. One of these must be non-null for " +
"issueSuccessRedirect() to work.");
}

WebUtils.issueRedirect(request, response, successUrl, null, contextRelative);
}
}
番外篇(简化代码)

如果我们的登录方式只是 用户名/密码 的方式登录的话。我们可以简化一部分代码。细心的同学是可以从上面的 application.properties 代码中看出一些端倪。

首先配置:

1
shiro.loginUrl=/login

ShiroConfig 中去除这段代码:

1
chainDefinition.addPathDefinition("/login", "anon");

LoginController 中注释掉这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* post表单提交,登录
* @param username
* @param password
* @param model
* @return
*/
@PostMapping("/login")
public Object login(String username, String password, Model model) {
Subject user = SecurityUtils.getSubject();
UsernamePasswordToken token = new UsernamePasswordToken(username, password);
try {
//shiro帮我们匹配密码什么的,我们只需要把东西传给它,它会根据我们在UserRealm里认证方法设置的来验证
user.login(token);
return "redirect:/success";
} catch (UnknownAccountException e) {
//账号不存在和下面密码错误一般都合并为一个账号或密码错误,这样可以增加暴力破解难度
model.addAttribute("message", "账号不存在!");
} catch (DisabledAccountException e) {
model.addAttribute("message", "账号未启用!");
} catch (IncorrectCredentialsException e) {
model.addAttribute("message", "密码错误!");
} catch (Throwable e) {
model.addAttribute("message", "未知错误!");
}
return "login";
}

好了,我们要登录的时候,直接请求 post /login 传上 username/password 就可以了。


原因就是利用 shiro 自带的认证逻辑 :源码 FormAuthenticationFilter.onAccessDenied

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
if (isLoginRequest(request, response)) {
if (isLoginSubmission(request, response)) {
if (log.isTraceEnabled()) {
log.trace("Login submission detected. Attempting to execute login.");
}
return executeLogin(request, response);
} else {
if (log.isTraceEnabled()) {
log.trace("Login page view.");
}
//allow them to see the login page ;)
return true;
}
} else {
if (log.isTraceEnabled()) {
log.trace("Attempting to access a path which requires authentication. Forwarding to the " +
"Authentication url [" + getLoginUrl() + "]");
}

saveRequestAndRedirectToLogin(request, response);
return false;
}
}

注意 由于 shiro 中在 request 中获取 用户名/密码 是固定的参数 uaername/password 。所以我们登录携带参数的 key 必须为:username 和 password 在源码中就可以看到:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class FormAuthenticationFilter extends AuthenticatingFilter {

//TODO - complete JavaDoc

public static final String DEFAULT_ERROR_KEY_ATTRIBUTE_NAME = "shiroLoginFailure";

public static final String DEFAULT_USERNAME_PARAM = "username";
public static final String DEFAULT_PASSWORD_PARAM = "password";
public static final String DEFAULT_REMEMBER_ME_PARAM = "rememberMe";

......

}

springboot 集成 shiro + jwt (前后端分离)

我们一边根据请求的过程一边来贴配置代码。

  • 登录部分

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
     /**
    * post表单提交,登录
    * @param username
    * @param password
    * @param model
    * @return
    */
    @PostMapping("/login")
    @ResponseBody
    public Object login(HttpServletRequest request, String username, String password, Model model) throws JoseException {
    // SysUser user = sysUserService.findByUserNameAndPwd(username,simpleHash.toString());
    SysUser user = sysUserService.findByUserName(username);
    if (user == null){
    return ApiResult.fail("用户名错误!");
    }
    SimpleHash simpleHash = new SimpleHash("md5",password.toCharArray(), ByteSource.Util.bytes(user.getSalt()),1);
    System.out.println(simpleHash);
    System.out.println(user.getPassword());
    if (simpleHash.toString().equals(user.getPassword())){
    //签发 jwt 并返回
    return ApiResult.ok(JwtUtils.getInstance().create(user));
    }
    return ApiResult.fail("用户名或密码错误!");
    }

    登录成功返回一个 jwt 串。前端拿到后请求其它接口带上 jwt 即可。

  • 过滤器部分

    当请求路径被拦截后,来到这个过滤器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63

    import com.wzy.platform.common.ApiResult;
    import org.apache.commons.lang.StringUtils;
    import org.apache.shiro.authc.AuthenticationException;
    import org.apache.shiro.subject.Subject;
    import org.apache.shiro.web.filter.authc.PassThruAuthenticationFilter;
    import org.apache.shiro.web.util.WebUtils;
    import org.springframework.beans.factory.annotation.Value;

    import javax.servlet.ServletRequest;
    import javax.servlet.ServletResponse;
    import javax.servlet.http.HttpServletRequest;
    /**
    * @ClassName ShiroCustomFilter
    * @Author wuzhiyong
    * @Date 2021/1/15 13:33
    * @Version 1.0
    **/
    public class ShiroCustomJwtFilter extends PassThruAuthenticationFilter {

    @Value("${custom-web-auth-token-name}")
    String tokenName;

    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) {
    // if (isLoginRequest(request, response)) {
    // if (isLoginSubmission(request, response)) {
    //
    // }
    // return true;
    // }

    String jwt = getJwt(request);
    if (StringUtils.isBlank(jwt)){
    //todo 返回错误码
    ApiResult.writeToJson(response, ApiResult.fail("请登录!"));
    return false;
    }
    ShiroCustomJwtToken token = new ShiroCustomJwtToken(jwt);
    Subject subject = getSubject(request, response);
    try{
    subject.login(token);
    }catch (AuthenticationException e){
    ApiResult.writeToJson(response, ApiResult.fail("身份认证失败!请重新登录。"));
    }
    return true;
    }

    String getJwt(ServletRequest request) {
    HttpServletRequest httpServletRequest = (HttpServletRequest) request;
    //从header中获取token
    String token = httpServletRequest.getHeader("token");
    //如果header中不存在token,则从参数中获取token
    if (StringUtils.isBlank(token)) {
    token = httpServletRequest.getParameter("token");
    }
    return token;
    }
    protected boolean isLoginSubmission(ServletRequest request, ServletResponse response) {
    return (request instanceof HttpServletRequest) && WebUtils.toHttp(request).getMethod().equalsIgnoreCase(POST_METHOD);
    }
    }

    逻辑上 先判断请求中有没有 jwt 没有就返回 提示:”身份认证失败!请重新登录。”。如果有,那么就 把 jwt 使用 ShiroCustomJwtToken 包装后执行 shiro 的认证逻辑 subject.login(token);

  • 自定义 Realm 部分

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65

    import com.wzy.platform.common.utils.JwtUtils;
    import com.wzy.platform.service.SysPermissionService;
    import com.wzy.platform.service.SysRoleService;
    import org.apache.shiro.authc.AuthenticationException;
    import org.apache.shiro.authc.AuthenticationInfo;
    import org.apache.shiro.authc.AuthenticationToken;
    import org.apache.shiro.authc.SimpleAuthenticationInfo;
    import org.apache.shiro.authz.AuthorizationException;
    import org.apache.shiro.authz.AuthorizationInfo;
    import org.apache.shiro.authz.SimpleAuthorizationInfo;
    import org.apache.shiro.realm.AuthorizingRealm;
    import org.apache.shiro.subject.PrincipalCollection;
    import org.apache.shiro.subject.SimplePrincipalCollection;
    import org.jose4j.jwt.MalformedClaimException;
    import org.jose4j.lang.JoseException;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.beans.factory.annotation.Autowired;
    /**
    * @ClassName ShiroCustomJwtRealm
    * @Author wuzhiyong
    * @Date 2021/1/25 9:32
    * @Version 1.0
    **/
    public class ShiroCustomJwtRealm extends AuthorizingRealm {
    private static final Logger LOGGER = LoggerFactory.getLogger(ShiroCustomRealm.class);

    @Autowired
    private SysPermissionService sysPermissionService;
    @Autowired
    private SysRoleService sysRoleService;

    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
    java.lang.String jwt = (java.lang.String) principals.getPrimaryPrincipal();
    Long id = null;
    try {
    JwtUtils.getInstance().validateJwt(jwt);
    id = Long.valueOf(JwtUtils.getInstance().getSubject(jwt));
    } catch (MalformedClaimException | JoseException e) {
    e.printStackTrace();
    // 有可能 认证的时候 jwt 有效, 这里授权的时候 jwt 过期失效了。
    throw new AuthorizationException("method AuthorizationInfo。get permissions or roles Exception in jwt");
    }
    SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
    info.addStringPermissions(sysPermissionService.selectPermissionByUserId(id));
    info.addRoles(sysRoleService.selectRolesByUserId(id));
    LOGGER.info("doGetAuthorizationInfo");
    return info;
    }

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
    ShiroCustomJwtToken token = (ShiroCustomJwtToken) authenticationToken;
    LOGGER.info("doGetAuthenticationInfo");
    return new SimpleAuthenticationInfo(new SimplePrincipalCollection(token.getPrincipal(),token.getPrincipal().toString()),token.getCredentials());
    }

    @Override
    public boolean supports(AuthenticationToken token){
    return token instanceof ShiroCustomJwtToken;
    }
    }

    Realm 的逻辑都一样 doGetAuthorizationInfo 这里拿出 jwt 后解析出 用户 id 然后再从数据库里查出角色和权限放入 SimpleAuthorizationInfo 中。doGetAuthenticationInfo 则是取出 jwt 并放入 SimpleAuthenticationInfo

  • 自定义 token

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26

    import org.apache.shiro.authc.AuthenticationToken;
    /**
    * @ClassName ShiroCustomJwtToken
    * @Author wuzhiyong
    * @Date 2021/1/25 9:33
    * @Version 1.0
    **/
    public class ShiroCustomJwtToken implements AuthenticationToken {

    String jwt;

    public ShiroCustomJwtToken(String jwt) {
    this.jwt = jwt;
    }

    @Override
    public Object getPrincipal() {
    return jwt;
    }

    @Override
    public Object getCredentials() {
    return jwt;
    }
    }
  • 自定义 jwt 密码验证器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27

    import com.wzy.platform.common.utils.JwtUtils;
    import org.apache.shiro.authc.AuthenticationInfo;
    import org.apache.shiro.authc.AuthenticationToken;
    import org.apache.shiro.authc.credential.CredentialsMatcher;
    import org.jose4j.lang.JoseException;
    /**
    * @ClassName ShiroCustomJwtCredentialsMatcher
    * @Author wuzhiyong
    * @Date 2021/1/25 10:04
    * @Version 1.0
    **/
    public class ShiroCustomJwtCredentialsMatcher implements CredentialsMatcher {
    @Override
    public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
    try {
    if (JwtUtils.getInstance().validateJwt((String) token.getPrincipal())==null){
    // throw new AuthenticationException("jwt 验证失败!");
    return false;
    }
    } catch (JoseException e) {
    e.printStackTrace();
    return false;
    }
    return true;
    }
    }

    这里只需要通过 jwtUtils 来验证 jwt 就可以了

  • 配置类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92

    import org.apache.shiro.mgt.DefaultSessionStorageEvaluator;
    import org.apache.shiro.mgt.DefaultSubjectDAO;
    import org.apache.shiro.mgt.SessionStorageEvaluator;
    import org.apache.shiro.realm.Realm;
    import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
    import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
    import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;

    import javax.servlet.Filter;
    import java.util.LinkedHashMap;
    import java.util.Map;
    /**
    * @ClassName ShiroConfig
    * @Author wuzhiyong
    * @Date 2020/12/27 12:26
    * @Version 1.0
    **/
    @Configuration
    public class ShiroConfig {


    @Bean
    public Realm realm() {
    //jwt realm
    ShiroCustomJwtRealm customRealm = new ShiroCustomJwtRealm();
    //配置 自定义的 jwt 密码验证器
    customRealm.setCredentialsMatcher(new ShiroCustomJwtCredentialsMatcher());
    customRealm.setCachingEnabled(false);
    return customRealm;
    }

    /**
    * 注入SessionStorageEvaluator,关闭Session存储
    */
    @Bean
    public SessionStorageEvaluator sessionStorageEvaluator() {
    DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
    //关闭会话存贮
    defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
    return defaultSessionStorageEvaluator;
    }

    @Bean
    public DefaultWebSecurityManager securityManager() {
    DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
    securityManager.setRealm(realm());
    DefaultSubjectDAO defaultSubjectDAO = new DefaultSubjectDAO();
    defaultSubjectDAO.setSessionStorageEvaluator(sessionStorageEvaluator());
    securityManager.setSubjectDAO(defaultSubjectDAO);
    return securityManager;
    }


    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean () {
    ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
    shiroFilterFactoryBean.setSecurityManager(securityManager());
    // shiroFilterFactoryBean.setLoginUrl("/login");
    // shiroFilterFactoryBean.setSuccessUrl("/");
    // shiroFilterFactoryBean.setUnauthorizedUrl("/unauth");
    Map<String, Filter> filters = shiroFilterFactoryBean.getFilters();
    //配置拦截器,实现无权限返回401,而不是跳转到登录页
    filters.put("authc_jwt", new ShiroCustomJwtFilter());

    //注意此处使用的是LinkedHashMap,是有顺序的,shiro会按从上到下的顺序匹配验证,匹配了就不再继续验证
    //所以上面的url要苛刻,宽松的url要放在下面,尤其是"/**"要放到最下面,如果放前面的话其后的验证规则就没作用了。
    Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
    filterChainDefinitionMap.put("/login", "anon");
    filterChainDefinitionMap.put("/static/**", "anon");
    filterChainDefinitionMap.put("/captcha.jpg", "anon");
    filterChainDefinitionMap.put("/favicon.ico", "anon");
    // filterChainDefinitionMap.put("/**", "authc_jwt");

    shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
    return shiroFilterFactoryBean;
    }

    public static DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator() {
    DefaultAdvisorAutoProxyCreator creator = new DefaultAdvisorAutoProxyCreator();
    /**
    * setUsePrefix(false)用于解决一个奇怪的bug。在引入spring aop的情况下。
    * 在@Controller注解的类的方法中加入@RequiresRole注解,会导致该方法无法映射请求,导致返回404。
    * 加入这项配置能解决这个bug
    */
    creator.setUsePrefix(true);
    return creator;
    }
    }

    配置完毕! 使用的时候。接口上加上相关注解就可以了。

异常类

UnknownAccountException (用户名错误或者不存在)

IncorrectCredentialsException(密码不正确)
AuthenticationException 异常是Shiro在登录认证过程中,认证失败需要抛出的异常。

AuthenticationException包含以下子类:

CredentitalsException 凭证异常

IncorrentCredentialsException 不正确的凭证

ExpiredCredentialsException 凭证过期

AccountException 账号异常

ConcurrentAccessException 并发访问异常(多个用户同时登录时抛出)

UnknownAccountException 未知的账号

ExcessiveAttemptsException 认证次数超过限制

DisabledAccountException 禁用的账号

LockedAccountException 账号被锁定
UnsupportedTokenException 使用了不支持的Token

注意

  1. shiro 的权限验证机制的逻辑与我们自己预想的可能会有不同。例如:

假设用户具有如下权限集合:

id parent_id res_name res_type permission
1 系统管理 menu m@sys
2 1 用户管理 menu m@sys:usr
3 1 角色管理 menu m@sys:role
4 一级菜单 menu m@lv1
5 4 二级菜单1 menu m@lv1:xxx
6 4 二级菜单2 menu m@lv1:yyy
7 2 用户添加 button b@usradd

方法上注解配置的权限为:

1
@RequiresPermissions("m@sys:role:edit")

当这个用户登录后访问该方法是有权限的 即使 权限中 没有 “m@sys:role:edit”。

shiro 这里的规则是:[root]:[sub_1]:[sub_2]…… (参看Subject.isPermitted()源码)

即以冒号”:“作为分割。左边表示根节点右边表示子节点。如果该用户配置了根节点权限那么其就具备了所有其子节点的权限

上面中用户具有”m@sys“权限。那么”m@sys:usr“ 与 ”m@sys:role“、“m@sys:role:edit”权限就自动具备了。

(个人认为这样设计的目的是为了方便我们在树型目录中给用户来配置权限,只要配置了根节点就具备了其子节点的属性。)

那么反过来就说明权限数据只要这些就行了:

id parent_id res_name res_type permission
1 系统管理 menu m@sys
4 一级菜单 menu m@lv1
7 2 用户添加 button b@usradd
  1. shiro 可能会导致一些事务失效,详情请百度
  2. jwt 生成后如果服务重启 jwt 将失效。因为服务启动每执行 RsaJwkGenerator.generateJwk() 方法,实例不一样。

参考:

jwt 官网:https://jwt.io/

博客:https://andaily.com/blog/?p=956

博客:https://blog.51cto.com/wyait/2125708

apache shiro 官网 10 分钟教程:https://shiro.apache.org/10-minute-tutorial.html

apache shiro springboot 教程:https://shiro.apache.org/spring-boot.html

博客:https://segmentfault.com/a/1190000014479154

博客:https://segmentfault.com/a/1190000013630601?utm_source=sf-related