SpringBoot 整合 SpringSecurity 梳理

文档

Spring Security Reference
SpringBoot+SpringSecurity+jwt整合及初体验
JSON Web Token 入门教程 - 阮一峰
JWT 官网

SpringSecurity

项目 GitHub 仓库地址:https://github.com/aaronlinv/springsecurity-jwt-demo

依赖

主要用到了: SpringSecurity,Thymeleaf,Web,Lombok

<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
</dependency>

页面

编写页面和 Controller 进行测试,具体页面可以看 代码
主要包含了首页(index),订单(order),还有 user,role,menu这三个位于 /system 下,需要 admin 权限

使用内存用户进行表单登录

static 下新建 login.html,用于登录

<form action="/login" method="post">
    <label for="username">账户</label><input type="text" name="username" ><br>
    <label for="password">密码</label><input type="password" name="password" ><br>
    <input type="submit" value="登录">
</form>

编写继承 WebSecurityConfigurerAdapter 的 Security 配置类,并开启 @EnableWebSecurity 注解,这个注解包含了 @Configuration
WebSecurityConfigurerAdapter 中有两个方法,它们名称相同,但是入参不同

protected void configure(HttpSecurity http) throws Exception
protected void configure(AuthenticationManagerBuilder auth) throws Exception

入参为 HttpSecurity 的 configure 可以配置拦截相关的参数
另一个入参为 AuthenticationManagerBuilder,则是用来配置验证相关的参数

@EnableWebSecurity
// @Configuration 被包括在上面的注解了
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    // 配置 PasswordEncoder 用于密码的加密和匹配
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 
        http
            // 配置表单登录相关参数
            .formLogin()
                // 登录页面
                .loginPage("/login.html")
                // 表单提交的地址
                .loginProcessingUrl("/login")
                // 登录成功后跳转的地址
                .defaultSuccessUrl("/index")

            // .and() 方法返回的是 HttpSecurity 对象
            .and()
                // 配置权限相关参数
                .authorizeRequests()
                // 匹配路径
                // 需要开放登录的地址,否则访问登录页面时因为没有权限,自动跳转到登录页,进入死循环,导致报错:重定向的次数过多
                .antMatchers("/login.html", "/login")
                // 允许访问
                .permitAll()

                // 匹配路径
                .antMatchers("/order")
                // 必须有指定的任意权限才能访问
                .hasAnyAuthority("ROLE_user", "ROLE_admin")

                // 匹配 /system 下的所有路径
                .antMatchers("/system/**")
                // 拥有指定角色才能访问
                .hasRole("admin")

                // 除了上面的路径,其他都需要认证
                .anyRequest().authenticated()

            // 返回 HttpSecurity 对象
            .and()
                // 关闭 csrf (跨站请求伪造)
                .csrf().disable();

        // 设置 注销地址
        http.logout().logoutUrl("/logout");
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 配置验证
        // 使用内存(非持久化)验证
        auth.inMemoryAuthentication()
                // 配置用户名
                .withUser("user")
                // 配置用 PasswordEncoder 加密后的密码
                .password(passwordEncoder().encode("1234"))
                // 配置角色
                .roles("user")
                .and()

                .withUser("admin")
                .password(passwordEncoder().encode("1234"))
                .roles("admin")

                .and()
                // 配置授权时默认使用的 PasswordEncoder
                .passwordEncoder(passwordEncoder());
        ;
    }
}

具体代码参考 这里
两个 configure 非常类似,入参对象的方法中包含了具体的配置项,如:formLogin,authorizeRequests,csrf,logout 等等,部分配置项还可以通过链式调用,进行该配置项更详细地配置,通过 .and() 可以回到 HttpSecurity 对象,再定义其他配置项

使用表单的方式登录需要配置:表单 (formLogin)、授权(authorizeRequests) 、跨站请求伪造(csrf)、注销(logout),还需要配置验证,先使用最简单的 inMemoryAuthentication,并指定账户密码,再指定密码编码器

然后启动服务,访问登录页面(注意这里的被修改为 8081),输入不同的账号密码,测试不同页面的访问情况,没有权限会提示:403
http://localhost:8081/login.html

使用 Json 传递参数,自定义 Handler

修改登录页面,使用 Ajax 向后端传递 账户和密码,需要使用 POST

<head>
    <meta charset="UTF-8">
    <title>登录</title>
    <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script>
</head>
<body>

