SpringSecurity 入门级别教程,有前后端分离校验模式案例,SpringSecurityOAuth2整合GateWay操作


demo

  1. 新建springboot工程,然后勾选web服务和spring security服务,idea会根据我们的springboot版本选择合适的spring security版本.
  2. 新建controller,模拟重定向去resource下static包下的main.html页面.
@GetMapping("/test")
    public String test(){
        return "redirect:main.html";
    }

这个时候,如果我们启动项目访问该路径,由于我们导入了SpringSecurity依赖,该项目在访问每个地址之前,都会先访问ss框架中自带的验证页面.初始账号是user,密码是不固定的,每次启动项目控制台都有输出.

重要接口

UserDetailsService:这个接口时ss框架提供给用户自定义实现登录功能的接口,这个接口只提供了loadUserByUsername这个方法,该方法通过传入一个username来实现逻辑.该方法的返回类型是UserDetails,这也是一个接口,定义了很多例如获取密码,获取用户名等等重要的方法,该接口有一个User实现类,该实现类是一个很重要的实现类,该实现类为用户登录的vo实现类.
PasswordEncoder: 这个接口时ss底层的密码解析,提供了注入密码加密,匹配等接口方法.在该接口中,有很多实现类,我们可以使用这些个实现类去实现密码的加密和匹配.官方推荐的实现类为:BCryptPasswordEncoder

自定义实现账号和密码

通过重要接口的分析,我们知道,只要我们实现UserDetailsService这个接口就可以自定义账号密码的登录逻辑

@Service
public class UserDetailServiceImpl implements UserDetailsService {

    @Autowired
    private PasswordEncoder pwd;

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        //s为用户名,查询数据库s是否存在,如果不存在抛出该UsernameNotFoundException
        if (!"admin".equals(s)){
            throw new UsernameNotFoundException("用户名不存在");
        }

        //查询数据库密码,然后装入User对象里
        String encode = pwd.encode("123");

        //定义用户权限
        List<GrantedAuthority> grantedAuthorities = AuthorityUtils.commaSeparatedStringToAuthorityList("admin,normal");

        return new User(s,encode,grantedAuthorities);
    }

但要注意的是,可以看到,中间我们自动注入了一个PasswordEncoder,这是一个接口,里面有很多实现类,具体我们要用哪个,肯定要我们自己去确定,所以我们需要@Bean一个实现类:

@Bean
    public PasswordEncoder getPasswordEncode(){
        return new BCryptPasswordEncoder();
    }

这样子等启动项目的时候,用的还是ss自带的登录界面,但是账号和密码的验证逻辑是我们在loadUserByUsername方法中确定了的.

自定义登录页面

  1. 我们先写一个登录页面:
<form action="/login" method="post">
    <label for="username">用户名:</label>
    <input name="username" id="username"/><br>
    <label for="password">密码: </label>
    <input name="password" id="password"/><br>
    <button type="submit">提交</button>
</form>
  1. 实现登录页面必须继承WebSecurityConfigurerAdapter类,然后实现configure方法:
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin().
                //表单提交后跳转的url,必须和action属性一样
                loginProcessingUrl("/login").
                //自定义的登录页面
                loginPage("/login.html").
                //登录成功后跳转的路径,必须是post请求,最好是controller的路径
                successForwardUrl("/test").
                //失败跳转页面,规则和成功跳转一样,跳转路径不能是底层默认解析的错误页面名字,即error
                failureForwardUrl("/error1");
        http.authorizeRequests()
                //login.html不需要验证
                .antMatchers("/login.html").permitAll()
                .antMatchers("/error1.html").permitAll()

                .anyRequest().authenticated();

        //关闭防火墙防护
        http.csrf().disable();
    }
  1. 这里需要注意的是,前面我们的form表单中,method属性必须是post,而且你的用户名和密码的name属性必须是username和password,这是ss底层的过滤器UsernamePasswordAuthenticationFilter决定的:

image.png
image.png
postOnly只允许post请求
但是用户名和密码的参数可以自定义设置:
image.png
这样子我们在表单提交的时候,name属性可以随意定义,只要在拦截这边规定接收什么参数就可以了.

前段后分离跳转

前面我们通过successForwardUrl方法进行controller的跳转,然后通过controller进行重定向,但在前后端分离的项目中,肯定不是这样做的,这个时候此方法就失效了.

  1. 在ss中,提供了自定义成功处理器去处理这样的问题:
public class SuccessHandler implements AuthenticationSuccessHandler {

    private String url;

    public SuccessHandler(String url) {
        this.url = url;
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {

        //重定向路径
        httpServletResponse.sendRedirect(url);

        //可以获取用户信息,注意是ss框架的User
        User user= (User) authentication.getPrincipal();

        //根据用户获取其信息
        System.out.println(user.getUsername());
        System.out.println(user.getAuthorities());
    }
}
  1. 比如我们要跳转到百度的地址,使用succeforWardUrl是不行的,我们需要用上面第一步的自定义类:

image.png

  1. 那为什么就要这样写呢?

我们可以看看successForwardUrl的底层源码,进入该方法会发现:

public FormLoginConfigurer<H> successForwardUrl(String forwardUrl) {
        this.successHandler(new ForwardAuthenticationSuccessHandler(forwardUrl));
        return this;
    }

这里new了一个ForwardAuthenticationSuccessHandler,而这个ForwardAuthenticationSuccessHandler类进入仔细看后,其还是实现了AuthenticationSuccessHandler接口,而且你会发现,人家去实现该接口的时候和我们自定义类的写法几乎是一样,只不过它转发地址的时候用的是请求转发,所以这个时候时跳转不到百度的,只有我们利用重定向跳转.

