Spring Security框架

Spring Security框架:(鉴权)
主要解决认证(Authenticate)和授权(Authorization)的框架。

 

Spring Security框架用于实现登录,内置加密,验证,放行等各种功能,可靠性强

同时还可以将当前登录用户的信息保存

特别的,对于用户具备的权限,有特殊的管理

在控制器运行前,可以使用注解来判断当前登录用户是否具备某个权限

@PreAuthorize("[权限名称]")

SpringSecurity在运行该方法之前进行核查

如果不具备这个权限会返回403状态码
 

     在Spring Boot项目中,添加`spring-boot-starter-security`依赖项。 注意: 以上依赖项是带有自动配置的,一旦添加此依赖,整个项目中所有的访问,默认都是必须先登录才可以访问的, 在浏览器输入任何此服务的URL,都会自动跳转到默认的登录页面。 默认的用户名是`user`,默认的密码是启动项目时自动生成的随机密码,在服务器端的控制台可以看到此密码。 当登录后,会自动跳转到此前尝试访问的页面。 Spring Security默认使用Session机制保存用户的登录状态,所以,重启服务后,登录状态会消失。在不重启的情况下, 可以通过 `/logout` 访问“退出登录”页面,确定后也可以清除登录状态。

关于BCrypt

  在Spring Security中,内置了BCrypt算法的工具类,此工具类可以实现使用BCrypt算法对密码进行加密、验证密码的功能。

BCrypt算法使用了随机盐,所以,多次使用相同的原文进行加密,得到的密文都将是不同的,
并且,使用的盐值会作为密文的一部分,也就不会影响验证密码了。

在Spring Security框架中,定义了`PasswordEncoder`接口,表示“密码编码器”,并且使用`BCryptPasswordEncoder`实现了此接口。
以管理员项目为例:

在添加管理员时,对密码进行加密
通常,应该自定义配置类,在配置类中使用`@Bean`方法,使得Spring框架能创建并管理`PasswordEncoder`类型的对象,
在后续使用过程中,可以自动装配此对象。

在根包下创建`config.SecurityConfiguration`类:
@Configuration
public class SecurityrConfiguration {
    
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
}
然后,在需要使用此对象的类中,自动装配即可,例如,在`AdminServiceImpl`类中添加:
@Autowired
private PasswordEncoder passwordEncoder;
在此类中,就可以使用到以上属性,例如:
String rawPassword = admin.getPassword();
String encodedPassword = passwordEncoder.encode(rawPassword);
admin.setPassword(encodedPassword);
注意:一旦在Spring容器中已经存在`PasswordEncoder`对象,Spring Security会自动使用它,
所以,会导致默认的随机密码不可用
(你提交的随机密码会被加密后再进行对比,而Spring Security默认的密码并不是密文,所以对比会失败)
对请求放行

  在默认情况下,Spring Security要求所有的请求都是必须先登录才允许访问的,
可以通过Spring Security的配置类对请求放行,
即不需要登录即可直接访问。

具体的做法:

- 使得当前`SecurityConfiguration`继承自`WebSecurityConfigurerAdapter`
- 重写`void configure(HttpSecurity http)`方法,对特定的请求路径进行访问
package cn.tedu.csmall.passport.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Slf4j
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Bean
    public PasswordEncoder passwordEncoder() {
        log.debug("创建密码编码器:BCryptPasswordEncoder");
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests() // 要求请求必须被授权
            .antMatchers("/**")  // 匹配一些路径
            .permitAll() // 允许访问
            .anyRequest() // 除以上配置以外的请求
            .authenticated(); // 经过认证的
    }
}
注意:此时,任何跨域的异步请求不允许提交,否则将出现`403`错误。
http.csrf().disable(); // 禁用防止伪造跨域攻击
如果没有以上配置,则所有的异步跨域访问(无论是否是伪造的攻击)都会被禁止,也就出现了403错误。
使用数据库中的用户名和密码 

  使用Spring Security时,应该自定义类,实现`UserDetailsService`接口,
 
  在此接口中,有`UserDetails loadUserByUsername(String username)`方法,
