Spring Security + JWT + Swagger2 登录验证一套流程

主要是三个框架的集成配置,以及各个独立的配置(主要是 JWT + Security 的登录验证)。

流程:

  • 构建 Spring Boot 基本项目,准备数据库表 User —— 用于存放登录实体类信息。
  • 配置 Security 和 Swagger2 环境,确保没有什么问题。
  • 构建 RespBean——公共返回实体类JwtTokenUtil——JWT token 工具类User——登录实体类
  • 让 User 实现 UserDetails 接口,重写部分方法。
  • 配置 Security 实现重写 UserDetailsService 方法,以及 PasswordEncoder——密码凭证器 并加上 @Bean 注解。这两个主要用于设置 Security 的认证。
  • 构建 jwtAuthenticationTokenFilter 类——自定义 JWT Token 拦截器,并在 SecurityConfig 的授权方法中添加此拦截器。
  • Swagger2Config 配置类中,配置有关 Security 的 Token 认证。
  • 启动项目查看代码是否准确。

1. 构建 Spring Boot 基本项目,准备数据库——User

项目子模块:authority-security,父模块已引入 Spring boot 依赖 2.3.0

1.1 导入依赖

                org.springframework.boot        spring-boot-starter-web                    org.projectlombok        lombok        true                    mysql        mysql-connector-java        runtime                    com.baomidou        mybatis-plus-boot-starter        3.3.1.tmp                    io.springfox        springfox-swagger2        2.7.0                    com.github.xiaoymin        swagger-bootstrap-ui        1.9.6                    org.springframework.boot        spring-boot-starter-security                    io.jsonwebtoken        jjwt        0.9.1                    org.apache.commons        commons-pool2    

构建数据库表:user

create table user(id int primary key auto_increment,username varchar not null,password varchar not null,info varchar(200),enabled tinyint(1) default 1)insert into user values(default,"admin","$2a$10$Himwt.wu3MPOLnNQ9YUH8O2quxgi7bMuomiNeFsVKRay87.qG5dgy","管理员 info ...",default)

username:admin;password:123

配置 application.yml 文件参数:

server:  port: 8082spring:  datasource:    driver-class-name: com.mysql.cj.jdbc.Driver    url: jdbc:mysql://localhost:3306/dbtest16?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai    username: admin    password: admin    hikari:      # 连接池名字      pool-name: DateHikari      # 最小空闲连接数      minimum-idle: 5      # 空闲连接存活最大事件,默认10分钟(600000)      idle-timeout: 180000      # 最大连接数:默认 10      maximum-pool-size: 10      # 从连接池返回的连接自动提交      auto-commit: true      # 连接最大存活时间,0 表示永久存活,默认 1800000(30 min)      max-lifetime: 1800000      # 连接超时事件 30 s      connection-timeout: 30000      # 测试连接是否可用的查询语句      connection-test-query: SELECT 1# MP 配置mybatis-plus:  # 配置 Mapper 映射文件  mapper-locations: classpath*:/mapper/*Mapper.xml  # 实体类的别名包  type-aliases-package: com.cnda.pojo  configuration:    # 自动驼峰命名    map-underscore-to-camel-case: false# MyBatis 的 SQL 打印是方法接口所在的包logging:  level:    com.cnda.mapper: debug# JWT 配置jwt:  # JWT 存储的请求头  tokenHeader: Authorization  # JWT 加密使用的密钥  secret: test-cnda-secret  # JWT 的有效时间 (60*60*24)  expiration: 604800  # JWT 负载中拿到开头 规定  tokenHead: Bearer

User 实体类代码:

@Data@AllArgsConstructor@NoArgsConstructorpublic class User {    private Integer id;    private String username;    private String password;    private String info;    private Boolean enabled;}

2. 配置 Security 和 Swagger2 的配置

