认证与鉴权

Spring Security 主要功能如下

  • 认证 Authentication
  • 授权 Authorization
  • 攻击防护

认证的方式也可以有多种多样

Authentication 常见的有如下几种

  • HTTP Authentication
  • Forms Authentication
  • Certificate
  • Tokens

编写一个 Resource

其实就是随便写一个 API

@RestController
public class HelloResource {

    @GetMapping("/hello")
    public String hello() {
        return "this is resource";
    }
}

编写 UserDetailsService

/**
 * 编写一个自定义的 UserDetailsService 用来加载用户
 * 注意,它一般不做密码校验,单纯是给 Security 其它组件
 * 提供数据,至于密码校验是由 AuthenticationManager 完成的
 **/
@Service
public class MyDetailsService implements UserDetailsService {

    /**
     * 这个 UserDetailsService 一般只用于到 DAO 层加载用户数据
     */
    @Override
    public UserDetails loadUserByUsername(String name) throws UsernameNotFoundException {
        // 这里使用官方提供的 User 类,第三个参数是权限列表,这里直接让它为空
        return new User("foo", "foopassword", new ArrayList<>());
    }
}

编写 SecurityConfigurer

/**
 * 这里首先继承了 WebSecurityConfigurerAdapter,它是所有 Web配置的接入点
 * Adapter 即适配器
 * <p>
 * 注意 @EnableWebSecurity 注解内置了 @Configurable
 **/
@EnableWebSecurity
public class SecurityConfigurer extends WebSecurityConfigurerAdapter {

    @Autowired
    private MyDetailsService myDetailsService;

    /**
     * 顾名思义,就是建造者模式,它用来构建一个 AuthenticationManager
     * 添加 UserDetailsService 和 AuthenticationProvider's 就在这里
     * <p>
     * 然后它还可以用来
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
                // 不做具体的 AuthenticationManager 选择这里的默认使用 DaoAuthenticationConfigurer
                // 这个 DetailsService 单纯就是从 Dao 层取得用户数据,它不进行密码校验
                .userDetailsService(myDetailsService)
                // 如果上面那个 userDetailsService 够简单其实可以像下面这样用 SQL 语句查询比对
                // .dataSource(dataSource)
                // .usersByUsernameQuery("Select * from users where username=?")
                // 这个 passwordEncoder 配置的实际就是 DaoAuthenticationConfigurer 的加密器
                .passwordEncoder(passwordEncoder());

    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        // return new BCryptPasswordEncoder();
        // 注意,虽然显示过时了,但是官方没有计划删除它,一般也就使用纯文本密码的测试时会用它
        return NoOpPasswordEncoder.getInstance();
    }
}

访问测试

输入项目地址访问

http://localhost:8080/hello

然后会自动跳转到登陆页面要求登陆(默认使用了 formLogin 这个过滤器)

整合 JWT

首先是导入依赖

<properties>
    <jwt.version>0.10.7</jwt.version>
</properties>
<!-- ... -->

<!-- JWT -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>${jwt.version}</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>${jwt.version}</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>${jwt.version}</version>
    <scope>runtime</scope>
</dependency>

创建一个 JWT 工具类

@Service
public class JwtUtil {

    // 注意,这里使用 secretKeyFor 方法自动随机生成一个适合指定编码长度的密钥,避免硬编码出错,以及安全问题
    private static final SecretKey SECRET_KEY = Keys.secretKeyFor(SignatureAlgorithm.HS256);
    // 不过正式的开发环境,这个密钥最好不要这样搞,第一次生成之后记录下来就行了,不然每次重启服务一次,全部 JWT 都失效了

    public String extractUsername(String token) {
        // 这里直接引用 Claims 类里面的 getSubject 方法
        return extractClaim(token, Claims::getSubject);
    }

    /*
     它等价于下面这个
     public String extractUsername(String token) {
         return extractClaim(token, (Claims claims)-> {
             return claims.getSubject();
         });
     }
    */