Spring Security会自动使用登录时输入的用户名来调用此方法,
此方法返回的结果中应该包含与用户名匹配的相关信息,例如密码等,
接下来,Spring Security会自动使用自动装配的密码编码器对密码进行验证。

所以,应该先将“允许访问的路径”进行调整,然后,自定义类实现以上接口,并重写接口中的方法。

关于“允许访问的路径”,可以将“Knife4j的API文档”的相关路径全部设置为允许直接访问(不需要登录),
并且,开启表单验证(使得未授权请求会自动重定向到登录表单),则配置为:
@Override
protected void configure(HttpSecurity http) throws Exception {
    // 请求路径白名单
    String[] urls = {
            "/favicon.ico",
            "/doc.html",
            "/**/*.js",
            "/**/*.css",
            "/swagger-resources/**",
            "/v2/api-docs"
    };

    http.csrf().disable(); // 禁用防止伪造跨域攻击

    http.authorizeRequests() // 要求请求必须被授权
        .antMatchers(urls) // 匹配一些路径
        .permitAll() // 允许访问
        .anyRequest() // 除以上配置以外的请求
        .authenticated(); // 经过认证的

    http.formLogin(); // 启用登录表单,未授权的请求均会重定向到登录表单
}
关于自定义的`UserDetailsService`接口的实现类:
package cn.tedu.csmall.passport.security;

import org.springframework.security.core.userdetails.User;
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;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        // 假设root是可用的用户名,其它用户名均不可用
        if ("root".equals(s)) {
            // 返回模拟的root用户信息
            UserDetails userDetails = User.builder()
                    .username("root")
                    .password("$2a$10$oxvr08D3W0oiesfGPZ8miuPy6kWGst6lz3.qZ29upo8yTjROWh4eC")
                    .accountExpired(false) // 账号是否已经过期
                    .accountLocked(false) // 账号是否已经锁定
                    .credentialsExpired(false) // 认证是否已经过期
                    .disabled(false) // 是否已经禁用
                    .authorities("这是临时使用的且无意义的权限值") // 权限,注意,此方法的参数值不可以为null
                    .build();
            return userDetails;
        }
        throw new UsernameNotFoundException("登录失败,用户名不存在!");
    }

}
接下来,只需要保证以上方法中返回`UserDetails`是基于数据库查询来返回结果即可

则需要:
- 在根包下创建`pojo.vo.AdminLoginInfoVO`,至少包含:`id`, `username`, `password`, `enable`
  - 还应该查询出此用户名对应的管理员的权限,但此部分暂不实现
- 在`AdminMapper`接口中添加抽象方法:`AdminLoginInfoVO getLoginInfoByUsername(String username);`
- 在`AdminMapper.xml`中配置以上抽象方法映射的SQL语句
- 在`AdminMapperTests`中编写并执行测试
- 在`UserDetailsServiceImpl`中的`loadUserByUsername()`方法中通过以上查询来返回结
关于`AdminMapper.xml`
<!-- AdminLoginInfoVO getLoginInfoByUsername(String username); -->
<select id="getLoginInfoByUsername" resultMap="LoginResultMap">
    SELECT
        <include refid="LoginQueryFields"/>
    FROM
        ams_admin
    WHERE
        username=#{username}
</select>

<sql id="LoginQueryFields">
    <if test="true">
        id, username, password, enable
    </if>
</sql>

<resultMap id="LoginResultMap" type="cn.tedu.csmall.passport.pojo.vo.AdminLoginInfoVO">
    <id column="id" property="id" />
    <result column="username" property="username" />
    <result column="password" property="password" />
    <result column="enable" property="enable" />
</resultMap>
关于`UserDetailsServiceImpl`
package cn.tedu.csmall.passport.security;

import cn.tedu.csmall.passport.mapper.AdminMapper;
import cn.tedu.csmall.passport.pojo.vo.AdminLoginInfoVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.User;
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;

