wangwang
wangwang
文章45
标签44
分类5
SpringSecurity入门与认证

SpringSecurity入门与认证

安全框架对比

Shiro

  • 易于理解的 Java Security API;

  • 简单的身份认证(登录),支持多种数据源(LDAP,JDBC,Kerberos,ActiveDirectory 等);

  • 对角色的简单的鉴权(访问控制),支持细粒度的鉴权;

  • 支持一级缓存,以提升应用程序的性能;

  • 内置的基于 POJO 企业会话管理,适用于 Web 以及非 Web 的环境; 异构客户端会话访问;

  • 非常简单的加密 API;

  • 不跟任何的框架或者容器捆绑,可以独立运行。

SpringSecurity

  • 拥有Shiro的所有功能
  • 拥有对Oauth,OpenID的支持
  • 权限细粒度更高
OpenID是Authentication,是网站对用户进行认证,让网站知道“你是你所声称的URL的属主”,例如使用身份证可以证明自己的身份.
OAuth是Authorization,其实并不包括认证,只不过“只有认证成功的人才能进行授权”,例如某些网站可以直接使用QQ,微信免注册登录授权.但是这样会授予你QQ,微信的使用权限. 如你要证明房子是自己的,只要出示房产证即可.而若是你给钥匙给别人开门.则授予了别人进入房子的权限.

SpringSecurity的功能

作用

1.认证:用户登录,解决的是”你是谁”

2.授权:判断用户拥有什么权限,可以访问什么资源,解决的是”你能干什么”

3.安全防护,防止跨站请求,session攻击等.

应用场景

1.传统web开发的项目的登录功能

2.用户授权功能

3.单一登录,同一时间同一账号只允许在一个地方登录

4.cas单点登录,即多个系统只要登录一次,就可以在多个系统中访问

5.集成oauth2,做登录授权,可以用于app登录和第三方登录(QQ,微信等)

入门案例

创建一个简单的Controller

@RestController
public class HelloSecurityController {


    @RequestMapping("/hello")
    public String hello() {
        return "Hello Security";
    }

}

集成SpringSecurity,启动项目,访问接口,即可访问到SpringSecurity默认提供的页面.

image-20221010204832030

认证基本原理

SpringSecurity功能的实现主要是由一系列过滤器相互配合完成,也称为过滤器链

过滤链

image-20221010205904052

默认加载15个过滤器,可以自行增加删除自定义过滤器.

1.WebAsyncManagerIntegrationFilter

根据请求封装获取WebAsyncManager,从WebAsyncManager获取/注册的安全上下文可调用处理器

2.SecurityContextPersistenceFilter

SecurityContextPersistenceFilter主要是使用SecurityContextRepository在session中保存或更新一个SecurityContext,并将SecurityContext给以后的过滤器使用,同时,存储了当前用户认证以及权限信息

3.HeaderWriterFilter

向请求的Header中添加相应的信息,可在http标签内部使用security:headers来控制

4.CsrfFilter

csrf又称跨域请求伪造,SpringSecurity会对所有post请求验证是否包含系统生成的csrf的
token信息,如果不包含,则报错。起到防止csrf攻击的效果。

5.LogoutFilter

匹配URL为/logout的请求,实现用户退出,清除认证信息。

6.UsernamePasswordAuthenticationFilter

表单认证操作全靠这个过滤器,默认匹配URL为/login且必须为POST请求。

7.DefaultLoginPageGeneratingFilter

如果没有在配置文件中指定认证页面,则由该过滤器生成一个默认认证页面。

8.DefaultLogoutPageGeneratingFilter

由此过滤器可以生产一个默认的退出登录页面

9.Basicauthenticationfilter

此过滤器会自动解析HTTP请求中头部名字为Authentication,且以Basic开头的头信息。

10.RequestCacheAwareFilter

通过HttpSessionRequestCache内部维护了一个RequestCache,用于缓存
HttpServletRequest

11.SecurityContextHolderAwareRequestFilter