<form action="/login" method="post">
    <label for="username">账户</label><input type="text" name="username" ><br>
    <label for="password">密码</label><input type="password" name="password" ><br>
    <input type="submit" onclick="login()" value="登录">
</form>
</body>
<script>
    function login() {
        $.ajax({
            type: "POST",
            url: "/login",
            data: {
                "username": $("#username").val(),
                "password": $("#password").val(),
            },
            success: function (data) {
                if (data.code == 20001) {
                    Location.href = "/index";
                } else {
                    alert(data.msg);
                }
            }
        })
    }
</script>

需要编写登录成功和登录失败时调用的 Handler,并配置到SecurityConfig 中

@Component
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        response.setContentType("application/json; charset=UTF-8");
        PrintWriter writer = response.getWriter();
        writer.write("{"code":"40001","msg":"登录失败"}");
        writer.flush();
        writer.close();
    }
}
@Component
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        response.setContentType("application/json; charset=UTF-8");
        PrintWriter writer = response.getWriter();
        writer.write("{"code":"20001","msg":"登录成功"}");
        writer.flush();
        writer.close();
    }
}

在 SecurityConfig 中 注入并配置 Handler

    @Autowired
    private AuthenticationSuccessHandler successHandler;

    @Autowired
    private AuthenticationFailureHandler failureHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
                .loginPage("/login.html")
                .loginProcessingUrl("/login")
                // 指定 Handler
                .successHandler(successHandler)
                .failureHandler(failureHandler)
                // 省略其他代码...
    }

具体代码参考 这里
登录页面进行测试:http://localhost:8081/login.html
首页:http://localhost:8081/

基于数据库的认证

创建数据库 jwt_demo ,导入表数据:sql 脚本
users 表,包括字段:user_id,user_name,password,status,roles
导入 MySQL 驱动和 JPA 的依赖

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

在 application.properties 中配置数据库信息

server.port=8081
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.username=root
spring.datasource.password=1234
spring.datasource.url=jdbc:mysql://localhost:3306/jwt_demo?serverTimezone=GMT%2B8&characterEncoding=utf-8

UserDetails 接口是 SpringSecurity 用来承载用户信息的载体,SpringSecurity 提供了对这个接口的实现类:org.springframework.security.core.userdetails.User,我们自己定义的用户类通常也叫User,所以导包时候要注意使用 我们自己定义的 User 类

@Entity
@Table(name = "users")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements UserDetails {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column
    private Long userId;

    @Column(name = "user_name")
    private String userName;

    @Column(name = "password")
    private String password;

    @Column(name = "status")
    private String status;

    @Column(name = "roles")
    private String roles;

    // 对象的权限列表,不需要持久化
    @Transient
    private List<GrantedAuthority> authorities;

    public void setAuthorities(List<GrantedAuthority> authorities) {
        this.authorities = authorities;
    }

    // 必须重写接口的对于 getPassword,getUsername,getAuthorities 等方法
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.authorities;
    }

    @Override
    public String getUsername() {
        return this.userName;
    }

    // 下面 4 个需要方法 return true,否则登录时会被限制
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

定义 JPA 的 Repository

@Repository
public interface UserDao extends JpaRepository<User, Long> {
}

定义 Service

public interface UserService {
    public User selectUserByUserName(String username);
}

定义 Service 对应的实现,通过查询用户名获得用户相关信息

@Service
public class UserServiceImpl implements UserService {
    @Autowired
    private UserDao userDao;

    @Override
    public User selectUserByUserName(String username) {
        User user = new User();
        user.setUserName(username);
        List<User> list = userDao.findAll(Example.of(user));
        return list.isEmpty() ? null : list.get(0);
    }
}

还需要编写 UserDetailService,供 SpringSecurity 的 DaoAuthenticationProvider 类中的 retrieveUser 方法调用,以此获得对应用户的信息

@Service
public class UserDetailService implements UserDetailsService {
    @Autowired
    private UserService userService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 调用 Service
        User user = userService.selectUserByUserName(username);
        if (user == null) {
            throw new UsernameNotFoundException("用户" + user.getUsername() + "不存在");
        }
        // 设置权限
        // commaSeparatedStringToAuthorityList 方式将字符串间通过 ',' 进行分割,然后返回 List
        user.setAuthorities(AuthorityUtils.commaSeparatedStringToAuthorityList(user.getRoles()));
        return user;
    }
}
// 省略其他...
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private UserDetailService userDetailService;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 将内存授权方式替换为自己实现的 UserDetailService
        auth.userDetailsService(userDetailService)
                .passwordEncoder(passwordEncoder());
    // 省略其他...
}

