一、Spring Security简介
Spring Security,这是一种基于 Spring AOP 和 Servlet 过滤器的安全框架。它提供全面的安全性解决方案,同时在 Web 请求级和方法调用级处理身份确认和授权。
网址:https://spring.io/projects/spring-security
在线文档
认证过程
用户使用用户名和密码进行登录。
Spring Security 将获取到的用户名和密码封装成一个实现了 Authentication 接口的 UsernamePasswordAuthenticationToken。
将上述产生的 token 对象传递给 AuthenticationManager 进行登录认证。
AuthenticationManager 认证成功后将会返回一个封装了用户权限等信息的 Authentication 对象。
通过调用 SecurityContextHolder.getContext().setAuthentication(...) 将 AuthenticationManager 返回的 Authentication 对象赋予给当前的 SecurityContext。
二、Maven依赖
<!--spring security-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
三、测试
- 创建控制器
@RestController
@RequestMapping("/user")
public class UserController {
@GetMapping
public String getUsers() {
return "Spring Security 测试";
}
}
-
访问接口
默认用户:user
- 禁用安全设置或者设置对应的用户和密码
在main方法上配置:
@SpringBootApplication(exclude = {
org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration.class
})
- 配置文件中指定用户名与密码
spring.security.user.name=admin
spring.security.user.password=123456
四、自定义用户认证逻辑
- 添加配置类
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
UserDetailsService customUserService() {
return new CustomUserDetailsService();
}
// 配置PasswordEncoder
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(customUserService()).passwordEncoder(passwordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin() // 表单登录
.and()
.authorizeRequests() // 对请求做授权, 定义哪些URL需要被保护、哪些不需要被保护
.anyRequest() // 任何请求,登录后可以访问
.authenticated() // 都需要身份认证
.and().csrf().disable(); // 暂时将防护跨站请求伪造的功能置为不可用
}
}
加密算法 | PasswordEncoder 实现类 |
---|---|
plaintext | PlaintextPasswordEncoder |
sha | ShaPasswordEncoder |
sha-256 | ShaPasswordEncoder,使用时new ShaPasswordEncoder(256) |
md4 | Md4PasswordEncoder |
md5 | Md5PasswordEncoder |
{sha} | LdapShaPasswordEncoder |
{ssha} | LdapShaPasswordEncoder |
- 添加自定义用户类
@Slf4j
public class CustomUserDetailsService implements UserDetailsService {
// 自带加密类
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.info("登录用户名:"+username);
// 根据用户名查找用户信息
/**
* 简单的对123456进行了加密的处理。我们可以进行测试,
* 发现每次打印出来的password都是不一样的,这就是配置的BCryptPasswordEncoder所起到的作用。
*/
String password = passwordEncoder.encode("123456");
// 参数:1.账号 2.密码 3.账户是否可用(删除) 4.账户是否过期 5.密码是否过期 6.账户是否被锁定(冻结)7.角色
return new User(username, password,
true, true, true, true,
AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
}
}
- 测试
访问地址:http://localhost:8000/user
五、单元测试
在写单元测试时,需要模拟某个用户的登录状态
在写单元测试时,需要模拟某个用户具有某个权限,但又不想改变数据库
编写单元测试时,需求完整调用某个用户的登录
@WithMockUser 模拟用户,手动指定用户名和授权
@WithAnonymousUser 模拟匿名用户
@WithUserDetails 模拟用户,给定用户名,通过自定义UserDetails来认证
@WithSecurityContext 通过SecurityContext构造器模拟用户
- 测试基类
@RunWith(SpringRunner.class)
@SpringBootTest
@Slf4j
public class BaseAdminApplicationTest {
// 模拟MVC对象,通过MockMvcBuilders.webAppContextSetup(this.wac).build()初始化。
public MockMvc mockMvc;
// 注入WebApplicationContext
@Autowired
private WebApplicationContext wac;
// 在测试开始前初始化工作
@Before
public void setup() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).apply(springSecurity()).build();
}
}
- 测试类
@Slf4j
public class AuthTest extends BaseAdminApplicationTest {
/**
* 1.登录测试
*
* @throws Exception
*/
@Test
public void testFormLoginSuccess() throws Exception {
// 测试登录成功
mockMvc
.perform(formLogin("/login").user("admin").password("123456"))
.andExpect(authenticated());
}
/**
* 2. 测试登录失败
*
* @throws Exception
*/
@Test
public void testFormLoginFail() throws Exception {
mockMvc
.perform(formLogin("/login").user("admin").password("invalid"))
.andExpect(unauthenticated());
}
/**
* 测试退出登录
*
* @throws Exception
*/
@Test
public void testLogoutFail() throws Exception {
// 测试退出登录
mockMvc.perform(logout("/logout")).andExpect(unauthenticated());
}
}
六、自定义登录认证
1.JWT依赖
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
1. 创建JWT用户类
@Data
public class JwtUser implements UserDetails {
private Long id;
private String username;
private String password;
private Collection<? extends GrantedAuthority> authorities;
public JwtUser() {
}
// 写一个能直接使用user创建jwtUser的构造器
public JwtUser(User user) {
id = user.getId();
username = user.getUsername();
password = user.getPassword();
authorities = Collections.singleton(new SimpleGrantedAuthority(user.getRole()));
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
@Override
public String toString() {
return "JwtUser{" +
"id=" + id +
", username='" + username + '\'' +
", password='" + password + '\'' +
", authorities=" + authorities +
'}';
}
}
2. 创建登录用户类
作用:获取表单登录信息与数据库实体不一定对应。
@Data
public class LoginUser {
private String username;
private String password;
private Integer rememberMe;
}
3. 数据库用户类
@Data
public class User {
private Long id;
private String username;
private String password;
private String role;
}
4. JWT工具类
@Slf4j
public class JwtTokenUtils {
public static final String TOKEN_HEADER = "Authorization";
public static final String TOKEN_PREFIX = "Bearer ";
private static String SECRET = "xxxxxxxx";
private static final String ISS = "echisan";
// 角色的key
private static final String ROLE_CLAIMS = "role";
// 过期时间是3600秒,既是1个小时
private static final long EXPIRATION = 60 * 60L;
// 选择了记住我之后的过期时间为7天
private static final long EXPIRATION_REMEMBER = 7 * 24 * 60 * 60L;
/**
* 创建token
*
* @param username
* @param role
* @param isRememberMe
* @return
*/
public static String createToken(String username, String role, boolean isRememberMe,String secret) {
long expiration = isRememberMe ? EXPIRATION_REMEMBER : EXPIRATION;
HashMap<String, Object> map = new HashMap<>();
if(!secret.isEmpty()){
SECRET=secret;
}
map.put(ROLE_CLAIMS, role);
return Jwts.builder()
.signWith(SignatureAlgorithm.HS512, SECRET)
.setClaims(map)
.setIssuer(ISS)
.setSubject(username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
.compact();
}
/**
* 从token中获取用户名
*
* @param token
* @return
*/
public static String getUsername(String token) {
return getTokenBody(token).getSubject();
}
/**
* 获取用户角色
*
* @param token
* @return
*/
public static String getUserRole(String token) {
return (String) getTokenBody(token).get(ROLE_CLAIMS);
}
/**
* 是否已过期
*
* @param token
* @return
*/
public static boolean isExpiration(String token) {
return getTokenBody(token).getExpiration().before(new Date());
}
/**
* 获取令牌体
*
* @param token
* @return
*/
private static Claims getTokenBody(String token) {
return Jwts.parser()
.setSigningKey(SECRET)
.parseClaimsJws(token)
.getBody();
}
}
5. 创建认证过滤器
@Slf4j
public class JWTAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private ThreadLocal<Integer> rememberMe = new ThreadLocal<>();
private AuthenticationManager authenticationManager;
public JWTAuthenticationFilter(AuthenticationManager authenticationManager) {
this.authenticationManager = authenticationManager;
// 设置过滤器处理地址
super.setFilterProcessesUrl("/auth/login");
}
/**
*
* @param request
* @param response
* @return
* @throws AuthenticationException
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
try {
// 获取登录信息
LoginUser loginUser = new ObjectMapper().readValue(request.getInputStream(), LoginUser.class);
log.info("登录用户:"+loginUser.getUsername()+"密码:"+loginUser.getPassword());
// 记住登录信息
rememberMe.set(loginUser.getRememberMe());
// 进行认证
return authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(loginUser.getUsername(), loginUser.getPassword(), new ArrayList<>())
);
} catch (IOException e) {
e.printStackTrace();
return null;
}
}
/**
* 成功验证后调用的方法
* 如果验证成功,就生成token并返回
* @param request
* @param response
* @param chain
* @param authResult
* @throws IOException
* @throws ServletException
*/
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult) throws IOException, ServletException {
// 获取Jwt用户信息
JwtUser jwtUser = (JwtUser) authResult.getPrincipal();
System.out.println("用户:" + jwtUser.toString()+"密码:"+jwtUser.getPassword());
// 记住登录信息
boolean isRemember = rememberMe.get() == 1;
// 获取授权信息
String role = "";
Collection<? extends GrantedAuthority> authorities = jwtUser.getAuthorities();
for (GrantedAuthority authority : authorities) {
role = authority.getAuthority();
}
// 创建令牌
String token = JwtTokenUtils.createToken(jwtUser.getUsername(), role, isRemember,jwtUser.getPassword());
// String token = JwtTokenUtils.createToken(jwtUser.getUsername(), false);
// 返回创建成功的token
// 但是这里创建的token只是单纯的token
// 按照jwt的规定,最后请求的时候应该是 `Bearer token`
response.setHeader("token", JwtTokenUtils.TOKEN_PREFIX + token);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
response.getWriter().write("认证失败, 原因: " + failed.getMessage());
}
}
6. 创建授权过滤器
public class JWTAuthorizationFilter extends BasicAuthenticationFilter {
public JWTAuthorizationFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
}
/**
*
* @param request
* @param response
* @param chain
* @throws IOException
* @throws ServletException
*/
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {
// 获取请求头信息
String tokenHeader = request.getHeader(JwtTokenUtils.TOKEN_HEADER);
// 如果请求头中没有Authorization信息则直接放行了
if (tokenHeader == null || !tokenHeader.startsWith(JwtTokenUtils.TOKEN_PREFIX)) {
chain.doFilter(request, response);
return;
}
// 如果请求头中有token,则进行解析,并且设置认证信息
SecurityContextHolder.getContext().setAuthentication(getAuthentication(tokenHeader));
super.doFilterInternal(request, response, chain);
}
/**
* 这里从token中获取用户信息并新建一个token
* @param tokenHeader
* @return
*/
private UsernamePasswordAuthenticationToken getAuthentication(String tokenHeader) {
// 获取令牌
String token = tokenHeader.replace(JwtTokenUtils.TOKEN_PREFIX, "");
// 获取用户信息
String username = JwtTokenUtils.getUsername(token);
// 获取角色信息
String role = JwtTokenUtils.getUserRole(token);
if (username != null){
return new UsernamePasswordAuthenticationToken(username, null,
Collections.singleton(new SimpleGrantedAuthority(role))
);
}
return null;
}
}
- 创建配置
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
@Qualifier("userDetailsServiceImpl")
private UserDetailsService userDetailsService;
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder(){
return new BCryptPasswordEncoder();
}
/**
* 设置加密方式
* @param auth
* @throws Exception
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
}
/**
* 配置http权限认证
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors().and().csrf().disable()
.authorizeRequests()
// 测试用资源,需要验证了的用户才能访问
.antMatchers("/user/**").authenticated()
.antMatchers(HttpMethod.DELETE, "/user/**").hasRole("ADMIN")
// 其它的都可以访问
.anyRequest().permitAll()
.and()
// 添加认证过滤器
.addFilter(new JWTAuthenticationFilter(authenticationManager()))
// 添加授权过滤器
.addFilter(new JWTAuthorizationFilter(authenticationManager()))
// 不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// 添加异常处理
.exceptionHandling().authenticationEntryPoint(new JWTAuthenticationEntryPoint());
}
/**
* 跨域请求配置
* @return
*/
@Bean
CorsConfigurationSource corsConfigurationSource() {
final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", new CorsConfiguration().applyPermitDefaultValues());
return source;
}
}
- 统一认证异常处理
public class JWTAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
String reason = "认证失败,原因:"+authException.getMessage();
response.getWriter().write(new ObjectMapper().writeValueAsString(reason));
}
}
- 用户业务类
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private BCryptPasswordEncoder bCryptPasswordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 通过username在数据库中查找用户,这里模拟
User user=new User();
user.setUsername("admin");
user.setPassword(bCryptPasswordEncoder.encode("123456"));
user.setId(1L);
user.setRole("admin");
return new JwtUser(user);
}
}
- 控制器类
验证控制器:
@RestController
@RequestMapping("/auth")
@Slf4j
public class AuthController {
@Autowired
private BCryptPasswordEncoder bCryptPasswordEncoder;
@PostMapping("/register")
public String registerUser(@RequestBody Map<String,String> registerUser){
User user = new User();
user.setUsername(registerUser.get("username"));
user.setPassword(bCryptPasswordEncoder.encode(registerUser.get("password")));
user.setRole("ROLE_USER");
return "注册成功";
}
}
用户控制器
@RestController
@RequestMapping("/user")
public class UserController {
@GetMapping
public String getUsers() {
return "Spring Security 测试";
}
}
七、认证测试
-
获取令牌
localhost:8000/auth/login
-
资源访问
localhost:8000/user
八、JWTToken超时刷新
九、spring security退出功能
注销默认地址:/logout
spring security实现注销功能涉及的三个核心类为LogoutFilter,LogoutHandler,LogoutSuccessHandler
LoginFilter是实现注销功能的过滤器,默认拦截/logout或者logout属性logout-url指定的url
LogoutHandler接口定义了退出登录操作的方法
LogoutSuccessHandler接口定义了注销之后的操作方法
登出处理器:
@Component
@Slf4j
public class LogoutAuthenticationHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
// TODO 注意:要想在此获取authentication数据,登录成功后,必须调用一次资源,才可获取。
// 与 Authentication auth = SecurityContextHolder.getContext().getAuthentication(); 等同
log.info(authentication.getPrincipal() + "");
// 在此可以删除redis中保存的token数据
log.info("注销成功");
httpServletResponse.setContentType("application/json;charset=utf-8");
httpServletResponse.getWriter().write(new ObjectMapper().writeValueAsString("ok"));
}
}
logout配置属性:
属性名 | 作用 |
---|---|
invalidate-session | 表示是否要在退出登录后让当前session失效,默认为true。 |
delete-cookies | 指定退出登录后需要删除的cookie名称,多个cookie之间以逗号分隔。 |
logout-success-url | 指定成功退出登录后要重定向的URL。需要注意的是对应的URL应当是不需要登录就可以访问的。 |
success-handler-ref | 指定用来处理成功退出登录的LogoutSuccessHandler的引用。 |
退出登录:
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null){
new SecurityContextLogoutHandler().logout(request, response, auth);
}
十、授权表达式
表达式 | 说明 |
---|---|
permitAll | 永远返回 true |
denyAll | 永远返回 false |
anonyous | 当前用户若是匿名用户返回 true |
rememberMe | 当前用户若是 rememberMe 用户返回 true |
authenticated | 当前用户若不是匿名(已认证)用户返回 true |
fullAuthenticated | 当前用户若既不是匿名用户又不是 rememberMe 用户时返回 true |
hasRole(role) | 当前用户权限集合中若拥有指定的 role 角色权限(匹配时会在你所指定的权限前加'ROLE_',即判断是否有“ROLE_role”权限)时返回 true |
hasAnyRole(role1, role2, ...) | 当前用户权限集合中若拥有任意一个角色权限时返回 true |
hasAuthority(authority) | 当前用户权限集合中若具有 authority 权限(匹配是否有“authority”权限)时返回 true |
hasAnyAuthority(authority) | 当前用户权限集合中若拥有任意一个权限时返回 true |
hasIpAddress("192.168.1.0/24") | 发送请求的IP匹配时fanhui true |
基于角色的访问控制 RBAC 数据模型(Role-Based Access Control)
十一、Spring Security过滤器链及认证流程
spring Security功能的实现主要是由一系列过滤器链相互配合完成。
过滤器链中主要的几个过滤器及其作用:
1.SecurityContextPersistenceFilter 会在请求开始时从配置好的 SecurityContextRepository 中获取 SecurityContext,然后把它设置给 SecurityContextHolder。在请求完成后将 SecurityContextHolder 持有的 SecurityContext 再保存到配置好的SecurityContextRepository,同时清除 securityContextHolder 所持有的 SecurityContext;
2.UsernamePasswordAuthenticationFilter 用于处理来自表单提交的认证。该表单必须提供对应的用户名和密码,其内部还有登录成功或失败后进行处理的 AuthenticationSuccessHandler 和 AuthenticationFailureHandler,这些都可以根据需求做相关改变;
3.FilterSecurityInterceptor 是用于保护Http 资源的,它需要一个AccessDecisionManager和一个AuthenticationManager 的引用。它会从 SecurityContextHolder 获取 Authentication,然后通过 SecurityMetadataSource 可以得知当前请求是否在请求受保护的资源。对于请求那些受保护的资源,如果Authentication.isAuthenticated()返回false或者FilterSecurityInterceptor
的alwaysReauthenticate 属性为 true,那么将会使用其引用的 AuthenticationManager 再认证一次,认证之后再使用认证后的 Authentication 替换 SecurityContextHolder 中拥有的那个。然后就是利用 AccessDecisionManager 进行权限的检查;
4.ExceptionTranslationFilter 能够捕获来自 FilterChain 所有的异常,并进行处理。
但是它只会处理两类异常:AuthenticationException 和 AccessDeniedException,其它的异常它会继续抛出。
--- 如果捕获到的是 AuthenticationException,那么将会使用其对应的 AuthenticationEntryPoint 的commence()处理。在处理之前,ExceptionTranslationFilter先使用 RequestCache 将当前的HttpServerletRequest的信息保存起来,以至于用户成功登录后可以跳转到之前的界面;
--- 如果捕获到的是 AccessDeniedException,那么将视当前访问的用户是否已经登录认证做不同的处理,如果未登录,则会使用关联的 AuthenticationEntryPoint 的 commence()方法进行处理,否则将使用关联的 AccessDeniedHandler 的handle()方法进行处理。
十二、认证流程
认证流程分为登录流程和注销流程:
(注意:这里的登录方式为"帐号+密码";箭头代表整个流程执行方向)
1)登录流程
---> SecurityContextPersistenceFilter(作用在第一节已经介绍)
---> UsernamePasswordAuthenticationFilter
(先获取用户名和密码,并将其封装成UsernamePasswordToken,然后调用AuthenticationManager进行验证)
---> AuthenticationManager
(根据token类型选择合适的AuthenticationProvider来处理认证请求) [默认实现类:ProviderManager]
AuthenticationManager是一个用来处理请求的接口,它自己不直接处理认证请求,而是委托给其所配置的Authentication
Provider列表,然后会依次使用每一个 AuthenticationProvider 进行认证,如果有一个AuthenticationProvider 认证后的结果不为 null,则表示该AuthenticationProvider已经认证成功,之后的AuthenticationProvider 将不再继续认证。然后直接以该 AuthenticationProvider 的认证结果作为 ProviderManager 的认证结果。如果所有的 AuthenticationProvider 的认证结果都为null,则表示认证失败,将抛出一个 ProviderNotFoundException。
---> AuthenticationProvider
(请求认证处理) [默认实现类:DaoAuthencationProvider]
DaoAuthenticationProvider认证过程:
DaoAuthenticationProvider先调用UserDetailsService 的loadUserByUsername()方法获取UserDetails,获取后再与UsernamePasswordAuthenticationFilter获取的username和password进行比较;如果认证通过后会将该 UserDetails 赋给认证通过的 Authentication的principal,然后再把该 Authentication 存入到 SecurityContext 中。默认情况下,在认证成功后ProviderManager也将清除返回的Authentication中的凭证信息。
注意:在这里面根据需要增加[自定义关键类(UserDetailService):实现UserDetailService接口并复写loadUserByUsername()]
问:为什么AuthenticationManager不直接认证请求?
答:因为token有多种类型。比如最简单的UsernamePasswordAuthenticationToken,还有spring social的token;
---> Authentication对象
Spring Security使用一个Authentication 对象来描述当前用户的相关信息。SecurityContextHolder中持有的是当前用户的 SecurityContext,而 SecurityContext 持有的是代表当前用户相关信息的 Authentication 的引用。这个 Authentication 对象不需要我们自己去创建,在与系统交互的过程中,Spring Security会自动为我们创建相应的Authentication对象,然后赋值给当前的SecurityContext。
2)注销流程
1、使HttpSession失效;
2、清除所有已经配置的remember-me认证;
3、清除SecurityContextHolder中的user信息,并设置Authentication中的Authenticated属性为false;
4、跳转到指定url;
附:
认证提供:
/**
* @描 述:
* AuthenticationProvider(身份验证提供者) 顾名思义,可以提供一个 Authentication 供Spring Security的上下文使用
* 1. 创建CustomAuthenticationProvider类
* 2. 当 CustomAuthenticationProvider 认证成功之后,JWTLoginFilter 中的 successfulAuthentication() 方法机会执行
* 本类没有用到
*/
@Slf4j
public class CustomAuthenticationProvider implements AuthenticationProvider {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private BCryptPasswordEncoder bCryptPasswordEncoder;
/**
* 验证登录信息,若登陆成功,设置 Authentication
* @param authentication
* @return
* @throws AuthenticationException
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
// 获取认证的用户名 & 密码
String username = authentication.getName();
String password = authentication.getCredentials().toString();
//通过用户名从数据库中查询该用户
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
//判断密码是否正确
String dbPassword = userDetails.getPassword();
String encoderPassword = bCryptPasswordEncoder.encode(password);
if (!dbPassword.equals(encoderPassword)) {
// throw new UsernameIsExitedException("密码错误");
log.info("密码错误");
}
Authentication auth = new UsernamePasswordAuthenticationToken(username, password, userDetails.getAuthorities());
return auth;
}
@Override
public boolean supports(Class<?> authentication) {
// 进行验证
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}
十三、数据权限校验和访问限制
Spring Security为我们定义了hasPermission的两种使用方式,它们分别对应着PermissionEvaluator的两个不同的hasPermission()方法。Spring Security默认处理Web、方法的表达式处理器分别为DefaultWebSecurityExpressionHandler和DefaultMethodSecurityExpressionHandler,它们都继承自AbstractSecurityExpressionHandler,其所持有的PermissionEvaluator是DenyAllPermissionEvaluator,其对于所有的hasPermission表达式都将返回false。
需要注意的是,Spring Security默认的角色前缀是”ROLE_”,使用hasRole方法时已经默认加上了,因此我们在数据库里面的用户角色应该是“ROLE_user”,在user前面加上”ROLE_”前缀。
- 自定义PermissionEvaluator
@Component
public class CustomPermissionEvaluator implements PermissionEvaluator {
@Autowired
private IRoleService iRoleService;
/**
* 在 hasPermission() 方法中,参数 1 代表用户的权限身份,参数 2 参数 3 分别和
* @PreAuthorize("hasPermission('/admin','r')") 中的参数对应,即访问 url 和权限。
* @param authentication
* @param targetUrl
* @param targetPermission
* @return
*/
@Override
public boolean hasPermission(Authentication authentication, Object targetUrl, Object targetPermission) {
// 获得loadUserByUsername()方法的结果
JwtUser jwtUser = (JwtUser)authentication.getPrincipal();
// 获得loadUserByUsername()中注入的角色
Collection<GrantedAuthority> authorities = jwtUser.getAuthorities();
// 遍历用户所有角色
for(GrantedAuthority authority : authorities) {
String roleName = authority.getAuthority();
// Integer roleId = iRoleService.selectByName(roleName).getId();
// // 得到角色所有的权限
// List<SysPermission> permissionList = permissionService.listByRoleId(roleId);
//
// // 遍历permissionList
// for(SysPermission sysPermission : permissionList) {
// // 获取权限集
// List permissions = sysPermission.getPermissions();
// // 如果访问的Url和权限用户符合的话,返回true
// if(targetUrl.equals(sysPermission.getUrl())
// && permissions.contains(targetPermission)) {
// return true;
// }
// }
}
return false;
}
@Override
public boolean hasPermission(Authentication authentication, Serializable serializable, String s, Object o) {
return false;
}
}
- 添加配置类
注意:@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
注解只能添加一次。
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class MethodSecurityConfig extends GlobalMethodSecurityConfiguration {
@Autowired
private CustomPermissionEvaluator customPermissionEvaluator;
/**
* 注入自定义PermissionEvaluator
*/
@Override
protected MethodSecurityExpressionHandler createExpressionHandler() {
DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
expressionHandler.setPermissionEvaluator(customPermissionEvaluator);
return expressionHandler;
}
}
- 添加注解进行测试
// 要与CustomPermissionEvaluator中的方法参数对应
@PreAuthorize("hasPermission('/system/user/users','read')")
@PreAuthorize: 在方法调用前,基于表达式计算结果来限制方法访问
@PostAuthorize: 允许方法调用,但是如果表达式结果为fasle则抛出异常
@PostFilter :允许方法调用,但必须按表达式过滤方法结果。
@PreFilter:允许方法调用,但必须在进入方法前过滤输入值
Spring的 @PreAuthorize/@PostAuthorize 注解更适合方法级的安全,也支持Spring 表达式语言,提供了基于表达式的访问控制。
@PreAuthorize 注解适合进入方法前的权限验证, @PreAuthorize可以将登录用户的roles/permissions参数传到方法中。
@PostAuthorize 注解使用并不多,在方法执行后再进行权限验证。
所以它适合验证带有返回值的权限。
@PostAuthorize ("returnObject.type == authentication.name")
User findById(int id);
@PreAuthorize("hasRole('ADMIN')")
void updateUser(User user);
@PreAuthorize("hasRole('ADMIN') AND hasRole('DBA')")
void deleteUser(int id);
Spring Security允许我们在定义URL访问或方法访问所应有的权限时使用Spring EL表达式,在定义所需的访问权限时如果对应的表达式返回结果为true则表示拥有对应的权限,反之则无。Spring Security可用表达式对象的基类是SecurityExpressionRoot,其为我们提供了如下在使用Spring EL表达式对URL或方法进行权限控制时通用的内置表达式。
表达式 | 描述 |
---|---|
hasRole([role]) | 当前用户是否拥有指定角色。 |
hasAnyRole([role1,role2]) | 多个角色是一个以逗号进行分隔的字符串。如果当前用户拥有指定角色中的任意一个则返回true。 |
hasAuthority([auth]) | 等同于hasRole |
hasAnyAuthority([auth1,auth2]) | 等同于hasAnyRole |
Principle | 代表当前用户的principle对象 |
authentication | 直接从SecurityContext获取的当前Authentication对象 |
permitAll | 总是返回true,表示允许所有的 |
denyAll | 总是返回false,表示拒绝所有的 |
isAnonymous() | 当前用户是否是一个匿名用户 |
isRememberMe() | 表示当前用户是否是通过Remember-Me自动登录的 |
isAuthenticated() | 表示当前用户是否已经登录认证成功了。 |
isFullyAuthenticated() | 如果当前用户既不是一个匿名用户,同时又不是通过Remember-Me自动登录的,则返回true。 |
hasPermission表达式:
pring Security为我们定义了hasPermission的两种使用方式,它们分别对应着PermissionEvaluator的两个不同的hasPermission()方法。Spring Security默认处理Web、方法的表达式处理器分别为DefaultWebSecurityExpressionHandler和DefaultMethodSecurityExpressionHandler,它们都继承自AbstractSecurityExpressionHandler,其所持有的PermissionEvaluator是DenyAllPermissionEvaluator,其对于所有的hasPermission表达式都将返回false。所以当我们要使用表达式hasPermission时,我们需要自已手动定义SecurityExpressionHandler对应的bean定义,然后指定其PermissionEvaluator为我们自己实现的PermissionEvaluator,然后通过global-method-security元素或http元素下的expression-handler元素指定使用的SecurityExpressionHandler为我们自己手动定义的那个bean。
十四、会话管理Session Management
- 配置SecurityConfig.java
@Autowired
SessionRegistry sessionRegistry;
// ============配置http权限认证方法 protected void configure(HttpSecurity http) throws Exception 添加
.and()
.sessionManagement()
.maximumSessions(1) // 控制单个用户只能创建一个session,也就只能在服务器登录一次
.sessionRegistry(sessionRegistry) // 注册session
.and()
.and()
.logout()
// ============
/**
* Session 注册
* @return
*/
@Bean
public SessionRegistry sessionRegistry(){
SessionRegistry sessionRegistry=new SessionRegistryImpl();
return sessionRegistry;
}
SessionRegistry
保存了所有认证成功后用户的SessionInformation信息,每次用户访问服务器的会从sessionRegistry中查询出当前用户的session信息 ,判断是否过期以及刷新最后一次方法时间,默认的实现类SessionRegistryImpl,监听了session的销毁事件,若销毁,那么删除掉session信息。SessionInformation
SessionInformation :记录认证用户的session信息 。
lastRequest:最后一次访问次数
principal:认证用户信息
sessionId:session的id
expired:是否过期SessionAuthenticationStrategy
实现类:
ChangeSessionIdAuthenticationStrategy:
调用HttpServletRequest的changeSessionId方法改变sessionid
SessionFixationProtectionStrategy:
首先让原来的session过期,然后创建一个新的session,把原来session的属性拷贝到新的session中
RegisterSessionAuthenticationStrategy:
用户认证成功后sessionRegistry调用registerNewSession,保存用户的信息和session
ConcurrentSessionControlAuthenticationStrategy:
允许用户同时在线数,有一个maximumSessions属性,默认是1。通过sessionRegistry判断用户数是否已经超过了最大允许数,若超过了,那么就让最近一个的session过期(让上一个用户强制下线)。
默认创建的SessionAuthenticationStrategy是组合CompositeSessionAuthenticationStrategy。
@Autowired
SessionRegistry sessionRegistry;
@Autowired
ConcurrentSessionControlAuthenticationStrategy concurrentSessionControlAuthenticationStrategy;
// 添加验证策略
.and()
.sessionManagement()
.sessionAuthenticationStrategy(concurrentSessionControlAuthenticationStrategy) // 验证策略
.maximumSessions(1) // 控制单个用户只能创建一个session,也就只能在服务器登录一次
.sessionRegistry(sessionRegistry) // 注册session
.and()
.and()
/**
* Session 注册
*
* @return
*/
@Bean
public SessionRegistry sessionRegistry() {
SessionRegistry sessionRegistry = new SessionRegistryImpl();
return sessionRegistry;
}
@Bean
public ConcurrentSessionControlAuthenticationStrategy concurrentSessionControlAuthenticationStrategy() {
return new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry);
}
- 自定义策略
CompositeSessionAuthenticationStrategy:组合使用多个SessionAuthenticationStrategy
@Slf4j
public class ControlAuthenticationStrategy implements SessionAuthenticationStrategy {
@Override
public void onAuthentication(Authentication authentication, HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws SessionAuthenticationException {
JwtUser jwtUser=(JwtUser)authentication.getPrincipal();
if(条件不满足){
// 抛出异常
throw new SessionAuthenticationException("同一个账号不可以,在两个地方登录");
}
}
}
- 并发用户示例
1)实体类(重写equals与hashCode方法)
@Override
public boolean equals(Object rhs) {
if (rhs instanceof JwtUser) {
return username.equals(((JwtUser) rhs).username);
}
return false;
}
@Override
public int hashCode() {
return username.hashCode();
}
2)工具类
public class SessionUtils {
/**
* 辨别用户是否已经登录
*
* @param request
* @param sessionRegistry
* @param loginedUser
*/
public static boolean userIsLogin(HttpServletRequest request, HttpServletResponse response, SessionRegistry sessionRegistry, JwtUser loginedUser) throws IOException {
// 获取上下文
SecurityContext sc = SecurityContextHolder.getContext();
// TODO 获取当前用户的所有session信息
List<SessionInformation> sessionsInfo = sessionRegistry.getAllSessions(sc.getAuthentication().getPrincipal(), false);
// 如果Sessiong不为空,大小为0
if (null != sessionsInfo && sessionsInfo.size() == 0) {
saveOrDeleteOnlineUser(Type.SAVE);
sessionRegistry.registerNewSession(request.getSession().getId(), sc.getAuthentication().getPrincipal());
}
// TODO 获取当前sessionID
String currentSessionId = request.getSession().getId();
// 获取所有注册用户列表
List<Object> o = sessionRegistry.getAllPrincipals();
// 遍历所有用户
for (Object principal : o) {
// 比较当前登录用户loginedUser 与 用户列表中的用户principal 用户名是否一致
if (principal instanceof JwtUser && (loginedUser.getUsername().equals(((JwtUser) principal).getUsername()))) {
// 获取principal 用户的session列表
List<SessionInformation> oldSessionsInfo = sessionRegistry.getAllSessions(principal, false);
// 如果有会话存在,且sessionId与当前session不相同,则认为有两个用户登录
if (null != oldSessionsInfo && oldSessionsInfo.size() > 0 && !oldSessionsInfo.get(0).getSessionId().equals(currentSessionId)) {
return true;
}
}
}
return false;
}
}
十五、logout 属性详解
logout-url LogoutFilter要读取的url,也就是指定spring security拦截的注销url
logout-success-url 用户退出后要被重定向的url
invalidate-session 默认为true,用户在退出后Http session失效
success-handler-ref 对一个LogoutSuccessHandler的引用,用来自定义退出成功后的操作
4.x则默认使用/logout
spring security退出功能相关类
spring security实现注销功能涉及的三个核心类为LogoutFilter,LogoutHandler,LogoutSuccessHandler
LoginFilter是实现注销功能的过滤器,默认拦截/logout或者logout属性logout-url指定的url
LogoutHandler接口定义了退出登录操作的方法:
void logout(HttpServletRequest request, HttpServletResponse response,
Authentication authentication);
LogoutSuccessHandler接口定义了注销之后的操作方法:
void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException;
spring security退出功能实现流程
spring security在实现注销功能时,大致流程如下
- 使得HTTP session失效(如果invalidate-session属性被设置为true);
- 清除SecurityContex(真正使得用户退出)
- 将页面重定向至logout-success-url指明的URL。
十六、常见问题:
- Encoded password does not look like BCrypt
添加配置:
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(customUserService()).passwordEncoder(passwordEncoder());
}
- Spring Security拦截器引起Java CORS跨域失败的问题
response响应头:
响应头字段名称 | 作用 |
---|---|
Access-Control-Allow-Origin | 允许访问的客户端的域名 |
Access-Control-Allow-Credentials | 是否允许请求带有验证信息,若要获取客户端域下的cookie时,需要将其设置为true。 |
Access-Control-Allow-Headers | 允许服务端访问的客户端请求头 |
Access-Control-Allow-Methods | 允许访问的HTTP请求方法 |
Access-Control-Max-Age | 用来指定预检请求的有效期(秒),在有效期内不在发送预检请求直接请求。 |
解决:
@Configuration
public class WebAppConfigurer extends WebMvcConfigurationSupport {
private CorsConfiguration buildConfig() {
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.addAllowedOrigin("*");
corsConfiguration.addAllowedHeader("*");
corsConfiguration.addAllowedMethod("*");
return corsConfiguration;
}
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", buildConfig());
return new CorsFilter(source);
}
}