SpringBoot3+JDK17+Shiro+OAuth2授权方式+JWT
OAuth2认证方式概念
OAuth2 是一种用于授权的开放标准,它允许用户授权第三方应用访问他们存储在授权服务器上的资源,而无需将用户名和密码提供给第三方应用。OAuth2 的核心概念是授权,而不是认证。下面是 OAuth2 的一些重要概念:
-
资源所有者(Resource Owner):资源所有者是指能够授权访问自己受保护资源的实体,通常是用户。
-
客户端(Client):客户端是请求访问受保护资源的应用程序。它可以是 Web 应用、移动应用、桌面应用或者后端服务。
-
授权服务器(Authorization Server):授权服务器负责验证资源所有者的身份,并颁发访问令牌给客户端。
-
资源服务器(Resource Server):资源服务器存储了受保护的资源,它负责接收和响应受保护资源的请求,同时验证访问令牌。
-
访问令牌(Access Token):访问令牌是用于访问受保护资源的令牌,它由授权服务器颁发给客户端,并用于向资源服务器请求受保护资源。
OAuth2 定义了多种授权方式,包括授权码授权、密码授权、客户端凭证授权、隐式授权和资源所有者密码凭证授权等。每种授权方式适用于不同的场景和安全需求。
再这篇文章中,博主使用的是授权服务器、与资源服务器是一台,其实就是使用Shiro来完成的,而访问令牌使用的是JWT(JSON WEB TOKEN)来完成的
依赖
注意:由于JDK17使用的是Jakarta EE规范,而截止2024年1月24日Shiro2.0还处于(alpha)测试阶段,所以只能使用目前最新的版本shiro1.13,但是Shiro1.13版本目前默认使用的是Java EE规范,所以不能直接引入shiro-spring-boot-web-starter依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.2</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<classifier>jakarta</classifier>
<version>1.13.0</version>
<exclusions>
<exclusion>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-core</artifactId>
</exclusion>
<exclusion>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-web</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-core</artifactId>
<classifier>jakarta</classifier>
<version>1.13.0</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-web</artifactId>
<classifier>jakarta</classifier>
<version>1.13.0</version>
<exclusions>
<exclusion>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.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>
涉及表结构(核心字段)
-
用户表(sys_user)
Field Type Null Key Default Extra Comment user_id bigint NO PRI NULL auto_increment username varchar(50) NO UNI 登录用户名 name varchar(128) YES NULL 姓名 password varchar(100) YES NULL 密码 -
角色表(sys_role)
Field Type Null Key Default Extra Comment role_id bigint NO PRI NULL auto_increment role_name varchar(100) YES NULL 角色名称 remark varchar(100) YES NULL 备注 -
权限表/菜单表(sys_menu)
Field Type Null Key Default Extra Comment perm_id bigint unsigned NO PRI NULL auto_increment perms varchar(500) YES NULL 授权(多个用逗号分隔,如:user:list,user:create) -
用户角色表(sys_user_role)
Field Type Null Key Default Extra Comment id bigint NO PRI NULL auto_increment user_id bigint YES NULL 用户ID role_id bigint YES NULL 角色ID -
角色权限表(sys_role_menu)
Field Type Null Key Default Extra Comment id bigint NO PRI NULL auto_increment role_id bigint YES NULL 角色ID perm_id bigint YES NULL 权限ID
springboot配置
server:
port: 端口号
spring:
datasource:
type: com.mysql.cj.jdbc.MysqlDataSource
username: 数据库账号
password: 数据库密码
url: jdbc:mysql://localhost:3306/数据库名?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowMultiQueries=true
mybatis-plus:
global-config:
banner: off
logging:
level:
org.apache.shiro.authc.AbstractAuthenticator: debug
Shiro组件选择
-
Filter选择:
shiro有内置的13中过滤器,已经在01.Shiro基础概念以及快速入门中有解释说明。
那么过滤器的选择有两种选择:(我这里是自定义过滤器,当然authcBearer
过滤器也有实现,在博文最后会给出源码)-
内置的过滤器:
authcBearer(BearerHttpAuthenticationFilter.class)
- 原理:获取请求头中的
Authorization
头信息,并以空格进行切分,所以Authorization
头中的值固定格式一般为:Bearer {token值}
- 原理:获取请求头中的
-
自定义过滤器:继承
HttpAuthenticationFilter
类重写createToken
、isAccessAllowed
、onAccessDenied
、onLoginFailure
方法即可public class OAuth2Filter extends AuthenticatingFilter { @Override protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception { //获取请求token String token = getRequestToken((HttpServletRequest) request); if (token == null || token.isEmpty()) { return null; } return new OAuth2Token(token); } //可以设置是请求是否允许访问(不用认证) @Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { if (((HttpServletRequest) request).getMethod().equals(RequestMethod.OPTIONS.name())) { return true; } return false; } //当拒绝访问时,执行的逻辑代码 @Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { //获取请求token,如果token不存在,直接返回401 String token = getRequestToken((HttpServletRequest) request); if (token == null || token.isEmpty()) { HttpServletResponse httpResponse = (HttpServletResponse) response; HttpServletRequest httpRequest = (HttpServletRequest) request; httpResponse.setHeader("Access-Control-Allow-Credentials", "true"); httpResponse.setHeader("Access-Control-Allow-Origin", httpRequest.getHeader("Origin")); httpResponse.setContentType("application/json"); httpResponse.getWriter().print("invalid token"); return false; } return executeLogin(request, response); } //当登录失败时,执行的逻辑代码 @Override protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) { HttpServletResponse httpResponse = (HttpServletResponse) response; HttpServletRequest httpRequest = (HttpServletRequest) request; httpResponse.setContentType("application/json;charset=utf-8"); httpResponse.setHeader("Access-Control-Allow-Credentials", "true"); httpResponse.setHeader("Access-Control-Allow-Origin", httpRequest.getHeader("Origin")); try { //处理登录失败的异常 Throwable throwable = e.getCause() == null ? e : e.getCause(); httpResponse.getWriter().print("登录失败 :" + throwable.getMessage()); } catch (IOException e1) { //其他逻辑 } return false; } /** * 获取请求的token */ private String getRequestToken(HttpServletRequest httpRequest) { return httpRequest.getHeader("Authorization"); } }
-
-
Realm选择:
内置的Realm并没有适用于OAuth2认证方式的,所以只能自定义Realm
继承AuthorizingRealm
类重写doGetAuthorizationInfo
、doGetAuthenticationInfo
方法即可@Component public class OAuth2Realm extends AuthorizingRealm { @Autowired private ISysUserService userService; /** * 默认使用的token为:{@link org.apache.shiro.authc.UsernamePasswordToken} * ,所以需要重写support方法,不然会报错token不匹配 * <p>或者在realm中设置authenticationTokenClass属性为你想要使用的Token</p> */ @Override public boolean supports(AuthenticationToken token) { return token instanceof OAuth2Token; } @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) { SysUser user = (SysUser)principalCollection.getPrimaryPrincipal(); Long userId = user.getUserId(); //用户权限列表 Set<String> permsSet = userService.getUserPermissions(userId); //用户角色列表 Set<String> rolesSet = userService.getUserRoles(userId); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); info.setStringPermissions(permsSet); info.setRoles(rolesSet); return info; } @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) { OAuth2Token auth2Token = (OAuth2Token) authenticationToken; SimpleAuthenticationInfo info = null; String token = auth2Token.getPrincipal(); String userId; try { DecodedJWT parse = JwtUtil.parse(token); userId = parse.getClaim("userId").toString(); } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { throw new IncorrectCredentialsException("无效的token"); } catch (JWTVerificationException e) { throw new JWTVerificationException("过期的token"); } SysUser user = userService.getById(userId); if (user == null) { throw new BizException("没有该用户"); } info = new SimpleAuthenticationInfo(user, token, getName()); return info; } }
-
Token选择:
-
内置的token:
BearerToken
,与authcBearer
过滤器是配套的,authcBearer
过滤器生成的token类型就是BearerToken
类型的。 -
自定义token:继承
AuthenticationToken
类,重写getPrincipal
、getCredentials
方法即可public class OAuth2Token implements AuthenticationToken { private String token; public OAuth2Token(String token){ this.token = token; } @Override public String getPrincipal() { return token; } @Override public Object getCredentials() { return token; } }
-
-
SecurityManage选择:
使用DefaultWebSecurityManager
即可
访问令牌(token)
- 博主使用的JWT(JSON WEB TOKEN),官方网址https://jwt.io/,官网上有关于JWT的详细介绍,这里不做过多解释。
- java生成JWT,有两种开源的java依赖包可以生成(我用的是java-jwt)
- https://github.com/jwtk/jjwt
- https://github.com/auth0/java-jwt
-
生成密钥对(博主使用的是RAS256加密方式)
public static Map<String, String> createKey() { Map<String, String> keyPairMap = new HashMap<>(); KeyPairGenerator rsa = null; try { rsa = KeyPairGenerator.getInstance("RSA"); } catch (NoSuchAlgorithmException e) { log.error(e.getMessage()); } if (rsa != null) { rsa.initialize(1024, new SecureRandom()); KeyPair keyPair = rsa.generateKeyPair(); RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); // 得到私钥 RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); // 得到公钥 //获取公、私钥值 String publicKeyValue = byte2Base64String(publicKey.getEncoded()); String privateKeyValue = byte2Base64String(privateKey.getEncoded()); keyPairMap.put("public", publicKeyValue); keyPairMap.put("private", privateKeyValue); } return keyPairMap; }
-
根据获取公钥字符串与私钥字符串(保存到服务器)
public static RSAPrivateKey getPrivateKey(String privateKeyBase64Str) throws NoSuchAlgorithmException, InvalidKeySpecException { PKCS8EncodedKeySpec pkcs8EncodedKeySpec = new PKCS8EncodedKeySpec(base64String2Byte(privateKeyBase64Str)); KeyFactory keyFactory; keyFactory = KeyFactory.getInstance("RSA"); return (RSAPrivateKey) keyFactory.generatePrivate(pkcs8EncodedKeySpec); } public static RSAPublicKey getPublicKey(String publicKeyBase64Str) throws NoSuchAlgorithmException, InvalidKeySpecException { X509EncodedKeySpec keySpec = new X509EncodedKeySpec(base64String2Byte(publicKeyBase64Str)); KeyFactory keyFactory; keyFactory = KeyFactory.getInstance("RSA"); return (RSAPublicKey) keyFactory.generatePublic(keySpec); } public static byte[] base64String2Byte(String base64Str) { return Base64.getDecoder().decode(base64Str); } public static String byte2Base64String(byte[] bytes) { return new String(Base64.getEncoder().encode(bytes)); }
-
生成jwt(当执行登录逻辑时生成)
private static final String PUBLIC_KEY_STR = "你的公钥字符串"; private static final String PRIVATE_KEY_STR = "你的私钥字符串"; public static String create(Integer userId, long expiresSeconds) throws NoSuchAlgorithmException, InvalidKeySpecException { Algorithm algorithm = getAlgorithm(); return JWT.create() .withClaim("userId", userId) .withExpiresAt(Instant.now().plusSeconds(expiresSeconds)) .sign(algorithm); } private static Algorithm getAlgorithm() throws NoSuchAlgorithmException, InvalidKeySpecException { RSAPrivateKey privateKey = RsaUtil.getPrivateKey(PRIVATE_KEY_STR); RSAPublicKey publicKey = RsaUtil.getPublicKey(PUBLIC_KEY_STR); return Algorithm.RSA256(publicKey, privateKey); }
-
校验jwt(当要访问资源时校验)
public static DecodedJWT parse(String jwtStr) throws NoSuchAlgorithmException, InvalidKeySpecException { Algorithm algorithm = getAlgorithm(); return JWT.require(algorithm).build().verify(jwtStr); } private static Algorithm getAlgorithm() throws NoSuchAlgorithmException, InvalidKeySpecException { RSAPrivateKey privateKey = RsaUtil.getPrivateKey(PRIVATE_KEY_STR); RSAPublicKey publicKey = RsaUtil.getPublicKey(PUBLIC_KEY_STR); return Algorithm.RSA256(publicKey, privateKey); }
-
Shiro配置
-
开启shiro注解支持
注意:如果不配置这两货,使用shiro的注解时会失效
@Configuration public class ShiroConfig { @Bean public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator(){ DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator=new DefaultAdvisorAutoProxyCreator(); defaultAdvisorAutoProxyCreator.setProxyTargetClass(true); return defaultAdvisorAutoProxyCreator; } @Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) { AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor(); advisor.setSecurityManager(securityManager); return advisor; } }
-
配置LifecycleBeanPostProcessor
@Configuration public class ShiroConfig { /** * 用于管理shiro组件的生命周期 * @return */ @Bean("lifecycleBeanPostProcessor") public static LifecycleBeanPostProcessor lifecycleBeanPostProcessor() { return new LifecycleBeanPostProcessor(); } }
-
配置SecurityManager、Reaml
注意1:自定义的Realm,用于OAuth2的Reaml。
注意2:自定义的Realm,交给IOC管理这里直接注入即可。@Configuration public class ShiroConfig { @Bean("securityManager") public SecurityManager securityManager(OAuth2Realm oAuth2Realm) { return new DefaultWebSecurityManager(oAuth2Realm); } }
-
配置过滤器链
@Configuration public class ShiroConfig { @Bean("shiroFilter") public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) { ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean(); Map<String, Filter> filters = new HashMap<>(); filters.put("oauth2", new OAuth2Filter()); shiroFilter.setFilters(filters); Map<String, String> filterMap = new LinkedHashMap<>(); filterMap.put("/oauth/login", "anon");//设置不需要认证的url filterMap.put("/**", "oauth2");//设置需要Bearer方式认证的url,并设置允许跨域访问,跨域的url直接放行, shiroFilter.setFilterChainDefinitionMap(filterMap); shiroFilter.setSecurityManager(securityManager); shiroFilter.setGlobalFilters(Arrays.asList("noSessionCreation"));//设置无状态服务(禁用会话) return shiroFilter; } }
-
整体的配置如下:
@Configuration public class ShiroConfig { /** * 用于管理shiro组件的生命周期 * * @return */ @Bean("lifecycleBeanPostProcessor") public static LifecycleBeanPostProcessor lifecycleBeanPostProcessor() { return new LifecycleBeanPostProcessor(); } /** * 开启Shiro注解功能 * * @return */ @Bean public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() { DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator(); defaultAdvisorAutoProxyCreator.setProxyTargetClass(true); return defaultAdvisorAutoProxyCreator; } /** * 开启Shro注解功能 */ @Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) { AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor(); advisor.setSecurityManager(securityManager); return advisor; } @Bean("securityManager") public SecurityManager securityManager(OAuth2Realm oAuth2Realm) { return new DefaultWebSecurityManager(oAuth2Realm); } @Bean("shiroFilter") public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) { ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean(); Map<String, Filter> filters = new HashMap<>(); filters.put("oauth2", new OAuth2Filter()); shiroFilter.setFilters(filters); Map<String, String> filterMap = new LinkedHashMap<>(); filterMap.put("/oauth/login", "anon");//设置不需要认证的url filterMap.put("/**", "oauth2");//设置需要oauth2方式认证的url,并设置允许跨域访问,跨域的url直接放行, shiroFilter.setFilterChainDefinitionMap(filterMap); shiroFilter.setSecurityManager(securityManager); shiroFilter.setGlobalFilters(Arrays.asList("noSessionCreation"));//设置无状态服务(禁用会话) return shiroFilter; } }
测试类(controller)
@RestController
@RequestMapping("/oauth")
public class Oauth2Controller {
@GetMapping("/login")
public String login() throws NoSuchAlgorithmException, InvalidKeySpecException {
return JwtUtil.create(2, 60*60);
}
@GetMapping("/other")
public String other(){
return "other";
}
@RequiresPermissions("sys:perm:read")
@GetMapping("/permRead")
public String permRead() {
return "授权:读";
}
@RequiresPermissions("sys:perm:write")
@GetMapping("/permWrite")
public String permWrite() {
return "授权:写";
}
@RequiresRoles("系统管理员")
@GetMapping("/roleSys")
public String roleSys() {
return "授权:系统管理员";
}
@RequiresRoles("普通管理员")
@GetMapping("/roleCom")
public String roleCom() {
return "授权:普通管理员";
}
}
测试
注意1:测试的用户名test002,该用户是普通管理员角色(角色id为2),只有读的权限没有写的权限
注意2:博主使用的是apifox测试接口,也可以用postman。
-
模拟登录接口(获取token),并讲token设置到其他接口的请求头(Authorization)中
-
模拟需要有“读”权限才能访问的接口
-
模拟需要有“写”权限才能访问的接口
-
模拟普通管理员能访问的接口
-
模拟系统管理员能访问的接口
源码
最后贴出源码,希望给个赞!谢谢
实现授权的另外一种思路
这里博主使用的是JWT的方式认证,这种方式是将用户信息存到token中存到客户端,使用SHA256非对称加密。也可以将用户信息存到服务端,使用将redis+uuid的方式,大致流程:
- 登录
- 使用唯一值(uuid的md5加密)作为redis的key也作为token,将用户信息(id)作为reids的值,并设置存活时间
- 将token返回给前端,作为访问资源的凭证