SpringSecurity(三)基本认证

认证

对于安全管理框架来说,认证共嗯那个可以说是一切的起点,所以我们要从最基本的认证开始,在SpringSecurity中,对认证功能做了大量的封装,我们只需要稍微配置一下就能使用认证功能,也就是因为大量的封装,所以在我们去理解它的逻辑时就显得有些不易了,我们先从最基本的用法开始。

  • SpringSecurity基本认证
  • 登录表单配置
  • 登录用户数据获取
  • 用户的四种定义方式
基本认证

在SpringBoot中使用SpringSecurity非常方便,创建一个新的SpringBoot项目,我们只需要引入web和SpringSecurity依赖即可。

 <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

我们在定义一个测试的接口

@RestController
public class HelloController {

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

接下来启动项目,/hello接口就被自动保护起来了,当用户访问/hello接口时,会自动跳转到登录页面,用户登录成功后,才能访问/hello接口


默认的登录用户名是user,登录密码则是一个随机生产的UUID 字符串,在项目启动日志中可以看到登录密码(这也意味着项目每次启动时,密码都会发生变化)

Using generated security password: 39fcc6b3-633b-443c-8bb4-68141bbe1506


输入用户名密码,就可以登录成功了。

流程分析

我们通过一个简单的流程图来看一下上面请求流程

在这里插入图片描述

流程图比较清晰的说明了整个请求过程:

​ 1、客户端(浏览器)发起请求去访问/hello接口,这个接口默认是需要认证之后才能访问到。

​ 2、这个请求会走一遍SpringSecurity中过滤链,在最后的FilterSecurityInterceptor过滤器中被拦截下来,因为系统用户未认证。请求拦截下来之后,接下来会抛出AccessDeniedException异常。

​ 3、抛出的AccessDeniedException异常在ExceptionTranslationFilter过滤器中被捕获,ExceptionTranslationFilter过滤器通过调用LoginUrlAuthenticationEntryPoint#commence方法给客户端返回302,要求客户端重定向到/login页面。

​ 4、客户端发送/login请求。

​ 5、/login请求被DefaultLoginPageGeneratingFilter过滤器拦截下来,并在该过滤器中返回登录页面。所以当用户访问/hello接口时会首先看到登录页面。

整个过程中,相当于客户端一共发送了两个请求,第一个请求是/hello,服务端收到之后,返回302,要求客户端重定向到/loogin,于是客户端又发送了/login请求。

原理分析

在上面中,我们只是引入了依赖,但是SpringBoot确做了很多的事情:

  • 开启Spring Security自动化配置,开启后,会自动创建一个名未SpringSecurityFilterChain的过滤器,并注入到Spring容器中,这个过滤器将负责所有的安全管理,包括用户的认证、授权、重定向到登录页面等(SpringSecurityFilterChain实际上代理了SpringSecurity中的过滤链)
  • 创建一个UserDetailService实例,UserDetailService负责提供用户数据,默认的用户数据是基于内存的用户,用户名名为user,密码则是随机生成的UUID字符串。
  • 给用户生成一个默认的登录页面。
  • 开启CSRF攻击防御
  • 开启会话固定攻击防御
  • 集成X-XSS-Protection
  • 集成X-Frame-Options以防止单击劫持

这里远远不止这些,在这里我们主要分析一下默认用户的生成以及默认页面的生成。

默认用户生成

SpringSecurity中定义了UserDetail接口来规范我们自定义的用户对象,这样方便一些旧系统,用户表已经固定的系统集成到SpringSecurity认证体系中,UserDetail接口如下:

public interface UserDetails extends Serializable {
    //返回当前用户所具备的权限
    Collection<? extends GrantedAuthority> getAuthorities();
	//返回当前用户密码
    String getPassword();
	//返回但钱用户用户名
    String getUsername();
	//返回当前用户是否过期
    boolean isAccountNonExpired();
	//返回当前用户是否锁定
    boolean isAccountNonLocked();
	//返回当前用户账户凭证(如密码)是否过期
    boolean isCredentialsNonExpired();
	//返回当前用户是否可用
    boolean isEnabled();
}

这里是用户对象的定义,而负责提供用户数据源的接口是UserDetailsService,该接口中只有一个查询用户的方法

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

loadUserByUsername有一个参数是username,这是用户在认证时传入的用户名,最常见的就是用户在登录表单中输入的用户名(实际中还可能存在其他情况,例如使用CAS单点登录,username并发表单输入的用户名, 而是CAS Server认证成功后回调的用户参数),我们在这里拿到用户名之后,再去数据库中查询用户,最终返回一个UserDetails实例。

在实例开发中,我们一般都需要自定义UserDetailsService的实现,如果我们不去定义,SpringSecurity也为UserDetailsService提供了默认是实现。

在这里插入图片描述