    public Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    // 这个 Function 表示一个接受一个参数并产生结果的函数。
    // <T> 函数输入的类型(就是 apply 方法的参数类型)
    // <R> 函数结果的类型(就是 apply 方法的返回值)
    public <R> R extractClaim(String token, Function<Claims, R> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        // 这个 Function 函数接口通过调用 apply 取得结果
        return claimsResolver.apply(claims);
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody();
    }

    private Boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        return createToken(claims, userDetails.getUsername());
    }

    private String createToken(Map<String, Object> claims, String subject) {

        return Jwts.builder().setClaims(claims).setSubject(subject).setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 10))
                // 这里需要显示指定使用 HS256(注意,上面只是生成一个适合长度的密钥,本体它还是一个普通字串)
                .signWith(SECRET_KEY, SignatureAlgorithm.HS256).compact();
    }

    public Boolean validateToken(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        // 检查 token 里面的信息是否与 UserDetails 相同,这里可以写多几个认证,但是只是测试,所以象征性比对个用户名就行了
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }
}

创建 Model 实体

用于封装请求参数

@Data
@AllArgsConstructor
@NoArgsConstructor
public class AuthenticationRequest {
    private String username;
    private String password;
}

用于封装响应的 JWT

@AllArgsConstructor
@Getter
public class AuthenticationResponse {
    private final String jwt;
}

创建一个认证端点

创建一个 /authenticate 接口专门用于生成 JWT,这里就直接加在上面那个 /hello 接口里面了

@Slf4j
@RestController
public class HelloResource {

    @Autowired
    private AuthenticationManager authenticationManager;

    /*
     @Autowired
     private MyDetailsService myDetailsService; // 用于取得用户数据
    */


    @Autowired
    private JwtUtil jwtUtil;


    @GetMapping("/hello")
    public String hello() {
        return "this is resource";
    }


    @PostMapping("/authenticate")
    public ResponseEntity<AuthenticationResponse> createAuthenticateToken(@RequestBody AuthenticationRequest authenticationRequest) throws Exception {
        Authentication authenticate = null;
        try {
            // 这里一步就是校验用户身份,点进去这个 authenticate(默认是 ProviderManager 这个实现类)
            // 它的参数类型是一个 Authentication,即传入一个未认证的 Authentication 进去,返回一个
            // 已经认证的 Authentication 出来
            authenticate = authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(
                            authenticationRequest.getUsername(),
                            authenticationRequest.getPassword()
                    )
            );
        } catch (BadCredentialsException e) {
            throw new Exception("登陆错误", e);
        }

        // 补充知识 使用局部变量修饰 final 的好处
        // 1、访问局部变量要比访问成员变量要快
        // 2、访问局部变量要比每次调用方法去获取对象要快
        // 3、使用final修饰可以避免变量被重新赋值(引用赋值)
        // 4、使用final修饰时,JVM不用去跟踪该引用是否被更改?

        // 其实如果上面已经认证通过了,这里的 (UserDetails) authenticate.getPrincipal() 其实也可以使用下面这个方式取得
        // final UserDetails userDetails = myDetailsService.loadUserByUsername(authenticationRequest.getUsername());
        // 不过有些时候会在 AuthenticationProvider 里面注入一些权限角色进这个 UserDetails 里面的 getAuthorities(); 方法里面
        final String jwt = jwtUtil.generateToken((UserDetails) authenticate.getPrincipal());
        return ResponseEntity.ok(new AuthenticationResponse(jwt));
    }
}

编写一个 JWT 过滤器

/**
 * OncePerRequestFilter 它能够确保在一次请求只通过一次 filter,而不需要重复执行
 **/
@Component
public class JwtRequestFilter extends OncePerRequestFilter {
    @Autowired
    private MyDetailsService myDetailsService;

    @Autowired
    private JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        // 标准的 Token 都是从这个 Authorization 里面取得数据的
        final String authorizationHeader = request.getHeader("Authorization");

        String username = null;
        String jwt = null;

