快速入门 Spring security

205 阅读10分钟

本文带你快速入门spring security,适合新手和只想使用不想深究原理的同学。

一般在做系统的时候大家或多或少都会涉及到权限处理问题。市面上常见的权限处理框架是 shirospring security。之前学习spring security看过很多教程,但是都多少都涉及到了一些原理性的东西,对于想快速入手使用的读者来说不太友好。所以,我就简单的把自己学到的总结一下,能够不看底层源码,让你快速入门。文中对认证授权模块都会覆盖。

建议学有余力或者想深入使用的小伙伴,还是应该多看看文档和源码。

1.前置

为了能够更好的理解后续的流程,我们先从宏观上去认识下整个框架的工作流程。

牢记:spring security是基于过滤器去完成认证和授权功能的。框架中每一个模块都是一个过滤器。过滤链走完了,最后到达接口,完成后续功能。

下面让我们快速上手吧!

2.环境搭建

本文使用了springboot + jwt + mysql + mybatis 实现了前后端分离的权限和授权。

基本的环境搭建就不再细说,可以通过http://start.spring.io快速搭建sringboot的项目,

下面是需要的依赖。(这里还没有引入spring security喔!)

<!-- JWT 的相关依赖和 fastjson --> 
       <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>
        <dependency>
            <groupId>javax.xml.bind</groupId>
            <artifactId>jaxb-api</artifactId>
            <version>2.3.0</version>
        </dependency>
        <dependency>
            <groupId>com.sun.xml.bind</groupId>
            <artifactId>jaxb-impl</artifactId>
            <version>2.3.0</version>
        </dependency>
        <dependency>
            <groupId>com.sun.xml.bind</groupId>
            <artifactId>jaxb-core</artifactId>
            <version>2.3.0</version>
        </dependency>
        <dependency>
            <groupId>javax.activation</groupId>
            <artifactId>activation</artifactId>
            <version>1.1.1</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.76</version>
        </dependency>
	<!-- 链接数据库的依赖 -->
		<dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.48</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.1.4</version>
        </dependency>
		<!-- get/set不用写了-->
		<dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>

3.基础代码

我们简单实现一个Controller接口,使用postman测试,能够正常访问。

1)编写Controller接口

@RestController
@RequestMapping("/test")
public class helloController {

    @GetMapping("/hello")
    public String helloSecurity(){
        return "hello,security";
    }

    @GetMapping("/permitall")
    public String againSecurity(){
        return "again,security";
    }

    @GetMapping("/admin")
    public String onlyAdmin(){
        return "hello,admin";
    }
}

2)引入spring security

<!-- spring security的依赖,可以不用提供版本号 -->
  <dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-starter-security</artifactId>
  </dependency>

在引入spring security后,再次启动项目,在浏览器访问以上接口会发现无法成功访问,需要进行登录。默认的账号为: user,密码则是在控制台打印出来。成功登录后即可访问。

接下来,我们要自定义认证和授权。

4.自定义认证模块

1)配置数据源

在数据库中建好用户表。

image-20210703221813374.png

在实际项目中,通常是需要五张表,将用户表、权限表和角色表分开,这样更利于权限的管理。这里为了快速上手,将角色集成在了用户表中,也省去了一些非必要字段。

然后在项目中配置数据源,编写mapper和sql语句。小伙伴记得要测试一下编写的代码是否能够跑通喔!

后续的认证需要从数据库中拿用户,那么我们在UT中往数据库插入几个权限不同的用户吧

@SpringBootTest
class SpringSecurityDemoApplicationTests {
    @Autowired
    private SecurityUserMapper mapper;

    // 密码加密器
    @Autowired
    private PasswordEncoder encoder;
    
    @Test
    void contextLoads() {
        SecurityUser user = new SecurityUser();
        user.setUsername("wangwu"); // 用户名,记得唯一喔
        user.setPassword(encoder.encode("345")); // 密码
        ArrayList<GrantedAuthority> authorities = new ArrayList<>();
        SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority("ROLE_user"); // 本文用到了ROLE_user ROLE_admin两种
        authorities.add(simpleGrantedAuthority);
        String s = JSONObject.toJSONString(authorities);
        user.setSqlAuthorities(s);
        int i = mapper.insertUser(user);
        System.out.println(i);
    }
}

最后在数据库的样子应该是这样的

image-20210703222836052.png

2) 编写JWT 工具类

public class TokenUtils {
    /**
     * token 过期时间, 单位: 秒.
     */
    private static final long TOKEN_EXPIRED_TIME = 5*60;

    public static final String JWT_ID = "xiaofatoken";
    /**
     * jwt 加密解密密钥(可自行填写)
     */
    private static final String JWT_SECRET = "testsecurity";

    /**
     * 创建JWT
     */
    public static String createJWT(Map<String, Object> claims, Long time) {
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; //指定签名算法
        Date now = new Date(System.currentTimeMillis());
        SecretKey secretKey = generalKey();
        long nowMillis = System.currentTimeMillis();//生成JWT的时间
        //下面就是在为payload添加各种标准声明和私有声明了
        JwtBuilder builder = Jwts.builder()//这里其实就是new一个JwtBuilder,设置jwt的body
                .setClaims(claims)          //如果有私有声明,一定要先设置这个自己创建的私有的声明,这个是给builder的claim赋值,一旦写在标准的声明赋值之后,就是覆盖了那些标准的声明的
                .setId(JWT_ID)                  //设置jti(JWT ID):是JWT的唯一标识,根据业务需要,这个可以设置为一个不重复的值,主要用来作为一次性token,从而回避重放攻击。
                .setIssuedAt(now)           //iat: jwt的签发时间
                .signWith(signatureAlgorithm, secretKey);//设置签名使用的签名算法和签名使用的秘钥
        if (time >= 0) {
            long expMillis = nowMillis + time * 1000;
            Date exp = new Date(expMillis);
            builder.setExpiration(exp);     //设置过期时间
        }
        return builder.compact();
    }

    /**
     * 验证jwt
     */
    public static Claims verifyJwt(String token) {
        //签名秘钥,和生成的签名的秘钥一模一样
        SecretKey key = generalKey();
        Claims claims;
        try {
            claims = Jwts.parser()  //得到DefaultJwtParser
                    .setSigningKey(key)         //设置签名的秘钥
                    .parseClaimsJws(token).getBody();
        } catch (Exception e) {
            claims = null;
        }//设置需要解析的jwt
        return claims;
    }
    /**
     * 由字符串生成加密key
     *
     * @return
     */
    public static SecretKey generalKey() {
        String stringKey = JWT_SECRET;
        byte[] encodedKey = Base64.decodeBase64(stringKey);
        SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
        return key;
    }

    /**
     * 根据userId和openid生成token
     */
    public static String generateToken(String username) {
        Map<String, Object> map = new HashMap<>();
        map.put("username", username);
        return createJWT(map, TOKEN_EXPIRED_TIME);
    }
}

3)编写实体类

这里一定要实现UserDetails接口。牢记!至于为什么,后面会有解释。

@Data
public class SecurityUser implements UserDetails {

    private Integer id;
    private String username;
    private String password;
    private boolean enable;
    private List<GrantedAuthority> authorities;
    private String sqlAuthorities;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;  // 没有过期返回true,否则false
    }

    @Override
    public boolean isAccountNonLocked() {
        return true; // 没有锁定返回true,否则false
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;  // 验证没有过期返回true,否则false
    }

    @Override
    public boolean isEnabled() {
        return enable; // 账号是否启用
    }
    
}

4)实现数据库查询

要自己实现从数据库中查到对象进行认证,那么需要自己写一个service实现接口UserDetailsService,这个接口中只有一个方法

public interface UserDetailsService {
    
	UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
    
}

可以看到,该方法返回的是UserDetails对象,想要返回自己定制的对象,也需要实现UserDetails接口。

根据自己的需求,完成自定义的逻辑

@Service
public class userDetailService implements UserDetailsService {

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Autowired
    private SecurityUserMapper securityUserMapper;

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        SecurityUser user = securityUserMapper.selectUserByUserName(s);
        String sqlAuthorities = user.getSqlAuthorities();
        if(!StringUtils.isEmpty(sqlAuthorities)){
            List<SimpleGrantedAuthority> authorityList = JSONObject.parseArray(sqlAuthorities, SimpleGrantedAuthority.class);
            ArrayList<GrantedAuthority> authorities = new ArrayList<>();
            authorityList.stream().forEach(item->authorities.add(item));
            user.setAuthorities(authorities);
        }
        System.out.println(user);
        return user;
    }
}

5)添加过滤器

我们需要添加两个必要的过滤器,一个是用来用户登录后验证token的,一个是处理异常的。

我们先编写验证token的拦截器。

@Component
public class LoginFilter extends OncePerRequestFilter {

    @Autowired
    private UserDetailsService userDetailsService;
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        String token = request.getHeader("token");
        Claims claims = TokenUtils.verifyJwt(token);
        if(claims!=null){
            String username = (String) claims.get("username");
            UserDetails user = userDetailsService.loadUserByUsername(username);
            UsernamePasswordAuthenticationToken authenticationToken =
                    new UsernamePasswordAuthenticationToken(user,
                            user.getPassword(), user.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        }
        filterChain.doFilter(request, response);
    }
}

这里可以使用缓存来保存用户信息,而不是每次都去数据库查询。

接下来编写一个处理异常的过滤器,用户认证失败后需要返回一个错误信息。

@Component
public class SecurityExceptionFilter implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException e) throws IOException, ServletException {
        response.setContentType("application/json;charset=utf-8");
        PrintWriter out = response.getWriter();
        // 直接提示前端认证错误
        out.write("认证错误");
        out.flush();
        out.close();
    }
}

至此,我们所有的准备工作都做完啦,接下了只需要将我们自定义的过滤器和认证操作替换默认操作即可啦。

6)配置

spring security的一些基本功能都需要进行配置,注意看注释。

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true)
public class securityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    UserDetailsService userDetailsService;

    @Autowired
    private LoginFilter loginFilter;
    // 自定义密码加密器
    @Bean
    PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }


    	//	设置自己的认证的对象查询操作,以及指定加密器
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 关闭csrf和frameOptions,如果不关闭会影响前端请求接口(这里不展开细讲了,感兴趣的自行了解)
        http.csrf().disable();
        http.headers().frameOptions().disable();
        // 开启跨域以便前端调用接口
        http.cors();
        // 采用jwt 需要禁用session
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
        // 把自己定义的过滤器加到默认的认证过滤器前面
        http.addFilterBefore(loginFilter, UsernamePasswordAuthenticationFilter.class);
       
        // 这是配置的关键,决定哪些接口开启防护,哪些接口绕过防护
        http.authorizeRequests()
                // 注意这里,是允许前端跨域联调的一个必要配置
                .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
                // 指定某些接口不需要通过验证即可访问。登陆、注册接口肯定是不需要认证的
                .antMatchers("/api/login", "/api/register").permitAll()
                // 这里意思是其它所有接口需要认证才能访问
                .anyRequest().authenticated()
                // 指定认证错误处理器
                .and().exceptionHandling().authenticationEntryPoint(new SecurityExceptionFilter());

    }
    @Bean
    @Override
    protected AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }
    
}

7) 登录接口

最后编写一个登录的接口,基本的认证逻辑都含在这个接口中.

AjaxResult 是一个返回前端的对象,这里可以简化成String,主要是返回给前端token,以便后端请求将token携带在请求头中。

这里只是简单的几步基本认证步骤,实际工作中可以添加其他附加功能。

@RestController
@RequestMapping("/api")
public class LoginController {

    @Autowired
    private AuthenticationManager authenticationManager;