@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private AdminMapper adminMapper;

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        log.debug("根据用户名【{}】从数据库查询用户信息……", s);
        // 调用AdminMapper对象,根据用户名(参数值)查询管理员信息
        AdminLoginInfoVO loginInfo = adminMapper.getLoginInfoByUsername(s);
        // 判断是否查询到有效结果
        if (loginInfo == null) {
            // 根据用户名没有找到任何管理员信息
            String message = "登录失败,用户名不存在!";
            log.warn(message);
            throw new UsernameNotFoundException(message);
        }

        // 准备返回结果
        log.debug("根据用户名【{}】从数据库查询到有效的用户信息:{}", s, loginInfo);
        UserDetails userDetails = User.builder()
                .username(loginInfo.getUsername())
                .password(loginInfo.getPassword())
                .accountExpired(false) // 账号是否已经过期
                .accountLocked(false) // 账号是否已经锁定
                .credentialsExpired(false) // 认证是否已经过期
                .disabled(loginInfo.getEnable() == 0) // 是否已经禁用
                .authorities("这是临时使用的且无意义的权限值") // 权限,注意,此方法的参数值不可以为null
                .build();
        log.debug("即将向Spring Security返回UserDetails:{}", userDetails);
        return userDetails;
    }

}
目前,在`管理员`项目中,登录是由Security框架提供的页面的表单来输入用户名、密码,
且由Security框架自动处理登录流程,不适合前后端分离的开发模式!所以,需要自行开发登录流程!
关于自定义的登录流程,主要需要:

- 在业务逻辑实现类中,调用Security的验证机制来执行登录认证
- 在控制器类中,自定义处理请求,用于接收登录请求及请求参数,并调用业务逻辑实现类来实现认证
关于在Service中调用Security的认证机制:

  当需要调用Security框架的认证机制时,需要使用`AuthenticationManager`对象,
可以在Security配置类中重写`authenticationManager()`方法,在此方法上添加`@Bean`注解,
由于当前类本身是配置类,所以Spring框架会自动调用此方法,并将返回的结果保存到Spring容器中:
@Bean
@Override
protected AuthenticationManager authenticationManager() throws Exception {
    return super.authenticationManager();
}
在`IAdminService`中添加处理登录的抽象方法:
void login(AdminLoginDTO adminLoginDTO);
在`AdminServiceImpl`中,可以自动装配`AuthenticationManager`对象:
@Autowired
private AuthenticationManager authenticationManager;

并实现接口中的方法:

@Override
public void login(AdminLoginDTO adminLoginDTO) {
    // 日志
    log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);
    // 调用AuthenticationManager执行认证
    Authentication authentication = new UsernamePasswordAuthenticationToken(
            adminLoginDTO.getUsername(), adminLoginDTO.getPassword());
    authenticationManager.authenticate(authentication);
    log.debug("认证通过!");
}
在控制器中接收登录请求,并调用Service:
在根包下创建`pojo.dto.AdminLoginDTO`类:
@Data
public class AdminLoginDTO implements Serializable {
    private String username;
    private String password;
}
在`AdminController`中添加处理请求的方法:
@ApiOperation("管理员登录")
@ApiOperationSupport(order = 50)
@PostMapping("/login")
public JsonResult<Void> login(AdminLoginDTO adminLoginDTO) {
    log.debug("准备处理【管理员登录】的请求:{}", adminLoginDTO);
    adminService.login(adminLoginDTO);
    return JsonResult.ok();
}
关于Session

HTTP协议本身是无状态协议,所以,无法识别用户的身份!

为了解决此问题,经编程时,引入了Session机制,用于保存用户的某些信息,可识别用户的身份!

Session的本身是在服务器端的内存中一个类似Map结构的数据,每个客户端在提交请求时,
都会携带一个由服务器端首次响应时分配的Session ID,作为Map的Key,由于此Session ID具有极强的唯一性,
所以,每个客户端的Session ID理论上都是不相同的,从而服务器可以识别客户端!

由于Session是保存在服务器端的内存中的,在一般使用时,并不适用于集群!

 Token

Token:令牌,票据。

目前,推荐使用Token来保存用户的身份标识,使之可以用于集群!