针对ServletRequest进行了一次包装,使得request具有更加丰富的API

12.AnonymousAuthenticationFilter

当SecurityContextHolder中认证信息为空,则会创建一个匿名用户存入到
SecurityContextHolder中。spring security为了兼容未登录的访问,也走了一套认证流程,
只不过是一个匿名的身份。

13.SessionManagementFilter

securityContextRepository限制同一用户开启多个会话的数量

14.ExceptionTranslationFilter

异常转换过滤器位于整个springSecurityFilterChain的后方,用来转换整个链路中出现的异
常

15.FilterSecurityInterceptor

获取所配置资源访问的授权信息,根据SecurityContextHolder中存储的用户信息来决定其
是否有权限。

认证方式

HttpBasic认证

HttpBasic模式要求传输的用户名密码使用Base64模式进行加密,如果用户名是 “admin” , 密码是“ admin”,则将字符串”admin:admin” 使用Base64编码算法加密。加密结果可能是: YWtaW46YWRtaW4=。HttpBasic模式真的是非常简单又简陋的验证模式,Base64的加密算法是 可逆的,想要破解并不难.

formLogin登录模式

Spring Security的HttpBasic模式,只是进行了通过携带Http的Header进行 简单的登录验证,而且没有定制的登录页面,Security 5.x版本默认会生成一个登录页面

表单认证

编写Security配置类,启动项目,访问页面即会进入登录页

/**
 * Security配置类
 * @author 12492 公众号:一只会飞的旺旺
 * @date 2022-10-10 21:09
 */
@Configuration
public class MySecurityConfiguration extends WebSecurityConfigurerAdapter {

    /**
     * http请求处理方法
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
//        http.httpBasic() //开启httpBasic认证
//                .and().authorizeRequests()
//                .anyRequest().authenticated(); //所有请求都需要登录认证才能访问

        http.formLogin() //开启表单登录
                .and().authorizeRequests()
                .anyRequest().authenticated(); //所有请求都需要登录认证才能访问
    }
}

常见问题

1.localhost将您重定向次数过多

image-20221010212825408

配置登录页面放行

http.formLogin().loginPage("login.html") //开启表单登录,指定登录页面
    .and().authorizeRequests()
    .antMatchers("/login.html").permitAll() //登录页面不需要认证
    .anyRequest().authenticated(); //所有请求都需要登录认证才能访问

2.访问login.html 报404错

image-20221010212959493

通过springboot整合thymeleaf生成静态页面,所以需要请求接口生成模板页面,修改login.html为toLoginPage

    protected void configure(HttpSecurity http) throws Exception {
//        http.httpBasic() //开启httpBasic认证
//                .and().authorizeRequests()
//                .anyRequest().authenticated(); //所有请求都需要登录认证才能访问

        http.formLogin().loginPage("toLoginPage") //开启表单登录
                .and().authorizeRequests()
                .antMatchers("/toLoginPage").permitAll() //登录页面不需要认证
                .anyRequest().authenticated(); //所有请求都需要登录认证才能访问
    }

3.访问login.html 后发现页面没有相关样式

image-20221010213216908

放行静态资源,重写另外一个configure方法

    /**
     * web安全配置
     * @param web
     * @throws Exception
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
        // 设置不拦截规则
        web.ignoring().antMatchers("/css/**", "/fonts/**", "/img/**", "/js/**","/favicon.ico");
    }

注意: HttpSecurity和WebSecurity的区别

1.WebSecurity 不仅通过 HttpSecurity 定义某些请求的安全控制,也通过其他方式定义其他某些 请求可以忽略安全控制;

2.HttpSecurity 仅用于定义需要安全控制的请求(当然 HttpSecurity 也可以指定某些请求不需要 安全控制);

3.可以认为 HttpSecurity 是 WebSecurity 的一部分, WebSecurity 是包含 HttpSecurity 的更大 的一个概念;

4.构建目标不同

  • WebSecurity 构建目标是整个 Spring Security 安全过滤器 FilterChainProxy
  • HttpSecurity 的构建目标仅仅是 FilterChainProxy 中的一个 SecurityFilterChain 。

表单登录

    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin().loginPage("/toLoginPage") //开启表单登录
                .loginProcessingUrl("/login") //登录表单提交地址
                .usernameParameter("username") //登录表单用户名参数名
                .passwordParameter("password") //登录表单密码参数名
                .successForwardUrl("/") //登录成功跳转地址
                .and().authorizeRequests()
                .antMatchers("/toLoginPage").permitAll() //登录页面不需要认证
                .anyRequest().authenticated(); //所有请求都需要登录认证才能访问
        //关闭csrf防护
        http.csrf().disable(); 
        //允许iframe嵌套
        http.headers().frameOptions().disable();
    }

基于数据库实现认证

重写认证管理的configure方法

    /**
     * 认证信息管理
     * @param auth
     * @throws Exception
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(new MyUserDetailsService());
    }

自定义用户认证实现类

/**
 * 基于数据库完成认证
 *
 * @author 12492 公众号:一只会飞的旺旺
 * @date 2022-10-10 21:46
 */
@Component
public class MyUserDetailsService implements UserDetailsService {

