Spring Security是一个当前流行的安全框架,它不仅实现了访问资源(crud)权限的控制,还可以基于它可以实现单点登陆认证业务.
本文主要介绍Spring Security的概念,认证及权限控制原理,以及单点登录的实现.
Spring Security是一个能够为基于Spring的企业应用系统提供声明式的安全访问控制解决方案的安全框架(简单说是对访问权限进行控制的一个框架)。它提供了一组可以在Spring应用上下文中配置的Bean,充分利用了Spring IoC,DI(控制反转Inversion of Control ,DI:Dependency Injection 依赖注入)和AOP(面向切面编程)功能,为应用系统提供声明式的安全访问控制功能,减少了为企业系统安全控制编写大量重复代码的工作。
Spring Security对Web安全性的支持大量地依赖于Servlet过滤器。通过这些过滤器拦截进入请求,判断是否已经登录认证且具访问对应请求的权限。
Spring Security 执行流程图:
要完成访问控制,Spring Security至少需要四个拦截器(调度器、认证管理器、权限资源关联器、访问决策器)进行配合完成:
SpringSecurity 采用的是责任链的设计模式,它有一条很长的过滤器链。
WebAsyncManagerIntegrationFilter:将 Security 上下文与 Spring Web 中用于处理异步请求映射的 WebAsyncManager 进行集成。
SecurityContextPersistenceFilter:在每次请求处理之前将该请求相关的安全上下文信息加载到 SecurityContextHolder 中,然后在该次请求处理完成之后,将 SecurityContextHolder 中关于这次请求的信息存储到一个“仓储”中,然后将 SecurityContextHolder 中的信息清除,例如在Session中维护一个用户的安全信息就是这个过滤器处理的。
HeaderWriterFilter:用于将头信息加入响应中。
CsrfFilter:用于处理跨站请求伪造。
LogoutFilter:用于处理退出登录。
UsernamePasswordAuthenticationFilter:用于处理基于表单的登录请求,从表单中获取用户名和密码。默认情况下处理来自 /login 的请求。从表单中获取用户名和密码时,默认使用的表单 name 值为 username 和 password,这两个值可以通过设置这个过滤器的usernameParameter 和 passwordParameter 两个参数的值进行修改。
DefaultLoginPageGeneratingFilter:如果没有配置登录页面,那系统初始化时就会配置这个过滤器,并且用于在需要进行登录时生成一个登录表单页面。
BasicAuthenticationFilter:检测和处理 http basic 认证。
RequestCacheAwareFilter:用来处理请求的缓存。
SecurityContextHolderAwareRequestFilter:主要是包装请求对象request。
AnonymousAuthenticationFilter:检测 SecurityContextHolder 中是否存在 Authentication 对象,如果不存在为其提供一个匿名 Authentication。
SessionManagementFilter:管理 session 的过滤器
ExceptionTranslationFilter:处理 AccessDeniedException 和 AuthenticationException 异常。
FilterSecurityInterceptor:可以看做过滤器链的出口。
RememberMeAuthenticationFilter:当用户没有登录而直接访问资源时, 从 cookie 里找出用户的信息, 如果 Spring Security 能够识别出用户提供的remember me cookie, 用户将不必填写用户名和密码, 而是直接登录进入系统,该过滤器默认不开启。
用户发起登录请求,通过Security 过滤器链,到达UsernamePasswordAuthenticationFilter 过滤器.
UsernamePasswordAuthenticationFilter过滤器的 attemptAuthentication() 方法将未认证的 Authentication 对象传入ProviderManager 类的 authenticate() 方法进行身份认证。ProviderManager 是 AuthenticationManager 接口的实现类,该接口是认证相关的核心接口,也是认证的入口。在实际开发中,我们可能有多种不同的认证方式,例如:用户名+ 密码、邮箱+密码、手机号+验证码等,而这些认证方式的入口始终只有一个,那就是 AuthenticationManager。
AuthenticationManager接口的常用实现类 ProviderManager 内部会维护一个 List 列表,存放多种认证方式,实际上这是委托者模式 (Delegate)的应用。每种认证方式对应着一个 AuthenticationProvider.
AuthenticationManager 根据认证方式的不同(根据传入的 Authentication 类型判断)委托对应的 AuthenticationProvider(实际上使用的是他的实现类DaoAuthenticationProvider) 进行用户认证。认证时需要UserDetailsService接口的实现类来传递用户信息.
自定义类XxUserDetailService实现UserDetailsService接口和其loadUserByUsername方法,这个方法根据用户输入的用户名,从数据库里面(常用微服务远程访问的方式)获取该用户的所有权限信息(统称用户信息)。MyUserDetailService拿到用户信息后,传给AuthenticationProvider(实际上使用的是他的实现类DaoAuthenticationProvider) 对比用户的密码(即验证用户),如果通过了,那么相当于通过了AuthenticationProcessingFilter拦截器,也就是登录验证通过。
/**
* Security 配置类
*/
@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
// 初始化密码编码器,用BCryptPasswordEncoder加密密码
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// 初始化认证管理对象,密码模式需要
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
// 放行和认证规则
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
// 放行的请求
.antMatchers("/loginController/**").permitAll()
.and()
.authorizeRequests()
// 其他请求必须认证才能访问
.anyRequest().authenticated();
}
}
@Configuration
public class RedisTokenStoreConfig {
// 注入 Redis 连接工厂
@Autowired
private RedisConnectionFactory redisConnectionFactory;
// 初始化RedisTokenStore 用于将 token 存储至 Redis
@Bean
public RedisTokenStore redisTokenStore() {
RedisTokenStore redisTokenStore = new RedisTokenStore(redisConnectionFactory);
redisTokenStore.setPrefix("TOKEN:"); // 设置key的层级前缀,方便查询
return redisTokenStore;
}
}
@Component
public class CustomerUserDetailService implements UserDetailsService {
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return User.withUsername("yuki").password(passwordEncoder.encode("123456")).roles("TEACHER").build();
}
}
@Configuration
//开启授权服务器
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private RedisTokenStore redisTokenStore;
@Autowired
private UserDetailsService CustomerUserDetailService;
/**
* 配置被允许访问此认证服务器的客户端信息
* 1.内存方式
* 2. 数据库方式
* @param clients
* @throws Exception
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
//暂时先放到内存中
clients.inMemory()
//配置客户端id
.withClient("WebClient")
//配置客户端密钥
.secret(passwordEncoder.encode("123456"))
//配置授权范围
.scopes("all")
//配置访问令牌过期时间
.accessTokenValiditySeconds(60*100)
//配置授权类型
.authorizedGrantTypes("password","refresh_token");
}
//参数名称叫授权服务器端点配置器
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
// password 要这个 AuthenticationManager 实例
endpoints.authenticationManager(authenticationManager)
//使用redis方式管理令牌
.tokenStore(redisTokenStore)
//启动刷新令牌需要在此处指定UserDetailsService
.userDetailsService(CustomerUserDetailService);
}
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
// 开启/oauth/check_token,作用是资源服务器会带着令牌到授权服务器中检查令牌是否正确,然后如果正确授权服务器会给资源服务器返回用户的信息
security.checkTokenAccess("permitAll()");
// 认证后可访问 /oauth/token_key, 默认拒绝访问,作用是获取jwt公钥用来解析jwt
// security.tokenKeyAccess("isAuthenticated()");
}
}
下面的操作都还是在认证服务器中进行,在进行之前先要在配置文件中配置好redis的信息,因为登录要采用redis来做
server.port=9050
#配置单节点的redis服务
spring.redis.host=127.0.0.1
spring.redis.database=0
spring.redis.port=6379
创建LoginController类
这里说一下GETTOKENURL是springsecurity为我们提供的一个方法,我们带着特定的参数访问它就可以获取到访问令牌以及刷新令牌了。
@RestController
@RequestMapping("/loginController")
@Slf4j
public class LoginController {
@Autowired
private RestTemplate restTemplate;
@Autowired
private RedisOperator redisOperator;
private static final String REDIS_USER_CODEKEY = "verifyCode";
private static final String GETTOKENURL = "http://localhost:9050/oauth/token";
//用户登录接口
@PostMapping("/login")
public Result login(String username,String password,String codeKey,String codeKeyIndex){
//通过redis检测验证码是否正确
String verifyCode = redisOperator.get(REDIS_USER_CODEKEY + ":" + codeKeyIndex);
log.info("接收到验证码: "+codeKey);
log.info("verifyCode: "+verifyCode);
if (!codeKey.equals(verifyCode)){
return new Result(HttpServletResponse.SC_FORBIDDEN,null,"验证码不正确");
}
// 构建请求头
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
// 构建请求体(请求参数)
MultiValueMap<String,Object> paramsMap=new LinkedMultiValueMap<>();
paramsMap.add("username", username);
paramsMap.add("password", password);
paramsMap.add("grant_type", "password");
//利用密码模式到sso授权服务器中拿到access_token和refresh_token,因为是同一个项目的模块,可以使用密码模式
//在请求头中带上客户端的账号密码
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(paramsMap, headers);
// 设置 Authorization
restTemplate.getInterceptors().add(
new BasicAuthenticationInterceptor("WebClient","123456"));
ResponseEntity<OAuth2AccessToken> result;
try {
//发送请求,从TokenEndpoint类中可以看到返回值是OAuth2AccessToken
result = restTemplate.postForEntity(GETTOKENURL, entity, OAuth2AccessToken.class);
}catch (HttpClientErrorException e){
return new Result(HttpServletResponse.SC_FORBIDDEN,null,e.getMessage());
}
//处理返回结果
if (result.getStatusCode()!= HttpStatus.OK){
return new Result(HttpServletResponse.SC_UNAUTHORIZED,null,"登录失败");
}
//在这里也可以使用vo对象,封装好前端需要的数据返回,这个token如果以前设置了token加强信息,这里也能获取到
return new Result(HttpServletResponse.SC_OK,result.getBody(),"登录成功");
}
//获取验证码的方法,将验证码存储到redis中
@GetMapping("/getVerifyCode")
public Result getVerifyCode() throws IOException {
//1.生成验证码
String codeKey = VerifyCodeUtils.generateVerifyCode(4);
log.info("验证码:" + codeKey);
//2.存储验证码 redis
String codeKeyIndex = UUID.randomUUID().toString();
//stringRedisTemplate.opsForValue().set(codeKey, code, 60, TimeUnit.SECONDS);
redisOperator.set(REDIS_USER_CODEKEY+":"+codeKeyIndex,codeKey,500);
//3.base64转换验证码
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
VerifyCodeUtils.outputImage(120, 60, byteArrayOutputStream, codeKey);
String data = "data:image/png;base64," + Base64Utils.encodeToString(byteArrayOutputStream.toByteArray());
//4.响应数据
Map<String, String> map = new HashMap<>();
map.put("data",data);
map.put("codeKeyIndex",codeKeyIndex);
return new Result(200,map,"获取验证码成功");
}
//重新登录,刷新令牌的使用,也要使用客户端id和密码进行查找新的令牌。备用
@GetMapping("/refresh")
public Result refresh(@RequestParam("refreshToken") String refreshToken){
//从请求头中解析出refresh_token
System.out.println(refreshToken);
// 构建请求头
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String,Object> paramsMap=new LinkedMultiValueMap<>();
paramsMap.add("grant_type", "refresh_token");
paramsMap.add("refresh_token",refreshToken);
//在请求头中带上客户端的账号密码
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(paramsMap, headers);
//用refresh_token获取到新的access_token
restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor("WebClient","123456"));
OAuth2AccessToken token;
try{
token = restTemplate.postForObject(GETTOKENURL,entity,OAuth2AccessToken.class);
}catch (HttpClientErrorException e){
return new Result(HttpServletResponse.SC_FORBIDDEN,null,e.getMessage());
}
return new Result(200,token,"登录成功");
}
}
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private RedisTokenStore redisTokenStore;
//获取用户信息接口
@RequestMapping("getUserInfo")
public Result getUserInfo(Authentication authentication){
return new Result(200,authentication,"获取用户信息成功");
}
//用户退出登录接口
@RequestMapping("/logout")
public Result logout(@RequestHeader("authorization") String authorization){
if (!StringUtils.isEmpty(authorization)){
String access_token = authorization.toLowerCase().replace("bearer ", "");
//根据访问令牌获取token信息
OAuth2AccessToken token = redisTokenStore.readAccessToken(access_token);
if (token!=null){
//根据token信息删除redis中的数据
redisTokenStore.removeAccessToken(token);
OAuth2RefreshToken refreshToken = token.getRefreshToken();
redisTokenStore.removeRefreshToken(refreshToken);
}
}
return new Result(200,null,"退出成功");
}
}
/**
* 资源服务
*/
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
// 配置放行的资源
http.authorizeRequests()
.anyRequest()
.authenticated()
.and()
.requestMatchers()
//登录后才能进行访问的资源路径
.antMatchers("/user/**");
}
}