相比Session ID是没有信息含义的,Token则是有信息含义的数据,
当客户端向服务器端提交登录请求后,服务器商认证通过就会将此用户的信息保存在Token中,
并将此Token响应到客户端,后续,客户端在每次请求时携带Token,服务器端即可识别用户的身份!

 JWT

JWT = JSON Web Token

JWT是使用JSON格式表示一系列的数据的Token。

当需要使用JWT时,应该在项目中添加依赖:
<!-- JJWT(Java JWT) -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>
然后,通过测试,实现生成JWT和解析JWT
@Slf4j
public class JwtTests {

    // 密钥(盐)
    String secretKey = "nmlfdasfdsaurefuifdknjfdskjhajhef";

    // 测试生成JWT
    @Test
    public void testGenerateJwt() {
        // 准备Claims
        Map<String, Object> claims = new HashMap<>();
        claims.put("id", 9527);
        claims.put("name", "liulaoshi");

        // JWT的组成部分:Header(头),Payload(载荷),Signature(签名)
        String jwt = Jwts.builder()
                // Header:用于声明算法与此数据的类型,以下配置的属性名是固定的
                .setHeaderParam("alg", "HS256")
                .setHeaderParam("typ", "jwt")
                // Payload:用于添加自定义数据,并声明有效期
                .setClaims(claims)
                .setExpiration(new Date(System.currentTimeMillis() + 3 * 60 * 1000))
                // Signature:用于指定算法与密钥(盐)
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
        log.debug("JWT = {}", jwt);
        // eyJhbGciOiJIUzI1NiIsInR5cCI6Imp3dCJ9
        // .
        // eyJuYW1lIjoibGl1bGFvc2hpIiwiaWQiOjk1MjcsImV4cCI6MTY1OTkzMTUyMX0
        // .
        // TFyWBZ3l-y6rYbEYiVBbQjqnFNsFFR07K8lDES9TPs4

        // eyJhbGciOiJIUzI1NiIsInR5cCI6Imp3dCJ9.eyJuYW1lIjoibGl1bGFvc2hpIiwiaWQiOjk1MjcsImV4cCI6MTY1OTkzOTM0N30.7rj8Lhus1EYXUxE4Zy1wx1WFpbvxIQEmya3-A9WZP20
        // eyJhbGciOiJIUzI1NiIsInR5cCI6Imp3dCJ9.eyJuYW1lIjoibGl1bGFvc2hpIiwiaWQiOjk1MjcsImV4cCI6MTY1OTkzOTUzMH0.lwD_PzrqGXEgQs3KmMjsYzTmhsKbGhKnd1WkDkFpj5M
    }

    @Test
    public void testParseJwt() {
        String jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6Imp3dCJ9.eyJuYW1lIjoibGl1bGFvc2hpIiwiaWQiOjk1MjcsImV4cCI6MTY1OTkzOTUzMH0.lwD_PzrqGXEgQs3KmMjsYzTmhsKbGhKnd1WkDkFpj5M";
        Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();
        Object id = claims.get("id");
        Object name = claims.get("name");
        log.debug("id={}", id);
        log.debug("name={}", name);
    }

}

如果JWT数据已经过期,将出现错误:

```
io.jsonwebtoken.ExpiredJwtException: JWT expired at 2022-08-08T12:05:21Z.
 Current time: 2022-08-08T14:11:34Z, a difference of 7573854 milliseconds.  
 Allowed clock skew: 0 milliseconds.
```

如果JWT签名有误(JWT数据的最后一段出错,或生成与解析时使用的secretKey不同),将出现错误:

```
io.jsonwebtoken.SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.
```

如果JWT数据格式有误,将出现错误:

```
io.jsonwebtoken.MalformedJwtException: Unable to read JSON value: {"alg|b:"HS256","typ":"jwt"}
```
关于JWT在项目中的应用
生成JWT
应该在用户登录时,视为”认证成功“后,生成JWT,并将此数据响应到客户端。