    @Autowired
    private UserService userService;


    /**
     * 根据username查询用户实体
     *
     * @param username
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userService.findByUsername(username);
        if (null == user) {
            throw new UsernameNotFoundException("用户不存在");
        }
        // 先声明一个权限集合
        List<GrantedAuthority> authorities = new ArrayList<>();
        // 简单构造
        //return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), authorities);
        // 复杂构造
        return new org.springframework.security.core.userdetails.User(user.getUsername(),
                "{noop}" + user.getPassword(), // {noop}表示不加密
                true, // 账号是否可用
                true, // 账号是否过期
                true, // 密码是否过期
                true, // 账号是否锁定
                authorities);
    }

}

密码加密:SpringSecurity中的PasswordEncoder就是对密码进行编码的工具接口

选择一种加密算法即可,常用的如BCrypt.

修改UserDetails构建时,密码返回方式.修改{noop}为{bcrypt}

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userService.findByUsername(username);
        if (null == user) {
            throw new UsernameNotFoundException("用户不存在");
        }
        // 先声明一个权限集合
        List<GrantedAuthority> authorities = new ArrayList<>();
        // 简单构造
        //return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), authorities);
        // 复杂构造
        return new org.springframework.security.core.userdetails.User(user.getUsername(),
                "{bcrypt}" + user.getPassword(), // {noop}表示不加密, {bcrypt}表示加密
                true, // 账号是否可用
                true, // 账号是否过期
                true, // 密码是否过期
                true, // 账号是否锁定
                authorities);
    }

获取当前登录用户

  • SecurityContextHolder

保留系统当前的安全上下文SecurityContext,其中就包括当前使用系统的用户的信息。

  • SecurityContext

安全上下文,获取当前经过身份验证的主体或身份验证请求令牌

代码实现:

    /**
     * 获取当前登录用户信息
     *
     * @return
     */
    @RequestMapping("/loginUser1")
    @ResponseBody
    public UserDetails getCurrentUser1() {
        return (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    }

    /**
     * 获取当前登录用户信息
     *
     * @return
     */
    @RequestMapping("/loginUser2")
    @ResponseBody
    public UserDetails getCurrentUser2(Authentication authentication) {
        return (UserDetails) authentication.getPrincipal();
    }

    /**
     * 获取当前登录用户信息
     *
     * @return
     */
    @RequestMapping("/loginUser3")
    @ResponseBody
    public UserDetails getCurrentUser3(@AuthenticationPrincipal UserDetails
                                               userDetails) {
        return userDetails;
    }

remember me 记住我

1.简单的Token生成方法

image-20221010221430517

Token=MD5(username+分隔符+expiryTime+分隔符+password)

后台开启remember-me功能

        http.formLogin().loginPage("/toLoginPage") //开启表单登录
                .loginProcessingUrl("/login") //登录表单提交地址
                .usernameParameter("username") //登录表单用户名参数名
                .passwordParameter("password") //登录表单密码参数名
                .successForwardUrl("/") //登录成功跳转地址
                .and().authorizeRequests()
                .antMatchers("/toLoginPage").permitAll() //登录页面不需要认证
                .anyRequest().authenticated() //所有请求都需要登录认证才能访问
                .and()
                .rememberMe()// 开启记住我功能
                .tokenValiditySeconds(2000)// token失效时间默认两周
                .rememberMeParameter("rememberMe");// 自定义表单name值

2.持久化Token生成方法

image-20221010221715431

存入数据库Token包含:

token: 随机生成策略,每次访问都会重新生成 
series: 登录序列号,随机生成策略。用户输入用户名和密码登录时,该值重新生成。使用 remember-me功能,该值保持不变 
expiryTime: token过期时间。 
CookieValue=encode(series+token) 

后台开启remember-me功能

添加如下配置

image-20221010221958796

添加持久化token方法

 /**
     * 持久化token,负责token与数据库之间的相关操作
     *
     * @return
     */
    @Bean
    public PersistentTokenRepository getPersistentTokenRepository() {
        JdbcTokenRepositoryImpl tokenRepository = new
                JdbcTokenRepositoryImpl();
        tokenRepository.setDataSource(dataSource);//设置数据源
        // 启动时创建一张表, 第一次启动的时候创建, 第二次启动的时候需要注释掉, 否则会报错
        tokenRepository.setCreateTableOnStartup(true);
        return tokenRepository;
    }

注意

为了防止Cookie伪造处理.可以在重要接口添加如下验证,如

    /**
     * 根据用户ID查询用户
     *
     * @return
     */
    @GetMapping("/{id}")
    @ResponseBody
    public User getById(@PathVariable Integer id) {
        //获取认证信息
        Authentication authentication =
                SecurityContextHolder.getContext().getAuthentication();
        // 判断认证信息是否来自RememberMe
        if (RememberMeAuthenticationToken.class.isAssignableFrom(authentication.getClass())) {
            throw new RememberMeAuthenticationException("认证信息来源于 RememberMe,请重新登录");
        }
        return userService.getById(id);
    }

自定义登录成功和失败处理

自定义成功处理

实现AuthenticationSuccessHandler接口,并重写onAnthenticationSuccesss()方法.

自定义失败处理

实现AuthenticationFailureHandler接口,并重写onAuthenticationFailure()方法

image-20221011092638023

SecurityConfiguration配置

.successHandler(myAuthenticationService)//自定义登录成功处理
.failureHandler(myAuthenticationService)//自定义登录失败处理

退出登录

LogoutFilter: 匹配URL为/logout的请求,实现用户退出,清除认证信息

image-20221011093024202

图形验证码

Spring Security的认证校验是由UsernamePasswordAuthenticationFilter过滤器完成的,所以我们 的验证码校验逻辑应该在这个过滤器之前。验证码通过后才能到后续的操作.

image-20221011093844274

自定义验证码过滤器

添加配置

image-20221011095034118

创建验证码过滤器

/**
 * 验证码验证filter 需要继承OncePerRequestFilter确保在一次请求只通过一次filter,而不
 * 需要重复执行
 *
 * @author pengwangwang
 * @date 2022/10/11 09:39
 **/
@Component
public class ValidateCodeFilter extends OncePerRequestFilter {

    @Autowired
    StringRedisTemplate stringRedisTemplate;


    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
        // 判断是否是登录请求
        if ("/login".equals(httpServletRequest.getRequestURI()) && "POST".equals(httpServletRequest.getMethod())) {
            try {
                validate(httpServletRequest);
            } catch (ValidateCodeException e) {
                e.printStackTrace();
                return;
            }
        }
        //如果不是登录请求,直接调用后面的过滤器链
        filterChain.doFilter(httpServletRequest, httpServletResponse);
    }


    public void validate(HttpServletRequest request) throws ValidateCodeException {
        // 获取ip
        String ip = request.getRemoteAddr();
        //拼接redis的key
        String key = "code_" + ip;
        // 获取redis中的验证码
        String code = stringRedisTemplate.opsForValue().get(key);
        String redisImageCode = request.getParameter("imageCode");
        if (StringUtils.hasText(redisImageCode)) {
            throw new ValidateCodeException("验证码不能为空");
        }
        if (redisImageCode == null) {
            throw new ValidateCodeException("验证码已过期");
        }
        if (!redisImageCode.equals(code)) {
            throw new ValidateCodeException("验证码不正确");
        }
        // 从redis中移除imageCode
        stringRedisTemplate.delete(key);
    }

}

自定义验证码异常类

/**
 * 验证码异常类
 * @author pengwangwang
 * @date 2022/10/11 09:42
 **/
public class ValidateCodeException extends AuthenticationException {