具体代码参考 这里

登录页面进行测试:http://localhost:8081/login.html
首页:http://localhost:8081/

整合 JWT

添加 jjwt 依赖

        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.0</version>
        </dependency>

在 application.properties 中配置 JWT 参数

token.header:Authorization
#  令牌秘钥
token.secret:askdhfkjahskjdfhkalsjhdf^112asdfasdf44^%$_@+asdfasdfaskjdhfkjashdfljkahsdklsfjasgdkjfgjahs(IS:)_@@+asdfasdfaskjdhfkjashdfljkahsdklsfja@+asdfasdfaskjdhfkjashdfljkahsdklsfjasgdkjfgjahssgdkjfgjahsdgfjhgsdfsadf+-asdfasdas+as++_sdfsdsasdfasdf
#   令牌有效期(默认30分钟)
token.expireTime:3600000

定义统一 API 封装格式

public class RestResult extends HashMap<String, Object> {
    private static final long serialVersionUID = 1L;
    // 状态码
    public static final String CODE_TAG = "code";
    // 返回内容
    public static final String MSG_TAG = "msg";
    // 数据对象
    public static final String DATA_TAG = "data";

    public RestResult() {

    }

    public RestResult(int code, String msg) {
        super.put(CODE_TAG, code);
        super.put(MSG_TAG, msg);
    }

    public RestResult(int code, String msg, Object data) {
        super.put(CODE_TAG, code);
        super.put(MSG_TAG, msg);
        if (data != null) {
            super.put(DATA_TAG, data);
        }
    }

    public static RestResult success() {
        return new RestResult(200, "成功");
    }
}

然后准备 JWT 工具类,实现:生成 token、从 token 中获取用户名、检查 token 是否过期、刷新 token、验证 token 等,这里的 KEY 通过双重锁 保证了线程安全

@Data
@Component
@Slf4j
public class JwtTokenUtils {
    @Value("${token.secret}")
    private String secret;

    @Value("${token.expireTime}")
    private Long expiration;

    @Value("${token.header}")
    private String header;

    private static Key KEY = null;

    /**
     * 生成token令牌
     *
     * @param userDetails 用户
     * @return 令token牌
     */
    public String generateToken(UserDetails userDetails) {
        log.info("[JwtTokenUtils] generateToken " + userDetails.toString());
        Map<String, Object> claims = new HashMap<>(2);
        claims.put("sub", userDetails.getUsername());
        claims.put("created", new Date());

        return generateToken(claims);
    }

    /**
     * 从令牌中获取用户名
     *
     * @param token 令牌
     * @return 用户名
     */
    public String getUsernameFromToken(String token) {
        String username = null;
        try {
            Claims claims = getClaimsFromToken(token);
            username = claims.get("sub", String.class);
            log.info("从令牌中获取用户名:" + username);
        } catch (Exception e) {
            username = null;
        }
        return username;
    }

    /**
     * 判断令牌是否过期
     *
     * @param token 令牌
     * @return 是否过期
     */
    public Boolean isTokenExpired(String token) {
        try {
            Claims claims = getClaimsFromToken(token);
            Date expiration = claims.getExpiration();
            return expiration.before(new Date());
        } catch (Exception e) {
            return false;
        }
    }

    /**
     * 刷新令牌
     *
     * @param token 原令牌
     * @return 新令牌
     */
    public String refreshToken(String token) {
        String refreshedToken;
        try {
            Claims claims = getClaimsFromToken(token);
            claims.put("created", new Date());


            refreshedToken = generateToken(claims);
        } catch (Exception e) {
            refreshedToken = null;
        }
        return refreshedToken;
    }

    /**
     * 验证令牌
     *
     * @param token 令牌
     * @param userDetails 用户
     * @return 是否有效
     */
    public Boolean validateToken(String token, UserDetails userDetails) {

        String username = getUsernameFromToken(token);
        return (username.equals(userDetails.getUsername()) &&
                !isTokenExpired(token));
    }

    /**
     * 从claims生成令牌
     *
     * @param claims 数据声明
     * @return 令牌
     */
    private String generateToken(Map<String, Object> claims) {
        Date expirationDate = new Date(System.currentTimeMillis() + expiration);
        return Jwts.builder().setClaims(claims)
                .setExpiration(expirationDate)
                .signWith(SignatureAlgorithm.HS256, getKeyInstance())
                .compact();
    }