在业务层,调用`AuthenticationManager`的`authenticate()`方法后,
得到的返回结果例如:
UsernamePasswordAuthenticationToken [
   Principal=org.springframework.security.core.userdetails.User [
      Username=root, 
      Password=[PROTECTED], 
      Enabled=true, 
      AccountNonExpired=true, 
      credentialsNonExpired=true, 
      AccountNonLocked=true, 
      Granted Authorities=[权限列表]
   ], 
   Credentials=[PROTECTED], 
   Authenticated=true, 
   Details=null, 
   Granted Authorities=[权限列表]
]
可以看到,认证返回的数据中将包含成功认证的用户信息,也是当初用于执行认证的信息(`UserDetailsServiceImpl`中返回的结果),
可以从此认证结果中获取用户相关数据,并写入到JWT中,则需要:

- 将业务接口中的登录方法返回值类型改为`String`,表示认证成功后返回的JWT
- 将业务实现类中的登录方法返回值一并修改
- 在业务实现类中,当认证成功后,获取需要写入到JWT中的数据(例如:用户名等),并生成JWT,返回JWT

关于业务实现类的登录方法:
@Override
public String login(AdminLoginDTO adminLoginDTO) {
    // 日志
    log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);
    // 调用AuthenticationManager执行认证
    Authentication authentication = new UsernamePasswordAuthenticationToken(
            adminLoginDTO.getUsername(), adminLoginDTO.getPassword());
    Authentication authenticateResult = authenticationManager.authenticate(authentication);
    log.debug("认证通过,返回的结果:{}", authenticateResult);
    log.debug("认证结果中的Principal的类型:{}",
            authenticateResult.getPrincipal().getClass().getName());

    // 处理认证结果
    User loginUser = (User) authenticateResult.getPrincipal();
    log.debug("认证结果中的用户名:{}", loginUser.getUsername());

    // 生成JWT
    String secretKey = "nmlfdasfdsaurefuifdknjfdskjhajhef";
    // 准备Claims
    Map<String, Object> claims = new HashMap<>();
    claims.put("username", loginUser.getUsername());
    // JWT的组成部分:Header(头),Payload(载荷),Signature(签名)
    String jwt = Jwts.builder()
            // Header:用于声明算法与此数据的类型,以下配置的属性名是固定的
            .setHeaderParam("alg", "HS256")
            .setHeaderParam("typ", "jwt")
            // Payload:用于添加自定义数据,并声明有效期
            .setClaims(claims)
            .setExpiration(new Date(System.currentTimeMillis() + 14 * 24 * 60 * 60 * 1000))
            // Signature:用于指定算法与密钥(盐)
            .signWith(SignatureAlgorithm.HS256, secretKey)
            .compact();
    log.debug("生成的JWT:{}", jwt);
    return jwt;
}
在控制器中,将处理登录请求的方法的返回值类型改为`JsonResult<String>`,并在调用业务方法时获取返回值,封装到返回的对象中:
@ApiOperation("管理员登录")
@ApiOperationSupport(order = 50)
@PostMapping("/login")
public JsonResult<String> login(AdminLoginDTO adminLoginDTO) {
    log.debug("准备处理【管理员登录】的请求:{}", adminLoginDTO);
    String jwt = adminService.login(adminLoginDTO);
    return JsonResult.ok(jwt);
}
重启项目,在Knife4j的调试功能中,使用正常的用户名和密码发起登录请求,将响应JWT结果,例如:
{
  "state": 20000,
  "data": "eyJhbGciOiJIUzI1NiIsInR5cCI6Imp3dCJ9.eyJleHAiOjE2NjExNTIzOTUsInVzZXJuYW1lIjoic3VwZXJfYWRtaW4ifQ.rFACBsBY8w8oNpR80n2YiplsEUIqw5bnCIsC5UAqsww"
}
在服务器端检查并解析JWT

经过以上登录认证并响应JWT后,客户端在后续发起请求时,应该自主携带JWT数据,而服务器端应该尝试检查并解析JWT。

由于客户端在发起多种不同请求时都应该携带JWT,且服务器端都应该检查并尝试解析,
所以,服务器端检查并解析的过程,应该发生在比较”通用“的组件中,即无论客户端提交的是哪个路径的请求,
这个组件都应该执行!通常,会使用”过滤器“组件进行处理。