        // 注意,一般它前面还有一个 “Bearer ”
        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            jwt = authorizationHeader.substring(7);
            // 尝试拿 token 中的 username
            // 若是没有 token 或者拿 username 时出现异常,那么 username 为 null
            username = jwtUtil.extractUsername(jwt);
        }

        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {

            // 用 UserDetailsService 从数据库中拿到用户的 UserDetails 类
            // UserDetails 类是 Spring Security 用于保存用户权限的实体类
            UserDetails userDetails = this.myDetailsService.loadUserByUsername(username);

            // 检查用户带来的 token 是否有效
            // 包括 token 和 userDetails 中用户名是否一样, token 是否过期, token 生成时间是否在最后一次密码修改时间之前
            // 若是检查通过
            if (jwtUtil.validateToken(jwt, userDetails)) {

                // 生成通过认证的 Authentication
                UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
                        new UsernamePasswordAuthenticationToken(
                                // 一般不在这里填入密码
                                userDetails, null, userDetails.getAuthorities());

                // 将这个请求本体存入进去
                usernamePasswordAuthenticationToken
                        .setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

                // 将权限写入本次会话,这个 Context 会在当前这个线程有效(它内部维护着一个 ThreadLocal)
                SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
            }
        }

        // 这里可以直接放行,反正后面 SpringSecurity 没有从上下文中取得这个请求的 usernamePasswordAuthenticationToken 也会将其拦截
        chain.doFilter(request, response);
    }
}

修改下 SecurityConfigurer

修改下上面的 SecurityConfigurer 配置类

@Override
public void configure(HttpSecurity http) throws Exception {
    // 先关闭 CSRF 防护(跨站请求伪造,其实就是使用 Cookie 的那堆屁事,如果使用 JWT 可以直接关闭它)
    http.csrf().disable()
            .authorizeRequests()
            // 这个 antMatcher 方法用于匹配请求(注意方法名后面要加 's')
            .antMatchers(HttpMethod.POST, "/authenticate").permitAll()
            .anyRequest().authenticated()
            // 这里关闭 Session 验证(就是 Cookie-Session 那个)
            .and().sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS);

    // 把自己注册的过滤器放在 UsernamePasswordAuthenticationFilter 之前
    http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
}

// 注意,早期使用这个 authenticationManager 是可以不用手动注册的,但是到了新版需要像这样手动注册
@Bean
@Override
protected AuthenticationManager authenticationManager() throws Exception {
    return super.authenticationManager();
}

编写 Mock 测试

// SpringBootTest 注解默认使用 webEnvironment = WebEnvironment.MOCK,它是不会对 Filter、Servlet进行初始化的。
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
// 这个注解可以用来自动注入 mockMvc,这里一定要使用这个注解来注入,不然默认的那种写法是没有添加 Filter的
@AutoConfigureMockMvc
class HelloResourceTest {

    @Autowired
    private WebApplicationContext webApplicationContext;
    @Autowired
    private MockMvc mockMvc;

    private String jwt;
    ObjectMapper objectMapper;

    @BeforeEach
    void setUp() throws Exception {
        objectMapper = new ObjectMapper();
        createAuthenticateToken(); // 生成 Token
    }

    @Test
    void hello() throws Exception {
        mockMvc.perform(
                MockMvcRequestBuilders.get("/hello")
                .header("Authorization", "Bearer " + jwt) // 别忘了要加个空格
        )
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.content().string("this is resource"))
                .andDo(MockMvcResultHandlers.print());
    }

    void createAuthenticateToken() throws Exception {

        AuthenticationRequest authenticationRequest = new AuthenticationRequest("foo", "foopassword");
        String json = objectMapper.writeValueAsString(authenticationRequest);

        mockMvc.perform(
                MockMvcRequestBuilders.post("/authenticate")
                        .accept(MediaType.APPLICATION_JSON_VALUE)
                        .contentType("application/json;charset=UTF-8")
                        .content(json.getBytes(StandardCharsets.UTF_8))
        )
                .andExpect(MockMvcResultMatchers.status().isOk())
                // .andDo(MockMvcResultHandlers.print())
                .andDo(result -> {
                    String body = result.getResponse().getContentAsString();
                    // 注意:最后这里要用 asText 不要用 toString,否则结果是有 " " 引号的
                    jwt = objectMapper.readTree(body).get("jwt").asText();
                });
    }
}

项目结构一览