Spring Security基本认证(2)(spring security authority)

  本篇文章为你整理了Spring Security基本认证(2)(spring security authority)的详细内容,包含有spring security认证和授权流程 spring security authority spring security permitall仍需认证 spring security app认证token Spring Security基本认证(2),希望能帮助你了解 Spring Security基本认证(2)。

    对于安全管理框架而言,认证功能可以说是一切的起点,所以我们要研究Spring Security, 就要从最基本的认证开始。在Spring Security中,对认证功能做了大量的封装,以至于开发者只需要稍微配置一下就能使用认证功能,然而要深刻理解其源码却并非易事。本文从最基本的用法开始讲解,最终再扩展到对源码的理解。

  本章涉及的主要知识点有:

  Spring Security 基本认证。

  登录表单配置。

  登录用户数据获取。

  用户的四种定义方式。

  1.Spring Security 基本认证

    1.1 快速入门

    在Spring Boot项目中使用Spring Security非常方便,创建一个新的Spring Boot项目,我 们只需要引入Web和Spring Security依赖即可,具体代码如下:

  

 dependency 

 

   groupId org.springframework.boot /groupId

   artifactId spring-boot-starter-security /artifactId

   /dependency

   dependency

   groupId org.springframework.boot /groupId

   artifactId spring-boot-starter-web /artifactId

   /dependency

 

    然后我们在项目中提供一个用于测试的/hello接口,代码如下

  

