一、概述
SpringSecurity是基于Spring的一个安全管理框架,他提供一般Web项目所需要的认证和授权功能。
认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户;
授权:经过认证后判断当前用户是否有权限进行某个操作。
引入SpringSecurity:
<!-- spring security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
二、认证
2.1 登录校验流程
实际的前后端分离项目中,我们的整个登录校验流程如下所示,其核心是使用token令牌对客户进行认证。
2.2 认证原理
SpringSecurity的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器。如下所示:
上图只展示了核心过滤器,其它的非核心过滤器并没有在图中展示。上图中所示的核心过滤器功能如下:
UsernamePasswordAuthenticationFilter:负责处理我们在登陆页面填写了用户名密码后的登陆请求。
ExceptionTranslationFilter: 处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException 。
FilterSecurityInterceptor: 负责权限校验的过滤器。
2.3 认证流程详解
概念速查:
Authentication接口: 它的实现类,表示当前访问系统的用户,封装了用户相关信息。
AuthenticationManager接口:定义了认证Authentication的方法
UserDetailsService接口:加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法。
UserDetails接口:提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中。
DaoAuthenticationProvider继承AbstractUserDetailsAuthenticationProvider抽象类,而AbstractUserDetailsAuthenticationProvider抽象类又实现了AuthenticationProvider这个接口。
AuthenticationProvider接口和AuthenticationManager接口都有 authenticate() 这个方法.
认证流程:
1、传入用户名和密码
2、UsernamePasswordAuthenticationFilter(前后端分离项目,该类可以替换成自己写的类MyUsernamePasswordAuthenticationFilter,来调用AuthenticationManager接口的authenticate()方法)会把用户名和密码封装成Authentication对象
3、然后又再调用AuthenticationManager接口中的authenticate()方法进行认证,在AuthenticationManager接口的实现类ProviderManager中又调用了AuthenticationProvider接口抽象类的authenticate()方法进行认证。
4、AbstractUserDetailsAuthenticationProvider的authenticate()方法中调用了抽象方法retrieveUser()方法
5、DaoAuthenticationProvider类继承了抽象类AbstractUserDetailsAuthenticationProvider并在重写方法retrieveUser()里调用了loadUserByUsername()方法
6、loadUserByUsername()方法会返回UserDetails对象,认证成功后逐一返回上一层
2.5 前后端登录思路
登录:
- 自定义登录接口 ;
- 调用ProviderManager的方法,然后进一步调用DaoAuthenticationProvider方法进行认证;
- 自定义UserDetailsService,并在其LoadUserByUsername方法中查询数据库,
- 将查询数据库的结果返回到DaoAuthenticationProvider方法,并通过PasswordEncoder进行密码校验,如果认证通过生成jwt把用户信息存入redis中,
- 最终将生成的token令牌传输给前端。
校验:
- 定义Jwt认证过滤器:
- 获取token
- 解析token获取其中的userid
- 从redis中获取用户信息
三、认证示例
3.1 SpringSecurity配置类:
/**
* spring security配置
*
*/
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter
{
/**
* 自定义用户认证逻辑
*/
@Autowired
private UserDetailsService userDetailsService;
/**
* 认证失败处理类
*/
@Autowired
private AuthenticationEntryPointImpl unauthorizedHandler;
/**
* 退出处理类
*/
@Autowired
private LogoutSuccessHandlerImpl logoutSuccessHandler;
/**
* token认证过滤器
*/
@Autowired
private JwtAuthenticationTokenFilter authenticationTokenFilter;
/**
* 配置认证管理器
* 解决 无法直接注入 AuthenticationManager
*
* @return
* @throws Exception
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception
{
return super.authenticationManagerBean();
}
/**
* 强散列哈希加密实现
*/
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder()
{
return new BCryptPasswordEncoder();
}
/**
* 身份认证接口
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception
{
auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
}
/**
* anyRequest | 匹配所有请求路径
* access | SpringEl表达式结果为true时可以访问
* anonymous | 匿名可以访问
* denyAll | 用户不能访问
* fullyAuthenticated | 用户完全认证可以访问(非remember-me下自动登录)
* hasAnyAuthority | 如果有参数,参数表示权限,则其中任何一个权限可以访问
* hasAnyRole | 如果有参数,参数表示角色,则其中任何一个角色可以访问
* hasAuthority | 如果有参数,参数表示权限,则其权限可以访问
* hasIpAddress | 如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问
* hasRole | 如果有参数,参数表示角色,则其角色可以访问
* permitAll | 用户可以任意访问
* rememberMe | 允许通过remember-me登录的用户访问
* authenticated | 用户登录后可访问
*/
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception
{
httpSecurity
// CSRF禁用,因为不使用session
.csrf().disable()
// 认证失败处理类
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
// 基于token,所以不需要session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
// 过滤请求
.authorizeRequests()
// 对于登录login 注册register 验证码captchaImage 允许匿名访问
.antMatchers("/login", "/register", "/captchaImage").anonymous()
.antMatchers(
HttpMethod.GET,
"/",
"/*.html",
"/**/*.html",
"/**/*.css",
"/**/*.js",
"/profile/**"
).permitAll()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated()
.and()
.headers().frameOptions().disable();
httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
// 添加JWT filter
httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
}
3.2 认证流程源码
上文中,详细叙述了SpringSecurity登录认证的流程,本节按照上述流程,以一个小案例,进一步对上述流程进行说明。
1. 编写登录接口:
@PostMapping("/login")
public ResponseResult login(@RequestBody LoginBody loginBody) {
ResponseResult ajax = ResponseResult.success();
// 登录验证并生成令牌
String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(),
loginBody.getUuid());
if(StringUtils.isNull(token)){
return ResponseResult.error("登录失败,请检查用户名和密码,并确认账号是否过期!");
}
ajax.put(Constants.TOKEN, token); // 响应添加token令牌
return ajax; // 返回响应值(包含Token令牌)。
}
在上述登录接口中,主要是调用了loginService.login()函数,函数传入登录用户及密码,执行登录流程,如下所示:
2. 编写MyUsernamePasswordAuthenticationFilter类,调用AuthenticationManager接口的authenticate()方法:
public String login(String username, String password, String code, String uuid) {
// 用户验证
Authentication authentication = null;
try {
// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
authentication = authenticationManager
.authenticate(new UsernamePasswordAuthenticationToken(username, password));
}
catch (Exception e) {
return null;
}
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
// 生成token
return tokenService.createToken(loginUser); // 根据用户信息,创建令牌
}
上述函数中,我们调用了AuthenticationManager接口的authenticate()方法进行认证。authenticate()方法如下所示:
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
...
try {
result = provider.authenticate(authentication);
if (result != null) {
this.copyDetails(authentication, result);
break;
}
} catch (InternalAuthenticationServiceException | AccountStatusException var13) {
this.prepareException(var13, authentication);
throw var13;
} catch (AuthenticationException var14) {
lastException = var14;
}
}
...
}
在该方法中,又调用了AuthenticationProvider接口实现类AbstractUserDetailsAuthenticationProvider的authenticate()方法。
3. 调用AuthenticationProvider接口实现类的authenticate()方法:
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
...
try {
user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
} catch (UsernameNotFoundException var6) {
this.logger.debug("User '" + username + "' not found");
if (this.hideUserNotFoundExceptions) {
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
throw var6;
}
try {
this.preAuthenticationChecks.check(user);
this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);
} catch (AuthenticationException var7) {
if (!cacheWasUsed) {
throw var7;
}
}
...
在AuthenticationProvider接口实现类AbstractUserDetailsAuthenticationProvider的authenticate()方法中:又调用了AbstractUserDetailsAuthenticationProvider类的子类DaoAuthenticationProvider类中的retrieveUser()方法:
protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
this.prepareTimingAttackProtection();
try {
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
if (loadedUser == null) {
throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation");
} else {
return loadedUser;
}
} catch (UsernameNotFoundException var4) {
this.mitigateAgainstTimingAttack(authentication);
throw var4;
} catch (InternalAuthenticationServiceException var5) {
throw var5;
} catch (Exception var6) {
throw new InternalAuthenticationServiceException(var6.getMessage(), var6);
}
}
在retrieveUser()方法 中,调用了UserDetailsService的实现类UserDetailsServiceImpl的方法loadUserByUsername()。UserDetailsServiceImpl类是由开发者自己实现的,并在方法loadUserByUsername()中编写操作数据库获取用户信息的代码,如下所示:
4. 编写UserDetailsService接口实现类,并重写loadUserByUsername()方法:
@Service
public class UserDetailsServiceImpl implements UserDetailsService
{
@Autowired
private ISysUserService userService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException
{
SysUser user = userService.selectUserByUserName(username);
if (StringUtils.isNull(user))
{
throw new ServiceException("登录用户:" + username + " 不存在");
}
else if (UserStatus.DELETED.getCode().equals(user.getDelFlag()))
{
throw new ServiceException("对不起,您的账号:" + username + " 已被删除");
}
else if (UserStatus.DISABLE.getCode().equals(user.getStatus()))
{
throw new ServiceException("对不起,您的账号:" + username + " 已停用");
}
return createLoginUser(user);
}
public UserDetails createLoginUser(SysUser user) {
return new LoginUser( user, null);
}
}
loadUserByUsername() 方法返回值为UserDetails,该类封装了从数据库中查询到的用户信息。然后返回到DaoAuthenticationProvider类中的retrieveUser()方法,接着进一步返回到AbstractUserDetailsAuthenticationProvider的authenticate()方法中,在该方法中,调用该类的方法additionalAuthenticationChecks():
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
if (authentication.getCredentials() == null) {
this.logger.debug("Authentication failed: no credentials provided");
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
} else {
String presentedPassword = authentication.getCredentials().toString();
if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
this.logger.debug("Authentication failed: password does not match stored value");
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
}
}
在调用函数 additionalAuthenticationChecks()时,将从数据库中查询到的用户信息UserDetails ,以及前端传入的用户信息UsernamePasswordAuthenticationToken 作为参数传入,函数内部通过调用passwordEncoder.matches()方法,来判断两者密码是否相等。并进一步确认用户是否通过认证,若密码不相同,则抛出BadCredentialsException异常,否则继续执行,返回到ProviderManager类(AuthenticationManager接口)的authenticate()方法,并进一步返回到login()方法;
5. 验证成功,计算token
密码比对一致之后,层层返回,会再次返回到初始的login()方法,继续执行该方法,计算出token值,存储到redis数据库中,并响应给前端,作为后续访问的依据。