先配置好这两个确保没有什么问题,因为重点是 JWT,这两个配置比较简单,当搭配了 JWT 之后,Swagger2 也需要与两者集成一些配置,这个后面再说,现在只配置基本设置。

2.1 配置 SecurityConfig

@EnableWebSecuritypublic class SecurityConfig extends WebSecurityConfigurerAdapter {    @Override    protected void configure(AuthenticationManagerBuilder auth) throws Exception {        super.configure(auth);    }    @Override    public void configure(WebSecurity web) throws Exception {        web.ignoring().antMatchers(                "/hello",            // 下面是对静态资源以及 swagger2 UI 的放行。            "/css/**",                "/js/**",                "/img/**",                "/index.html",                "favicon.ico",                "/doc.html",                "/webjars/**",                "/swagger-resources/**",                "/v2/api-docs/**",                "/ws/**"        );    }    @Override    protected void configure(HttpSecurity http) throws Exception {        super.configure(http);    }}

上面使用 WebSecurity 放行了 /hello 请求,在 LoginController 中。

@RestControllerpublic class LoginController {    @RequestMapping("/hello")    public String hello(){        return "Hello Word!";    }}

这意味除了 localhost:8082/hello 会被放行,其他请求都会被 Security 拦截重定向到 /login(这个请求 Security 内部已经实现了包括相关页面)。

2.2 配置 Swagger2Config

@Configuration@EnableSwagger2public class Swagger2Config {    @Bean    public Docket docket(){        return new Docket(DocumentationType.SWAGGER_2)                .apiInfo(apiInfo()) // 配置 apiInfo                .select() // 选择那些路径和api会生成document                .apis(RequestHandlerSelectors.basePackage("com.cnda.controller")) // // 对哪些 api进行监控,RequestHandlerSelectors.basePackage 基于包扫描                .paths(PathSelectors.any()) // 对所有路径进行监控                .build();    }    private ApiInfo apiInfo(){        return new ApiInfoBuilder()                .title("在线接口文档")                .description("在线接口文档")                .contact(new Contact("cnda","http://localhost:8082/doc.html","xxx@xxx.com"))                .build();    }}

运行效果:

修改一下 Rustful 风格,并加了一个 /hello1 请求,不放行,打印内容相同。

可以看到 Security 和 Swagger2 基本配置完成。

3. 构建 JWT 工具类、公共响应对象

JWT 工具类主要用于生成 JWT,判断 JWT 是否有效,刷新 JWT 等方法。

公共响应对象——RespBean,返回的都已 JSON 格式返回。

3.1 JwtUtil

@Componentpublic class JwtUtil {    // 准备两个存放在荷载的内容    private static final String CLAIM_KEY_SUB = "sub";    private static final String CLAIM_KEY_CREATE = "ibt";    // 提取 application.yml 中 JWT 的参数:    // 1. expiration Long    @Value("${jwt.expiration}")    private Long expiration;    // 2. secret String    @Value("${jwt.secret}")    private String secret; // 密钥    // 根据用户名构建 token    public String foundJWT(UserDetails userDetails) {        String username = userDetails.getUsername();        Map claims = new HashMap();        claims.put(CLAIM_KEY_SUB, username);        claims.put(CLAIM_KEY_CREATE, new Date());        return foundJWT(claims);    }    // 根据荷载 map 构建 token    private String foundJWT(Map claims) {        return Jwts.builder()                .setClaims(claims)                .setExpiration(getExpiration()) // 过期时间                .signWith(SignatureAlgorithm.HS512, secret) // 设置签名算法和密钥                .compact();    }    // 判断 token 是否有效    public boolean validateToken(String token,UserDetails userDetails){        // 从 token 中获取 username 与 userDetails 中的username 对比        String username = getUsernameInToken(token);        // 判断 username 是否一致以及 token 是否过期        return username.equals(userDetails.getUsername()) && !isExpired(token);    }    // 判断 token 是否过期    // true 过期 false 没过期    private boolean isExpired(String token) {        Date expiration = getClaimsInToken(token).getExpiration();        return expiration.before(new Date());    }    // 从 token 中提取荷载信息    public Claims getClaimsInToken(String token){        Claims claims = null;        try {            claims = Jwts.parser()                    .setSigningKey(secret)                    .parseClaimsJws(token)                    .getBody();        }catch (Exception e){            e.printStackTrace();        }        return claims;    }    // 从 token 中提取用户名信息    public String getUsernameInToken(String token){        String username;        try {            username = getClaimsInToken(token).getSubject();        }catch (Exception e){            username = null;        }        return username;    }    // token 是否能刷新    public boolean tokenCanRef(String token){        return !isExpired(token); // 有效地 token 才能被刷新    }    // 刷新 token    public String refToken(String token){        Claims claimsInToken = getClaimsInToken(token);        claimsInToken.put(CLAIM_KEY_CREATE,new Date());        return foundJWT(claimsInToken);    }    // 设置过期时间    private Date getExpiration() {        return new Date(System.currentTimeMillis() + expiration * 1000);    }}

3.2 RespBean 公共返回对象

@Data@AllArgsConstructor@NoArgsConstructorpublic class RespBean {    private long code;    private String message;    private Object obj;    /**     * 返回响应结果     */    private static RespBean result(long code, String message, Object obj) {        return new RespBean(code, message, obj);    }    /*       返回成功响应        */    public static RespBean success(String message) {        return result(200, message, null);    }    /*    返回成功响应以及数据体     */    public static RespBean success(String message, Object obj) {        return result(200, message, obj);    }    /*    返回错误响应     */    public static RespBean error(String message) {        return result(500, message, null);    }}

4. 让 User 实体类实现 UserDetails 的方法成为 Security 验证的用户核心主体

由于 Security 框架的性质,自定义授权和认证时,一般情况下会自定义 UserDetails。

@Data@AllArgsConstructor@NoArgsConstructorpublic class User implements UserDetails {    private Integer id;    private String username;    private String password;    private String info;    private Boolean enabled;    @Override    public Collection getAuthorities() { // 权限角色        return null;    }    @Override    public boolean isAccountNonExpired() {        return false;    }    @Override    public boolean isAccountNonLocked() {        return false;    }    @Override    public boolean isCredentialsNonExpired() {        return false;    }    @Override    public boolean isEnabled() { // 这里数据库实现了该字段,直接用即可        return this.enabled;    }}

5. 重写 UserDetailsServer 和 PasswordEncoder5.1 重写 UserDetailsServer

这个类就只有一个方法:

loadUserByUsername(UserDetails details),该方法用于根据用户名加载用户信息,用作于 Security 的后续认证,同时也可以用一个类去实现该接口,这里为了方便,同时也是 Lambda 表达式。

注意:这里的 UserMapper 没有代码展示了,就一个根据用户名查询用户信息的 SQL。

@Resourceprivate UserMapper mapper;@Bean@Overrideprotected UserDetailsService userDetailsService() {    return username -> {        User user = mapper.find(username);        if (user!=null){            return user;        }        throw new UsernameNotFoundException("用户名或密码不正确");    };}

5.2 PasswordEncoder——密码凭证器

这个类主要用于验证表单提交的密码是否和 重写之后的 UserDetailsServer 得到的 UserDetails 中的加密密码一致。

@Beanpublic PasswordEncoder encoder(){    return new BCryptPasswordEncoder();}

5.3 配置到 SecurityConfig 的认证中

@Overrideprotected void configure(AuthenticationManagerBuilder auth) throws Exception {    auth.userDetailsService(userDetailsService()).passwordEncoder(encoder());}

6. 配置 JWT 的拦截器

public class JwtTokenFilter extends OncePerRequestFilter {    @Autowired    private JwtUtil jwtUtil;    @Autowired    private UserDetailsService service;    @Value("${jwt.tokenHeader}")    private String tokenHeader;    @Value("${jwt.tokenHead}")    private String tokenHead;    @Override    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {        // 获取请求头中的指定的值        String headerToken = httpServletRequest.getHeader(tokenHeader);        // 保证 header中的 token 不为 null,且以指定字串开头——Bearer        if (headerToken!=null && headerToken.startsWith(tokenHead)){            // 截取有效 token            String jwtToken = headerToken.substring(tokenHead.length());            String username = jwtUtil.getUsernameInToken(jwtToken);            // 判断 UserDetails 中的用户主体是否为null            if (username!=null && SecurityContextHolder.getContext().getAuthentication() == null){                // SecurityContextHolder.getContext().getAuthentication() == null 代表着此时 Security 中没有登录的用户主体                // 此时可以使用有效地 jwtToken 进行用户认证                UserDetails userDetails = service.loadUserByUsername(username);                // 判断 token 是否有效                if (jwtUtil.validateToken(jwtToken,userDetails)){                    // 如果有效则使用 token 中的信息进行登录                    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails,null, userDetails.getAuthorities());                    // 根据请求设置 Details,包含了部分请求信息和主体信息。具体效果不清楚...坑                    authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));                    // 将 authenticationToken 设置到 SecurityContext 中                    SecurityContextHolder.getContext().setAuthentication(authenticationToken);                }            }        }        filterChain.doFilter(httpServletRequest,httpServletResponse);    }}

6.1 将 JWT 拦截器设置到 SecurityConfig 的授权方法中。

@Overrideprotected void configure(HttpSecurity http) throws Exception {    // 由于我们使用的是 JWT 令牌的形式来验证用户,所以可以将 csrf 防御关闭    // JWT 能有效防止 csrf 攻击,强行使用 csrf 可能导致令牌泄露    http.csrf()        .disable()        // 基于 token,不需要使用 Session 了        .sessionManagement() // Session 管理        // 管理 Session 创建策略        //    ALWAYS, 总是创建HttpSession        //    NEVER, 只会在需要时创建一个HttpSession        //    IF_REQUIRED, 不会创建HttpSession,但如果它已经存在,将可以使用HttpSession        //    STATELESS; 永远不会创建HttpSession,它不会使用HttpSession来获取SecurityContext        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)        .and()        .authorizeRequests() // 授权请求        // 除了上面的请求,其他所有请求都需要认证        .anyRequest()        .authenticated()        .and()        // 禁止缓存        .headers()        .cacheControl();    // 自定义拦截器 JWT 过滤器    http.addFilterBefore(jwtTokenFilter(), UsernamePasswordAuthenticationFilter.class); // 将过滤器按照一定顺序加入过滤器链。}@Beanpublic JwtTokenFilter jwtTokenFilter() {    return new JwtTokenFilter();}

7. 完善 LoginController 请求,运行项目。

LoginController

@RestControllerpublic class LoginController {    @Autowired    private UserService service;    @GetMapping("/hello")    public String hello(){        return "Hello Word!";    }    @GetMapping("/hello1")    public String hello1(){        return "Hello1 Word!";    }    @PostMapping("/login")    public RespBean loginUser(@RequestBody User user, HttpServletRequest request){        return service.login(user.getUsername(),user.getPassword(),request);    }}

UserService,使用的时 MVC 模式,所以只展示实现类:

@Servicepublic class UserServiceImpl implements UserService {    @Autowired    private UserDetailsService userDetailsService;    @Autowired    private PasswordEncoder passwordEncoder;    @Autowired    private JwtUtil jwtUtil;    @Value("${jwt.tokenHead}")    private String tokenHead;    @Override    public RespBean login(String username, String password, HttpServletRequest request) {        UserDetails userDetails = userDetailsService.loadUserByUsername(username);        if (userDetails==null || !passwordEncoder.matches(password,userDetails.getPassword())){            return RespBean.error("用户名或密码错误!");        }        if (!userDetails.isEnabled()){            return RespBean.error("用户状态异常!");        }        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(userDetails,null, userDetails.getAuthorities());        String jwt = jwtUtil.foundJWT(userDetails);        SecurityContextHolder.getContext().setAuthentication(token);        Map msg = new HashMap();        msg.put("tokenHead",tokenHead);        msg.put("token", jwt);        return RespBean.success("登录成功!",msg);    }}

7.1 完善 Swagger2Config 配置

由于 JWT 的加入,所以 Swagger2 的方法请求也是需要带入 JWT 令牌,提供了 Security 的全局认证。

只展示了修改的部分。

@Beanpublic Docket docket(){    return new Docket(DocumentationType.SWAGGER_2)        .apiInfo(apiInfo()) // 配置 apiInfo        .select() // 选择那些路径和api会生成document        .apis(RequestHandlerSelectors.basePackage("com.cnda.controller")) // // 对哪些 api进行监控,RequestHandlerSelectors.basePackage 基于包扫描        .paths(PathSelectors.any()) // 对所有路径进行监控        .build()        // 添加和 Security 相关的配置。        .securityContexts(securityContexts())        .securitySchemes(securitySchemes());}// 以下方法相对于给 Swagger 添加了一个在 Security 的全局授权,并且以正则的形式设置了授权的请求 url/**     * securityContexts     * 请求体内容     */private List securityContexts(){    List securityContexts = new ArrayList();    securityContexts.add(getContextByPath("/hello/.*"));    return securityContexts;}// 通过正则表达式来设置哪些路径// 通过 Path 获取到对应的 SecurityContextprivate SecurityContext getContextByPath(String pathRegex) {    return SecurityContext.builder()        .securityReferences(defaultAuth())        .forPaths(PathSelectors.regex(pathRegex)) // 按照 String 的 matches 方法进行匹配        .build();}/**     * 配置默认的全局鉴权策略;其中返回的 SecurityReference 中,reference 即为 ApiKey 对象里面的name,保持一致才能开启全局鉴权     * @return SecurityReference     */private List defaultAuth() {    List references = new ArrayList();    // scope 参数:    AuthorizationScope authorizationScope = new AuthorizationScope("global","accessEverything");    AuthorizationScope[] authorizationScopes = new AuthorizationScope[1];    authorizationScopes[0] = authorizationScope;    references.add(new SecurityReference("Authorization",authorizationScopes));    return references;}/**     * securitySchemes     * 安全体方案     */private List securitySchemes(){    List apiKeys = new ArrayList();    // 设置请求头信息    apiKeys.add(new ApiKey("Authorization","Authorization","Header"));    return apiKeys;}

修改的部分直接 CV 大法即可。

7.2 运行项目查看效果:

可以看到利用 Swagger2 的调试,返回 JWT Token 令牌成功!

{  "code": 200,  "message": "登录成功!",  "obj": {    "tokenHead": "Bearer",    "token": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImlidCI6MTY3Nzk4NzIwNjgyMSwiZXhwIjoxNjc4NTkyMDA2fQ.p_GUqevx8gvCK2txxeEX-RQFm69yDCxCYNlZbeHgVIizSUDO6gaT3a2jGXvzXqofH2uxkQBgN4WfeSIlGydiNA"  }}

将令牌设置到 Swagger2 中

这样之前的 /hello1 就可以请求成功了:

说明 Swagger2 设置 JWT 也成功了,每次发送请求,头部都会携带 JWT 令牌。

总结

还是对 Security 不太熟悉,Swagger2 的配置比较固定

JWT 主要也是两个点:

  • JWT Token Utile 工具类,主要用于管理 JWT 令牌。
  • JWT Token Filter JWT 拦截器,这个就是 Security 和 JWT 的集成了,以及请求发来的时候解析 JWT 从而完成免登录这一操作。