@RestController

 

  public class HelloController {

  @GetMapping("/hello")

   public String hello() {

   return "hello spring security";

  }

 

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

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

    Using generated security password: 8ef9c800-17cf-47a3-9984-8ff936db6dd8

    输入默认的用户名和密码,就可以成功登录了,这就是Spring Security的强大之处,只需要引入一个依赖,所有的接口就会被自动保护起来。

    1.2 流程分析

    通过一个简单的流程图来看一下上面案例中的请求流程,如下图所示

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

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

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

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

  客户端发送/login请求。

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

    在整个过程中,相当于客户端一共发送了两个请求,第一个请求是/hello,服务端收到之 后,返回302,要求客户端重定向到/login,于是客户端又发送了/login请求。现在去理解上面这一个流程图可能还有些困难,等学完后面的内容之后,再回过头来看这个流程图,应该就会比较清晰了。

    1.3 原理分析

    幵启Spring Security自动化配置,开启后,会自动创建一个名为springSecurityFilterChain 的过滤器,并注入到Spring容器中,这个过滤器将负责所有的安全管理,包括用户的认证、授权、重定向到登录页面等(springSecmityFilterChain实际上代理了 Spring Security中的过滤器链)。

  创建一个UserDetailsSeivice实例,UserDetailsService负责提供用户数据,默认的用户数据是基于内存的用户,用户名为user,密码则是随机生成的UUID字符串。

  给用户生成一个默认的登录页面。

  幵启CSRF攻击防御。

  开启会话固定攻击防御。

  集成 X-XSS-Protection

  集成X-Frame-Options以防止单击劫持。

    这里涉及的细节还是非常多的,登录的细节会在后面详细介绍,这里主要分析一下默认用户的生成以及默认登录页面的生成

    1.3.1 默认用户生成

    Spring Security中定义了 UserDetails接口来规范开发者自定义的用户对象,这样方便一些旧系统、用户表己经固定的系统集成到Spring Security认证体系中。

    UserDetails接口定义如下:

  

public interface UserDetails extends Serializable {

 

   Collection ? extends GrantedAuthority getAuthorities();

   String getPassword();

   String getUsername();

   boolean isAccountNonExpired();

   boolean isAccountNonLocked();

   boolean isCredentialsNonExpired();

   boolean isEnabled();

  }

 

    该接口中一共定义了 7个方法:

  getAuthorities方法:返回当前账户所具备的权限。

  getPassword方法:返回当前账户的密码。

  getUsemame方法:返回当前账户的用户名。

  isAccountNonExpired方法:返回当前账户是否未过期。

  isAccountNonLocked方法:返回当前账户是否未锁定。

  isCredentialsNonExpired方法:返回当前账户凭证(如密码)是否未过期。

  isEnabled方法:返回当前账户是否可用。

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

  

public interface UserDetailsService {

 

   UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;

  }

 

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

    在实际项目中,一般需要开发者自定义UserDetailsService的实现。如果开发者没有自定义 UserDetailsService 的实现,Spring Security 也为 UserDetailsService 提供了默认实现,如下图

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

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

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

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

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

  UserDetailsServiceDelegator 则是提供了 UserDetailsService 的懒加载功能

  ReactiveUserDetailsServiceAdapter 是 webflux-web-security 模块定义的 UserDetailsService 实现。

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

    大家知道,Spring Boot之所以能够做到零配置使用Spring Security,就是因为它提供了众多的自动化配置类,其中,针对UserDetailsService的自动化配置类是UserDetailsServiceAuto Configurationr这个类的源码并不长,我们一起来看一下:

  

@Configuration(proxyBeanMethods = false)

 

  @ConditionalOnClass(AuthenticationManager.class)

  @ConditionalOnBean(ObjectPostProcessor.class)

  @ConditionalOnMissingBean(

   value = { AuthenticationManager.class, AuthenticationProvider.class, UserDetailsService.class },

   type = { "org.springframework.security.oauth2.jwt.JwtDecoder",

   "org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector" })

  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);

   @Bean

   @ConditionalOnMissingBean(

   type = "org.springframework.security.oauth2.client.registration.ClientRegistrationRepository")

   @Lazy

   public InMemoryUserDetailsManager inMemoryUserDetailsManager(SecurityProperties properties,

   ObjectProvider PasswordEncoder passwordEncoder) {

   SecurityProperties.User user = properties.getUser();

   List String roles = user.getRoles();

   return new InMemoryUserDetailsManager(

   User.withUsername(user.getName()).password(getOrDeducePassword(user, passwordEncoder.getIfAvailable()))

   .roles(StringUtils.toStringArray(roles)).build());

   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;

  }

 

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

    (1)当前 classpath 下存在 AuthenticationManager 类。  

    (2 )当前项目中,系统没有提供 AuthenticationManager、AutlienticationProvider、UserDetailsService 以及 ClientRegistrationRepository 实例。

  默认情况下,上面的条件都会满足,此时Spring Security会提供一个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 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 {

   private String name = "user";

   private String password = UUID.randomUUID().toString();

   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, 这是 Spring Security 提供的一个实现了UserDetails接口的用户类,该类提供了相应的静态方法,用来构造一个默认的Uset实例。同时,默认的用户密码还在getOrDeducePassword方法中进行了二次处理,由于默认的encoder 为null,所以密码的二次处理只是给密码加了一个前缀{noop},表示密码是明文存储的(关于 {noop}将在后续密码加密中做详细介绍)。

    经过以上的源码梳理,相信大家已经明白了 Spring Security 默认的用户名/密码是来自哪里了!另外,当看了 Security Properties的源码后,只要对Spring Boot中properties属性的加载机制有一点了解,就会明白,只要我们在项目的application.properties配置文件中添加如下配置, 就能定制SecurityProperties.User类中各属性的值:

  spring.security.user.name=javaboy

  spring.security.user.password=123

  spring.security.user.roles=admin, user

  配置完成后,重启项目,此时登录的用户名就是javaboy,登录密码就是123 ,登录成功后用户具备admin和user两个角色。

    1.3.2 默认页面生成

    在上面的案例中,一共存在两个默认页面,一个就是默认的登录页面,另外一个则是注销登录页面。当用户登录成功之后,在浏览器中输入http://localhost:8080/logout就可以看到注销登录页面,如图所示。

    那么这两个页面是从哪里来的呢?这里剖析一下,

    在前面我们介绍了 Spring Security中常见的过滤器,在这些常见的过滤器中就包含两个和页面相关的过滤器:DefaultLoginPageGeneratingFilter 和 DefaultLogoutPageGeneratingFilter

    通过过滤器的名字就可以分辨出DefaultLoginPageGeneratingFilter过滤器用来生成默认的登录页面,DefaultLogoutPageGeneratingFilter过滤器则用来生成默认的注销页面。

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

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

  

public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {

 

   public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)

   throws IOException, ServletException {

   HttpServletRequest request = (HttpServletRequest) req;

   HttpServletResponse response = (HttpServletResponse) res;

   boolean loginError = isErrorPage(request);

   boolean logoutSuccess = isLogoutSuccess(request);

   if (isLoginUrlRequest(request) loginError logoutSuccess) {

   String loginPageHtml = generateLoginPageHtml(request, loginError,

   logoutSuccess);

   response.setContentType("text/html;charset=UTF-8");

   response.setContentLength(loginPageHtml.length());

   response.getWriter().write(loginPageHtml);

   return;

   chain.doFilter(request, response);

   private String generateLoginPageHtml(HttpServletRequest request, boolean loginError,

   boolean logoutSuccess) {

   String errorMsg = "none";

   if (loginError) {

   HttpSession session = request.getSession(false);

   if (session != null) {

   AuthenticationException ex = (AuthenticationException) session

   .getAttribute(WebAttributes.AUTHENTICATION_EXCEPTION);

   errorMsg = ex != null ? ex.getMessage() : "none";

   StringBuilder sb = new StringBuilder();

   sb.append(" html head title Login Page /title /head

   if (formLoginEnabled) {

   sb.append(" body onload=document.f.").append(usernameParameter)

   .append(".focus(); \n");

   if (loginError) {

   sb.append(" p Your login attempt was not successful, try again. br/ br/ Reason: ");

   sb.append(errorMsg);

   sb.append(" /p

   if (logoutSuccess) {

   sb.append(" p You have been logged out /p

   if (formLoginEnabled) {

   sb.append(" h3 Login with Username and Password /h3

   sb.append(" form name=f action=").append(request.getContextPath())

   .append(authenticationUrl).append(" method=POST \n");

   sb.append(" table \n");

   sb.append(" tr td User: /td td input type=text name=");

   sb.append(usernameParameter).append(" value=").append(" /td /tr \n");

   sb.append(" tr td Password: /td td input type=password name=")

   .append(passwordParameter).append("/ /td /tr \n");

   if (rememberMeParameter != null) {

   sb.append(" tr td input type=checkbox name=")

   .append(rememberMeParameter)

   .append("/ /td td Remember me on this computer. /td /tr \n");

   sb.append(" tr td colspan=2 input name=\"submit\" type=\"submit\" value=\"Login\"/ /td /tr \n");

   renderHiddenInputs(sb, request);

   sb.append(" /table \n");

   sb.append(" /form

   if (openIdEnabled) {

   sb.append(" h3 Login with OpenID Identity /h3

   sb.append(" form name=oidf action=").append(request.getContextPath())

   .append(openIDauthenticationUrl).append(" method=POST \n");

   sb.append(" table \n");

   sb.append(" tr td Identity: /td td input type=text size=30 name=");

   sb.append(openIDusernameParameter).append("/ /td /tr \n");

   if (openIDrememberMeParameter != null) {

   sb.append(" tr td input type=checkbox name=")

   .append(openIDrememberMeParameter)

   .append(" /td td Remember me on this computer. /td /tr \n");

   sb.append(" tr td colspan=2 input name=\"submit\" type=\"submit\" value=\"Login\"/ /td /tr \n");

   sb.append(" /table \n");

   renderHiddenInputs(sb, request);

   sb.append(" /form

   if (oauth2LoginEnabled) {

   sb.append(" h3 Login with OAuth 2.0 /h3

   sb.append(" table \n");

   for (Map.Entry String, String clientAuthenticationUrlToClientName : oauth2AuthenticationUrlToClientName.entrySet()) {

   sb.append(" tr td

   sb.append(" a href=\"").append(request.getContextPath()).append(clientAuthenticationUrlToClientName.getKey()).append("\"

   sb.append(clientAuthenticationUrlToClientName.getValue());

   sb.append(" /a

   sb.append(" /td /tr \n");

   sb.append(" /table \n");

   sb.append(" /body /html

   return sb.toString();

  }

 

  DefaultLoginPageGeneratingFliter的源码执行流程还是非常清晰的,我们梳理一下:

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

  如果当前请求是登录出错请求、注销成功请求或者登录请求中的任意一个,就会调用generateLoginPageHtml方法去生成登录页面。在该方法中,如果有异常信息就把异常信息 取出来一同返回给前端,然后根据不同的登录场景,生成不同的登录页面。生成过程其实就是字符串拼接,拼接岀不同的登录表单

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

  这就是DefaultLoginPageGeneratingFilter的工作过程。这里重点搞明白为什么/hello请求没有被拦截,而/login请求却被拦截了,其他都很好懂。

  理解了 DefaultLoginPageGeneratingFilter,再来看 DefaultLogoutPageGeneratingFilter 就更容易了,DefaultLogoutPageGeneratingFilter 部分核心源码如下 :

  

public class DefaultLogoutPageGeneratingFilter extends OncePerRequestFilter {

 

   private RequestMatcher matcher = new AntPathRequestMatcher("/logout", "GET");

   protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

   if (this.matcher.matches(request)) {

   this.renderLogout(request, response);

   } else {

   filterChain.doFilter(request, response);

   private void renderLogout(HttpServletRequest request, HttpServletResponse response) throws IOException {

   String page = " !DOCTYPE html \n html lang=\"en\" \n head \n meta charset=\"utf-8\" \n meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\" \n meta name=\"description\" content=\"\" \n meta name=\"author\" content=\"\" \n title Confirm Log Out? /title \n 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 link href=\"https://getbootstrap.com/docs/4.0/examples/signin/signin.css\" rel=\"stylesheet\" crossorigin=\"anonymous\"/ \n /head \n body \n div "container\" \n form "form-signin\" method=\"post\" action=\"" + request.getContextPath() + "/logout\" \n h2 "form-signin-heading\" Are you sure you want to log out? /h2 \n" + this.renderHiddenInputs(request) + " button "btn btn-lg btn-primary btn-block\" type=\"submit\" Log Out /button \n /form \n /div \n /body \n /html

   response.setContentType("text/html;charset=UTF-8");

   response.getWriter().write(page);

  }

 

    从上述源码中可以看出,请求到来之后,会先判断是否是注销请求/logout,如果是/logout 请求,则渲染一个注销请求的页面返回给客户端,渲染过程和前面登录页而的渲染过程类似, 也是字符串拼接(这里省略了字符串拼接,读者可以参考DefaultLogoutPageGeneratingFilter 的源码);否则请求继续往下走,执行下一个过滤器。

    通过前面的分析,相信大家对这个简单的案例己经有所了解,看似只是加了一个依赖, 但实际上Spring Security和Spring Boot在背后都默默做了很多事情,当然还有很多没有介绍到的,将在后面和大家一起继续深究。

  以上就是Spring Security基本认证(2)(spring security authority)的详细内容,想要了解更多 Spring Security基本认证(2)的内容,请持续关注盛行IT软件开发工作室。

郑重声明:本文由网友发布,不代表盛行IT的观点,版权归原作者所有,仅为传播更多信息之目的,如有侵权请联系,我们将第一时间修改或删除,多谢。

留言与评论(共有 条评论)
   
验证码: