Security实现前后端分离
说明
上一篇和上上一篇我大致介绍了一下security基础使用和oauth2的一些流程,这里在深入了解一些相关的配置项。
首先我们在梳理一下相关概念,首先基本的security是负责用户认证这这一环节,总而言之就是用户使用用户名密码登录后可以访问后端接口;然后引入OAuth2相关依赖之后,就有了授权服务器,客户端,资源服务器这三个概念,而且最新的官方依赖也是按照这三个概念提供统一的依赖引入,而之前的security基本认证就属于授权服务器的一个环节。
问题
1、首先security基本的认证方式中用户名密码的传递似乎不太安全
2、授权服务器,客户端,资源服务器相关流程虽然走了一遍,但是都是使用的基础配置,大部分基础配置对于token、用户登录状态数据的存储、或者用户信息的存储都是基于内存
3、最好能够有一些更实际的应用场景
注意:以下示例或配置基于security 5.8.0相关版本
JWT
什么是JWT
JSON Web Token(JWT)是一个开放的行业标准(RFC 7519),它定义了一种简介的、自包含的协议格式,用于在通信双方传递json对象,传递的信息经过数字签名可以被验证和信任。JWT可以使用HMAC算法或使用RSA的公钥/私钥对来签名,防止被篡改。 官网: https://jwt.io/ 标准: https://tools.ietf.org/html/rfc7519
JWT令牌的优点:
- jwt基于json,非常方便解析。
- 可以在令牌中自定义丰富的内容,易扩展。
- 通过非对称加密算法及数字签名技术,JWT防止篡改,安全性高。
- 资源服务使用JWT可不依赖授权服务即可完成授权。
缺点:
JWT令牌较长,占存储空间比较大。
JWT组成
一个JWT实际上就是一个字符串,它由三部分组成,头部(header)、载荷(payload)与签名(signature)。
这里以oauth2 授权服务器端返回的token做为示例,你可以将这个token在其官网首页进行解析
一下是一个示例,三部分会进行base64加密,然后用.号分隔,其中签名部分解密还需要秘钥,具体后面详细介绍
eyJraWQiOiJiY2Q4MmUxMC04MTdhLTQ3ZTQtODVlZi02OTUwNTU3NzA3ODAiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJtZXNzYWdpbmctY2xpZW50IiwiYXVkIjoibWVzc2FnaW5nLWNsaWVudCIsIm5iZiI6MTY2OTUzOTk3Niwic2NvcGUiOlsib3BlbmlkIiwicHJvZmlsZSIsIm1lc3NhZ2UucmVhZCIsIm1lc3NhZ2Uud3JpdGUiXSwiaXNzIjoiaHR0cDovLzEyNy4wLjAuMTo4MDgwIiwiZXhwIjoxNjY5NTQwMjc2LCJpYXQiOjE2Njk1Mzk5NzZ9.OS0gIJ28bl591DCB8K9dgLfFfqm--uw5OE3pbD20cXpswJigIw5wowyuUPZhjTauwFIMtRiITNLJNqt492X1paSU7GO-Y6fcRq7cNA9q8lF5GDsReuYg1h-RP5EV5X0SOFBsKHxcU9Lxe_-ujcjlyje5sthNc_kq6btn7bbd2-w01VotxTxNQwZtZpSIt-VHmFLN86YQUrRi6NA4TIiUxkyr23ik9UDtn-lW22AQyEJBKfQ1EUOuBKCv2llEKBN9VXUTZbf509RAT0ctwuh4fdTcHOs1Z5TaGqHxSsn_Pc_-BjN-c6dWrBzlsaqWHELgtmpSUZnZ2rFkowCBrYFUpw
逆向对三部分进行base64解密,可以得到以下三部分
头部(header)
头部用于描述关于该JWT的最基本的信息:类型(即JWT)以及签名所用的算法(如HMACSHA256或RSA)等。
这也可以被表示成一个JSON对象:
{
"kid": "bcd82e10-817a-47e4-85ef-695055770780",
"alg": "RS256"
}
这里的签名算法是RSA256,会有一对公私钥。
载荷(payload)
第二部分是载荷,就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包含三个部分:
- 标准中注册的声明(建议但不强制使用)
iss: jwt签发者
sub: jwt所面向的用户
aud: 接收jwt的一方
exp: jwt的过期时间,这个过期时间必须要大于签发时间
nbf: 定义在什么时间之前,该jwt都是不可用的.
iat: jwt的签发时间
jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
- 公共的声明 公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可解密.
- 私有的声明 私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。
{
"sub": "messaging-client",
"aud": "messaging-client",
"nbf": 1669539976,
"scope": [
"openid",
"profile",
"message.read",
"message.write"
],
"iss": "http://127.0.0.1:8080",
"exp": 1669540276,
"iat": 1669539976
}
签名(signature)
jwt的第三部分是一个签证信息,这个签证信息由三部分组成:
- header (base64后的)
- payload (base64后的)
- secret(盐,一定要保密)
这个部分需要base64加密后的header和base64加密后的payload使用.连接组成的字符串,然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了jwt的第三部分:
JWT使用
在其官网界面又不同类型的实现,以便我们使用,就java而言,jjwt对JWT相关标准的实现更全面,所以我们可以通过jjwt来使用JWT
https://jwt.io/libraries
依赖
<!--JWT依赖 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
测试类
public class JWTTest {
public static void main(String[] args) {
//创建一个JwtBuilder对象
JwtBuilder jwtBuilder = Jwts.builder()
//声明的标识{"jti":"666"}
.setId("666")
//主体,用户{"sub":"Fox"}
.setSubject("sry")
//签发日期{"ita":"xxxxxx"}
.setIssuedAt(new Date())
//签名手段,参数1:算法,参数2:盐,有多种算法
.signWith(SignatureAlgorithm.HS256, "123123");
//获取token
String token = jwtBuilder.compact();
System.out.println(token);
//三部分的base64解密
System.out.println("=========");
String[] split = token.split("\\.");
System.out.println(Base64Codec.BASE64.decodeToString(split[0]));
System.out.println(Base64Codec.BASE64.decodeToString(split[1]));
//无法解密
System.out.println(Base64Codec.BASE64.decodeToString(split[2]));
Jws<Claims> claimsJws = Jwts.parser().setSigningKey("123123").parseClaimsJws(token);
Claims body = claimsJws.getBody();
System.out.println(body.getId());
System.out.println(body.getSubject());
System.out.println(body.getIssuer());
String signature = claimsJws.getSignature();
System.out.println(signature);
System.out.println("结束");
}
}
eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI2NjYiLCJzdWIiOiJzcnkiLCJpYXQiOjE2Njk1NTY0ODd9.R8pLjaHmvAXy_MUGV1mIJ6_aG5etyicZtEWN8hAK1G8
=========
{"alg":"HS256"}
{"jti":"666","sub":"sry","iat":1669556487}
G�K����1A��b 项zܢq�DX�! �F
666
sry
null
R8pLjaHmvAXy_MUGV1mIJ6_aG5etyicZtEWN8hAK1G8
过期校验
还是上述代码,添加一个失效时间设置,然后打断点,等个分钟即可
// 设置失效时间
.setExpiration(new Date(System.currentTimeMillis() + 60 * 1000))
Exception in thread "main" io.jsonwebtoken.ExpiredJwtException: JWT expired at 2022-11-27T21:48:44Z. Current time: 2022-11-27T21:49:34Z, a difference of 50244 milliseconds. Allowed clock skew: 0 milliseconds.
自定义属性
// 自定义属性
.claim("role","admin")
.claim("sex","1")
// 自定义属性方式二
.addClaims(new HashMap<>())
Security前后端分离
密码加密传输
Security默认的密码处理方式
官方文档:https://docs.spring.io/spring-security/reference/5.8/servlet/authentication/passwords/input.html
from: 默认是明文密码
basic: 密码和明文会放在请求头中,并且使用base64加密,BasicAuthenticationFilter 中会解密出明文,然后进行下一步认证
// 开启httpbasic,密码和用户名会使用:分隔,然后base64加密传到后端
http.httpBasic().realmName("Realm");
Digest:能够处理HTTP头中显示的摘要式身份验证凭据 ,DigestAuthenticationFilter中有相关处理逻辑
您不应该在现代应用程序中使用Digest,因为它被认为是不安全的。最明显的问题是,必须以明文、加密或MD5格式存储密码。所有这些存储格式都被认为是不安全的。相反,您应该使用单向自适应密码散列(即bcrypt、pbkdf2、scrypt等)。
总之似乎都不太适合
怎么加密密码
泄露渠道:
- 数据库被“偷”
- 服务器被入侵
- 通讯被窃听
- 内部人员泄露数据
- 撞库
有了早些年处于热搜的“CSDN几百万用户数据泄露”、“京东账号密码数据库泄露”、“12306数据库泄露”事件,业界终于认识到数据库的“不太安全性”,以及做出了一些基本的防御措施,比如:
- 严禁密码明文存储(防泄露)
- 单向变换(防泄露)
- 密码本身复杂度要求(防猜解)
- “加盐”(防猜解)
实际应用中甚至有专门的安全键盘提供商,可以专门处理密码加密问题,然后加解密就使用他们的工具包即可,当然大部分场景下大概要就应该没这么高。
1、如果你整个流程中不需要知道用户的密码明文,做一些校验或者特殊处理,可以前端直接校验密码复杂度,然后对明文进行加盐,加盐后使用MD5加密传到后端,盐值没有特殊需求的话可以使用固定值,或者一个和用户关联的不会修改的值。
2、比如你想后端进行校验和处理的话,可以前端使用非对称加密(sm2 rsa)公钥进行加密,为了安全,加密数据可以包含一些请求内容的签名和时间戳,以便后端进行一些校验。后端解密后,在对密码进行加盐,如果已经生成了用户id的话,可以使用用户id作为盐值。或者直接使用SCryptPasswordEncoder和BCryptPasswordEncoder加密后存储到数据库
当然,就登录而言,还有一系列的验证环节,最好是可以使用https,然后校验码,ip,验证手机号等。
这部分我这里就不搞了。
代码
处理登录请求的过滤器
过滤器直接继承了UsernamePasswordAuthenticationFilter,这样的话,我们在配置类中进行配置的时候,部分配置我们可以沿用Security的默认配置,并且和其默认配置一样支持修改,比如登录地址,登录参数名称
1、这里由于重写了successfulAuthentication和unsuccessfulAuthentication方法,所以登录失败处理器和登录成功处理器配置应该就没用了
2、总之和UsernamePasswordAuthenticationFilter样,这个过滤器只支持x-www-form-urlencoded方式提交请求参数
3、在这里你可以实现一些登录错误次数统计登录成功或失败日志记录的功能
public class JWTAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private boolean postOnly = true;
public JWTAuthenticationFilter(AuthenticationManager authenticationManager, boolean postOnly) {
super(authenticationManager);
this.postOnly = postOnly;
}
@SneakyThrows
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
String username = obtainUsername(request);
username = (username != null) ? username.trim() : "";
String password = obtainPassword(request);
password = (password != null) ? password : "";
UsernamePasswordAuthenticationToken token = UsernamePasswordAuthenticationToken.unauthenticated(username,
password);
return this.getAuthenticationManager().authenticate(token);
}
/**
* 原有逻辑中登录成功后,转发到登录成功处理器,这里重写之后,就没有登录成功处理器什么事了
* */
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
User user = (User) authResult.getPrincipal();
// 从User中获取权限信息
Collection<? extends GrantedAuthority> authorities = user.getAuthorities();
// 创建Token
String token = JwtTokenUtil.createToken(user.getUsername(), authorities.toString());
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
// 在请求头里返回创建成功的token
// 设置请求头为带有"Bearer "前缀的token字符串
response.setHeader("token", JwtTokenUtil.TOKEN_PREFIX + token);
response.setContentType("text/json;charset=utf-8");
response.getWriter().write("登录成功");
}
/**
* 原有逻辑中登录失败后,转发到登录失败处理器,这里重写之后,就没有登录失败处理器什么事了
* */
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
String returnData="";
if (failed instanceof AccountExpiredException) {
returnData="账号过期";
}else if (failed instanceof BadCredentialsException) {
returnData="密码错误";
}else if (failed instanceof CredentialsExpiredException) {
returnData="密码过期";
}else if (failed instanceof DisabledException) {
returnData="账号不可用";
}else if (failed instanceof LockedException) {
returnData="账号锁定";
}else if (failed instanceof InternalAuthenticationServiceException) {
returnData="用户不存在";
}else{
returnData="未知异常";
}
response.setContentType("text/json;charset=utf-8");
response.getWriter().write(returnData);
}
}
UserDetailsService实现
认证管理器会从UserDetailsService的具体实现中去获取用户信息,这里是从数据库获取用户信息
你可以实现一些针对用户信息的验证
1、比如用户的状态,密码错误次数,密码过期,你可以抛出一些认证异常,异常你可以参考AuthenticationException中的相关实现
package cn.sry1201.security.service;
import cn.sry1201.security.domain.User;
import cn.sry1201.security.mapper.PermissionMapper;
import cn.sry1201.security.mapper.UserMapper;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Autowired
private PermissionMapper permissionMapper;
public User getByUsername(String username) {
return userMapper.getByUsername(username);
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//从mysql查询用户
User user = getByUsername(username);
if(user!=null){
List<String> roles = permissionMapper.getRoles(user.getId());
String rolesStr = StringUtils.join(roles, ",");
// 封装成UserDetails的实现类,用户名,权限,密码
UserDetails userDetails = org.springframework.security.core.userdetails.User.withUsername(user.getUsername())
.password(user.getPassword()).roles(rolesStr).build();
return userDetails;
}else {
throw new UsernameNotFoundException("用户名不存在");
}
}
}
解析token并认证
主要是从token中获取权限
@Component
public class JWTAuthorizationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String tokenHeader = request.getHeader(JwtTokenUtil.TOKEN_HEADER);
// 若请求头中没有Authorization信息 或是Authorization不以Bearer开头 则直接放行
if (tokenHeader == null || !tokenHeader.startsWith(JwtTokenUtil.TOKEN_PREFIX)){
filterChain.doFilter(request, response);
return;
}
// 从请求头中解析出认证信息
UsernamePasswordAuthenticationToken authentication = getAuthentication(tokenHeader);
// 若请求头中有token 则调用下面的方法进行解析 并设置认证信息
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
}
/**
* 从token中获取用户信息并新建一个token
*
* @param tokenHeader 字符串形式的Token请求头
* @return 带用户名和密码以及权限的Authentication
*/
private UsernamePasswordAuthenticationToken getAuthentication(String tokenHeader) {
// 去掉前缀 获取Token字符串
String token = tokenHeader.replace(JwtTokenUtil.TOKEN_PREFIX, "");
// 从Token中解密获取用户名
String username = JwtTokenUtil.getUsername(token);
// 从Token中解密获取用户角色
String role = JwtTokenUtil.getUserRole(token);
// 将[ROLE_XXX,ROLE_YYY]格式的角色字符串转换为数组
String[] roles = StringUtils.strip(role, "[]").split(", ");
Collection<SimpleGrantedAuthority> authorities=new ArrayList<>();
for (String s:roles)
{
authorities.add(new SimpleGrantedAuthority(s));
}
if (username != null)
{
return new UsernamePasswordAuthenticationToken(username, null,authorities);
}
return null;
}
}
认证失败处理
默认是跳转登录界面
@Component
public class JWTAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.setCharacterEncoding("utf-8");
response.setContentType("text/javascript;charset=utf-8");
response.getWriter().print("未登录,没有访问权限");
}
}
没有权限处理
默认是响应403
public final class MyAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.setCharacterEncoding("utf-8");
response.setContentType("text/javascript;charset=utf-8");
response.getWriter().print("没有访问权限");
}
}
JWT token处理工具类
这里对过期的token直接抛出异常,并且没做处理,请自行处理
public class JwtTokenUtil {
// Token请求头
public static final String TOKEN_HEADER = "Authorization";
// Token前缀
public static final String TOKEN_PREFIX = "Bearer ";
// 过期时间
public static final long EXPIRITION = 24 * 60 * 60 ;
// 应用密钥
public static final String APPSECRET_KEY = "123123";
// 角色权限声明
private static final String ROLE_CLAIMS = "role";
/**
* 生成Token
*/
public static String createToken(String username,String role) {
Map<String,Object> map = new HashMap<>();
map.put(ROLE_CLAIMS, role);
String token = Jwts
.builder()
.setSubject(username)
.setClaims(map)
.claim("username",username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRITION * 1000))
.signWith(SignatureAlgorithm.HS256, APPSECRET_KEY).compact();
return token;
}
/**
* 校验Token
*/
public static Claims checkJWT(String token) {
try {
final Claims claims = Jwts.parser().setSigningKey(APPSECRET_KEY).parseClaimsJws(token).getBody();
return claims;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
/**
* 从Token中获取username
*/
public static String getUsername(String token){
Claims claims = Jwts.parser().setSigningKey(APPSECRET_KEY).parseClaimsJws(token).getBody();
return claims.get("username").toString();
}
/**
* 从Token中获取用户角色
*/
public static String getUserRole(String token){
Claims claims = Jwts.parser().setSigningKey(APPSECRET_KEY).parseClaimsJws(token).getBody();
return claims.get("role").toString();
}
/**
* 校验Token是否过期
*/
public static boolean isExpiration(String token){
Claims claims = Jwts.parser().setSigningKey(APPSECRET_KEY).parseClaimsJws(token).getBody();
return claims.getExpiration().before(new Date());
}
}
配置类
仅适用于新版本的security
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
@Autowired
JWTAuthorizationFilter jwtAuthorizationFilter;
/**
* 认证管理器,登录的时候参数会传给 authenticationManager
*
* @return
* @throws Exception
*/
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http, AuthenticationManager authenticationManager) throws Exception {
// 设置部分接口权限
http.authorizeHttpRequests(authorize -> authorize
// 37是角色id
.requestMatchers(antMatcher("/user/**")).hasRole("37")
.requestMatchers(antMatcher("/user2/**")).hasRole("380")
.requestMatchers(regexMatcher("/admin/.*")).hasRole("admin")
.anyRequest().authenticated()
);
// 添加JWT登录拦截器
http.addFilter(new JWTAuthenticationFilter(authenticationManager, true))
// 添加JWT鉴权拦截器
.addFilterBefore(jwtAuthorizationFilter, BasicAuthenticationFilter.class)
// 设置Session的创建策略为:Spring Security不创建HttpSession
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
// 认证异常(匿名用户访问无权限资源时的异常处理),// 没有访问权限异常处理器
http.exceptionHandling((exceptions) -> exceptions
.authenticationEntryPoint(new JWTAuthenticationEntryPoint())
.accessDeniedHandler(new MyAccessDeniedHandler()));
// 跨站请求伪造拦截关闭,前后端分离,并且使用jwt机制,这个并没有作用。
http.csrf().disable();
return http.build();
}
}
跨域配置
如果前后端地址不一致,还需要开启跨域配置
其他实现方式
我们可以直接在配置中放通某个接口的权限,然后在接口中进行认证操作,由于AuthenticationManager已经被注入容器,所以我们是可以获取到的
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private AuthenticationManager authenticationManager;
@Override
public ResponseResult login(User user) {
// AuthenticationManager的authenticate 进行用户认证
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword());
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
......
}
新版本security配置生效规则
WebSecurityConfigurerAdapter被提示已经废弃,我们可以使用@Bean来构建相关配置,HttpSecurityConfiguration中有@Autowired来引入我们的配置,没有配置也会有默认的配置生效,当然还有一些其他的配置类
总之之前我就没弄明白这些个配置的逻辑,无语,照葫芦画瓢吧
控制一个用户只能登陆一次
用户在这个手机登录后,他又在另一个手机登录相同账户,对于之前登录的账户是否需要被挤兑,或者说在第二次登录时限制它登录
,更或者像腾讯视频VIP账号一样,最多只能五个人同时登录,第六个人限制登录。
security默认有一个过滤器,我们进行配置后,ConcurrentSessionFilter会进行相关控制
但是现在我们显然不能够再使用这套机制,JWT本身不会在后端存储,如果我们需要控制,那么可以根据 (用户id ) 存储一个(用户当前登录端)数据到redis中,然后就是客户在机器a登录后,存放redis信息,用户在b机器上登录后,更新redis信息,让a客户端再次请求时,响应前端你已经被登出之类的信息。
这个模式同样可以支持多端登录,但是每个终端只能登陆一次,比如app端和pc端
动态权限
官方文档
Security新版本中AuthorizationFilter替代了FilterSecurityInterceptor进行权限验证,然后交给authorizationManager进行权限判断
,然后RequestMatcherDelegatingAuthorizationManager#check就会通过我们配置类中设置的权限配置器一个一个的进行匹配;相比原先FilterSecurityInterceptor中的验证个人感觉简单了许多,而且FilterSecurityInterceptor相关的AccessDecisionManager也已经废弃
首先就是你要确定你验证权限的模式。
1、比如你可以设置接口只要登录就能访问,这就没事了
2、登录基础上,都能访问,当时前端会查询权限列表,根据权限展示页面
3、在2的基础上,虽然前端验证了,但是后端还是需要验证
然后我们继续在第三步基础上往后讲
后端要验证权限,首先就是根据角色划分权限,然后就是角色之间是否互斥(这个应该是配置的时候控制),用户是否只能使用单个角色登录,或者一登录就是包含了其所有角色合并的权限,角色是否分级
相关概念
我们示例中默认是包含了所有角色权限的
这是官方的实例,我并没有测试,只是告诉你大致可以这么配置复杂的权限
这个示例似乎不太友好,至少如果权限很多的话,过滤器中for循环可能会耗费一段时间
@Bean
SecurityFilterChain web(HttpSecurity http, AuthorizationManager<RequestAuthorizationContext> access)
throws Exception {
http.authorizeHttpRequests((authorize) -> authorize
.anyRequest().access(access));
// ...
return http.build();
}
@Bean
AuthorizationManager<RequestAuthorizationContext> requestMatcherAuthorizationManager(HandlerMappingIntrospector introspector) {
MvcRequestMatcher.Builder mvcMatcherBuilder = new MvcRequestMatcher.Builder(introspector);
RequestMatcher permitAll =
new AndRequestMatcher(
mvcMatcherBuilder.pattern("/resources/**"),
mvcMatcherBuilder.pattern("/signup"),
mvcMatcherBuilder.pattern("/about"));
RequestMatcher admin = mvcMatcherBuilder.pattern("/admin/**");
RequestMatcher db = mvcMatcherBuilder.pattern("/db/**");
RequestMatcher any = AnyRequestMatcher.INSTANCE;
AuthorizationManager<HttpServletRequest> manager = RequestMatcherDelegatingAuthorizationManager.builder()
.add(permitAll, (authentication, object) -> new AuthorizationDecision(true))
.add(admin, AuthorityAuthorizationManager.hasRole("ADMIN"))
.add(db, AuthorityAuthorizationManager.hasRole("DBA"))
.add(any, new AuthenticatedAuthorizationManager())
.build();
return (authentication, context) -> manager.check(authentication,context.getRequest());
}
当然我们基础示例中讲过,这个access中可以自定义是否授权,所以我们可以匹配所有请求,然后走我们自定义的方法
这里是自定义AuthorizationManager,实现其check方法
httpSecurity.authorizeHttpRequests()
.anyRequest()
.access((authenticationSupplier, requestAuthorizationContext) -> {
// 当前用户的权限信息 比如角色
Collection<? extends GrantedAuthority> authorities = authenticationSupplier.get().getAuthorities();
// 当前请求上下文
// 我们可以获取携带的参数
Map<String, String> variables = requestAuthorizationContext.getVariables();
// 我们可以获取原始request对象
HttpServletRequest request = requestAuthorizationContext.getRequest();
//todo 根据这些信息 和业务写逻辑即可 最终决定是否授权 isGranted
boolean isGranted = true;
return new AuthorizationDecision(isGranted);
});
也可以
.anyRequest().access("@mySecurityExpression.hasPermission(request,authentication)")
@Component
public class MySecurityExpression{
@Override
public boolean hasPermission(HttpServletRequest request, Authentication authentication) {
// 获取主体
Object obj = authentication.getPrincipal();
if (obj instanceof UserDetails){
UserDetails userDetails = (UserDetails) obj;
//
String name = request.getParameter("name");
//获取权限
Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();
//判断name值是否在权限中
return authorities.contains(new SimpleGrantedAuthority(name));
}
return false;
}
}
总之这样之后,你大概可以缓存全部权限数据,然后每次登陆会从缓存中获取权限,和从token中获取的登录用户的权限,然后进行判定是否有权限登录
缓存的话是可以修改的,比如使用ConCurrentHashMap缓存,然后用角色作为key,角色的权限列表作为value,修改某个角色的权限时,甚至由于ConcurrentHashMap的是多线程=安全的,我们直接修改某个key,完全不会有任何影响。权限修改既生效,当然你还要修改后用户访问报错的问题(比如你可以通知前端再次获取一下权限,websocket连接的情况下,总之似乎包没有权限也没啥)。
其他登录方式
- 微信认证
- QQ认证
- 手机号+验证码认证
- 图形验证码认证
- 邮箱认证
微信认证和qq认证不知道是不是可以直接通过oauth2配置实现,然后剩下的的三种模式完全可以套用我示例中的实现。
可以参考本文 其他实现方式,一种登录登录方式一个接口,登录成功后统一返回token.
功能点
比如实现修改密码后用户退出登录: redis缓存
用户在使用过程中token失效怎么处理: 可以添加一个refresh_token,前端设置一个刷新token的间隔时间,当用户请求时,判断一下是否需要刷新,如果用户长时间没有操作,导致token和refresh_token都失效了,那么就提示用户重新登录吧(具体看业务而定),一般refresh_token是需要进行存储的
app第一次登录成功后,每次打开都不需要再次输入登录密码:大致和security rememberme 功能的实现是一个概念,remember me 会有一个cookie设置失效时间,这样cookie关闭浏览器也不会失效,后端有专门的表对这样的信息进行存储, 然后app端的话应该也是需要存储一个这样的数据,然后到后端进行匹配,一般这样的数据应该和机器有一定关联性,总之换了台机器应该就没用了。