在项目的根包下创建`filter.JwtAuthrozationFilter`类,继承自`OncePerRequestFilter`,
并在此类上添加`@Component`注解
@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {

    public JwtAuthorizationFilter() {
        log.debug("创建过滤器:JwtAuthorizationFilter");
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        log.debug("执行JwtAuthorizationFilter");

        // 过滤器链继续执行,相当于:放行
        filterChain.doFilter(request, response);
    }

}
关于客户端提交请求时携带JWT数据,业内通用的做法是在请求头中添加`Authorization`属性,
其值就是JWT数据,所以,服务器端获取JWT的做法应该是:从请求头中的`Authorization`属性中获取JWT数据!
package cn.tedu.csmall.passport.filter;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {

    public JwtAuthorizationFilter() {
        log.debug("创建过滤器:JwtAuthorizationFilter");
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        log.debug("执行JwtAuthorizationFilter");
        // 从请求头中获取JWT
        String jwt = request.getHeader("Authorization");
        log.debug("从请求头中获取JWT:{}", jwt);

        // 判断JWT数据是否不存在
        if (!StringUtils.hasText(jwt) || jwt.length() < 80) {
            log.debug("获取到的JWT是无效的,直接放行,交由后续的组件继续处理!");
            // 过滤器链继续执行,相当于:放行
            filterChain.doFilter(request, response);
            // 返回,终止当前方法本次执行
            return;
        }

        // 尝试解析JWT
        String secretKey = "nmlfdasfdsaurefuifdknjfdskjhajhef";
        Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();
        Object username = claims.get("username");
        log.debug("从JWT中解析得到username:{}", username);

        // 准备Authentication对象,后续会将此对象封装到Security的上下文中
        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority("临时使用的权限"));
        Authentication authentication = new UsernamePasswordAuthenticationToken(
                username, null, authorities);

        // 将用户信息封装到Security的上下文中
        SecurityContext securityContext = SecurityContextHolder.getContext();
        securityContext.setAuthentication(authentication);
        log.debug("已经向Security的上下文中写入:{}", authentication);

        // 过滤器链继续执行,相当于:放行
        filterChain.doFilter(request, response);
    }

}
完成后,还需要将此过滤器添加在Security框架的`UsernamePasswordAuthenticationFilter`过滤器之前,
需要在Security配置类中,先自动装配自定义的过滤器对象:
@Autowired
private JwtAuthorizationFilter jwtAuthorizationFilter;
然后,在`configurer(HttpSecurity http)`方法中添加:
// 将“JWT过滤器”添加在“认证过滤器”之前
http.addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class);
最后,在JWT过滤器执行之初,先清除Security上下文中的数据, 
以避免”一旦提交JWT将认证对象存入到Security上下文中,后续不携带JWT也能访问“的问题:
// 清除Security上下文中的数据
SecurityContextHolder.clearContext();
完成后,启动项目,在Knife4j的调试功能中,携带JWT可以发起任何需要登录才能访问的请求,反之,这些请求不携带JWT将不允许访问。
处理登录成功的管理的权限列表

   目前,存入到Security上下文中的认证信息(Authentication对象)并不包含有效的权限信息(目前是个假信息),
为了后续能够判断用户的权限,需要:

- 当认证(登录)成功后,取出管理员的权限,并将其存入到JWT数据中
- 后续的请求中的JWT应该已经包含权限,则可以从JWT中解析出权限信息,并存入到认证信息(Authentication对象)中
- 在操作过程中,应该先将权限列表转换成JSON再存入到JWT中,在解析JWT时,得到的权限信息也是一个JSON数据,需要将其转换成对象才能继续使用

关于JSON格式的转换,有许多工具都可以实现,例如:fastjson
<!-- fastjson:实现对象与JSON的相互转换 -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.75</version>
</dependency>
在`AdminServiceImpl`处理登录时,当认证成功时,需要从认证结果中取出权限列表,转换成JSON字符串,并存入到JWT中:
Collection<GrantedAuthority> authorities = loginUser.getAuthorities();
log.debug("认证结果中的权限列表:{}", authorities);
String authorityListString = JSON.toJSONString(authorities); // 【重要】将权限列表转换成JSON格式,用于存储到JWT中

