Spring Security学习笔记 学习时间:2024年7月17日
1 入门项目
1 2 3 4 <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-security</artifactId > </dependency >
1 2 3 4 5 6 7 8 9 @RestController public class HelloController { @GetMapping("/hello") public String hello () { return "Hello, 来跟 学习 Spring Security吧!" ; } }
把项目启动起来后,在浏览器中对Web接口进行访问,会发现接口是无法直接访问的。在访问接口之前会自动跳转到/login
地址,进入到一个登录界面。这是因为Spring Boot中”约定大约配置”的规则,只要添加了Spring Security的依赖包,就会自动开启安全限制,在访问Web接口之前会进行安全拦截。只有输入了用户名和密码,才能访问项目中的Web接口。
默认用户名为user
,密码为随机uuid
:
1 2 3 4 5 6 7 8 9 10 11 public class ReactiveUserDetailsServiceAutoConfiguration { 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; } }
密码生成机制:
1 2 3 4 5 6 7 8 9 10 11 public class SecurityProperties { public static class User { private String name = "user" ; private String password = UUID.randomUUID().toString(); private List<String> roles = new ArrayList (); private boolean passwordGenerated = true ; } }
1 2 3 4 5 spring: security: user: name: zym password: zym123
2 认证 认证: 所谓的认证,就是用来判断系统中是否存在某用户,并判断该用户的身份是否合法的过程,解决的其实是用户登录的问题。认证的存在,是为了保护系统中的隐私数据与资源,只有合法的用户才可以访问系统中的资源。
在Spring Security中,常见的认证方式可以分为HTTP层面和表单层面,常见的认证方式如下:
HTTP基本认证;
Form表单认证
HTTP摘要认证;
2.1 HTTP基本认证 2.1.1 概述 在Spring Security 4.x版本中,默认采用的登录方式是Http基本认证(或者Basic认证) ,该方式会弹出一个对话框,要求用户输入用户名和密码。在每次进行基本认证请求时,都会在Authorization请求头中利用Base64 对 用户:密码
字符串进行编码。这种方式并不安全,并不适合在Web项目中使用,但它是一些现代主流认证的基础,而且在Spring Security的OAuth中,内部认证的默认方式就是用的Http基本认证。
客户端首先发起一个未携带认证信息的请求;
然后服务器端返回一个401 Unauthorized的响应信息,并在WWW-Authentication
头部中说明认证形式:当进行HTTP基本认证时,WWW-Authentication会被设置为Basic realm=“被保护的页面”
;
接下来客户端会收到这个401 Unauthorized响应信息,并弹出一个对话框,询问用户名和密码。当用户输入后,客户端会将用户名和密码使用冒号进行拼接并用Base64编码,然后将其放入到请求的Authorization头部并发送给服务器;
最后服务器端对客户端发来的信息进行解码得到用户名和密码,并对该信息进行校验判断是否正确,最终给客户端返回响应内容。
HTTP基本认证是一种无状态的认证方式,与表单认证相比,HTTP基本认证是一种基于HTTP层面的认证方式,无法携带Session信息,也就无法实现Remember-Me功能。另外,用户名和密码在传递时仅做了一次简单的Base64编码,几乎等同于以明文传输,极易被进行密码窃听和重放攻击。所以在实际开发中,很少会使用这种认证方式来进行安全校验。
2.1.2 代码实现 新建配置类SecurityConfig
:
1 2 3 4 5 6 7 8 9 10 11 12 13 @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure (HttpSecurity http) throws Exception { http.authorizeRequests() .anyRequest() .authenticated() .and() .httpBasic(); } }
在SecurityConfig类上添加@EnableWebSecurity
注解后,会自动被Spring发现并注册。
注:http.authorizeRequests()
首先对url进行匹配,然后进行权限控制。支持链式调用。
url匹配规则
anyRequest()
:表示匹配所有的url请求
1 2 3 http.authorizeRequests() .anyRequest().authenticated();
antMatcher()
:传递一个ant表达式参数,表示匹配所有满足ant表达式的请求。
?
:匹配一个字符
*
:匹配0个或多个字符
**
:匹配0个或多个目录
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 http.authorizeRequests() .antMatchers("/showLogin" , "/errPage" ) .anonymous() .antMatchers( "/css/**" , "/js/**" , "/images/**" , "/fonts/**" , "/favicon.ico" ) .anonymous() .anyRequest() .authenticated();
权限控制方法
permitAll()
:表示所匹配的URL任何人都允许访问
anonymous()
:表示可以匿名访问匹配的URL。
denyAll()
:表示所匹配的URL都不允许被访问。
authenticated()
:表示所匹配的URL都需要被认证才能访问
rememberMe()
:允许通过remember-me登录的用户访问
hasRole()
:如果有参数,参数表示角色,则其角色可以访问
2.1.3 认证流程 当第一次调用接口时,查看响应头,可以看到WWW-Authenticate
认证信息:
1 WWW-Authenticate:Basic realm="Realm"
其中:
WWW-Authenticate: 表示服务器告知浏览器进行代理认证工作。
Basic: 表示认证类型为Basic认证。
realm=”Realm”: 表示认证域名为Realm域。
根据401和以上响应头信息,浏览器会弹出一个对话框,要求输入用户名/密码,Basic认证会将其拼接成 “用户名:密码” 格式,中间是一个冒号,并利用Base64编码成加密字符串xxx
;然后在请求头中附加Authorization: Basic xxx
,发送给后台认证;后台需要利用Base64来进行解码xxx
,得到用户名和密码,再校验 用户名:密码 信息。
如果认证错误,浏览器会保持弹框;
如果认证成功,浏览器会缓存有效的Base64编码,在之后的请求中,浏览器都会在请求头中添加该有效编码。
2.1.4 认证的销毁 在成功认证之后,Basic认证会把Authorization认证信息缓存在浏览器 中一段时间,之后每次请求接口时都会自动带上,所以直到 用户关闭浏览器才会销毁认证信息 ,也就是说我们无法在服务端进行有效的注销。
2.2.1 默认配置 在之前的章节中,创建的第一个Spring Security项目中实现的效果,其实就是表单认证。每次在访问某个Web接口之前,都会重定向到一个Security自带的login登录页面上,这个登录页面,就是表单认证的效果。
下图显示,hello
请求被重定向到login
:
在默认的表单认证配置中,自动配置了一些url和页面:
/login(get) : get请求时会跳转到这个页面,只要我们访问任意一个需要认证的请求时,都会跳转到这个登录界面。
/login(post) : post请求时会触发这个接口,在登录页面点击登录时,默认的登录页面表单中的action就是关联这个login接口。
/login?error : 当用户名或密码错误时,会跳转到该页面。
/: 登录成功后,默认跳转到该页面,如果配置了index.html页面,则 ”/“ 会重定向到index.html页面,当然这个页面要由我们自己实现。
/logout: 注销页面。
/login?logout: 注销成功后跳转到的页面。
2.2.2 自定义配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override public void configure (WebSecurity web) throws Exception { web.ignoring() .antMatchers("/js/**" , "/css/**" , "/images/**" ); } @Override protected void configure (HttpSecurity http) throws Exception { http.authorizeRequests() .anyRequest() .authenticated() .and() .formLogin() .loginPage("/myLogin.html" ) .permitAll() .and() .csrf() .disable(); } }
myLogin.html
页面,注意form表单中action的值,请暂时先写成”/myLogin.html“。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 <!DOCTYPE html > <html lang ="en" > <body > <div class ="login" > <h2 > Access Form</h2 > <div class ="login-top" > <h1 > 登录验证</h1 > <form action ="/myLogin.html" method ="post" > <input type ="text" name ="username" placeholder ="username" /> <input type ="password" name ="password" placeholder ="password" /> <div class ="forgot" > <a href ="#" > 忘记密码</a > <input type ="submit" value ="登录" > </div > </form > </div > <div class ="login-bottom" > <h3 > 新用户 <a href ="#" > 注 册</a > </h3 > </div > </div > </body > </html >
访问接口,被重定向到/myLogin.html
:
2.2.3 细化配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override public void configure (WebSecurity web) throws Exception { web.ignoring() .antMatchers("/js/**" , "/css/**" , "/images/**" ); } @Override protected void configure (HttpSecurity http) throws Exception { http.authorizeRequests() .anyRequest() .authenticated() .and() .formLogin() .loginPage("/myLogin.html" ) .permitAll() .defaultSuccessUrl("/index.html" ,true ) .loginProcessingUrl("/login" ) .failureUrl("/error.html" ) .usernameParameter("zym" ) .passwordParameter("zym123" ) .and() .logout() .logoutUrl("/logout" ) .logoutSuccessUrl("/myLogin.html" ) .permitAll() .deleteCookies("myCookie" ) .and() .csrf() .disable(); } }
2.3 HTTP摘要认证 暂略
2.4 前后端分离方案 2.4.1 概述 在之前的章节讲解表单认证时,处理登录成功时,跳转到某个页面的API是如下两个方法:
defaultSuccessUrl
successForwardUrl
处理登录失败时,跳转页面的API是如下两个方法:
failureUrl()
failureForwardUrl()
上面的方法,无论是认证成功还是认证失败,都是在前后端不分离时的处理方案,直接从Java后端跳转到某个页面上。在前后端分离模式下,既然后端没有页面,页面都在前端,那就可以考虑使用JSON来进行信息交互了,我们把认证成功或认证失败的信息,以JSON的格式传递给前端,由前端来决定到底该往哪个页面跳转就好了。
2.4.2 认证成功 ① API
successHandler()
:successHandler方法的参数是一个 AuthenticationSuccessHandler
对象,这个对象中我们要实现的方法是 onAuthenticationSuccess
。
onAuthenticationSuccess()
:方法中有三个参数,分别是:
HttpServletRequest
: 利用该参数我们可以实现服务端的跳转;
HttpServletResponse
: 利用该参数我们可以做客户端的跳转,也可以返回 JSON 数据;
Authentication
: 这个参数则保存了我们刚刚登录成功的用户信息。
② 实现
定义SecurityAuthenticationSuccessHandler类:该类需要实现SavedRequestAwareAuthenticationSuccessHandler接口。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public class SecurityAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { @Override public void onAuthenticationSuccess (HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException { Object principal = authentication.getPrincipal(); response.setContentType("application/json;charset=utf-8" ); PrintWriter out = response.getWriter(); out.write(new ObjectMapper ().writeValueAsString(principal)); out.flush(); out.close(); } }
配置类:在SecurityConfig配置类中,调用successHandler()方法,把前面定义的SecurityAuthenticationSuccessHandler类关联进来。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure (HttpSecurity http) throws Exception { http.authorizeRequests() .anyRequest() .authenticated() .and() .formLogin() .permitAll() .successHandler(new SecurityAuthenticationSuccessHandler ()) .and() .csrf() .disable(); } }
登录成功后,后端向前端发送json:
2.4.3 认证失败
定义类SecurityAuthenticationFailureHandler,实现ExceptionMappingAuthenticationFailureHandler接口,来专门处理认证失败时的返回结果。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public class SecurityAuthenticationFailureHandler extends ExceptionMappingAuthenticationFailureHandler { @Override public void onAuthenticationFailure (HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { response.setContentType("application/json;charset=utf-8" ); PrintWriter out = response.getWriter(); out.write(exception.getMessage()); out.flush(); out.close(); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure (HttpSecurity http) throws Exception { http.authorizeRequests() .anyRequest() .authenticated() .and() .formLogin() .permitAll() .successHandler(new SecurityAuthenticationSuccessHandler ()) .failureHandler(new SecurityAuthenticationFailureHandler ()) .and() .csrf() .disable(); } }
登录失败:
2.4.4 退出登录
定义一个SecurityLogoutSuccessHandler类,实现LogoutSuccessHandler
接口,在这里负责输出退出登录时的JSON结果。
1 2 3 4 5 6 7 8 9 10 public class SecurityLogoutSuccessHandler implements LogoutSuccessHandler { @Override public void onLogoutSuccess (HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { response.setContentType("application/json;charset=utf-8" ); PrintWriter out = response.getWriter(); out.write("注销成功" ); out.flush(); out.close(); } }
配置类:调用logoutSuccessHandler()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @Override protected void configure (HttpSecurity http) throws Exception { http.authorizeRequests() .anyRequest() .authenticated() .and() .formLogin() .permitAll() .successHandler(new SecurityAuthenticationSuccessHandler ()) .failureHandler(new SecurityAuthenticationFailureHandler ()) .and() .logout() .logoutSuccessHandler(new SecurityLogoutSuccessHandler ()) .and() .csrf() .disable(); }
2.4.5 未认证 如果用户没有登录,就访问一个需要认证后才能访问的页面。这个时候,我们不应该让用户重定向到登录页面,而是给用户一个尚未登录的提示,前端收到提示之后,再自行决定页面跳转。因为在前后端分离时,后端没有页面,未认证时也没办法直接重定向到登录页面。
定义一个SecurityAuthenticationEntryPoint类,实现AuthenticationEntryPoint
接口,在这里负责输出未认证时的JSON结果。
1 2 3 4 5 6 7 8 9 10 public class SecurityAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence (HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { response.setContentType("application/json;charset=utf-8" ); PrintWriter out = response.getWriter(); out.write("尚未登录,请先登录" ); out.flush(); out.close(); } }
配置类:调用authenticationEntryPoint()
方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @Override protected void configure (HttpSecurity http) throws Exception { http.authorizeRequests() .anyRequest() .authenticated() .and() .formLogin() .permitAll() .successHandler(new SecurityAuthenticationSuccessHandler ()) .failureHandler(new SecurityAuthenticationFailureHandler ()) .and() .logout() .logoutSuccessHandler(new SecurityLogoutSuccessHandler ()) .and() .csrf() .disable() .exceptionHandling() .authenticationEntryPoint(new SecurityAuthenticationEntryPoint ()); }
3 授权 所谓授权,比如说某个用户想要访问某个资源(接口、页面、功能等),我们应该先去检查该用户是否具备对应的权限,如果具备就允许访问,如果不具备,则不允许访问。
在Spring Security中,授权粒度有如下几种:
支持基于 URL 的请求授权
基于方法访问的授权
基于对象访问的授权
授权实现方式:
基于内存模型实现授权
基于默认数据库模型实现授权
基于自定义数据库模型实现授权
3.1 基于内存模型 略
3.2 基于默认数据库模型 略
3.3 基于自定义数据库模型 3.3.1 接口 Spring Security 支持MySQL、Oracle等多种不同的数据源,这些不同的数据源最终都由 UserDetailsService
这个接口的子类来负责进行操作。
Spring Security还提供了另一个UserDetailsService
的实现子类,也就是JdbcUserDetailsManager
。利用JdbcUserDetailsManager可以帮助我们以JDBC的方式对接数据库进行增删改查等操作。它内部设定了一个默认的数据库模型,只要遵从这个模型,我们就可以很方便的实现在数据库中创建用户名和密码、角色等信息,但是灵活性不足。
1 2 3 public interface UserDetailsService { UserDetails loadUserByUsername (String username) throws UsernameNotFoundException; }
从源码中可以看出,可以利用loadUserByUsername()方法,根据用户名查询出对应的UserDetails信息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public interface UserDetails extends Serializable { Collection<? extends GrantedAuthority > getAuthorities(); String getPassword () ; String getUsername () ; boolean isAccountNonExpired () ; boolean isAccountNonLocked () ; boolean isCredentialsNonExpired () ; boolean isEnabled () ; }
从UserDetails的源码中我们了解到,UserDetails其实就是一个包含了User信息的类,其中包含了用户名、密码、角色及账号状态等信息。
3.3.2 代码实现 ① 测试接口 1 2 3 4 5 6 7 8 @RestController @RequestMapping("/admin") public class AdminController { @GetMapping("/hello") public String hello () { return "hello, admin" ; } }
1 2 3 4 5 6 7 8 @RestController @RequestMapping("/user") public class UserController { @GetMapping("/hello") public String hello () { return "hello, user" ; } }
1 2 3 4 5 6 7 8 @RestController @RequestMapping("/visitor") public class VisitorController { @GetMapping("/hello") public String hello () { return "hello, visitor" ; } }
授权规则:
/visitor/hello 任何人都可以访问;
/admin/hello 具有 admin 角色的人才能访问;
/user/hello 具有 user 角色的人才能访问;
所有 user 角色能够访问的接口资源,admin 角色也都能够访问。
② 准备数据库 1 2 3 4 5 6 7 8 CREATE TABLE users (id bigint(20) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL, password varchar(60) NOT NULL, enable tinyint(4) NOT NULL DEFAULT 1, roles text character set utf8, PRIMARY KEY (id), KEY username (username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin;
③ User实体类 创建一个User实体类,这个用户实体类需要实现 UserDetails 接口,并实现接口中的方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 @Data public class User implements UserDetails { private Long id; private String username; private String password; private String roles; private boolean enable; private List<GrantedAuthority> authorities; @Override public Collection<? extends GrantedAuthority > getAuthorities() { return this .authorities; } @Override public String getPassword () { return this .password; } @Override public String getUsername () { return this .username; } @Override public boolean isAccountNonExpired () { return true ; } @Override public boolean isAccountNonLocked () { return true ; } @Override public boolean isCredentialsNonExpired () { return true ; } @Override public boolean isEnabled () { return true ; } }
accountNonExpired、accountNonLocked、credentialsNonExpired、enabled 这四个属性分别用来描述用户的状态,表示账户是否没有过期、账户是否没有被锁定、密码是否没有过期、以及账户是否可用;
roles 属性表示用户的角色;
getAuthorities 方法返回用户的角色信息,一个用户可能会有多个角色,所以这里返回值是一个集合类型,我们在这个方法中把自己的 Role 角色稍微转化一下即可。
④ Mapper接口 1 2 3 4 5 @Repository public interface UserMapper { @Select("SELECT * FROM users WHERE username=#{username}") User findByUserName (@Param("username") String username) ; }
⑤ 实现UserDetailsService接口 在service层,定义一个UserDetailsService子类,实现UserDetailsService接口,然后实现该接口中的loadUserByUsername()方法。这个方法的参数是用户在登录的时候传入的用户名,然后根据用户名去查询用户信息(查出来之后,系统会自动进行密码比对)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @Service public class MyUserDetailsService implements UserDetailsService { @Resource private UserMapper userMapper; @Override public UserDetails loadUserByUsername (String username) throws UsernameNotFoundException { User user = userMapper.findByUserName(username); if (user == null ) { throw new UsernameNotFoundException ("用户不存在" ); } user.setAuthorities(AuthorityUtils.commaSeparatedStringToAuthorityList(user.getRoles())); return user; } }
⑥ 配置类 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Resource private MyUserDetailsService userDetailsService; @Override protected void configure (HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/admin/**" ) .hasRole("ADMIN" ) .antMatchers("/user/**" ) .hasRole("USER" ) .antMatchers("/visitor/**" ) .permitAll() .anyRequest() .authenticated() .and() .formLogin() .permitAll(); } @Override protected void configure (AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService) .passwordEncoder(NoOpPasswordEncoder.getInstance()); } }
4 验证码 暂略
5 自动登录和注销 5.1 实现方案 自动登录是将用户的登录信息保存在用户浏览器的cookie中,当用户下次访问时,自动实现校验并建立登录状态的一种机制 。
所以基于上面的原理,Spring Security 就为我们提供了两种比较好的实现自动登录的方案:
基于散列加密算法机制 :加密用户必要的登录信息,并生成令牌来实现自动登录,利用TokenBasedRememberMeServices 类来实现。
基于数据库等持久化数据存储机制 :生成持久化令牌来实现自动登录,利用PersistentTokenBasedRememberMeServices 来实现。
5.2 基于散列加密算法 5.2.1 代码实现 ① 配置加密令牌key 创建一个application.yml文件,在其中添加数据库配置,以及一个用来加密令牌的key字符串,字符串的值随便自定义就行 。
1 2 3 4 5 6 7 8 spring: datasource: url: jdbc:mysql://localhost:3306/db-security?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=GMT username: XXX password: XXX security: remember-me: key: zym
② 配置类 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Value("${spring.security.remember-me.key}") private String rememberKey; @Override protected void configure (HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/admin/**" ) .hasRole("ADMIN" ) .antMatchers("/user/**" ) .hasRole("USER" ) .antMatchers("/visitor/**" ) .permitAll() .anyRequest() .authenticated() .and() .formLogin() .permitAll() .successHandler(new SecurityAuthenticationSuccessHandler ()) .failureHandler(new SecurityAuthenticationFailureHandler ()) .and() .rememberMe() .userDetailsService(userDetailsService) .key(rememberKey) .and() .csrf() .disable(); } @Resource private MyUserDetailsService userDetailsService; @Override protected void configure (AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService) .passwordEncoder(NoOpPasswordEncoder.getInstance()); } }
请求结果:
5.2.2 cookies加密原理 首次登录时:
1 2 hashInfo=md5Hex(username +":" +expirationTime +":" password+":" +key)
将该hashInfo
作为cookies存储在浏览器。
再次登录时:
1 2 rememberCookie=base64(username+":" +expirationrime+":" +hashInfo)
步骤:
首先后端根据登陆成功的用户的用户名,密码和过期时间等信息散列生成cookie,发回并保存在浏览器中;
在浏览器关闭并重新打开之后,用户再去访问 /user/hello
接口时,此时浏览器就会携带remember-me这个cookie到服务端;
服务器端拿到cookie之后,利用Base64进行解码,计算出用户名和过期时间,再根据用户名查询到用户密码;
最后还要通过 MD5 散列函数计算出散列值,并将计算出的散列值和浏览器传递来的散列值进行对比,以此确认这个令牌是否有效。
5.2.3 自动登录原理 ① 令牌生成 令牌生成的核心处理方法定义在:TokenBasedRememberMeServices#onLoginSuccess
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 @Override public void onLoginSuccess (HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) { String username = retrieveUserName(successfulAuthentication); String password = retrievePassword(successfulAuthentication); ...... if (!StringUtils.hasLength(password)) { UserDetails user = getUserDetailsService().loadUserByUsername(username); password = user.getPassword(); } int tokenLifetime = calculateLoginLifetime(request, successfulAuthentication); long expiryTime = System.currentTimeMillis(); expiryTime += 1000L * (tokenLifetime < 0 ? TWO_WEEKS_S : tokenLifetime); String signatureValue = makeTokenSignature(expiryTime, username, password); setCookie(new String [] { username, Long.toString(expiryTime), signatureValue }, tokenLifetime, request, response); } protected String makeTokenSignature (long tokenExpiryTime, String username, String password) { String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey(); MessageDigest digest; digest = MessageDigest.getInstance("MD5" ); return new String (Hex.encode(digest.digest(data.getBytes()))); }
首先从登录成功的 Authentication 对象中提取出用户名/密码;
由于登录成功之后,密码可能被擦除了,所以如果一开始没有拿到密码,就再从 UserDetailsService 中重新加载用户并重新获取密码;
接下来获取令牌的有效期,令牌有效期默认是两周;
再接下来调用 makeTokenSignature()方法 去计算散列值,实际上就是根据 username、令牌有效期以及 password、key 一起计算一个散列值。如果我们没有自己去设置这个 key,默认是在 RememberMeConfigurer#getKey 方法中进行设置的,它的值是一个 UUID 字符串。但是如果服务端重启,这个默认的 key 是会变的,这样就导致之前派发出去的所有 remember-me 自动登录令牌失效,所以我们可以指定这个 key。
最后,将用户名、令牌有效期以及计算得到的散列值放入 Cookie 中并随response返回。
② 令牌解析 核心流程就是首先获取用户名和过期时间,再根据用户名查询到用户密码,然后通过 MD5 散列函数计算出散列值。最后再将拿到的散列值和浏览器传递来的散列值进行对比,就能确认这个令牌是否有效,进而确认登录是否有效。
5.3 基于持久化令牌 在持久化令牌方案中,最核心的是series和token两个值,这两个值都是用MD5散列计算生成的随机字符串。不同的是,series仅在用户使用密码重新登录时更新,而 token 会在每一个新的session会话中都重新生成 。
持久化令牌方案 避免了散列加密方案中,一个令牌可以同时在多端登录的问题 ,这是因为每个session会话都会引发token的更新,即每个token仅支持单实例登录 。
5.3.1 代码实现 ① 数据表 创建一张persistent_logins表,用来存储我们自动登录时生成的持久化令牌信息,该表SQL脚本如下:
1 create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, token varchar(64) not null, last_used timestamp not null)
在该表中,series是主键,可以根据series进行令牌信息的查询等操作。
② 配置类 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Value("${spring.security.remember-me.key}") private String rememberKey; @Autowired private DataSource dataSource; @Override protected void configure (HttpSecurity http) throws Exception { JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl (); tokenRepository.setDataSource(dataSource); http.authorizeRequests() .antMatchers("/admin/**" ) .hasRole("ADMIN" ) .antMatchers("/user/**" ) .hasRole("USER" ) .antMatchers("/visitor/**" ) .permitAll() .anyRequest() .authenticated() .and() .formLogin() .permitAll() .and() .rememberMe() .userDetailsService(userDetailsService) .key(rememberKey) .tokenRepository(tokenRepository) .tokenValiditySeconds(60 * 60 * 24 * 7 ) .and() .csrf() .disable(); } @Resource private MyUserDetailsService userDetailsService; @Override protected void configure (AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService) .passwordEncoder(NoOpPasswordEncoder.getInstance()); } }
这时候,我们只需要在登录页面中输入 用户名和密码,勾选“记住我”功能之后,Spring Security就会生成一个持久化令牌,在这个令牌中就保存了当前登陆的用户信息,该令牌信息会被自动持久化存储到persistent_logins表中。
5.3.2 两种方式的对比 散列加密方案和持久化令牌方案,这两种方案都是把信息存储在cookie中,所以都有被盗取用户身份信息的可能性,当然持久化令牌方案的安全性更高一些 。但是如果要你追求最安全的方式,那就尽量不要实现自动登录功能,所以我们要在用户体验和提高安全性之间选择平衡点。
如果我们一定要实现自动登录功能,*可以限制以cookie身份登录时的部分执行权限 ***,比如在修改密码、修改邮箱(防止找回密码)、查看隐私信息(如完整的手机号码、银行卡号等)时,我们可以进一步校验用户的登录密码,或者设置独立密码来做二次校验,以提高安全性。
5.4 注销
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 http.authorizeRequests() .antMatchers("/admin/**" ) .hasRole("ADMIN" ) .antMatchers("/user/**" ) .hasRole("USER" ) .antMatchers("/app/**" ) .permitAll() .anyRequest() .authenticated() .and() .formLogin() .permitAll() .and() .rememberMe() .userDetailsService(userDetailsService) .key(rememberKey) .tokenRepository(tokenRepository) .tokenValiditySeconds(60 * 60 * 24 * 7 ) .and() .logout() .logoutUrl("/user/logout" ) .logoutSuccessUrl("/login" ) .invalidateHttpSession(true ) .clearAuthentication(true ) .deleteCookies("cookie01" ,"cookie02" ) .and() .csrf() .disable();
如果我们想自己编写退出登录时的业务逻辑,也可以在UserController中定义一个“/logout”接口,处理退出登录功能。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @RestController @RequestMapping("/user") public class UserController { @GetMapping("hello") public String hello () { return "hello, user" ; } @RequestMapping("/logout") public void logout (HttpSession session) { session.invalidate(); System.out.println("logout执行了..." ); } }
6 密码加密 6.1 简介 在一般的项目里,常用的就是信息摘要算法,也可以被称为散列加密函数,或者称为散列算法、哈希函数 。这是一种可以从任何数据中创建数字“指纹”的方法,常用的散列函数有 MD5 消息摘要算法、安全散列算法(Secure Hash Algorithm)等 。
Spring Security提供了多种密码加密算法,但官方推荐使用的是BCryptPasswordEncoder 方案。
6.2 代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 @RestController @RequestMapping("/user") public class UserController { @Resource private PasswordEncoder passwordEncoder; @Resource private UserService userService; @GetMapping("/hello") public String hello () { return "hello, user" ; } @GetMapping("/register") public User registerUser (@RequestBody User user) { user.setEnable(true ); user.setRoles("USER" ); user.setPassword(passwordEncoder.encode(user.getPassword())); userService.save(user); return user; } }
配置类:放行/user/register
接口,注入PasswordEncoder
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Value("${spring.security.remember-me.key}") private String rememberKey; @Autowired private DataSource dataSource; @Override protected void configure (HttpSecurity http) throws Exception { JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl (); tokenRepository.setDataSource(dataSource); http.authorizeRequests() .antMatchers("/admin/**" ) .hasRole("ADMIN" ) .antMatchers("/user/register" ) .permitAll() .antMatchers("/user/**" ) .hasRole("USER" ) .antMatchers("/visitor/**" ) .permitAll() .anyRequest() .authenticated() .and() .formLogin() .permitAll() .and() .rememberMe() .userDetailsService(userDetailsService) .key(rememberKey) .tokenRepository(tokenRepository) .tokenValiditySeconds(60 * 60 * 24 * 7 ) .and() .csrf() .disable(); } @Resource private MyUserDetailsService userDetailsService; @Bean public PasswordEncoder passwordEncoder () { return new BCryptPasswordEncoder (); } @Override protected void configure (AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder()); } }
测试接口:
7 权限控制 在Spring Security 中,对接口的拦截或放行,有四种常见的权限控制方式:
利用Ant表达式实现权限控制;(已学)
利用授权注解结合SpEl表达式实现权限控制;
利用过滤器注解实现权限控制;
利用动态权限实现权限控制。
7.1 授权注解结合SpEl表达式 常用的授权注解有3个:
@PreAuthorize
:方法执行前进行权限检查;
@PostAuthorize
:方法执行后进行权限检查;
@Secured
:类似于 @PreAuthorize。
代码实现
首先需要利用@EnableGlobalMethodSecurity注解开启授权注解功能 ,代码如下:
1 2 3 4 5 @EnableGlobalMethodSecurity(prePostEnabled = true,securedEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter { }
然后在具体的接口方法上利用授权注解进行权限控制,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 @RestController public class UserController { @Secured({"ROLE_USER"}) @GetMapping("/user/hello") public String helloUser () { return "hello, user" ; } @PreAuthorize("hasRole('ADMIN')") @GetMapping("/admin/hello") public String helloAdmin () { return "hello, admin" ; } @PreAuthorize("#age>100") @GetMapping("/age") public String getAge (@RequestParam("age") Integer age) { return String.valueOf(age); } @GetMapping("/visitor/hello") public String helloVisitor () { return "hello, visitor" ; } }
7.2 过滤器注解 在Spring Security中还提供了另外的两个注解,即**@PreFilter和@PostFilter, 这两个注解可以对集合类型的参数或返回值进行过滤**。使用@PreFilter和@PostFilter时,Spring Security将移除对应表达式结果为false的元素。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @Slf4j @RestController public class FilterController { @PostFilter("filterObject.id%2==0") @GetMapping("/users") public List<User> getAllUser () { List<User> users = new ArrayList <>(); for (int i = 0 ; i < 10 ; i++) { users.add(new User (i, "zym-" + i)); } return users; } }
其余略
8 跨域问题 8.1 简介 在同源策略中,域名、协议、端口都要相同。
在前后端分离的项目中,需要解决跨域问题,常用的手段:
Java后端进行跨域解决CORS
使用AJAX的JSONP
使用jQuery的JSONP插件
document.domain + iframe 跨域解决方案
window.name + iframe 跨域解决方案
location.hash + iframe 跨域解决方案
postMessage跨域解决方案
WebSocket协议跨域解决方案
node代理跨域解决方案
nginx代理跨域解决方案
8.2 代码实现 8.2.1 未引入ss的跨域解决方案 在SpringBoot环境中,创建一个端口号为8080的web项目,注意这个web项目没有引入Spring Security的依赖包。然后在其中创建一个IndexController,定义两个测试接口以便被ajax进行跨域访问。
1 2 3 4 5 6 7 8 9 10 11 12 13 @RestController public class IndexController { @GetMapping("/hello") public String hello () { return "get hello" ; } @PostMapping("/hello") public String hello2 () { return "post hello" ; } }
定义一个index.html
页面,利用axios跨域访问8080项目中的web接口。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <title > Document</title > <script src ="https://cdn.bootcdn.net/ajax/libs/axios/0.26.0/axios.js" > </script > <link href ="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/4.6.1/css/bootstrap.min.css" rel ="stylesheet" > </head > <body > <div class ="container" > <button class ="btn btn-primary" > 发送GET请求</button > <button class ="btn btn-warning" > 发送POST请求</button > </div > <script > const btns = document .querySelectorAll ("button" ); btns[0 ].onclick = function ( ) { axios ({ method : "GET" , url : "http://localhost:8080/hello" }).then (response => { console .log (response); }); }; btns[1 ].onclick = function ( ) { axios ({ method : "POST" , url : "http://localhost:8080/hello" }).then (response => { console .log (response); }); }; </script > </body > </html >
然后分别执行get与post请求,这时候就可以在浏览器的控制台上看到产生了CORS跨域问题,出现了CORS error
状态,在请求头中出现了Referer Policy: strict-origin-when-cross-origin
。
解决
方式1:在接口方法上利用@CrossOrigin注解解决跨域问题
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @RestController public class IndexController { @CrossOrigin(value = "http://127.0.0.1:5500/") @GetMapping("/hello") public String hello () { return "get hello" ; } @CrossOrigin(value = "http://127.0.0.1:5500/") @PostMapping("/hello") public String hello2 () { return "post hello" ; } }
方式2:通过实现WebMvcConfigurer接口来解决跨域问题
1 2 3 4 5 6 7 8 9 10 11 12 @Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addCorsMappings (CorsRegistry registry) { registry.addMapping("/**" ) .allowedOrigins("http://127.0.0.1:5500/" ) .allowedMethods("*" ) .allowedHeaders("*" ); } }
然后成功请求:
8.2.2 引入ss的跨域解决方案 为了提高网站的安全性,在上面Spring Boot项目的基础之上,添加Spring Security的依赖包,但是暂时不进行任何别的操作。
接着重启8080这个Spring Boot项目,然后在前端中再次进行跨域请求,我们会发现在引入Spring Security后,再次产生了跨域问题。
通过实验可知,如果使用了 Spring Security,上面的跨域配置会失效,因为请求会被 Spring Security 拦截。那么在Spring Security环境中,如何解决跨域问题呢?这里我们有3种方式可以开启 Spring Security 对跨域的支持。
① 开启cors方法 在配置类中开启cors方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure (HttpSecurity http) throws Exception { http.authorizeRequests() .anyRequest() .permitAll() .and() .formLogin() .permitAll() .and() .httpBasic() .and() .cors() .and() .csrf() .disable(); } }
② 进行全局配置 第二种方式是去除上面的跨域配置,直接在 Spring Security 中做全局配置,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure (HttpSecurity http) throws Exception { http.authorizeRequests() .anyRequest() .permitAll() .and() .formLogin() .permitAll() .and() .httpBasic() .and() .cors() .configurationSource(corsConfigurationSource()) .and() .csrf() .disable(); } @Bean public CorsConfigurationSource corsConfigurationSource () { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource (); CorsConfiguration configuration = new CorsConfiguration (); configuration.setAllowCredentials(true ); configuration.addAllowedOriginPattern("*" ); configuration.setAllowedMethods(Collections.singletonList("*" )); configuration.setAllowedHeaders(Collections.singletonList("*" )); configuration.setMaxAge(Duration.ofHours(1 )); source.registerCorsConfiguration("/**" , configuration); return source; } }
9 源码分析 9.1 整体流程 用户提交用户名和密码后:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 sequenceDiagram participant UPAF as UsernamePassword<br/>AuthenticationFilter participant AM as Authentication<br/>Manager participant DAP as DaoAuthentication<br/>Provider participant UDS as UserDetails<br/>Service participant SCH as SecurityContext<br/>Holder UPAF->>UPAF: 1.将请求信息封装为Authentication,<br/>实现类为UsernamePasswordAuthenticationToken UPAF->>AM: 2.认证authenticate() AM->>DAP: 3.委托认证authenticate() DAP->>UDS: 4.获取用户信息<br/>loadUserByUsername() UDS->>DAP: 5.返回UserDetails DAP->>DAP: 6.通过passwordEncoder对比UserDetails中的密码与Authentication<br/>中的密码一致 DAP->>DAP: 7.填充Authentication,如权限信息 DAP->>UPAF: 8.返回Authentication UPAF->>SCH: 9.通过SecurityContextHolder.getContext().setAuthentication()方法将Authentication保存至安全上下文
9.2 基础API Authentication,SecurityContext,SecurityContextHolder。三者关系:
SecurityContextHolder 用来保存 SecurityContext,利用SecurityContextHolder的getContext()方法可以得到SecurityContext对象;然后再通过调用 SecurityContext 对象中的getAuthentication()方法,就可以获取 Authentication 对象;最后我们利用Authentication对象,可以进一步获取已认证用户的详细信息。
9.2.1 Authentication接口 Authentication 是 spring-security-core 核心包中的接口,直接继承自 Principal 接口 ,而 Principal 是位于 java.security 包中,由此可知 Authentication 是 Spring Security 中的核心接口。Authentication接口中封装了用户信息,它有个很重要的子类UsernamePasswordAuthenticationToken 。
1 2 3 4 5 6 7 8 9 10 11 12 13 public interface Authentication extends Principal , Serializable { Collection<? extends GrantedAuthority > getAuthorities(); Object getCredentials () ; Object getDetails () ; Object getPrincipal () ; boolean isAuthenticated () ; void setAuthenticated (boolean isAuthenticated) throws IllegalArgumentException; }
作用:在用户登录认证之前,用户名密码等信息 会被封装为一个 Authentication 的具体实现类对象。在登录认证成功之后又会生成一个信息更全面的Authentication对象,从该对象中可以得到用户的权限信息列表、密码、用户细节信息、用户身份信息、认证信息等 。这个 Authentication对象会被保存在 SecurityContextHolder 所持有的 SecurityContext 中 ,供后续的程序进行调用,如访问权限的鉴定等。
9.2.2 SecurityContext接口 安全上下文,该类用于存储认证授权的相关信息,实际上就是存储 “当前用户 “ 的账号信息和相关权限,即代表当前用户信息的 Authentication 对象会被 SecurityContext 所持有(引用),SecurityContext上下文对象则被SecurityContextHolder 所持有(引用)。
1 2 3 4 5 public interface SecurityContext extends Serializable { Authentication getAuthentication () ; void setAuthentication (Authentication authentication) ; }
9.2.3 SecurityContextHolder类 ① 线程安全性 由于一个请求从开始到结束都由一个线程处理,这个线程中途不会去处理其他的请求,所以在这段时间内,这个线程就相当于跟当前用户是一一对应的。SecurityContextHolder类就是用于把SecurityContext存储在当前线程中。
在Web环境下,SecurityContextHolder是利用ThreadLocal 来存储SecurityContext对象的。所以SecurityContextHolder可以用来设置和获取SecurityContext对象,该类主要是给框架内部使用,我们可以利用它获取当前用户的SecurityContext对象,进而进行请求检查,和访问控制等。
因为Sevlet中的线程都是被池化复用的,一旦处理完当前的请求,这个线程可能马上就会被分配去处理其他的请求,而且也不能保证用户下次的请求会被分配到与上次相同的线程。因此在每一次请求结束后都会自动清除当前线程的ThreadLocal对象。
② 存储策略 SecurityContextHolder可以通过设置来调整3种存储策略,三种策略详情如下:
MODE_THREADLOCAL :表示将 SecurityContext对象 存储在当前线程中,(默认);
MODE_INHERITABLETHREADLOCAL :表示将 SecurityContext对象 存储在线程中,但子线程可以获取到父线程中的 SecurityContext;
MODE_GLOBAL :表示 SecurityContext对象内容 在所有线程中都相同。
9.3 Filter API 9.3.1 内置过滤器 Spring Security内置的过滤器:
默认加载Filter
过滤器作用
WebAsyncManagerIntegrationFilter
将 WebAsyncManager 与SpringSecurity上下文集成
SecurityContextPersistenceFilter
在处理请求之前,将安全信息加载到SecurityContextHolder中
HeaderWriterFilter
处理头信息加入响应中
CsrfFilter
处理CSRF攻击
LogoutFilter
处理注销登录
UsernamePasswordAuthenticationFilter
处理表单认证
DefaultLoginPageGeneratingFilter
配置默认登录页面
DefaultLogoutPageGeneratingFilter
配置默认注销页面
BasicAuthenticationFilter
处理 HttpBasic 登录
RequestCacheAwareFilter
处理请求缓存
SecurityContextHolderAwareRequestFilter
包装原始请求
AnoymousAuthenticationFilter
配置匿名认证
SessionManagementFilter
处理Session并发问题
ExceptionTraslationFilter
处理认证/授权中的异常
9.3.2 自定义过滤器 添加过滤器的方法(都是HttpSecurity中的),第一个参数都是要加入的过滤器,第二个参数是针对已存在过滤器的Class对象:
addFilterAt(filter,atFilter)
:这个相当于线性表的插入操作,把 filter 插入在 atFilter 的位置上。
addFilterBefore(filter,atFilter)
:这个是在 atFilter 前插入一个 filter 过滤器;
addFilterAfter(filter,atFilter)
:这个顾名思义是在 atFilter 后插入一个 filter 过滤器;
可以继承Spring Security中内置的过滤器以进行重写逻辑,另一方面可以继承Spring框架的过滤器。
例如:OncePerRequestFilter
是Spring框架提供的一个过滤器,确保在一次HTTP请求期间只执行一次特定的过滤器逻辑。它继承了GenericFilterBean类,并实现了javax.servlet.Filter接口。
1 2 3 4 5 6 7 8 9 10 11 12 13 @Component public class MyFilter extends OncePerRequestFilter { @Override protected void doFilterInternal (HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException { System.out.println("执行过滤逻辑" ); filterChain.doFilter(httpServletRequest, httpServletResponse); } }
注册:
1 2 3 4 5 6 7 8 9 10 11 12 13 @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter { @Resource private MyFilter myFilter; @Override protected void configure (HttpSecurity httpSecurity) throws Exception { httpSecurity.addFilterBefore(myFilter, UsernamePasswordAuthenticationFilter.class); } }
9.4 认证管理API 9.4.1 AuthenticationManager接口 AuthenticationManager的作用是校验Authentication,如果验证失败会抛出AuthenticationException异常 。
1 2 3 public interface AuthenticationManager { Authentication authenticate (Authentication authentication) throws AuthenticationException; }
9.4.2 ProviderManager类 Spring Security 中 AuthenticationManager 接口的默认实现类 是 ProviderManager,但它本身并不直接处理身份认证请求,它会委托给内部配置的 AuthenticationProvider
(一个接口)列表providers
。该列表会进行循环遍历,依次对比匹配以查看它是否可以执行身份验证,每个 Provider 验证程序在验证后,将会抛出异常或返回一个完全填充的 Authentication 对象。源码如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 public class ProviderManager implements AuthenticationManager , MessageSourceAware,InitializingBean { private List<AuthenticationProvider> providers = Collections.emptyList(); public Authentication authenticate (Authentication authentication) throws AuthenticationException { Class<? extends Authentication > toTest = authentication.getClass(); AuthenticationException lastException = null ; AuthenticationException parentException = null ; Authentication result = null ; Authentication parentResult = null ; boolean debug = logger.isDebugEnabled(); for (AuthenticationProvider provider : getProviders()) { if (!provider.supports(toTest)) { continue ; } try { result = provider.authenticate(authentication); if (result != null ) { copyDetails(authentication, result); break ; } } catch (AccountStatusException | InternalAuthenticationServiceException e) { prepareException(e, authentication); throw e; } catch (AuthenticationException e) { lastException = e; } } if (result == null && parent != null ) { try { result = parentResult = parent.authenticate(authentication); } catch (ProviderNotFoundException e) { } catch (AuthenticationException e) { lastException = parentException = e; } } if (result != null ) { if (eraseCredentialsAfterAuthentication && (result instanceof CredentialsContainer)) { ((CredentialsContainer) result).eraseCredentials(); } if (parentResult == null ) { eventPublisher.publishAuthenticationSuccess(result); } return result; } if (lastException == null ) { lastException = new ProviderNotFoundException (messages.getMessage( "ProviderManager.providerNotFound" , new Object [] { toTest.getName() }, "No AuthenticationProvider found for {0}" )); } if (parentException == null ) { prepareException(lastException, authentication); } throw lastException; } }
9.5 认证实现API 9.5.1 AuthenticationProvider接口 AuthenticationManager
负责管理协调认证工作的,但并不负责认证功能的真正实现,认证功能的真正实现是由 AuthenticationManager 中引用的 AuthenticationProvider 类来完成的,通过源码我们可以看到AuthenticationManager 中引用了一个providers列表。
1 2 private List<AuthenticationProvider> providers = Collections.emptyList();
AuthenticationProvider
接口有多个实现子类。
9.5.2 DaoAuthenticationProvider类 DaoAuthenticationProvider是AuthenticationProvider接口的实现类,Spring Security中默认就是使用DaoAuthenticationProvider来实现基于数据库模型认证授权工作。
DaoAuthenticationProvider 在进行认证的时候,需要调用 UserDetailsService 对象的loadUserByUsername() 方法来获取用户信息 UserDetails,其中包括用户名、密码和所拥有的权限等。
authenticate()
认证方法源码剖析
在AuthenticationProvider接口中有个authenticate()方法,但是该方法并没有默认实现。
1 2 3 4 5 public interface AuthenticationProvider { Authentication authenticate (Authentication authentication) throws AuthenticationException; boolean supports (Class<?> authentication) ; }
这个方法是在AuthenticationProvider的子类AbstractUserDetailsAuthenticationProvider
中实现的,实现源码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 protected abstract UserDetails retrieveUser (String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException;public Authentication authenticate (Authentication authentication) throws AuthenticationException { Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication, () -> messages.getMessage( "AbstractUserDetailsAuthenticationProvider.onlySupports" , "Only UsernamePasswordAuthenticationToken is supported" )); String username = (authentication.getPrincipal() == null ) ? "NONE_PROVIDED" : authentication.getName(); boolean cacheWasUsed = true ; UserDetails user = this .userCache.getUserFromCache(username); if (user == null ) { cacheWasUsed = false ; try { user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); } catch (UsernameNotFoundException notFound) { logger.debug("User '" + username + "' not found" ); if (hideUserNotFoundExceptions) { throw new BadCredentialsException (messages.getMessage( "AbstractUserDetailsAuthenticationProvider.badCredentials" , "Bad credentials" )); } else { throw notFound; } } Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract" ); } try { preAuthenticationChecks.check(user); additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication); } catch (AuthenticationException exception) { if (cacheWasUsed) { cacheWasUsed = false ; user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); preAuthenticationChecks.check(user); additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication); } else { throw exception; } } postAuthenticationChecks.check(user); if (!cacheWasUsed) { this .userCache.putUserInCache(user); } Object principalToReturn = user; if (forcePrincipalAsString) { principalToReturn = user.getUsername(); } return createSuccessAuthentication(principalToReturn, authentication, user); }
retrieveUser
抽象方法由DaoAuthenticationProvider来具体实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 protected final UserDetails retrieveUser (String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { prepareTimingAttackProtection(); try { UserDetails loadedUser = this .getUserDetailsService().loadUserByUsername(username); if (loadedUser == null ) { throw new InternalAuthenticationServiceException ( "UserDetailsService returned null, which is an interface contract violation" ); } return loadedUser; }catch (UsernameNotFoundException ex) { mitigateAgainstTimingAttack(authentication); throw ex; }catch (InternalAuthenticationServiceException ex) { throw ex; }catch (Exception ex) { throw new InternalAuthenticationServiceException (ex.getMessage(), ex); } }
9.5.3 authenticate流程
在ProviderManager类中遍历List<AuthenticationProvider> providers集合,判断AuthenticationProvider类是否支持对当前对象进行认证;
如果支持,则调用AuthenticationProvider接口对象的authenticate(authentication)方法进行认证;
结合当前具体的认证实现模型,如果是基于数据库模型来进行表单认证,则执行AbstractUserDetailsAuthenticationProvider类中的authenticate(authentication)方法具体进行认证;
AbstractUserDetailsAuthenticationProvider类中的authenticate(authentication)方法内部会调用retrieveUser()抽象方法加载对象;
在DaoAuthenticationProvider类中具体执行retrieveUser()方法,在该方法中调用UserDetailsService对象的loadUserByUsername(username)方法,从而实现根据用户名从数据库中查询用户信息;
9.6 实体接口API 9.6.1 UserDetails接口 UserDetails 是 Spring Security 中的核心接口,它表示用户的详细信息,这个接口涵盖了一些必要的用户信息字段。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public interface UserDetails extends Serializable { Collection<? extends GrantedAuthority > getAuthorities(); String getPassword () ; String getUsername () ; boolean isAccountNonExpired () ; boolean isAccountNonLocked () ; boolean isCredentialsNonExpired () ; boolean isEnabled () ; }
可以实现这个接口,然后自定义其他的属性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 @Data @TableName("users") public class User implements UserDetails { @TableId(value = "id", type = IdType.AUTO) private Long id; @TableField("username") private String username; @TableField("password") private String password; @TableField("roles") private String roles; @TableField("enable") private boolean enable; private List<GrantedAuthority> authorities; @Override public Collection<? extends GrantedAuthority > getAuthorities() { return this .authorities; } @Override public String getPassword () { return this .password; } @Override public String getUsername () { return this .username; } @Override public boolean isAccountNonExpired () { return true ; } @Override public boolean isAccountNonLocked () { return true ; } @Override public boolean isCredentialsNonExpired () { return true ; } @Override public boolean isEnabled () { return true ; } }
9.6.2 UserDetailsService接口 UserDetailsService也是一个接口,该接口中只有一个方法loadUserByUsername(),该方法可以用来获取UserDetails对象,源码如下:
1 2 3 public interface UserDetailsService { UserDetails loadUserByUsername (String username) throws UsernameNotFoundException; }
同样可以实现接口,定义自己的业务逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @Service public class MyUserDetailsService implements UserDetailsService { @Resource private UserService userService; @Override public UserDetails loadUserByUsername (String username) throws UsernameNotFoundException { QueryWrapper<User> queryWrapper = new QueryWrapper <>(); queryWrapper.eq("username" , username); User user = userService.getOne(queryWrapper); if (user == null ) { throw new UsernameNotFoundException ("用户不存在" ); } user.setAuthorities(AuthorityUtils.commaSeparatedStringToAuthorityList(user.getRoles())); return user; } }