    public ValidateCodeException(String msg) {
        super(msg);
    }

}

session管理

会话超时

在spring配置文件application.properties中添加超时配置

# session配置超时时间
server.servlet.session.timeout=60

修改自定义设置session超时后地址

image-20221011101527246

并发控制

并发控制即同一个账号同时在线个数,同一个账号同时在线个数如果设置为1表示,该账号在同一时间内只能有一个有效的登录,如果同一个账号又在其它地方登录,那么就将上次登录的会话过期,即后面的登录会踢掉前面的登录.

1.配置session超时时间

# session配置超时时间
server.servlet.session.timeout=600

2.设置最大会话数量

3.阻止用户第二次登录

image-20221011101905284

集群Session

实际场景中,一般一个服务至少会有两台服务器提供服务,前面由nginx进行负载均衡,但是这样会导致在一个服务器登录之后,session无法进行共享,导致访问另一台服务器时,需要重新登录

image-20221011102300381

为了解决这一问题,我们通常会将用户登录的会话信息,保存到第三方库中,如redis,mongodb,mysql等.这样所有服务器都能从同一个库去获取当前的登录信息.就不需要再次登录了.

image-20221011102411525

1.引入依赖

<!-- 基于redis实现session共享 -->
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>

2.设置session存储类型

#使用redis共享session
spring.session.store-type=redis

3.进行测试

csrf防护机制

CRSF是什么?

CSRF(Cross-site request forgery),中文名称:跨站请求伪造 你这可以这么理解CSRF攻击:攻击者盗用了你的身份,以你的名义发送恶意请求。CSRF能够做的 事情包括:以你名义发送邮件,发消息,盗取你的账号,甚至于购买商品,虚拟货币转账……造成的问 题包括:个人隐私泄露以及财产安全。

CRSF原理

image-20221012133623800

1.登录受信任网站A,并在本地生成Cookie。

2.在不登出A的情况下,访问危险网站B。

3.触发网站B中的一些元素

防御策略

1.验证HTTP Referer字段
2.在请求地址中添加token并验证
3.在HTTP头中自定义属性并验证

开启csrf防御

image-20221012134251219

跨域处理

配置跨域信息

    /**
     * 跨域配置信息源
     *
     * @return
     */
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        // 设置允许跨域的站点
        corsConfiguration.addAllowedOrigin("*");
        // 设置允许跨域的http方法
        corsConfiguration.addAllowedMethod("*");
        // 设置允许跨域的请求头
        corsConfiguration.addAllowedHeader("*");
        // 允许带凭证
        corsConfiguration.setAllowCredentials(true);
        // 对所有的url生效
        UrlBasedCorsConfigurationSource source = new
                UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", corsConfiguration);
        return source;
    }

开启跨域配置

image-20221012134617938

本文作者:wangwang
本文链接:https://www.wangwangit.com/SpringSecurity%E4%B8%80/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可