// 生成JWT时的Claims相关代码
claims.put("authorities", authorityListString);
log.debug("生成JWT,向JWT中存入authorities:{}", authorityListString);
然后,在JWT过滤器中,当成功的解析JWT时,应该获取权限列表的JSON字符串,
并将其转换为认证对象要求的格式(`Collection<? extends GrantedAuthority`):
Object authorityListString = claims.get("authorities");
log.debug("从JWT中解析得到authorities:{}", authorityListString);

// 准备Authentication对象,后续会将此对象封装到Security的上下文中
List<SimpleGrantedAuthority> authorities = JSON.parseArray(
        authorityListString.toString(), SimpleGrantedAuthority.class);
Authentication authentication = new UsernamePasswordAuthenticationToken(
        username, null, authorities);
完成后,启动项目,正常登录,在服务器端的控制台可以看到相关日志,将显示存入到Security上下文的认证信息中包含权限列表。
使用Security框架检查权限

首先,需要在Security的配置类上开启全局的在方法上检查权限:
@EnableGlobalMethodSecurity(prePostEnabled = true) // 新增
然后,在控制器类中处理请求的方法上使用`@PreAuthorize`注解检查权限:
@PreAuthorize("hasAuthority('/ams/admin/update')") // 新增
以上注解表示:必须具有`/ams/admin/update`权限才允许向此路径提交请求。

提示:Security会根据上下文中的权限列表进行对比,来检查当前登录的用户是否具有此权限。
自定义UserDetails

  Security使用`UserDetails`接口类型的对象表示需要认证的用户、认证结果中的Principal,
但是,Security框架中`UserDetails`接口的实现类`User`中并不包含`id`及其它个性化属性,则可以自定义类进行扩展:
package cn.tedu.csmall.passport.security;

import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;

import java.util.Collection;

@Setter
@Getter
@EqualsAndHashCode
@ToString(callSuper = true)
public class AdminDetails extends User {

    /**
     * 管理员id
     */
    private Long id;

    public AdminDetails(String username, String password, boolean enabled,
                        Collection<? extends GrantedAuthority> authorities) {
        super(username, password, enabled,
                true, true, true,
                authorities);
    }

}
接下来,在`UserDetailsServiceImpl`的`UserDetails loadUserByUsername(String username)`方法的实现中, 
使用自定义的`AdminDetails`作为此方法的返回结果类型:
package cn.tedu.csmall.passport.security;

import cn.tedu.csmall.passport.mapper.AdminMapper;
import cn.tedu.csmall.passport.pojo.vo.AdminLoginInfoVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
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.ArrayList;
import java.util.List;

@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private AdminMapper adminMapper;

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        log.debug("根据用户名【{}】从数据库查询用户信息……", s);
        // 调用AdminMapper对象,根据用户名(参数值)查询管理员信息
        AdminLoginInfoVO loginInfo = adminMapper.getLoginInfoByUsername(s);
        // 判断是否查询到有效结果
        if (loginInfo == null) {
            // 根据用户名没有找到任何管理员信息
            String message = "登录失败,用户名不存在!";
            log.warn(message);
            throw new UsernameNotFoundException(message);
        }

        log.debug("根据用户名【{}】从数据库查询到有效的用户信息:{}", s, loginInfo);
        // 从查询结果中找出权限信息, 
        List<String> permissions = loginInfo.getPermissions(); // /ams/admin/delete
        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        for (String permission : permissions) {
            authorities.add(new SimpleGrantedAuthority(permission));
        }

        // 返回AdminDetails类型的对象
        AdminDetails adminDetails = new AdminDetails(
                loginInfo.getUsername(), loginInfo.getPassword(),
                loginInfo.getEnable() == 1, authorities);
        adminDetails.setId(loginInfo.getId());

        log.debug("即将向Spring Security返回UserDetails:{}", adminDetails);
        return adminDetails;
    }

}
后续,在`AdminServiceImpl`处理登录时,当认证通过,在认证结果中的Principal就是`AdminDetails`类型的。
所以,当认证通过后,可以将认证结果中的Principal取出,强制转换为`AdminDetails`类型,并取出`id`值,用于生成JWT数据:
// 处理认证结果
AdminDetails loginUser = (AdminDetails) authenticateResult.getPrincipal();
log.debug("认证结果中的管理员id:{}", loginUser.getId());
log.debug("认证结果中的用户名:{}", loginUser.getUsername());
Collection<GrantedAuthority> authorities = loginUser.getAuthorities();
log.debug("认证结果中的权限列表:{}", authorities);
// 【重要】将权限列表转换成JSON格式,用于存储到JWT中
String authorityListString = JSON.toJSONString(authorities);