  • UserDetailsManager 在UserDetailsService的基础上,继续定义了添加用户、更新用户、删除用户、修改密码以及判用户是否存在共5种方法。

  • JdbcDaoImpl在UserDetailsService基础上,通过spring-jdbc实现了从数据库种查询用户的方法。

  • InMermoryUserDetailsManager实现了UserDetailsManager种关于用户的增删改查方法,不过都是基于内存操作的,数据并没有持久化。

  • JdbcUserDetailsManager继承自JdbcDaoImpl同时又实现了UserDetailsManager接口,因此可以通过JdbcUserDetailsManager实现对用户的增删改查操作,这些操作都会持久化到数据库种。不过JdbcUserDetailsManager有一个局限性,就是操作数据中用户的SQL都是提前写好的,不够灵活,因此在实际开发种JdbcUserDetailsManager使用并不多。

  • CachingUserDetailsService的特点是会将UserDetailsService缓存起来。

当我们使用SpringSecurity时,如果仅仅只是引入一个SpringSecurity依赖,则默认使用的用户就是由InMemoryUserDetailsManager提供的。


我们都知道SpringBoot之所以能够做到零配置的使用,就算因为它提供了众多的自动化配置类,其中,针对UserDetailsService的自动化配置类是UserDetailsServiceAutoConfiguration。我们来看一下

@Configuration(
    proxyBeanMethods = false
)
@ConditionalOnClass({AuthenticationManager.class})
@ConditionalOnBean({ObjectPostProcessor.class})
@ConditionalOnMissingBean(
    value = {AuthenticationManager.class, AuthenticationProvider.class, UserDetailsService.class, AuthenticationManagerResolver.class},
    type = {"org.springframework.security.oauth2.jwt.JwtDecoder", "org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector", "org.springframework.security.oauth2.client.registration.ClientRegistrationRepository"}
)
public class UserDetailsServiceAutoConfiguration {
    private static final String NOOP_PASSWORD_PREFIX = "{noop}";
    private static final Pattern PASSWORD_ALGORITHM_PATTERN = Pattern.compile("^\\{.+}.*$");
    private static final Log logger = LogFactory.getLog(UserDetailsServiceAutoConfiguration.class);

    public UserDetailsServiceAutoConfiguration() {
    }

    @Bean
    @Lazy
    public InMemoryUserDetailsManager inMemoryUserDetailsManager(SecurityProperties properties, ObjectProvider<PasswordEncoder> passwordEncoder) {
        User user = properties.getUser();
        List<String> roles = user.getRoles();
        return new InMemoryUserDetailsManager(new UserDetails[]{org.springframework.security.core.userdetails.User.withUsername(user.getName()).password(this.getOrDeducePassword(user, (PasswordEncoder)passwordEncoder.getIfAvailable())).roles(StringUtils.toStringArray(roles)).build()});
    }

    private String getOrDeducePassword(User user, PasswordEncoder encoder) {
        String password = user.getPassword();
        if (user.isPasswordGenerated()) {
            logger.info(String.format("%n%nUsing generated security password: %s%n", user.getPassword()));
        }

        return encoder == null && !PASSWORD_ALGORITHM_PATTERN.matcher(password).matches() ? "{noop}" + password : password;
    }
}

从上述代码中可以看到,有两个比较重要的条件促使系统自动提供一个InMemoryUserDetailsManager的实例:

  • 当前classpath下存在AuthenticationManager类。
  • 当前项目中,系统没有提供AuthenticationManager、AuthenticationProvider、UserDetailsService以及ClientRegistrationRepository实例。

默认情况下,上面的条件都会满足,此时SpringSecurity会提供一个InMemoryUserDetailsManager实例,从InMemoryUserDetailsManager方法种可以看到,用户数据源自SecurityProperties#getUser方法:

@ConfigurationProperties(prefix = "spring.security")
public class SecurityProperties {

	
	public static final int BASIC_AUTH_ORDER = Ordered.LOWEST_PRECEDENCE - 5;

	
	public static final int IGNORED_ORDER = Ordered.HIGHEST_PRECEDENCE;

	
	public static final int DEFAULT_FILTER_ORDER = OrderedFilter.REQUEST_WRAPPER_FILTER_MAX_ORDER - 100;