    /**
     * 从令牌中获取数据声明
     *
     * @param token 令牌
     * @return 数据声明
     */
    private Claims getClaimsFromToken(String token) {
        Claims claims = null;

        try {
            claims = Jwts.parser().setSigningKey(getKeyInstance()).parseClaimsJws(token).getBody();
        } catch (Exception e) {
            claims = null;
        }
        return claims;
    }

    private Key getKeyInstance() {
        if (KEY == null) {
            synchronized (JwtTokenUtils.class) {
                if (KEY == null) {// 双重锁
                    byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(secret);
                    KEY = new SecretKeySpec(apiKeySecretBytes, SignatureAlgorithm.HS256.getJcaName());
                }
            }
        }
        return KEY;
    }
}

然后定义 JwtAuthTokenFilter,用于过滤请求

@Component
public class JwtAuthTokenFilter extends OncePerRequestFilter {
    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private JwtTokenUtils jwtTokenUtils;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        // 从请求头中获取 Authorization 的值,即 token
        String jwtToken = request.getHeader(jwtTokenUtils.getHeader());

        if (!ObjectUtils.isEmpty(jwtToken)) {
            // 从 token 中获取用户名,用户名存储在负载中,负载一般没有加密,所以负载的内容是可以见,不能在其中存放敏感信息
            // 可以通过 https://jwt.io/ 进行解码
            String username = jwtTokenUtils.getUsernameFromToken(jwtToken);

            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                // 通过 userDetailsService 从数据库中获取对应用户的信息
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                // 这里校验 token 有效性
                if (jwtTokenUtils.validateToken(jwtToken, userDetails)) {
                    // 将 UserDetails 对象 封装为 UsernamePasswordAuthenticationToken 对象
                    // 第一参数是 Object principal,传入的是 UserDetails 对象,在后面的 Service 中会取出 principal
                    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                    // 交给SpringSecurity管理,在之后的过滤器不会被拦截进行二次授权了
                    SecurityContextHolder.getContext().setAuthentication(authenticationToken);
                }
            }
        }
        // 将请求转发给过滤器链上的下一个对象
        chain.doFilter(request, response);
    }
}

编写 JwtAuthService,处理登录的相关逻辑,使用 AuthenticationManager 对传入的账号密码进行认证,成功返回 生成的 token

@Service
public class JwtAuthService {
    @Autowired
    private JwtTokenUtils jwtTokenUtils;

    @Autowired
    private AuthenticationManager authenticationManager;

    public String login(String username, String password) {
        Authentication authentication = null;
        try {
            authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
        } catch (Exception e) {
            throw new RuntimeException("用户名或密码有误");
        } 
        // 这里就是获取的就是在前面 JwtAuthTokenFilter 中传入的 principal
        User loginUser = (User) authentication.getPrincipal();
        return jwtTokenUtils.generateToken(loginUser);
    }
}

用于登录的 Controller

@RestController
public class JwtLoginController {
    @Autowired
    private JwtAuthService jwtAuthService;

    @PostMapping({"/login", "/"})
    public RestResult login(String username, String password) {
        RestResult result = RestResult.success();
        String token = jwtAuthService.login(username, password);
        result.put("token", token);
        return result;
    }
}

在 SecurityConfig 中 注入并配置 Handler

    // 省略其他代码...
    @Autowired
    private JwtAuthTokenFilter jwtAuthTokenFilter;

    // 重写 AuthenticationManager,避免报错
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // http.formLogin()
        //         .loginPage("/login.html")
        //         .loginProcessingUrl("/login")
        //         // .defaultSuccessUrl("/index")
        //         // .defaultSuccessUrl("/index")
        //         .successHandler(successHandler)
        //         .failureHandler(failureHandler)
        http.sessionManagement()
                // 不创建和使用 session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()

                .authorizeRequests()
                .antMatchers("/login")
                .anonymous()

                .antMatchers(HttpMethod.GET, "/*.html", "/**/*.html", "/**/*.css", "/**/*.js")
                .permitAll()
        // 省略其他代码...

        // 使用 JWT 过滤器
        http.addFilterBefore(jwtAuthTokenFilter, UsernamePasswordAuthenticationFilter.class);
    }
     // 省略其他代码...

可以通过 Postman 先指定参数(注意是用 POST),获取 token:
http://localhost:8081/login?username=user&password=1234

在 Headers 中添加 Authorization,值为获取到的 token
使用 GET 访问:http://localhost:8081/order
因为 user 没有管理权限,所以访问管理页面会 403:http://localhost:8081/system/role

具体代码参考 这里