一、SpringSecurity的介绍
Spring Security是一个能够为基于Spring的企业应用系统提供声明式的(可配置的)安全访问控制解决方案的安全框架(简单说是对访问权限进行控制 )。
它提供了一组可以在Spring应用上下文中配置的Bean,充分利用了Spring IOC,DI和AOP功能,为应用系统提供声明式的安全访问控制功能,减少了为企业系统安全控制编写大量重复代码的工作。
应用的安全性包括:会话管理、密码加密、用户认证(Authentication)和用户授(Authorization)四个部分。后两个功能是最常用的两个功能。
用户认证(你是谁?):验证某个用户是否为系统中的合法主体,也就是说用户能否访问该系统 。用户认证一般要求用户提供用户名和密码。系统通过校验用户名和密码来完成认证过程
用户授权(你来干嘛?):验证某个用户是否有权限执行某个操作在一个系统中,不同用户所具有的权限是不同的。比如对一个文件来说,有的用户只能进行读取,而有的用户可以进行修改。一般来说,系统会为不同的用户分配不同的角色,而每个角色则对应一系列的权限
二、SpringSecurity的实现原理
1.默认登录页及配置的总体介绍
-
WebSecurityConfigurerAdapter是SpringSecurity核心配置类,它提供默认的配置 ,这个抽象类中,提供了一个方法formLogin(),内容如下:
protected void configure(HttpSecurity http) throws Exception { logger.debug("Using default configure(HttpSecurity). If subclassed this will potentially override subclass configure(HttpSecurity)."); http .authorizeRequests() .anyRequest().authenticated() .and() .formLogin().and() .httpBasic(); }
-
点击formLogin()查看formLogin()源码,跳转到HttpSecurity类中,这个方法返回一个 FormLoginConfigurer类型,再点击FormLoginConfigurer进入继续来看看这个FormLoginConfigurer,在FormLoginConfigurer中有个initDefaultLoginFilter()方法
private void initDefaultLoginFilter(H http) { DefaultLoginPageGeneratingFilter loginPageGeneratingFilter = http .getSharedObject(DefaultLoginPageGeneratingFilter.class); if (loginPageGeneratingFilter != null && !isCustomLoginPage()) { loginPageGeneratingFilter.setFormLoginEnabled(true); loginPageGeneratingFilter.setUsernameParameter(getUsernameParameter()); loginPageGeneratingFilter.setPasswordParameter(getPasswordParameter()); loginPageGeneratingFilter.setLoginPageUrl(getLoginPage()); loginPageGeneratingFilter.setFailureUrl(getFailureUrl()); loginPageGeneratingFilter.setAuthenticationUrl(getLoginProcessingUrl()); } }
-
initDefaultLoginFilter()这个方法,初始化一个默认登录页的过滤器,可以看到第一句代码,默认的过滤器是DefaultLoginPageGeneratingFilter ,进入到这个过滤器中
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.getBytes(StandardCharsets.UTF_8).length); response.getWriter().write(loginPageHtml); return; } chain.doFilter(request, response); }
在描述中可以看到,如果没有配置login页,这个过滤器会被创建,然后看doFilter()方法,登录页面的配置是通过generateLoginPageHtml()方法创建的
-
再来看看这个generateLoginPageHtml()方法内容
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(WebAttributes.AUTHENTICATION_EXCEPTION); errorMsg = ex != null ? ex.getMessage() : "Invalid credentials"; } } StringBuilder sb = new StringBuilder(); sb.append("<!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>Please sign in</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 class=\"container\">\n"); String contextPath = request.getContextPath(); if (this.formLoginEnabled) { sb.append(" <form class=\"form-signin\" method=\"post\" action=\"" + contextPath + this.authenticationUrl + "\">\n" + " <h2 class=\"form-signin-heading\">Please sign in</h2>\n" + createError(loginError, errorMsg) + createLogoutSuccess(logoutSuccess) + " <p>\n" + " <label for=\"username\" class=\"sr-only\">Username</label>\n" + " <input type=\"text\" id=\"username\" name=\"" + this.usernameParameter + "\" class=\"form-control\" placeholder=\"Username\" required autofocus>\n" + " </p>\n" + " <p>\n" + " <label for=\"password\" class=\"sr-only\">Password</label>\n" + " <input type=\"password\" id=\"password\" name=\"" + this.passwordParameter + "\" class=\"form-control\" placeholder=\"Password\" required>\n" + " </p>\n" + createRememberMe(this.rememberMeParameter) + renderHiddenInputs(request) + " <button class=\"btn btn-lg btn-primary btn-block\" type=\"submit\">Sign in</button>\n" + " </form>\n"); } if (openIdEnabled) { sb.append(" <form name=\"oidf\" class=\"form-signin\" method=\"post\" action=\"" + contextPath + this.openIDauthenticationUrl + "\">\n" + " <h2 class=\"form-signin-heading\">Login with OpenID Identity</h2>\n" + createError(loginError, errorMsg) + createLogoutSuccess(logoutSuccess) + " <p>\n" + " <label for=\"username\" class=\"sr-only\">Identity</label>\n" + " <input type=\"text\" id=\"username\" name=\"" + this.openIDusernameParameter + "\" class=\"form-control\" placeholder=\"Username\" required autofocus>\n" + " </p>\n" + createRememberMe(this.openIDrememberMeParameter) + renderHiddenInputs(request) + " <button class=\"btn btn-lg btn-primary btn-block\" type=\"submit\">Sign in</button>\n" + " </form>\n"); } if (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"); for (Map.Entry<String, String> clientAuthenticationUrlToClientName : oauth2AuthenticationUrlToClientName.entrySet()) { sb.append(" <tr><td>"); String url = clientAuthenticationUrlToClientName.getKey(); sb.append("<a href=\"").append(contextPath).append(url).append("\">"); String clientName = HtmlUtils.htmlEscape(clientAuthenticationUrlToClientName.getValue()); sb.append(clientName); 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"); for (Map.Entry<String, String> relyingPartyUrlToName : saml2AuthenticationUrlToProviderName.entrySet()) { sb.append(" <tr><td>"); String url = relyingPartyUrlToName.getKey(); sb.append("<a href=\"").append(contextPath).append(url).append("\">"); String partyName = HtmlUtils.htmlEscape(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(); }
综上,默认登录页及配置的总体介绍。
2.用户名和密码生成原理
-
在项目启动的日志中,可以发现有这样一条信息 :
2021-01-21 20:46:40.263 INFO 6416 --- [ main] .s.s.UserDetailsServiceAutoConfiguration : Using generated security password: 160fdae6-d8f0-4a6c-8f0f-67db850f1d8e 2021-01-21 20:46:40.602 INFO 6416 --- [ main] o.s.s.web.DefaultSecurityFilterChain : Creating filter chain: any request, [org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@d2291de, org.springf
可以看到,自动配置类是UserDetailsServiceAutoConfiguration,密码是 :160fdae6-d8f0-4a6c-8f0f-67db850f1d8e,现在知道了密码,那用户名是什么还不知道。
-
进入到 UserDetailsServiceAutoConfiguration去,在这个UserDetailsServiceAutoConfiguration 类的描述中可以知道,这个类是设置一些 Spring Security 相关默认的自动配置,把InMemoryUserDetailsManager 中得user 和 password 信息设置为默认得用户和密码,可以通过提供的AuthenticationManager、AuthenticationProvider 或者 UserDetailsService 的 bean 来覆盖默认的自动配置信息
@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()方法获取,返回一个新的带有UserDetials信息参数构造的InMemoryUSerDetailsManager对象 ,第一个参数为:User.withUsername(user.getName()),
其中user 对象是上面SecurityProperties.User类型 的,通过SecurityProperties 对象中获取的 ,看下SecurityProperties类 :@ConfigurationProperties(prefix = "spring.security") public class SecurityProperties { ... } 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; } ... }
> 通过配置文件中的,前缀为spring.security 的配置可以改变默认配置信息,再看看SecurityProperties 的getUser()方法 ,通过一步步的跟踪,发现默认的用户名是user 。
3.框架核心过滤器
想要对WEB资源进行保护,最好的办法就是Filter,想要对方法进行保护,最好的办法就是AOP,SpringSecurity在我们进行用户认证和授权的时候,会通过各种各样的拦截器来控制权限的访问,从而实现安全。SpringSecurity常见的过滤器有:
Filter | 含义 |
---|---|
WebAsyncManagerIntegrationFilter | 异步 , 提供了对securityContext和WebAsyncManager的集 成 |
SecurityContextPersistenceFilter | 同步 , 从配置的SecurityContextRepository而不是request 中获取信息存到SecurityContextHolder,并且当请求结束清 理contextHolder时将值存回repository中(默认使用 HttpSessionSecurityContextRepository).在该过滤器中每一 个请求仅执行一次,该filter需在任何认证处理机制其作用之 前执行。认证处理机制如basic,cas等期望在执行时从 SecurityContextHolder中获取SecurityContext |
HeaderWriterFilter | 是一个向HttpServletResponse写入http请求头的约定 |
CsrfFilter | 通过使用同步token模式来进行csrf防护 |
LogoutFilter | 记录用户的退出 |
RequestCacheAwareFilter | 用于用户登录成功后,重新恢复因为登录被打断的请求 , 请 求信息被保存到cache中 |
SecurityContextHolderAwareRequestFilter | 包装请求对象request |
AnonymousAuthenticationFilter | 是在UsernamePasswordAuthenticationFilter、 BasicAuthenticationFilter、 RememberMeAuthenticationFilter这些过滤器后面的,所 以如果这三个过滤器都没有认证成功,则为当前的 SecurityContext中添加一个经过匿名认证的token,但是通 过servlet的getRemoteUser等方法是获取不到登录账号的。 因为SecurityContextHolderAwareRequestFilter过滤器在 AnonymousAuthenticationFilter前面 |
SessionManagementFilter | 管理session |
ExceptionTranslationFilter | 处理过滤器链抛出的所有AccessDeniedException和 AuthenticationException异常 |
FilterSecurityInterceptor | 通过实现了filter来增加http资源的安全性。这个安全拦截器 需要FilterInvocationSecurityMedataSource |
UsernamePasswordAuthenticationFilter | 登陆用户密码验证过滤器 ,基于用户名和密码的认证逻辑 |
BasicAuthenticationFilter | 处理一个http请求的basic认证头,将结果放入 SecurityContextHolder |
DefaultLoginPageGeneratingFilter | 当一个用户没有配置login页面时使用。仅当跳转到login页面 时用到 |
4.核心组件
1.Authentication
-
Authentication 是一个接口,用来表示用户认证信息的。
-
在用户登录认证之前相关信息会封装为一个Authentication 具体实现类的对象 ,在登录认证成功之后又会生成一个信息更全面,包含用户权限等信息的 Authentication 对象,然后把它保存在 SecurityContextHolder所持有的 SecurityContext 中,供后续的程序进 行调用,如访问权限的鉴定等。
-
Authentication 对象不需要我们自己去创建,在与系统交互的过程中,Spring Security 会自动为我们创建相应的 Authentication 对象 ,然后赋值给当前的 SecurityContext ,但是往往我们需要在程序中获取当前用户的相关信息,比如最常见的是获取当前登录用户的用户名。在程序的任何地方,通过如下方式我们可以获取到当前用户的用户名。
public String getCurrentUsername() { //getAuthorities:权限信息 //getCredentials:认证信息(证书信息) //getPrincipal:用户信息 Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); //通过instanceof 判断是否获取了自定义用户实体对象 if (principal instanceof User) { return ((User) principal).getName(); } return null; }
此外,调用 SecurityContextHolder.getContext() 获取 SecurityContext 时,如果对应的SecurityContext 不存在,则 Spring Security 将为我们建立一个空的 SecurityContext 并进行返回
2.SecurityContextHolder
- SecurityContextHolder 是用来保存 SecurityContext的
- SecurityContext 中含有当前正在访问系统的用户的详细信息
- 默认情况下,SecurityContextHolder 将使用 ThreadLocal 来保存SecurityContext ,这也就意味着在处于同一线程中的方法中我们可以从 ThreadLocal 中获取到当前的 SecurityContext,因为线程池的原因,如果我们每次在请求完成后都将 ThreadLocal 进行清除的话,那么我们把SecurityContext 存放在 ThreadLocal 中还是比较安全的
- 这些工作 Spring Security 已经自动为我们做了,即在每一次 request 结束后都将清除当前线程的ThreadLocal
- SecurityContextHolder 中定义了一系列的静态方法,而这些静态方法内部逻辑基本上都是通过SecurityContextHolder 持有的SecurityContextHolderStrategy 来实现的,如 getContext()、setContext()、clearContext()等
- 默认使用的 strategy 就是基于 ThreadLocal 的ThreadLocalSecurityContextHolderStrategy
- Spring Security 还提供了两种类型的 strategy 实现,GlobalSecurityContextHolderStrategy 和InheritableThreadLocalSecurityContextHolderStrategy ,前者表示全局使用同一个 SecurityContext,如C/S 结构的客户端;后者使用InheritableThreadLocal 来存放 SecurityContext,即子线程可以使用父线程中
存放的变量 - 一般而言,我们使用默认的 strategy 就可以了,但是如果要改变默认的 strategy,Spring Security 为我们提供了两种方法,这两种方式都是通过改变 strategyName 来实现的 。SecurityContextHolder 中为三种不同类型的 strategy 分别命名为 MODE_THREADLOCAL、MODE_INHERITABLETHREADLOCAL 和 MODE_GLOBAL ,第一种方式是通过 SecurityContextHolder 的静态方法 setStrategyName() 来指定需要使用的 strategy;第二种方式是通过系统属性进行指定,其中属性名默认为 “spring.security.strategy”,属性值为对应 strategy 的名称。
3.AuthenticationManager 和AuthenticationProvider
-
AuthenticationManager 是一个用来处理认证(Authentication)请求的接口认证是由 AuthenticationManager 来管理的,但是真正进行认证的是AuthenticationManager 中定义的AuthenticationProvider。
-
AuthenticationManager 中可以定义有多个 AuthenticationProvider在其中只定义了一个方法 authenticate(),该方法只接收一个代表认证请求的Authentication 对象作为参数,如果认证成功,则会返回一个封装了当前用户权限等信息的 Authentication 对象进行返回。
package com.kejizhentan.security; import com.kejizhentan.entity.User; import com.kejizhentan.service.IUserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; /** * @Description: 用户信息动态验证的实现类(这个类相当于用户登录的业务控制层controller类) * @Author: kejizhentan * @CreateDate: 2021/1/31 21:09 * @UpdateRemark: 无 * @Version: 1.0 */ public class MyAuthenticationProvider implements AuthenticationProvider { /** * @author kejizhentan * @params Authentication * @return Authentication * @date 2021/1/31 21:10 * @Description 用户信息判断的业务逻辑方法 */ @Override public Authentication authenticate(Authentication auth) throws AuthenticationException { if(判断认证是否成功){ //登录成功业务处理 //参数1:代表用户实体对象 //参数2:代表的安全证书信息 //参数3:角色信息(集合) return new UsernamePasswordAuthenticationToken(user,null,user.getAuthorities()); } //如果有数据返回则代表用户信息验证成功,如果返回null则表示用户名或者密码错误 //安全框架中认证失败的话都是通过抛异常来解决的,而不是用return来提示的 throw new BadCredentialsException("authError"); } /** * @author kejizhentan * @params 无 * @return boolean类型,返回true表示支持动态验证,false表示不支持动态验证 * @Description 是否支持动态信息验证的设置类 */ @Override public boolean supports(Class<?> aClass) { return true; } }
-
在 Spring Security 中,AuthenticationManager 的默认实现是ProviderManager,而且它不直接自己处理认证请求,而是委托给其所配置的 AuthenticationProvider 列表,然后会依次使用每一个 AuthenticationProvider 进行认证,如果有一个 AuthenticationProvider 认证后的结果不为 null,则表示该 AuthenticationProvider 已经认证成功,之后的 AuthenticationProvider 将不再继续认证。然后直接以该 AuthenticationProvider 的认证结果作为 ProviderManager 的认证结果,如果所有的 AuthenticationProvider 的认证结果都为 null,则表示认证失败,将抛出一个ProviderNotFoundException,校验认证请求最常用的方法是根据请求的用户名加载对应的 UserDetails,然后比对 UserDetails 的密码与认证请求的密码是否一致,一致则表示认证通过Spring Security 内部的 DaoAuthenticationProvider 就是使用的这种方式。其内部使用 UserDetailsService来负责加载 UserDetails ,在认证成功以后会使用加载的 UserDetails 来封装要返回的 Authentication 对象,加载的 UserDetails 对象是包含用户权限等信息的。认证成功返回的 Authentication 对象将会保存在当前的 SecurityContext 中默认情况下,在认证成功后 ProviderManager 将清除返回的 Authentication 中的凭证信息,如密码。
-
如果你在无状态的应用中将返回的 Authentication 信息缓存起来了,那么以后你再利用缓存的信息去认证将会失败,因为它已经不存在密码这样的凭证信息了。所以在使用缓存的时候你应该考虑到这个问题:一种解决办法是设置 ProviderManager 的eraseCredentialsAfterAuthentication 属性为 false,或者想办法在缓存时将凭证信息一起缓存。
4.UserDetailsService
-
通过 Authentication.getPrincipal() 的返回类型是 Object,但很多情况下其返回的其实是一个 UserDetails 的实例。
-
UserDetails 是 Spring Security 中一个核心的接口 ,其中定义了一些可以获取用户名、密码、权限等与认证相关的信息的方法
-
Spring Security 内部使用的 UserDetails 实现类大都是内置的 User 类,我们如果要使用 UserDetails 时也可以直接使用该类
在 Spring Security 内部很多地方需要使用用户信息的时候基本上都是使用的UserDetails,比如在登录认证的时候。 -
登录认证的时候 Spring Security 会通过 UserDetailsService 的loadUserByUsername() 方法获取对应的UserDetails 进行认证,认证通过后会将该 UserDetails 赋给认证通过的 Authentication 的 principal,然后再把该 Authentication 存入到 SecurityContext 中,之后如果需要使用用户信息的时候就是通过 SecurityContextHolder 获取存放在 SecurityContext 中的Authentication 的 principal。通常我们需要在应用中获取当前用户的其它信息,如 Email、电话等。这时存放在 Authentication 的principal 中只包含有认证相关信息的 UserDetails 对象可能就不能满足我们的要求了。这时我们可以实现自己的 UserDetails,在该实现类中我们可以定义一些获取用户其它信息的方法,这样将来我们就可以直接从当前 SecurityContext 的 Authentication 的 principal 中获取这些信息了。
-
UserDetailsService 也是一个接口,我们也需要实现自己UserDetailsService 来加载我们自定义的UserDetails 信息。然后把它指定的AuthenticationProvider 即可。
-
另外 Spring Security 还为我们提供了 UserDetailsService 另外一个实现,InMemoryDaoImplInMemoryDaoImpl 主要是测试用的,其只是简单的将用户信息保存在内存中。
package com.kejizhentan.entity; import lombok.Data; import lombok.Getter; import lombok.Setter; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import java.util.Collection; import java.util.Date; import java.util.List; /** * @Description: 用户的实体类 * @Author: kejizhentan * @CreateDate: 2021/2/3 20:28 * @UpdateUser: kejizhentan * @UpdateDate: 2021/2/3 20:28 * @UpdateRemark: 无 * @Version: 1.0 */ /** * @Data注解的作用 * 添加注解 @Data,即可省去手写getter, setter, toString的麻烦 * 如果只想给某个属性加上get方法,则直接在属性的上面加上@getter和@Setter注解即可 */ @Data public class User implements UserDetails { /** *@Getter *@Setter */ private Long userId;//用户ID private String username;//用户姓名 private String email;//邮箱 private String phone;//手机 private String password;//密码 private int status;//用户状态 0-正常 1-封禁 private Date createTime;//创建时间 private Date lastLoginTime;//上次登录时间 private Date lastUpdateTime;//上次更新记录时间 private String avatar;//头像 public List<GrantedAuthority> authorities;//给用户授权 @Override public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } }
package com.kejizhentan.utils; import com.kejizhentan.entity.User; import org.springframework.security.core.context.SecurityContextHolder; /** * @Author: kejizhentan * @Date: 2019/11/8 11:24 * @Description: 用户信息工具类 */ public class UserInfoUtil { /** * @Description: 获取用户主键ID * @Author: kejizhentan * @Date: 2019/11/8 11:25 * @Param: [] * @Return: java.lang.Long * @Exception: */ public static Long getUserId(){ return getUser().getUserId(); } /** * @Description: 自定义方法获取用户对象信息 * @Author: kejizhentan * @Date: 2019/11/8 11:27 * @Param: [] * @Return: com.xdl.entity.User * @Exception: */ public static User getUser(){ //getAuthorities:权限信息 //getCredentials:认证信息(证书信息) //getPrincipal:用户信息 Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); if(principal instanceof User){ return (User) principal; } return null; } }
5.GrantedAuthority
- Authentication 的 getAuthorities() 可以返回当前 Authentication 对象拥有的权限,即当前用户拥有的权限。其返回值是一个 GrantedAuthority 类型的数组,每一个 GrantedAuthority 对象代表赋予给当前用户的一种权限。GrantedAuthority 是一个接口,其通常是通过 UserDetailsService 进行加载,然后赋予给 UserDetails,GrantedAuthority 中只定义了一个 getAuthority() 方法,该方法返回一个字符串,表示对应权限的字符串表示,如果对应权限不能用字符串表示,则应当返回 null。
- Spring Security 针对 GrantedAuthority 有一个简单实现SimpleGrantedAuthority。该类只是简单的接收一个表示权限的字符串。Spring Security 内部的所有 AuthenticationProvider 都是使用SimpleGrantedAuthority 来封装 Authentication 对象。
三、认证过程梳理
- 用户使用用户名和密码进行登录
- Spring Security 将获取到的用户名和密码封装成一个实现了 Authentication 接口的 UsernamePasswordAuthenticationToken
- 将上述产生的 token 对象传递给 AuthenticationManager 进行登录认证
- AuthenticationManager 认证成功后将会返回一个封装了用户权限等信息的 Authentication 对象
- 通过调用 SecurityContextHolder.getContext().setAuthentication(…) 将 AuthenticationManager 返回的 Authentication 对象赋予给当前的 SecurityContext
- 在认证成功后,用户就可以继续操作去访问其它受保护的资源了,但是在访问的时候将会使用保存在 SecurityContext 中的 Authentication 对象进行相关的权限鉴定,如不存在对应的访问权限,则会返回 403 错误码
四、SpringBoot整合SpringSecurity实现登录功能
-
新建Maven工程,并导入以下包:
<dependencies> <!--thymelea模板的包--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!--springboot前端的基础包--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!--导入安框架springsecurity的依赖包--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <!--导入mysql包--> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.12</version> </dependency> <!--导入mybatis包--> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>1.3.2</version> </dependency> <!--导入jdbc包--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <!--导入对象工具包--> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> <!--jpa包--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</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> </dependencies>
-
准备数据库表结构
create table user ( userId int (11), username varchar (96), email varchar (96), phone varchar (45), password varchar (300), status int (2), createTime datetime , lastLoginTime datetime , lastUpdateTime datetime , avatar varchar (765), PRIMARY KEY (userId) );
-
新建实体对象类(实现UserDetails )
package com.kejizhentan.entity; import lombok.Data; import lombok.Getter; import lombok.Setter; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import java.util.Collection; import java.util.Date; import java.util.List; /** * @Description: 用户的实体类 * @Author: kejizhentan * @CreateDate: 2021/2/3 20:28 * @UpdateUser: kejizhentan * @UpdateDate: 2021/2/3 20:28 * @UpdateRemark: 无 * @Version: 1.0 */ /** * @Data注解的作用 * 添加注解 @Data,即可省去手写getter, setter, toString的麻烦 * 如果只想给某个属性加上get方法,则直接在属性的上面加上@getter和@Setter注解即可 */ @Data public class User implements UserDetails { /** *@Getter *@Setter */ private Long userId;//用户ID private String username;//用户姓名 private String email;//邮箱 private String phone;//手机 private String password;//密码 private int status;//用户状态 0-正常 1-封禁 private Date createTime;//创建时间 private Date lastLoginTime;//上次登录时间 private Date lastUpdateTime;//上次更新记录时间 private String avatar;//头像 public List<GrantedAuthority> authorities;//给用户授权 @Override public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } }
-
配置application.xml文件
#设置端口号 server.port=9999 #配置数据源 spring.datasource.driver-class-name=com.mysql.jdbc.Driver spring.datasource.url=jdbc:mysql://localhost:3306/housedemo spring.datasource.username=root spring.datasource.password=123456 #指定mybatis的数据文件xml目录 mybatis.mapper-locations=classpath:mapper/*.xml #配置mybatis日志文件框架输出信息,打印到控制台 mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
-
增加前端登录和首页面
login.html:<!DOCTYPE html> <html lang="en" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>登录</title></head> <body> <form th:action="@{/login}" method="post"><input type="text" id="username" name="username" placeholder="手机号"/> <br/> <input type="password" id="password" name="password" placeholder="密码"/> <br/> <p th:if="${param.authError}" style="color: red">用户名或者密码错误</p> <br/> <button type="submit">登录</button> </form> </body> </html>
index.html:
<!DOCTYPE html> <html lang="en" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>首页面</title></head> <body><h3>登录成功</h3> <br/ <form th:action="@{/user/logout}" method="post" id="logoutForm"> <button type="submit" form="logoutForm">注销</button> </form> </body> </html>
-
增加接口页面跳转控制器
@Controller public class LoginController { @Autowired private IUserService userService; @GetMapping("/user/toLogin") public String toLogin() { return "login"; } @PostMapping("/user/logout") public String logout() { return "login"; } }
-
增加权限控制等类
package com.kejizhentan.security; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Configurable; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.WebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.web.authentication.AuthenticationFailureHandler; /** * @Description: springSecurity的配置类 * @Author: kejizhentan * @CreateDate: 2021/1/10 21:48 * @UpdateUser: kejizhentan * @UpdateDate: 2021/1/10 21:48 * @UpdateRemark: 无 * @Version: 1.0 */ @Configuration @EnableWebSecurity public class MySecurityConfig extends WebSecurityConfigurerAdapter { /* * @author kejizhentan * @params http请求 * @return 无 * @exception 无 * @date 2021/1/26 20:21 */ @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests()//默认所有的请求都是认证的 /** * 以上两个类注解的作用 * @Configuration * 用于定义配置类,可替换xml配置文件,被注解的类内部包含有一个或多个被@Bean注解的方法, * 这些方法将会被AnnotationConfigApplicationContext或AnnotationConfigWebApplicationContext类进行扫描, * 并用于构建bean定义,初始化Spring容器。 * @EnableWebSecurity注解有两个作用: * 1: 加载了WebSecurityConfiguration配置类, 配置安全认证策略。 * 2: 加载了AuthenticationConfiguration, 配置了认证信息。 * * antMatchers:匹配接口地址http是否和参数一致 * 参数值:支持表达式(参数不是值,类似于占位符) * 0:表示接下来的接口地址只能是数值(例如:http://www.kejizhentan.com/Hello/1或者http://www.kejizhentan.com/Hello?id=1) * *:表示接下来的接口地址后面只能有一级(例如:http://www.kejizhentan.com/Hello) * **:表示接下来的接口地址后面可以有多级(例如:http://www.kejizhentan.com/Hello/Hello/Hello/...) * permitAll():允许所有的角色访问(不拦截) * hasRole:当前http请求只允许参数是ADMIN的角色访问(该角色不拦截) * hasAnyRole("角色1","角色2","..."):当前http请求允许角色1、角色2.。。等多个角色访问(配置的角色不拦截) * anyRequest():拦截所有请求 * authenticated():判断http请求是否授权(角色) * and:一个业务的结束 * .anyRequest().authenticated():如果注视掉此行代码,则说明/地址不拦截,即http://www.kejizhentan.com地址不拦截 * * * */ .antMatchers("/user/toUserLogin").permitAll() //.antMatchers("/user/toHello").hasRole("ADMIN") //.antMatchers("/user/toHello").hasAnyRole("角色1","角色2","...") //.anyRequest().authenticated() .and() .formLogin() //自定义异常信息处理(在当前页面提示错误信息) .failureHandler(authFailureHandler()) .and() //退出功能 .logout() //退出成功后跳转的页面地址 .logoutSuccessUrl("/user/toUserLogin") .and() .httpBasic(); /** * * 1.什么是 CSRF * CSRF(Cross-site request forgery)跨站请求伪造,也被称为“OneClick Attack” 或者 Session Riding。通过伪造用户请求访问受信任站点的非法请求访问。 * 跨域:只要网络协议,ip 地址,端口中任何一个不相同就是跨域请求。 * 客户端与服务进行交互时,由于 http 协议本身是无状态协议,所以引入了 cookie 进行记录客户端身份。在 cookie 中会存放 session id 用来识别客户端身份的。在跨域的情况下,session id 可能被第三方恶意劫持,通过这个 session id 向服务端发起请求时,服务端会认为这个请求是合法的,可能发生很多意想不到的事情 *2.Spring Security 中 CSRF * 从 Spring Security4 开始 CSRF 防护默认开启。默认会拦截请求。进行 CSRF 处理。CSRF 为了保证不是其他第三方网站访问,要求访问时携带参数名为_csrf 值为 token(token 在服务端产生)的内容,如果 token 和服务端的 token 匹配成功,则正常访问。 * 网站伪造技术(防止恶意攻击的),开启网站伪造的话点击退出还是会跳转到springsecurity自定义的退出界面,然后再跳转到自己指定的页面 * 关闭网站伪造之后点击退出按钮就直接跳转到指定的登录界面了 */ http.csrf().disable(); http.headers().frameOptions().sameOrigin(); } /** * @author kejizhentan * @params 无 * @return 自定义异常处理类 * @date 2021/1/31 20:54 * @Description 自定义异常处理方法 */ public MyAuthenticationFailureHandler authFailureHandler() { return new MyAuthenticationFailureHandler(); } /** * @author kejizhentan * @params * @return * @exception * @date 2021/1/27 21:23 * @Description 自定义方法设置用户名和密码 * @Remark: * springsecurity设置账号和密码的时候一定要设置角色,否则会报:java.lang.IllegalArgumentException: Cannot pass a null GrantedAuthority collection * 解决方式:设置角色,角色名称可以是任意的非空字符串 * */ @Autowired public void configureConfig(AuthenticationManagerBuilder auth) throws Exception { /*设置静态的登录用户名和密码的方式 auth.inMemoryAuthentication().passwordEncoder(new BCryptPasswordEncoder()).withUser("admin") .password(new BCryptPasswordEncoder().encode("123456")).roles("ADMIN");*/ //动态验证用户信息 auth.authenticationProvider(authProvider()); } /** * @author kejizhentan * @params 无 * @date 2021/1/31 21:01 * @Description 动态验证用户信息的方法 */ @Bean public MyAuthenticationProvider authProvider() { //验证业务信息 return new MyAuthenticationProvider(); } }
package com.kejizhentan.security; import com.kejizhentan.entity.User; import com.kejizhentan.service.IUserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; /** * @Description: 用户信息动态验证的实现类(这个类相当于用户登录的业务控制层controller类) * @Author: kejizhentan * @CreateDate: 2021/1/31 21:09 * @UpdateRemark: 无 * @Version: 1.0 */ public class MyAuthenticationProvider implements AuthenticationProvider { //非对称算法的工具类 BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); @Autowired IUserService userService; /** * @author kejizhentan * @params Authentication * @return Authentication * @date 2021/1/31 21:10 * @Description 用户信息判断的业务逻辑方法 */ @Override public Authentication authenticate(Authentication auth) throws AuthenticationException { //获取用户输入的用户名 String inputname = auth.getName(); //获取用户输入的密码 String password = (String) auth.getCredentials(); // 调用service-->dao-->用户名和密码当做参数传给sql中 //根据用户输入的用户名查询用户信息,如果有数据返回,则代表用户信息是正确的 User user = userService.queryUserByUserName(inputname); if(user == null){ //安全(权限)框架的错误处理方式都是通过抛异常来解决的 throw new BadCredentialsException("authError"); } //参数1:代表用户输入的明文密码 //参数2:代表数据库中的密文密码(根据用户对象查询) if(encoder.matches(password,user.getPassword())){ //登录成功业务处理 //参数1:代表用户实体对象 //参数2:代表的安全证书信息 //参数3:角色信息(集合) return new UsernamePasswordAuthenticationToken(user,null,user.getAuthorities()); } //如果有数据返回则代表用户信息验证成功,如果返回null则表示用户名或者密码错误 throw new BadCredentialsException("authError"); } /** * @author kejizhentan * @params 无 * @return boolean类型,返回true表示支持动态验证,false表示不支持动态验证 * @Description 是否支持动态信息验证的设置类 */ @Override public boolean supports(Class<?> aClass) { return true; } }
package com.kejizhentan.security; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; /** * @Description: 自定义错误页面的跳转信息类 * @Author: kejizhentan * @CreateDate: 2021/1/31 20:52 * @Version: 1.0 */ public class MyAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { //自定义默认错误页面的跳转地址 super.setDefaultFailureUrl("/user/toUserLogin?"+exception.getMessage()); super.onAuthenticationFailure(request, response, exception); } }
-
增加接口和mybatis信息
package com.kejizhentan.service; import com.kejizhentan.entity.User; /** * @Description: 用户登录的service接口 * @Author: kejizhentan * @CreateDate: 2021/2/3 20:48 * @UpdateUser: kejizhentan * @UpdateDate: 2021/2/3 20:48 * @UpdateRemark: 特殊说明 * @Version: 1.0 */ public interface IUserService { /** * @author kejizhentan * @params 用户名 * @return 返回用户对象 * @exception 无 * @date 2021/2/3 20:53 * @Description 根据用户名查询用户对象 */ User queryUserByUserName(String username); }
package com.kejizhentan.service.impl; import com.kejizhentan.dao.UserDAO; import com.kejizhentan.entity.User; import com.kejizhentan.service.IUserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; /** * @Description: 用户登录Service的实现类 * @Author: kejizhentan * @CreateDate: 2021/2/3 20:58 * @UpdateUser: kejizhentan * @UpdateDate: 2021/2/3 20:58 * @UpdateRemark: 特殊说明 * @Version: 1.0 */ @Service public class UserServiceImpl implements IUserService { @Autowired UserDAO userDAO; @Override public User queryUserByUserName(String username) { return userDAO.queryUserByUserName(username); } }
package com.kejizhentan.dao; import com.kejizhentan.entity.User; import org.apache.ibatis.annotations.Mapper; /** * @Description: 用户登录的持久化层DAO * @Author: kejizhentan * @CreateDate: 2021/2/3 21:06 * @UpdateUser: kejizhentan * @UpdateDate: 2021/2/3 21:06 * @UpdateRemark: 特殊说明 * @Version: 1.0 */ public interface UserDAO { /** * @author kejizhentan * @params 用户名 * @return 返回用户对象 * @exception 无 * @date 2021/2/3 20:53 * @Description 根据用户名查询用户对象 */ User queryUserByUserName(String username); }
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.kejizhentan.dao.UserDAO"> <!--根据用户名查询用户信息--> <select id="queryUserByUserName" parameterType="java.lang.String" resultType="com.kejizhentan.entity.User"> select * from user u where u.username = #{username} </select> </mapper>
注意:
1.注意实体对象中GrantedAuthority类型List集合的封装
2.注意加密算法BCryptPasswordEncoder的使用