	private final Filter filter = new Filter();

	private final User user = new User();

	public User getUser() {
		return this.user;
	}

	public Filter getFilter() {
		return this.filter;
	}

	public static class Filter {

		private int order = DEFAULT_FILTER_ORDER;

		private Set<DispatcherType> dispatcherTypes = new HashSet<>(
				Arrays.asList(DispatcherType.ASYNC, DispatcherType.ERROR, DispatcherType.REQUEST));

		public int getOrder() {
			return this.order;
		}

		public void setOrder(int order) {
			this.order = order;
		}

		public Set<DispatcherType> getDispatcherTypes() {
			return this.dispatcherTypes;
		}

		public void setDispatcherTypes(Set<DispatcherType> dispatcherTypes) {
			this.dispatcherTypes = dispatcherTypes;
		}

	}

	public static class User {

		/**
		 * Default user name.
		 */
		private String name = "user";

		/**
		 * Password for the default user name.
		 */
		private String password = UUID.randomUUID().toString();

		/**
		 * Granted roles for the default user name.
		 */
		private List<String> roles = new ArrayList<>();

		private boolean passwordGenerated = true;

		public String getName() {
			return this.name;
		}

		public void setName(String name) {
			this.name = name;
		}

		public String getPassword() {
			return this.password;
		}

		public void setPassword(String password) {
			if (!StringUtils.hasLength(password)) {
				return;
			}
			this.passwordGenerated = false;
			this.password = password;
		}

		public List<String> getRoles() {
			return this.roles;
		}

		public void setRoles(List<String> roles) {
			this.roles = new ArrayList<>(roles);
		}

		public boolean isPasswordGenerated() {
			return this.passwordGenerated;
		}

	}

}

从SecurityProperties.User类中,我们就可以看到默认的用户名是user,默认的密码是一个UUID 字符串。

在回到InMemoryUserDetailsManager方法中,构造InMemoryUserDetailsManager实例时需要一个User对象

这里的User对象不是SecurityProperties.User,而是org.springframework.security.core.userdetails.User,这是SpringSecurity提供了一个实现了UserDetails接口的用户类,该类提供了相应的静态方法,用来构造一个默认的User实例。同时,默认的用户密码还在getOrDeducePassword方法中进行了二次处理,由于默认的encodeer为null,所以密码进行二次处理只是给密码加了一个前缀{noop},表示密码是明文存储的。

private String getOrDeducePassword(SecurityProperties.User user, PasswordEncoder encoder) {
		String password = user.getPassword();
		if (user.isPasswordGenerated()) {
			logger.info(String.format("%n%nUsing generated security password: %s%n", user.getPassword()));
		}
		if (encoder != null || PASSWORD_ALGORITHM_PATTERN.matcher(password).matches()) {
			return password;
		}
		return NOOP_PASSWORD_PREFIX + password;
	}

经过上述梳理,我们已经明白SpringSecurity默认的用户名/密码是来自哪里!另外我们如果熟悉SpringBoot中properties属性的加载机制有一点了解,我们就会明白,只要我们在项目的application.yml/application.yml配置文件中添加如下配置,就能定制SecurityProperties.User类中各属性的值:

spring.security.user.name=test
spring.security.user.password=123456
spring.security.user.roles=admin,user

可以注意一点,当我们在定制了账户名和密码之后,在启动中,SpringSecurity就不会打印之前随机生成UUID字符串了。

默认页面生成

在上面案例中,一共存在两个默认页面,一个是我们看到的登录页面,还有一个注销登录页面。当用户登录成功后,在浏览器输入 http://localhost:8080/logout 就可以看到注销登录页面

在这里插入图片描述

那么这两个页面是从哪里来的呢?

在上一篇文章中,我们就介绍了SpringSecurity常见的过滤器,在这些常见的过滤器中就包含两个和页面相关的过滤器:DefaultLoginPageGeneratingFilter和DefaultLogoutPageGeneratingFilter。

  • DefaultLoginPageGeneratingFilter

DefaultLoginPageGeneratingFilter作为SpringSecurity过滤器链的一员,在第一次请求/hello接口的时候,就会经过DefaultLoginPageGeneratingFilter过滤器,但是由于/hello接口和登录无关,因此DefaultLoginPageGeneratingFilter过滤器并未干涉/hello接口。等到第二次重定向到/login页面的时候,这个时候和DefaultLoginPageGeneratingFilter就有关系了,此时请求就会在DefaultLoginPageGeneratingFilter中进行处理,生成登录页面返回给客户端。

我们来看一下DefaultLoginPageGeneratingFilter的源码,这里我列出核心部分:

public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
    
	  private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        boolean loginError = this.isErrorPage(request);
        boolean logoutSuccess = this.isLogoutSuccess(request);
        if (!this.isLoginUrlRequest(request) && !loginError && !logoutSuccess) {
            chain.doFilter(request, response);
        } else {
            String loginPageHtml = this.generateLoginPageHtml(request, loginError, logoutSuccess);
            response.setContentType("text/html;charset=UTF-8");
            response.setContentLength(loginPageHtml.getBytes(StandardCharsets.UTF_8).length);
            response.getWriter().write(loginPageHtml);
        }
    }
	//--------------------------------------------------------------------------
      private String generateLoginPageHtml(HttpServletRequest request, boolean loginError, boolean logoutSuccess) {
        String errorMsg = "Invalid credentials";
        if (loginError) {
            HttpSession session = request.getSession(false);
            if (session != null) {
                AuthenticationException ex = (AuthenticationException)session.getAttribute("SPRING_SECURITY_LAST_EXCEPTION");
                errorMsg = ex != null ? ex.getMessage() : "Invalid credentials";
            }
        }

        String contextPath = request.getContextPath();
        StringBuilder sb = new StringBuilder();
        sb.append("<!DOCTYPE html>\n");
        sb.append("<html lang=\"en\">\n");
        sb.append("  <head>\n");
        sb.append("    <meta charset=\"utf-8\">\n");
        sb.append("    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\">\n");
        sb.append("    <meta name=\"description\" content=\"\">\n");
        sb.append("    <meta name=\"author\" content=\"\">\n");
        sb.append("    <title>Please sign in</title>\n");
        sb.append("    <link href=\"https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css\" rel=\"stylesheet\" integrity=\"sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M\" crossorigin=\"anonymous\">\n");
        sb.append("    <link href=\"https://getbootstrap.com/docs/4.0/examples/signin/signin.css\" rel=\"stylesheet\" crossorigin=\"anonymous\"/>\n");
        sb.append("  </head>\n");
        sb.append("  <body>\n");
        sb.append("     <div class=\"container\">\n");
        if (this.formLoginEnabled) {
            sb.append("      <form class=\"form-signin\" method=\"post\" action=\"" + contextPath + this.authenticationUrl + "\">\n");
            sb.append("        <h2 class=\"form-signin-heading\">Please sign in</h2>\n");
            sb.append(createError(loginError, errorMsg) + createLogoutSuccess(logoutSuccess) + "        <p>\n");
            sb.append("          <label for=\"username\" class=\"sr-only\">Username</label>\n");
            sb.append("          <input type=\"text\" id=\"username\" name=\"" + this.usernameParameter + "\" class=\"form-control\" placeholder=\"Username\" required autofocus>\n");
            sb.append("        </p>\n");
            sb.append("        <p>\n");
            sb.append("          <label for=\"password\" class=\"sr-only\">Password</label>\n");
            sb.append("          <input type=\"password\" id=\"password\" name=\"" + this.passwordParameter + "\" class=\"form-control\" placeholder=\"Password\" required>\n");
            sb.append("        </p>\n");
            sb.append(this.createRememberMe(this.rememberMeParameter) + this.renderHiddenInputs(request));
            sb.append("        <button class=\"btn btn-lg btn-primary btn-block\" type=\"submit\">Sign in</button>\n");
            sb.append("      </form>\n");
        }

        if (this.openIdEnabled) {
            sb.append("      <form name=\"oidf\" class=\"form-signin\" method=\"post\" action=\"" + contextPath + this.openIDauthenticationUrl + "\">\n");
            sb.append("        <h2 class=\"form-signin-heading\">Login with OpenID Identity</h2>\n");
            sb.append(createError(loginError, errorMsg) + createLogoutSuccess(logoutSuccess) + "        <p>\n");
            sb.append("          <label for=\"username\" class=\"sr-only\">Identity</label>\n");
            sb.append("          <input type=\"text\" id=\"username\" name=\"" + this.openIDusernameParameter + "\" class=\"form-control\" placeholder=\"Username\" required autofocus>\n");
            sb.append("        </p>\n");
            sb.append(this.createRememberMe(this.openIDrememberMeParameter) + this.renderHiddenInputs(request));
            sb.append("        <button class=\"btn btn-lg btn-primary btn-block\" type=\"submit\">Sign in</button>\n");
            sb.append("      </form>\n");
        }

        Iterator var7;
        Entry relyingPartyUrlToName;
        String url;
        String partyName;
        if (this.oauth2LoginEnabled) {
            sb.append("<h2 class=\"form-signin-heading\">Login with OAuth 2.0</h2>");
            sb.append(createError(loginError, errorMsg));
            sb.append(createLogoutSuccess(logoutSuccess));
            sb.append("<table class=\"table table-striped\">\n");
            var7 = this.oauth2AuthenticationUrlToClientName.entrySet().iterator();

            while(var7.hasNext()) {
                relyingPartyUrlToName = (Entry)var7.next();
                sb.append(" <tr><td>");
                url = (String)relyingPartyUrlToName.getKey();
                sb.append("<a href=\"").append(contextPath).append(url).append("\">");
                partyName = HtmlUtils.htmlEscape((String)relyingPartyUrlToName.getValue());
                sb.append(partyName);
                sb.append("</a>");
                sb.append("</td></tr>\n");
            }

            sb.append("</table>\n");
        }

        if (this.saml2LoginEnabled) {
            sb.append("<h2 class=\"form-signin-heading\">Login with SAML 2.0</h2>");
            sb.append(createError(loginError, errorMsg));
            sb.append(createLogoutSuccess(logoutSuccess));
            sb.append("<table class=\"table table-striped\">\n");
            var7 = this.saml2AuthenticationUrlToProviderName.entrySet().iterator();

            while(var7.hasNext()) {
                relyingPartyUrlToName = (Entry)var7.next();
                sb.append(" <tr><td>");
                url = (String)relyingPartyUrlToName.getKey();
                sb.append("<a href=\"").append(contextPath).append(url).append("\">");
                partyName = HtmlUtils.htmlEscape((String)relyingPartyUrlToName.getValue());
                sb.append(partyName);
                sb.append("</a>");
                sb.append("</td></tr>\n");
            }

            sb.append("</table>\n");
        }

        sb.append("</div>\n");
        sb.append("</body></html>");
        return sb.toString();
    }


}