  1. 而失败跳转处理器写法也类似,只不过在重要逻辑处理的时候加上了一个失败异常处理的参数:
public class FailHandler implements AuthenticationFailureHandler {
    
    private String url;

    public FailHandler(String url) {
        this.url = url;
    }

    @Override
    public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        httpServletResponse.sendRedirect(url);
    }
}
  1. 这个时候,失败处理器的用法也是一样的:

image.png

授权认证

在前面自定义登录页面的configure方法中,http.authorizeRequests()开头后的代码便是授权认证.

  1. 拦截所有请求:.anyRequest().authenticated()
  2. 排除拦截路径:.antMatchers(“/login.html”).permitAll(),这里的参数/login.html表示我们放行这个请求页面.其实这里是一个可变参数,不仅可以填入请求路径和页面,还可以放一些静态资源的拦截路径.所以,这里的参数有个匹配的规则:

image.png
比如我填入的参数是/**/*.js 表示拦截所有js文件,/js/**表示拦截js目录下的所有目录
除此之外,antMatchers方法参数可以是两个,第一个参数指定匹配的路径必须是某种请求方式,该参数通过HttpMethod枚举类去指定.如果不指定,路径就不会受到限制.比如.antMatchers(HttpMethod.GET,“/p”)就表示过来的/p请求必须是get请求,如果不是就会报错.

  1. .regexMatchers(“.+[.]png”).permitAll() 表示正则方法匹配,拦截所有.png结尾的图片.该方法用的不多.
  2. .mvcMatchers(“/demo”).servletPath(“/xxxx”).permitAll() 这个配置表示放行配置全局的Servlet路径,比如如果在properties文件中指定spring.mvc.servlet.path=/xxxx,这就表示我们访问项目的每一个地址前都要加上/xxxx的路径,但是如果你没有同时在ss拦截的时候配置.mvcMatchers(“/demo”).servletPath(“/xxxx”).permitAll(),你访问/xxxx/demo还是不能访问的.该方式其实也是放行拦截而已,所以你也可以用antMatchers(“/xxxx/demo”).permitAll()去放行

内置访问控制

前面的permiAll便是内置访问控制的一种方法,表示所有人都可以访问该路径,在ss中,内置访问控制方法其实不止一个
denyAll: 表示任何人都不能访问
anonymous: 任何人都可以访问,和permiall类似,只不过该方法被调用的时候,会执行filter链
authenticated: 表示所匹配的url需要被验证才能被访问
rememberMe: 该方法便是登录页面中记住我的功能,表示只有勾选了记住我的功能才能被访问
fullyAuthenticated: 表示没有勾选记住我的用户才能访问,且访问方式是一步步进行验证

权限,角色,ip控制

.antMatchers("/main.html").hasAuthority("admin")
.antMatchers("/main.html").hasAnyAuthority("admin1","aon")

上面代表权限控制,不是路径拦截,注意区分.
在自定义登录逻辑的时候,我们做过这样一个操作:
AuthorityUtils.commaSeparatedStringToAuthorityList(“admin,normal”)这个操作就表示给通过该方法的请求给予相应的权限.
而上面的控制就代表拥有了某个权限才可以通过,不然就报错.hasAuthority表示拥有某个权限,hasAnyAuthority中传入可变参数,表示拥有任何一个权限就给通过.

同时,我们还可以为其添加上角色控制,角色控制可以和权限一样,直接加在_commaSeparatedStringToAuthorityList的参数中,但是必须以ROLE_开头,比如_commaSeparatedStringToAuthorityList(“admin,normal,ROLE_abcd”)表示拥有权限admin,normal,和角色abcd,不是ROLE_abcd,但必须以ROLE_开头.在角色判断的时候和权限判断几乎一样,使用hasRole和hasAnyRole

不仅如此,ss框架还提供了ip控制,可以指定ip地址才能访问.antMatchers(“/main.html”).hasIpAddress(“127.0.0.1”) 表示访问/main.html页面必须是127.0.0.1的地址,如果是localhost,本机ip这样的地址是不能访问,虽然它们都代表你的机器.

自定义403处理方案

主要是实现AccessDeniedHandler接口中hande方法

@Component
public class ErrorHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException {
        //设置403错误
        httpServletResponse.setStatus(HttpServletResponse.SC_FORBIDDEN);
        httpServletResponse.sendRedirect("/error1.html");
    }
}

然后另起一个http

//        异常处理
        http.exceptionHandling().accessDeniedHandler(e);

这个e是刚才配置的组件errorHandler,需要手动注入该组件.这个时候,我们可以测试,比如前一章指定了ip控制,如果我们没有指定错误处理方案,一旦ip地址不对,就会报原始的error错误403页面,一旦指定了错误方案,因为重定向的原因,会跳转到错error1.html页面.

access表达式的使用

前面的控制方法中,例如permitall,hasRole等,其底层都是通过this.access()方法去实现的,观察期规律不难发现,如果我们使用permitAll方法,其底层是this.access(“permitAll”).
所以permitAll在调用时,还可以这样做:
.antMatchers(“/login.html”).access(“permitAll()”)
上面的方式就等同于调用了.antMatchers(“/login.html”).permitAll()

在开发中,我们还可以通过自定义的方式去处理拦截请求:

//MyService是自定义的接口而已
@Service
public class MyServiceImpl implements MyService {
    @Override
    public boolean hasPermission(HttpServletRequest request, Authentication authentication) {

        Object principal = authentication.getPrincipal();
        //获取的principal为userDetails类型,即为User类型,User实现了UserDetails接口
        if (principal instanceof UserDetails){
            Collection<? extends GrantedAuthority> authorities =
                    authentication.getAuthorities();
            //在contains参数中,SimpleGrantedAuthority是GrantedAuthority的实现类
            //表示new 某个权限,该权限的名称是uri
            return authorities.contains(new SimpleGrantedAuthority(request.getRequestURI()));
        }
        return false;
    }
}

这个时候.anyRequest().authenticated();便可以使用.anyRequest().access(“@myServiceImpl.hasPermission(request,authentication)”);
进行代替,access后面通过表达式,可以执行到我们的自定义的方法hasPermission中,等请求一过来,便会将request和authentication参数传入,然后判断,如果里面包含以uri命名的权限,便可以通过.
前面我们登录成功跳转的路径是/test:
image.png
但注意的是,这个/test不是uri,而是controller的请求路径,最后我们响应的是对应controller重定向的路径:
image.png
所以这里的uri是main.html,此刻,如果我们没有在自定义登录逻辑中写上/main.html的权限,这边便会报403错误:
image.png

Secured注解

该注解提供了简单的角色控制方式,只需要在对应的controller方法或类上加上该注解,就表示访问该controller类或方法时必须要拥有此角色.
要使用该注解,必须在启动类上标注@EnableGlobalMethodSecurity(securedEnabled = true) 前者是开启全局的注解模式,括号内参数代表开启该Secured注解模式.
从这个注解我们可以看到,基于注解模式的ss框架是默认完全不开启的,所以我们要使用这些的某个功能,都得在全局注解里开启.
注意: Secured这个注解里只有一个参数,代表角色名称,标注的时必须带有ROLE_,前面必须带ROLE_只是在设置角色的时候,但现在使用注解也必须带上ROLE_,且这个注解只支持角色,不是权限控制注解.
image.png

PreAuthorize和PostAuthrize注解

这两个注解前者用的比较多,都可以标注在方法或类上,表示拥有某个access表达式的方法或类可以执行.
image.png
但这两个注解的执行顺序是不同的,pre开头的注解表示方法或类执行之前进行权限或角色判断,而后者表示方法执行之后才判断.所以一般使用的是前者,因为我们要使用权限管理功能就是要对某个方法或类进行限制执行的.

remember-me功能实现

该功能很常见,在登录页面点击记住我选择框,在某个时间段内不需要再次登录.
ss框架对该功能进行了封装,使得我们用很简单的几行代码便可以实现该功能,在ss中,每次记住我,都会在数据库中插入一条记录,等时间一过,下一次再次登录再次记住我便再会插入一条数据,所以我们需要用到数据库.

  1. 使用数据库有jdbc和connector就可以了,所以这里你可以导入mp框架和connector依赖
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.3.2</version>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
  1. 在properties文件中配置数据源:
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/mp?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=15717747056HYb!
  1. 获取该数据源的时候,就要注入到配置类中,然后新造一个http:
 //rememberme功能
        //前端对应的CheckBox的name属性一定得是remember-me
        http.rememberMe()
                //默认失效时间是两周,单位秒
                .tokenValiditySeconds(10)
                //name属性不一定是remember-me的话修改如下
//                .rememberMeParameter("remember-me")

                //自定义登录逻辑
                .userDetailsService(userDetailService)
                //dao对象
                .tokenRepository(persistentTokenRepository);
@Bean
    public PersistentTokenRepository persistentTokenRepository(){
        //JdbcTokenRepositoryImpl是PersistentTokenRepository接口的一个实现类
        JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
        jdbcTokenRepository.setDataSource(dataSource);
        //第一次自动建表,如果有第二次,请注释掉
//        jdbcTokenRepository.setCreateTableOnStartup(true);
        return jdbcTokenRepository;
    }

记得事先注入组件

    //javax.sql.DataSource
    @Autowired
    private DataSource dataSource;

    //自定义登录逻辑
    @Autowired
    private UserDetailServiceImpl userDetailService;

    @Autowired
    private PersistentTokenRepository persistentTokenRepository;
  1. 注意: 如果不进行rememberMeParameter方法的操作,前端的CheckBox的name属性名字必须是remember-me

thymeleaf整合SpringSecurity

在前后端不分离的项目中,我们还常常使用ss框架和前端模板引擎thymeleaf来整合做到一些权限的控制

  1. 首先要导入thymeleaf解析模板和二者的整合模板依赖:
        <!-- https://mvnrepository.com/artifact/org.thymeleaf.extras/thymeleaf-extras-springsecurity4 -->
        <dependency>
            <groupId>org.thymeleaf.extras</groupId>
            <artifactId>thymeleaf-extras-springsecurity5</artifactId>
        </dependency>


        <!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-thymeleaf -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
  1. 在controller做一个请求做页面跳转:
    @RequestMapping("/demo")
    public String demo(){
        return "demo";
    }
  1. 该页面会被thymeleaf解析模板解析,在template目录下的html文件会被解析,所以在该目录下新建一个html页面,然后写入thymeleaf的命令空间:
<!DOCTYPE html>
<html lang="en"
      xmlns="http://www.w3.org/1999/xhtml"
      xmlns:sec="http:///www.thymeleaf.org/thymeleaf-extras-springsecurity5"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
登录账号:<span sec:authentication="name" ></span>
登录账号:<span sec:authentication="principal.username" ></span>
凭证:<span sec:authentication="credentials" ></span>
权限和角色:<span sec:authentication="authorities" ></span>
客户端地址:<span sec:authentication="details.remoteAddress" ></span>
sessionId:<span sec:authentication="details.sessionId" ></span><br>
<button sec:authorize="hasRole('a')">角色按钮</button>
<button sec:authorize="hasAuthority('admin')">权限按钮</button>

</body>
</html>

通过一些固定的写法,我们可以在前端获取到后端的一些数据,如账号,凭证,权限和角色,客户端地址等等,同时我们还可以通过sec:authorize这个属性去控制一些标签的显示,表示拥有了该角色或者权限便可以显示.

登出

登出很简单,只需要在前端对应的跳转地址写上/logout就可以了,该地址会ss框架自动解析,并跳转到对应/login页面,但这样子地址栏会包含?logout参数字样,所以我们一般自己去实现logout.
前端还是一样的/logout,不过这次是跳转到登出的http去处理:

//登出
        http.logout()
                //可以不指定,底层默认就是logout,这个url和前端必须一样
                .logoutUrl("/logout")
                //指定登出跳转地址
                .logoutSuccessUrl("/login.html");

CSRF

前面我们学习的时候时关闭了csrf防火墙保护的,这肯定是不好的,但是如果我们不关闭的话访问页面就运行不了,这是为什么?要搞明白这个问题,我们首先得明白csrf是什么?

  1. csrf 被称作跨站请求伪造,通过伪造用户请求去访问信任网站,这是很危险的一个操作.
  2. 在客户端与服务端进行交互的过程中,由于http本身就是无状态协议,所以需要cookie去保存sessionId,但cookie本身就是不安全的,里面的sessionid可能被会被劫持,而服务端判断用户是否合法就是根据sessionId进行判断,如果sessionId被劫持,就有可能被伪造身份去访问
  3. 所以,在ss4版本开始,是默认开启csrf模式的,该模式在学习阶段可以关闭以下,但是真正上线的时候肯定是不允许的,所以我们还有其他操作去避开csrf的限制.

下面演示我们是如果在csrf下进行登录的.
首先,我们要用thymeleaf解析,所以这个时候login.html就不能放在static目录下了,要放在template包下.

<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml"
      xmlns:sec="http:///www.thymeleaf.org/thymeleaf-extras-springsecurity5"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<form action="/login" method="post">
    <input type="hidden" th:value="${_csrf.token}" name="_csrf" th:if="${_csrf}">
    <label for="username">用户名:</label>
    <input name="username" id="username"/><br>
    <label for="password">密码: </label>
    <input name="password" id="password"/><br>
    记住我:<input type="checkbox" name="remember-me" value="true"/>
    <button type="submit">提交</button>
</form>
</body>
</html>

上面类型是hidden的input标签意思是,要携带一个_csrf.token这个值过去,name属性也必须是_csrf,至于th:if中表示有这个值就传递,没有就放nul
为什么一定要传这个值? 这就有点像jwt中的token了,验证用户信息就通过token进行传递,而ss框架为避免csrf攻击,也采取这样的方式,就是每次登录都会从_csrf中产生一个token传递,这个时候ss底层会进行一些规则匹配,有效避免了csrf攻击.

注意: 要演示完全这个功能,必须将页面放在thymeleaf的template包下,而且我们在拦截路径里记得修改自定义登录页面为controller的路径,也要将该路径放行,因为是登录页面
image.pngimage.png
这样子效果才能出来.

OAuth2

举一个简单的应用场景:
某个网站和微信是两个不同的产品,但可能面对同一个用户,网站开启了基于微信登录的接口.而微信登录接口时如何实现的?总不能将微信账号和密码交给这个网站吧?
那么这个时候,网站开启的接口就有个微信二维码,一旦扫描了这个二维码,网站便给微信发送请求,这个请求只是请求,微信接收到请求,再不透露任何用户账号和密码的情况向用户申请登录的权限.注意:这个时候网站获得的权限只有登录权限,并没有获取到账号和密码一些重要的隐私信息.

那这样的场景总得有一个协议基础吧?OAuth便可以做到.

官方点应该这么解释:OAuth是一个在不提供用户名和密码的情况下,授权第三方应用访问Web资源的安全协议.

在OAuth的版本中,1.0过于复杂,直到2.0才被广泛使用,在2.0中,分为三个角色:Client、Server、Resource Owner,通俗来说就是,代理者,服务者,资源管理者,前面提到微信登录这个例子,代理者便是网站,服务者就是微信,资源管理者就是用户

在OAuth2中,服务者向资源管理者提供服务,而代理者想要得到信息就要被授权,这个授权通常是用户向服务者授权后,服务者才能被允许返回给代理者想要的,这里就涉及到了两种常用的授权模式

授权码模式

image.png
上图虽然有四个框,但其实是三个角色,'资源主人’其实就是用户代理,这个用户代理通常是浏览器,因为用户必须通过浏览器浏览网站,而这个网站就充当Client,至于第三个AS,这个就是相当于微信了.

  1. 首先第一步便是用户代理产生一个授权请求,发送一个重定向的URI,因为这个授权请求一般也是用户操作用户代理去发送的,所以这个时候,用户也会去真正授权,也就是User authenticares.
  2. 用户授权完毕后,AS会返回一个授权码给用户代理.
  3. 用户代理得到授权码,因为Client(网站)也是在用户代理中,所以网站也会得到,网站得到授权码再次发送重定向请求,这个时候AS会返回一个Token,根据这个token便可以获取相应的信息了.相当于发返回token后,网站这边才能真正的微信登录.

下面是代码的形式,这里为了方便,需要新建一个springboot工程,且导入相应的依赖,这里的依赖不能缺少任何一个:

    <properties>
        <java.version>1.8</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <spring-boot.version>2.3.7.RELEASE</spring-boot.version>
        <spring-cloud.version>Hoxton.SR9</spring-cloud.version>
    </properties>

    <dependencies>
        <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>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-security</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

我们先配备ss框架的基本环境:
首先是自定义登录逻辑

@Service
public class UserServiceImpl implements UserDetailsService {

    @Autowired
    private PasswordEncoder pwd;

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {

        if (!"admin".equals(s)){
            throw new UsernameNotFoundException("用户名为admin");
        }

        String encode = pwd.encode("123");

        return new User("admin",encode, AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));

    }
}

其次是config的配置,配置ss拦截的一些路径等

@Configuration
@EnableWebSecurity
public class UserConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                //拦截
                .authorizeRequests()
                .antMatchers("/oauth/**","/login/**","/logout/**")
                .permitAll()
                .anyRequest()
                .authenticated()
                .and()
                .formLogin()
                .permitAll();
    }
}

基本的ss环境搭建好,可以测试一下是否成功

环境搭建好了之后,下面来写服务者,像微信:

//授权服务器
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    @Autowired
    PasswordEncoder pwd;

    //使用授权模式
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                //配置client-id
                .withClient("admin")
                //配置client-secret
                .secret(pwd.encode("123456"))
                //配置token的有效期
                .accessTokenValiditySeconds(3600)
                //配置重定向地址
                .redirectUris("https://www.baidu.com")
                //配置申请的权限范围
                .scopes("all")
                //配置授权类型
                .authorizedGrantTypes("authorization_code");

    }
}

服务者写好后,开始写资源管理者:

//资源服务器
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .requestMatchers()
                .antMatchers("/user/**");
    }
}

这个资源管理者可以很清楚的看到,我们对所有请求进行了拦截,并且放行了/user/**请求,所以我们模拟一个/user请求:

@RestController
@RequestMapping("/user")
public class UserController {

    @GettMapping("/getCurrentUser")
    public Object getCurrentUser(Authentication authentication){
        return authentication.getPrincipal();
    }

}

这个请求就相当于资源,这里我返回了用户信息,如果进行普通的访问是不可以的,因为有服务者限制着,而我们要访问,就必须按照授权码模式进行,知道Client获取到服务者返回的token后,带着token去请求才有用.

写好代码后,我们可以开始测试了,首先是启动项目,访问项目地址,但要访问下面这个地址:
localhost:8080/oauth/authorize?response_type=code&client_id=admin&redirect_uri=http://www.baidu.com&scope=all
如果没有意外,会出现下面这个授权地址:
image.png
这就相当于我们像微信申请了一个授权,同时我们是用户,我们就点击Approve,然后Authrize去授权.一旦授权完后,因为我们随意跳转的地址是百度,所以会跳转到百度界面,这不是最重要的,最重要的是授权完毕后,我们得到了一个授权码,在标题栏中可以看到:
image.png
得到这个授权码后,我们就可以拿着这个授权码,重新向服务者发送一个重定向请求,因为这个请求是post请求,所以只能用postman测试,请看下图,要准备两步,且设置的信息要和代码中设置的一致:
image.png
image.png
image.png
可以看到,我们得到这个token了,就相当于微信同意我们去登录了,我们登录成功后,就可以拿着这个token去浏览资源管理中的信息了,也就是刚才那个controller地址:
image.png
这个时候,我们就获取到了地址返回的信息.

密码模式

前面说到,OAuth授权不是不能获取密码吗?为什么还要出现密码模式?
首先,涉及到第三方软件的时候,输入账号和密码并不是一个最坏的选择,因为如果涉及的第三方软件都是一个公司的产品,这个时候也可以使用密码模式了.OAuth这个时候作用便是能让密码模式更加安全,减少攻击.

密码模式只需要修改一下服务者就可以了:


    @Autowired
    PasswordEncoder pwd;

    @Autowired
    AuthenticationManager authenticationManager;

    @Autowired
    UserServiceImpl userService;


    //使用密码模式
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authenticationManager(authenticationManager)
                .userDetailsService(userService);
    }

    //使用授权模式
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                //配置client-id
                .withClient("admin")
                //配置client-secret
                .secret(pwd.encode("123456"))
                //配置token的有效期
                .accessTokenValiditySeconds(3600)
                //配置重定向地址
                .redirectUris("https://www.baidu.com")
                //配置申请的权限范围
                .scopes("all")
                //配置授权类型
//                .authorizedGrantTypes("authorization_code");
        //密码模式
                .authorizedGrantTypes("password");
    }

注意AuthenticationManager这个组件是没有的,要在继承WebSecurityConfigurerAdapter类的UserConfig中注册相应的组件,这个组件是重写该类的一个方法:

//密码模式
    @Override
    @Bean
    protected AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }

这个时候就可以去测试了,密码模式,顾名思义,肯定要用到密码,所以这个时候,就要用到资源管理的账号和密码了.也就是用户的账号和密码.
image.png
image.png

使用redis存储token

前面生成的token都是存储在缓存中的,在实际开发中肯定是不行的,一般都存在缓存里,比如redis数据库中,下面就来演示密码模式下,将一些信息存储在redis的操作,首先是导入boot-starter-redis的依赖,还有一个common-pool的连接池依赖:

 <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>

导入依赖后,配置redis关于OAuth2的配置:

@Autowired
    private RedisConnectionFactory redisConnectionFactory;

    @Bean
    public TokenStore tokenStore(){
        return new RedisTokenStore(redisConnectionFactory);
    }

这个时候,保存进redis只需要一步很简单的操作即可:

@Autowired
    @Qualifier("tokenStore")
    TokenStore tokenStore;


    //使用密码模式
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authenticationManager(authenticationManager)
                .userDetailsService(userService)
                .tokenStore(tokenStore);
    }

再次执行一遍密码模式的token信息请求:
image.png

JWT

为什么要使用JWT

传统Session认证的弊端:
我们知道HTTP本身是一种无状态的协议,这就意味着如果用户向我们的应用提供了用户名和密码来进行用户认证,认证通过后HTTP协议不会记录下认证后的状态,那么下一次请求时,用户还要再一次进行认证,因为根据HTTP协议,我们并不知道是哪个用户发出的请求,所以为了让我们的应用能识别是哪个用户发出的请求,我们只能在用户首次登录成功后,在服务器存储一份用户登录的信息,这份登录信息会在响应时传递给浏览器,告诉其保存为cookie,以便下次请求时发送给我们的应用,这样我们的应用就能识别请求来自哪个用户了,这是传统的基于session认证的过程
image.png

然而,传统的session认证有如下的问题:

  1. 每个用户的登录信息都会保存到服务器的session中,随着用户的增多,服务器开销会明显增大
  2. 由于session是存在与服务器的物理内存中,所以在分布式系统中,这种方式将会失效。虽然可以将session统一保存到Redis中,但是这样做无疑增加了系统的复杂性,对于不需要redis的应用也会白白多引入一个缓存中间件
  3. 对于非浏览器的客户端、手机移动端等不适用,因为session依赖于cookie,而移动端经常没有cookie
  4. 因为session认证本质基于cookie,所以如果cookie被截获,用户很容易收到跨站请求伪造攻击(crf)。并且如果浏览器禁用了cookie,这种方式也会失效
  5. 前后端分离系统中更加不适用,后端部署复杂,前端发送的请求往往经过多个中间件到达后端,cookie中关于session的信息会转发多次
  6. 由于基于Cookie,而cookie无法跨域,所以session的认证也无法跨域,对单点登录不适用

JWT认证的优势:
对比传统的session认证方式,JWT的优势是:

  1. 简洁:JWT Token数据量小,传输速度也很快
  2. 因为JWT Token是以JSON加密形式保存在客户端的,所以JWT是跨语言的,原则上任何web形式都支持

不需要在服务端保存会话信息,也就是说不依赖于cookie和session,所以没有了传统session认证的弊端,特别适用于分布式微服务

  1. 单点登录友好:使用Session进行身份认证的话,由于cookie无法跨域,难以实现单点登录。但是,使用token进行认证的话, token可以被保存在客户端的任意位置的内存中,不一定是cookie,所以不依赖cookie,不会存在这些问题
  2. 适合移动端应用:使用Session进行身份认证的话,需要保存一份信息在服务器端,而且这种方式会依赖到Cookie(需要 Cookie 保存 SessionId),所以不适合移动端

因为这些优势,目前无论单体应用还是分布式应用,都更加推荐用JWT token的方式进行用户认证

**
JWT由3部分组成:标头(Header)、有效载荷(Payload)和签名(Signature)。在传输的时候,会将JWT的3部分分别进行Base64编码后用.进行连接形成最终传输的字符串

J W T S t r i n g = B a s e 64 ( H e a d e r ) . B a s e 64 ( P a y l o a d ) . H M A C S H A 256 ( b a s e 64 U r l E n c o d e ( h e a d e r ) + " . " + b a s e 64 U r l E n c o d e ( p a y l o a d ) , s e c r e t ) JWTString = Base64(Header).Base64(Payload).HMACSHA256(base64UrlEncode(header) + “.” + base64UrlEncode(payload), secret)
JWTString=Base64(Header).Base64(Payload).HMACSHA256(base64UrlEncode(header)+“.”+base64UrlEncode(payload),secret)

1.Header
JWT头是一个描述JWT元数据的JSON对象,alg属性表示签名使用的算法,默认为HMAC SHA256(写为HS256);typ属性表示令牌的类型,JWT令牌统一写为JWT。最后,使用Base64 URL算法将上述JSON对象转换为字符串保存

{
  "alg": "HS256",
  "typ": "JWT"
}

2.Payload
有效载荷部分,是JWT的主体内容部分,也是一个JSON对象,包含需要传递的数据。 JWT指定七个默认字段供选择

iss:发行人
exp:到期时间
sub:主题
aud:用户
nbf:在此之前不可用
iat:发布时间
jti:JWT ID用于标识该JWT

这些预定义的字段并不要求强制使用。除以上默认字段外,我们还可以自定义私有字段,一般会把包含用户信息的数据放到payload中,如下例:

{
“sub”: “1234567890”,
“name”: “Helen”,
“admin”: true
}

请注意,默认情况下JWT是未加密的,因为只是采用base64算法,拿到JWT字符串后可以转换回原本的JSON数据,任何人都可以解读其内容,因此不要构建隐私信息字段,比如用户的密码一定不能保存到JWT中,以防止信息泄露。JWT只是适合在网络中传输一些非敏感的信息

3.Signature
签名哈希部分是对上面两部分数据签名,需要使用base64编码后的header和payload数据,通过指定的算法生成哈希,以确保数据不会被篡改。首先,需要指定一个密钥(secret)。该密码仅仅为保存在服务器中,并且不能向用户公开。然后,使用header中指定的签名算法(默认情况下为HMAC SHA256)根据以下公式生成签名

H M A C S H A 256 ( b a s e 64 U r l E n c o d e ( h e a d e r ) + " . " + b a s e 64 U r l E n c o d e ( p a y l o a d ) , s e c r e t ) HMACSHA256(base64UrlEncode(header) + “.” + base64UrlEncode(payload), secret)
HMACSHA256(base64UrlEncode(header)+“.”+base64UrlEncode(payload),secret)

在计算出签名哈希后,JWT头,有效载荷和签名哈希的三个部分组合成一个字符串,每个部分用.分隔,就构成整个JWT对象

注意JWT每部分的作用,在服务端接收到客户端发送过来的JWT token之后

header和payload可以直接利用base64解码出原文,从header中获取哈希签名的算法,从payload中获取有效数据
signature由于使用了不可逆的加密算法,无法解码出原文,它的作用是校验token有没有被篡改。服务端获取header中的加密算法之后,利用该算法加上secretKey对header、payload进行加密,比对加密后的数据和客户端发送过来的是否一致。注意secretKey只能保存在服务端,而且对于不同的加密算法其含义有所不同,一般对于MD5类型的摘要加密算法,secretKey实际上代表的是盐值
JWT的种类
其实JWT(JSON Web Token)指的是一种规范,这种规范允许我们使用JWT在两个组织之间传递安全可靠的信息,JWT的具体实现可以分为以下几种:

nonsecure JWT:未经过签名,不安全的JWT
JWS:经过签名的JWT
JWE:payload部分经过加密的JWT

1.nonsecure JWT
未经过签名,不安全的JWT。其header部分没有指定签名算法

2.JWS
JWS ,也就是JWT Signature,其结构就是在之前nonsecure JWT的基础上,在头部声明签名算法,并在最后添加上签名。创建签名,是保证jwt不能被他人随意篡改。我们通常使用的JWT一般都是JWS

为了完成签名,除了用到header信息和payload信息外,还需要算法的密钥,也就是secretKey。加密的算法一般有2类:

对称加密:secretKey指加密密钥,可以生成签名与验签
非对称加密:secretKey指私钥,只用来生成签名,不能用来验签(验签用的是公钥)
JWT的密钥或者密钥对,一般统一称为JSON Web Key,也就是JWK

到目前为止,jwt的签名算法有三种:

HMAC【哈希消息验证码(对称)】:HS256/HS384/HS512
RSASSA【RSA签名算法(非对称)】(RS256/RS384/RS512)
ECDSA【椭圆曲线数据签名算法(非对称)】(ES256/ES384/ES512)

jjwt 对称签名

@Test
    void contextLoads() {
        //设置过期时间
        long l = System.currentTimeMillis() + 60 * 1000;


        String s = UUID.randomUUID().toString();

        Map<String,Object> map=new HashMap<>();
        map.put("map","abcd");
        JwtBuilder jwtBuilder= Jwts.builder()
                //声明的表示{"jti":"8888"}
                .setId("8888")
                //主体用户
                .setSubject("hyb")
                //创建日期
                .setIssuedAt(new Date())
                //设计过期时间
                .setExpiration(new Date(l))
                //签名 算法+盐
                .signWith(SignatureAlgorithm.HS256,s )
                //自定义声明
                .claim("user","hyb")
                .claim("password","*******")
                //可以直接存入一个map
                .addClaims(map);

        String compact = jwtBuilder.compact();
        System.out.println(compact);

        //尝试base64解析
        String[] split = compact.split("\\.");
        System.out.println(Base64Codec.BASE64.decodeToString(split[0]));
        System.out.println(Base64Codec.BASE64.decodeToString(split[1]));
        System.out.println(Base64Codec.BASE64.decodeToString(split[2]));


        //解析token=================
        Claims body = Jwts.parser()
                .setSigningKey(s)
                .parseClaimsJws(compact)
                .getBody();
        System.out.println(body.getId());
        System.out.println(body.getSubject());
        System.out.println(body.getIssuedAt());
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yy-MM-dd hh:mm:ss");
        System.out.println(simpleDateFormat.format(body.getExpiration()));
        System.out.println(body.get("user"));
        System.out.println(body.get("password"));
        System.out.println(body.get("map"));
    }

OAuth2整合jwt

整合

前面我们使用密码模式生成过token,但那个token太短了,在开发中一般使用jwt去生成Oauth2的token.
在这里我们只需要修改配置类:

@Bean
    public TokenStore jwtTokenStore(){
        return new JwtTokenStore(jwtAccessTokenConverter());
    }

    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter(){
        JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
        jwtAccessTokenConverter.setSigningKey("hyb");
        return jwtAccessTokenConverter;
    }

然后替换调用之前使用redis存储token的代码

@Autowired
    @Qualifier("jwtTokenStore")
    TokenStore jwtTokenStore;

    @Qualifier("jwtAccessTokenConverter")
    @Autowired
    JwtAccessTokenConverter accessTokenConverter;



    //使用密码模式
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authenticationManager(authenticationManager)
                .userDetailsService(userService)
                .tokenStore(jwtTokenStore)
                .accessTokenConverter(accessTokenConverter);
    }

这个时候我们启动项目,然后像之前一样测试,会发现生成的token中便是jwt规格的字符串,这个字符串我们拿去官网解析还是可以得到用户信息的
image.png
image.png
image.png

拓展token存储的内容

前面我们在做对称签名的例子的时候,发现是可以自定义签名的,而在于OAut2的整合中,我们如何做到若站token中存储的key呢?

public class JwtTokenEnhancer implements TokenEnhancer {
    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken oAuth2AccessToken, OAuth2Authentication oAuth2Authentication) {
        Map<String,Object> map=new HashMap<>();
        //下面便是新增的key和value
        map.put("new key","new Value");
        ((DefaultOAuth2AccessToken)oAuth2AccessToken).setAdditionalInformation(map);
        return oAuth2AccessToken;
    }
}

这个JwtTokenEnhancer是实现token增强接口TokenEnhancer的实现类,要存在IOC容器中.
这里以密码模式为例:

 @Autowired
    JwtTokenEnhancer jwtTokenEnhancer;


    //使用密码模式
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        //自定义token解析后的key
        TokenEnhancerChain tokenEnhancerChain=new TokenEnhancerChain();
        List<TokenEnhancer> list=new ArrayList<>();
        list.add(jwtTokenEnhancer);
        list.add(accessTokenConverter);
        tokenEnhancerChain.setTokenEnhancers(list);

        endpoints.authenticationManager(authenticationManager)
                .userDetailsService(userService)
                .tokenStore(jwtTokenStore)
                .accessTokenConverter(accessTokenConverter)
                .tokenEnhancer(tokenEnhancerChain);
    }

设置完毕后,去postman测试,拿到token字符串然后去官网解析:
image.png

刷新令牌模式

刷新令牌模式也是除授权码模式和密码模式的另外一种,该模式提供了一个刷新令牌,通过该刷新令牌下次便可以直接获取token,而不用每次都进行授权码模式或者密码模式去获取.

  1. 我们在设计模式的设置多个模式:

.authorizedGrantTypes(“password”,“refresh_token”,“authorization_code”)

  1. 设置好之后,直接启动项目即可,然后利用postman,先进行一个授权码模式或者密码模式,我这里先进行密码模式.然后会发现:

image.png
在第一次密码模式后,会产生一个刷新令牌,拿着这个刷新令牌去代替密码模式:
image.png
执行后你会发现,用这个刷新令牌再次获得了token和刷新令牌,这样就避免了每次都要进行繁琐的授权码和密码模式.

token解析失败

@PostMapping("/getCurrentUser")
    public Object getCurrentUser(Authentication authentication, HttpServletRequest request){

        String authentication1 = request.getHeader("Authentication");
        String bearer = authentication1.substring(authentication1.indexOf("bearer") + 7);

        return Jwts.parser().setSigningKey("hyb".getBytes(StandardCharsets.UTF_8))
                .parseClaimsJws(bearer).getBody();
    }

修改了这个controller连接,获取请求头,然后解析token,利用postman测试,但测试失败,与up主不一致:
首先是设置请求头:
image.png
这个VALUE项这里,必须以bearer开头,且中间空格后带上token.
image.png
这里的up主说直接设置No Auth就可以了,但是设置之后请求失败,会提示没有权限,因为我是基于密码模式,我又尝试了将密码设置进去,但这个时候还是提示没有权限,我又设置了权限模式,并将产生的token放进入,但又提示说是无效的token.

SpringSecurityOAuth2实现SSO失败

  • 跟着B站的up主去做的,对比了好几遍,一直没有成功,不知道是不是版本的问题.
  1. 新建一个springboot工程,然后导入和上面一样的依赖,然后修改properties文件:
# 应用名称
spring.application.name=SpringSecurityOAuth2AndSSO
# 应用服务 WEB 访问端口
server.port=8081

server.servlet.session.cookie.name=OAUTH2-CLIENT-SESSIION01
security.oauth2.client.id=admin
security.oauth2.client.client-secret=123456
security.oauth2.client.user-authorization-uri=http://localhost:8080/oauth/authorize
security.oauth2.client.access-token-uri=http://localhost:8080/oauth/token
security.oauth2.resource.jwt.key-uri=http://localhost:8080/oauth/token_key
# 下面这句话没有配置的话,直接就启动不了,这句话up主是没有的
security.oauth2.resource.jwt.key-value=access_token


  1. 这个时候,可以修改一下代理的模块:

image.png

  1. 然后在启动类上@EnableOAuth2Sso,这个时候就报错A redirect is required to get the users approval,这个时候查stackoverflow,发现加上下面配置就不会报错:
@Bean
    public FilterRegistrationBean oauth2ClientFilterRegistration(
            OAuth2ClientContextFilter filter) {
        FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setFilter(filter);
        registration.setOrder(-100);
        return registration;
    }
  1. 写一个controller请求,根据up的效果是,访问这个controller请求,会被代理模块代理,首先进入代理模块的自定义登录逻辑,登录后自动授权.但一进行测试,就又会报[Not a URI because there is no client] is not a valid HTTP URL的错误.

SpringSecurityOAuth2+GateWay整合成功案例,前后端分离模式

  1. 这个标题是后来加上的,因为我在优化项目的时候成功了,上面在学习过程中为啥不成功我也不知道,如果想要看成功案例,请移步我的两篇博客,有两个版本
    SpringSecurityOAtu2+JWT实现微服务版本的单点登录
    SpringSecurity+GateWay网关+OAuth2鉴权,前后端分离模式,两种验证模式,入门级教程
  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值