// 生成JWT
String secretKey = "nmlfdasfdsaurefuifdknjfdskjhajhef";
// 准备Claims
Map<String, Object> claims = new HashMap<>();
claims.put("id", loginUser.getId());
claims.put("username", loginUser.getUsername());
claims.put("authorities", authorityListString);
log.debug("生成JWT,向JWT中存入id:{}", loginUser.getId());
log.debug("生成JWT,向JWT中存入username:{}", loginUser.getUsername());
log.debug("生成JWT,向JWT中存入authorities:{}", authorityListString);
至此,当登录成功后,生成的JWT中将包含`id`。

接下来,在JWT过滤器中,解析JWT时,就可以解析得到`id`的值:
// 尝试解析JWT
String secretKey = "nmlfdasfdsaurefuifdknjfdskjhajhef";
Claims claims = Jwts.parser().setSigningKey(secretKey)
    .parseClaimsJws(jwt).getBody();
Long id = claims.get("id", Long.class);
String username = claims.get("username", String.class);
String authorityListString = claims.get("authorities", String.class);
log.debug("从JWT中解析得到id:{}", id);
log.debug("从JWT中解析得到username:{}", username);
log.debug("从JWT中解析得到authorities:{}", authorityListString);
解析得到的`id`和`username`都应该封装到认证对象中,
进而将认证对象存入到Security上下文中,
由于`UsernamePasswordAuthenticationToken`中的Principal是`Object`类型的,
表示“当事人”,即“当前成功登录的用户”,所以,可以自定义数据类型,封装`id`和`username`,
并将封装后的对象存入到`UsernamePasswordAuthenticationToken`中:
package cn.tedu.csmall.passport.security;

import lombok.Data;

import java.io.Serializable;

/**
 * 用于保存到Security上下文中的、当前登录的管理员信息(不包含权限信息)
 */
@Data
public class LoginPrincipal implements Serializable {

    /**
     * 当事人id
     */
    private Long id;
    /**
     * 当事人用户名
     */
    private String username;

}

// 准备Authentication对象,后续会将此对象封装到Security的上下文中
LoginPrincipal loginPrincipal = new LoginPrincipal();
loginPrincipal.setId(id);
loginPrincipal.setUsername(username);
List<SimpleGrantedAuthority> authorities = JSON.parseArray(
        authorityListString, SimpleGrantedAuthority.class);
Authentication authentication = new UsernamePasswordAuthenticationToken(
        loginPrincipal, null, authorities);
至此,每次客户端携带有效的JWT提交请求时,都可以从中解析得到`id`、`username`,
这些数据也会保存到Security上下文中,则在任何控制器处理请求的方法上,
可以添加`@AuthenticationPrincipal LoginPrincipal loginPrincipal`,
即可注入Security上下文中的`LoginPrincipal`对象,
则可以获取到当事人(当前成功登录的用户)的`id`、`username`,例如:
@ApiOperation("查询角色列表")
@ApiOperationSupport(order = 401)
@GetMapping("")
public JsonResult<List<RoleListItemVO>> list(
        @ApiIgnore @AuthenticationPrincipal LoginPrincipal loginPrincipal) {
    log.debug("准备处理【查询角色列表】的请求");
    log.debug("当前登录的用户(当事人)的id:{}", loginPrincipal.getId());
    log.debug("当前登录的用户(当事人)的用户名:{}", loginPrincipal.getUsername());
    List<RoleListItemVO> list = roleService.list();
    return JsonResult.ok(list);
}

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值