(1)、在doFilter中,首先会判断当前请求是否为登录出错请求、注销成功请求或者登录请求。如果是这三个请求中的任意一个,就会在DefaultLoginPageGeneratingFilter过滤器中生成登录页面并返回,否则继续往下走,执行下一个过滤器(这就是一开始的/hello请求没有被拦截下来的原因)

(2)、如果当前请求是登录出错请求、注销成功请求或者登录请求中的任意一个就会调用generateLoginPageHtml方法去生成登录页面,在该方法中,如果有异常信息就把异常信息取出来一同返回给前端,然后根据不同的登录常见,生成不同的页面。

(3)、登录页面生成后,接下来通过HttpServletResponse将登录页面写回到前端,然后调用return跳出过滤器链

  • DefaultLogoutPageGeneratingFilter
public class DefaultLogoutPageGeneratingFilter extends OncePerRequestFilter {
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        if (this.matcher.matches(request)) {
            this.renderLogout(request, response);
        } else {
            if (this.logger.isTraceEnabled()) {
                this.logger.trace(LogMessage.format("Did not render default logout page since request did not match [%s]", this.matcher));
            }

            filterChain.doFilter(request, response);
        }

    }
}

从上述源码中可以看出,请求到来之后,会先判断是否注销请求/logou,如果是,则渲染一个注销请求的页面返回给客户端,渲染过程和前面页面(页面过程类似这里就不展示了),否则请求继续往下走,执行下一个过滤器

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 7
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

陈橙橙丶

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值