    @PostMapping("/login")
    public AjaxResult login(@RequestBody LoginUser loginUser){
        // LoginUser对象主要接受用户名和密码
        // 1 创建UsernamePasswordAuthenticationToken
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginUser.getUsername(),
                        loginUser.getPassword());
        // 2 认证
        Authentication authentication = authenticationManager.authenticate(authenticationToken);
        // 3 保存认证信息
        SecurityContextHolder.getContext().setAuthentication(authentication);
        // 4 生成自定义token
        SecurityUser userDetail = (SecurityUser) authentication.getPrincipal();
		
        String s = TokenUtils.generateToken(userDetail.getUsername());
        System.out.println(s);
        AjaxResult ajax = AjaxResult.success(s);
        return ajax;
    }
}

简单说下几个步骤:

  1. 根据传入的用户名和密码创建一个UsernamePasswordAuthenticationToken对象,在查看源码可以发现这个类实现了Authentication接口,而我们的认证流程都需要实现这个接口的对象。
  2. 调用authenticationManager帮助我们进行认证操作,认证过程简单概括就是拿我们传入的对象和数据库的对象进行比对。这里传入步骤1创建的Authentication对象。
  3. 认证过程如果出错,则会通过我们编写的异常过滤器返回前端。没有出现异常则会走到这一步,我们需要将认证后的Authentication对象放入到SecurityContex中。
  4. 生成token返回给前端,后续访问需要带上token。

8)测试

使用postman进行测试

输入正确的账号和密码会返回token

image-20210703233414434.png

输入错误的账号和密码,会返回我们自定义消息

image-20210703233519351.png

成功登录获取token后,可以带上token去请求其他的接口。

image-20210703233655068.png

带上token我们可以成功访问接口,那么不带token呢

image-20210703233735574.png

不带token的话则会无法访问。

至此,我们认证模块则全部完成。

5.授权模块

完成了认证模块,我们还需要根据不同的角色(不同角色对应不同权限)来限制访问接口。

有了以上的准备,加入权限认证模块就简单很多啦。

还记得在上面配置类中有一个注解吗?

@EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true)

就是他,让我们开启了权限验证功能,接下来我们只需要编写一个权限不足处理器以及给对应的方法加上权限注解即可啦。

1)权限不足处理器

public class AuthorizeHandler implements AccessDeniedHandler{
    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException e) throws IOException, ServletException {
        response.setContentType("application/json;charset=utf-8");
        Writer out = response.getWriter();
        out.write("没有相关权限");
        out.flush();
        out.close();
    }
}

2)对应权限注解

spring security允许我们使用权限注解加上EL表达式对访问的方法进行权限控制。如果权限足够则会返回true能够访问,否则会进入我们编写的无权处理器。当然我们可以通过hasPermission()来扩展表达式.这里就不过多介绍了。

	// 满足任一角色即可访问
    @PreAuthorize("hasAnyRole('admin','user')")
    @GetMapping("/hello")
    public String helloSecurity(){
        return "hello,security";
    }

	// 允许任何角色访问
    @PreAuthorize("permitAll()")
    @GetMapping("/permitall")
    public String againSecurity(){
        return "again,security";
    }

	// 仅允许拥有admin角色的用户访问
    @PreAuthorize("hasAnyRole('admin')")
    @GetMapping("/admin")
    public String onlyAdmin(){
        return "hello,admin";
    }

3)配置

小伙伴记得在配置类在添加我们的无权处理器喔

 	@Override
  	 protected void configure(HttpSecurity http) throws Exception {
         
    	http.exceptionHandling().accessDeniedHandler(new AuthorizeFilter());
         
    }

4)测试

我们使用普通用户访问onlyAdmin这个接口会被告知没有权限

image-20210703235135459.png

image-20210703235247461.png

使用拥有admin权限的用户进行访问相同接口,能够成功访问!

image-20210703235409097.png

image-20210703235432558.png

6.总结

至此,spring security简单的快速入门上手已经全部完成。简单的功能已经足够我目前的使用啦!更多深入的功能还需要不断的学习。

初次写文章,写的不好,多多指教!

更多功能尽在:spring security官方文档