我报名参加金石计划1期应战——瓜分10万奖池,这是我的第1篇文章,点击检查活动详情

版本 Java (1.8+) Spring Boot (2.7.3) Spring Security (5.7.3)

一、介绍Security

官方原话“Spring Security is a framework that provides authentication, authorization, and protection against common attacks”即”Spring Security 是一个供给身份验证、授权和避免常见攻击的框架”。它是Spring供给的一个安全框架,能够依据运用者需要定制相关验证授权操作,配合Spring Boot能够快速开发一套完善的权限系统。

二、快速上手

  • 创立一个Spring Boot项目并导入如下依赖或 点击下载示例代码
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-security</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
  • 运行 Spring Boot 应用程序

若是正确发动了,能够看到 Spring Security 生成了一段默许暗码。

...
2022-09-13 23:56:07.841  WARN 19924 --- [           main] .s.s.UserDetailsServiceAutoConfiguration : 
Using generated security password: 70a36ac6-70c1-4f72-822c-71165988c56e
...
  • 拜访 http://localhost:8080/ 会跳转到/login登录页面,输入账号(user)暗码(操控台自动生成的暗码)以持续拜访。

Srping Security主要解决的问题是安全拜访操控,其完成原理是通过Filter对进入系统的恳求进行拦截。当初始化Spring Security时,它创立了一个名为 springSecurityFilterChain的Servlet 过滤器,负责程序中的所以安全操控。

三、基本原理

DelegatingFilterProxy

从必要常识里咱们知道了Filter的作业原理,在Spring中运用自定义的Filter有个问题那便是Filter有必要在Servlet容器发动前就注册好,但是Spring运用ContextLoaderListener来加载Spring Bean,所以规划了DelegatingFilterProxy。本质上来说DelegatingFilterProxy便是一个Filter,其直接完成了Filter接口,它嵌入在Servlet Filter Chain中,但是在doFilter中其实调用的从Spring 容器中获取到的代理Filter的完成类delegate。

Spring Security与RBAC用户模型

FilterChainProxy和SecurityFilterChain

FilterChainProxy 是 Spring Security 供给的一个特殊 Filter,DelegatingFilterProxy并不是直接实例化和调用Spring Security Filter,而是构建了一个FilterChainProxy,当有恳求进来就会去履行doFilter办法调用SecurityFilterChain所包含的各个Filter,一起 这些Filter作为Bean被Spring办理,它是Spring Security运用的核心。

Spring Security与RBAC用户模型

此外,SecurityFilterChain 供给了更大的灵活性,Servlet容器中,仅依据URL调用过滤器。 但是,FilterChainProxy能够使用RequestMatcher接口,依据HttpServletRequest中的任何内容确定调用,比原生的Servlet更灵活,此外,FilterChainProxy能够构建多条SecurityFilterChain,你的应用程序能够为不同的状况供给完全独立的装备,如下图所示。

Spring Security与RBAC用户模型

过滤器链中主要的几个过滤器及其效果

  1. SecurityContextPersistenceFilter :这个Filter是整个拦截过程的进口,会在恳求开始时从装备好的 SecurityContextRepository 中获取 SecurityContext,然后把它设置给 SecurityContextHolder。在恳求完成后将 SecurityContextHolder 持有的 SecurityContext 再保存到装备好的 SecurityContextRepository,一起铲除 securityContextHolder 所持有的 SecurityContext。
  2. UsernamePasswordAuthenticationFilter :用于处理来自表单提交的认证。该表单有必要供给对应的用户名和暗码,其内部还有登录成功或失利后进行处理的 AuthenticationSuccessHandler 和 AuthenticationFailureHandler,这些都能够依据需求做相关改动;。
  3. LogoutFilter:用来处理完成用户登出和铲除认证信息作业,登出成功后履行LogoutSuccessHandler,这儿能够自定义完成一些功用。
  4. FilterSecurityInterceptor: 是用于维护web资源的,运用AccessDecisionManager对当时用户进行授权拜访
  5. ExceptionTranslationFilter: 能够捕获来自 FilterChain 一切的反常,并进行处理。但是它只会处理两类反常: AuthenticationException 和 AccessDeniedException,其它的反常它会持续抛出。

反常处理

Spring Security与RBAC用户模型

  1. 首要,ExceptionTranslationFilter 调用 FilterChain.doFilter(request, response) 来调用应用程序的其余部分。
  2. 如果用户未通过身份验证或者是 AuthenticationException,则发动身份验证。
    • SecurityContextHolder 被铲除
    • HttpServletRequest 保存在 RequestCache 中。当用户成功认证后,RequestCache 用于重放原始恳求。
    • AuthenticationEntryPoint 用于发动身份验证。例如,它可能重定向到登录页面或BASIC认证等。
    • 否则,如果是 AccessDeniedException,则回绝拜访。调用 AccessDeniedHandler 来处理回绝拜访。

表单登录

以上示例在未授权的状况下拜访会通过以下安全过滤器:

Security filter chain: [
  DisableEncodeUrlFilter
  WebAsyncManagerIntegrationFilter
  SecurityContextPersistenceFilter
  HeaderWriterFilter
  CsrfFilter
  LogoutFilter
  UsernamePasswordAuthenticationFilter
  DefaultLoginPageGeneratingFilter
  DefaultLogoutPageGeneratingFilter
  BasicAuthenticationFilter
  RequestCacheAwareFilter
  SecurityContextHolderAwareRequestFilter
  AnonymousAuthenticationFilter
  SessionManagementFilter
  ExceptionTranslationFilter
  FilterSecurityInterceptor
]

当没有登录的时分默许是anonymousUser匿名用户,通过一些列过滤器处理后,最后由FilterSecurityInterceptor进行权限校验授权,AccessDecisionManager进行授权投票,匿名用户不允许拜访该接口,恳求被回绝重定向到登录页面,接着由DefaultLoginPageGeneratingFilter(自定义表单则不会初始化这个Filter)生成默许登录界面输出到浏览器。登录时通过UsernamePasswordAuthenticationFilter,只需用户恳求满足该过滤器要求,则认证成功,接着是授权成功拜访通过。

每个过滤器都有不同的功用,组织在一起形成了强大的安全系统,你能够在过滤链中自定义过滤器,里边的逻辑我就不一一细说了没啥好讲的,官方文档中都有介绍。下面讲讲我自己的一些完成吧。

四、我完成思路是什么,我是怎样完成的

背景:拓宽Spring Security完成根据Token的API认证授权基础程序

选用的广为熟知的RBAC 模型,根据人物的拜访操控(Role-Based Access Control)

拓宽点:

  • 禁用CSRF(有个过滤器校验会报错)、会话办理设置为无状况STATELESS(因为咱们要自定义处理登录刊出逻辑)
  • 自定义UserDetailsService 重写loadUserByUsername办法,从数据库中读取账号信息
  • 添加自定义Token认证过滤器
  • 自定义登录成功和失利处理器successHandler与failureHandler
  • 自定义刊出处理器LogoutSuccessHandler
  • 自定义反常处理器AuthenticationEntryPoint与AccessDeniedHandler
  • 自定义AuthorizationManager

开发调试能够设置一下日志输出等级,这样能助于咱们更快的剖析和排查问题:

logging:
  level:
    org.springframework.web: trace
    org.springframework.security: trace

另外 @EnableWebSecurity 这个注解debug特点设置为true也能看到更多的日志信息,这对咱们很有帮助。

SecurityConfiguration 核心装备类

@EnableWebSecurity(debug = false)
public class SecurityConfiguration {
	private final AppUserDetailsService userDetailsService;
	private final AbstractStringCacheStore cacheStore;
	private final AuthenticationTokenFilter authenticationTokenFilter;
	private final PermissionAuthorizationManager<RequestAuthorizationContext> permissionAuthorizationManager;
	public SecurityConfiguration(AppUserDetailsService userDetailsService, AbstractStringCacheStore cacheStore, PermissionAuthorizationManager<RequestAuthorizationContext> permissionAuthorizationManager) {
		this.userDetailsService = userDetailsService;
		this.cacheStore = cacheStore;
		this.permissionAuthorizationManager = permissionAuthorizationManager;
		this.authenticationTokenFilter = new AuthenticationTokenFilter(cacheStore, userDetailsService);
	}
	@Bean
	public WebSecurityCustomizer webSecurityCustomizer() {
		return (web) -> web.ignoring()
				// Spring Security should completely ignore URLs starting with /resources/
				.antMatchers("/resources/**");
	}
	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
		http.authorizeHttpRequests()
				.antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
				.antMatchers("/").permitAll()
				.antMatchers("/user/info").authenticated() // 需要认证
				.anyRequest().access(permissionAuthorizationManager) // 动态权限认证
				.and()
					.userDetailsService(userDetailsService)
					.formLogin()
					.permitAll()
					.successHandler(new CustomizeAuthenticationSuccessHandler(cacheStore))
					.failureHandler(new AuthenticationEntryPointFailureHandler(new CustomizeAuthenticationEntryPoint()))
				.and()
					.logout()
					.logoutSuccessHandler(new CustomizeLogoutSuccessHandler(cacheStore))
				.and()
					.exceptionHandling()
					.authenticationEntryPoint(new CustomizeAuthenticationEntryPoint())
					.accessDeniedHandler(new CustomizeAccessDeniedHandler())
				.and()
					.csrf().disable()
					.sessionManagement()
					.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
				.and()
					.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class)
					.addFilterBefore(authenticationTokenFilter, LogoutFilter.class);
		return http.build();
	}

释义: AuthenticationTokenFilter-Token认证过滤器(除了自定义开放的接口外都会被调用) PermissionAuthorizationManager-动态权限授权办理器(根据人物与资源权限表) CustomizeAuthenticationSuccessHandler-登录处理器(登录成功后被调用用于生成Token) CustomizeLogoutSuccessHandler-刊出处理器(刊出成功后被调用用于铲除Toekn) CustomizeAuthenticationEntryPoint-认证失利处理器(认证出现反常被调用) CustomizeAccessDeniedHandler-授权失利处理器(授权出现反常被调用,如权限不足以拜访某接口) AbstractStringCacheStore-缓存类(用于缓存Token)

CustomizeAuthenticationSuccessHandler 登录处理器

@Slf4j
public class CustomizeAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
	private final AbstractStringCacheStore cacheStore;
	/**
	 * Expired seconds.
	 */
	private static final int ACCESS_TOKEN_EXPIRED_SECONDS = 24 * 3600;
	private static final int REFRESH_TOKEN_EXPIRED_DAYS = 30;
	public CustomizeAuthenticationSuccessHandler(AbstractStringCacheStore cacheStore) {
		this.cacheStore = cacheStore;
	}
	@Override
	public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
		AppUserDetails userDetails = (AppUserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
		// Generate new token
		AuthToken token = new AuthToken();
		token.setAccessToken(BottleUtils.randomUUIDWithoutDash());
		token.setExpiredIn(ACCESS_TOKEN_EXPIRED_SECONDS);
		token.setRefreshToken(BottleUtils.randomUUIDWithoutDash());
		// Cache those tokens, just for clearing
		cacheStore.putAny(SecurityUtils.buildAccessTokenKey(userDetails), token.getAccessToken(),
				ACCESS_TOKEN_EXPIRED_SECONDS, TimeUnit.SECONDS);
		cacheStore.putAny(SecurityUtils.buildRefreshTokenKey(userDetails), token.getRefreshToken(),
				REFRESH_TOKEN_EXPIRED_DAYS, TimeUnit.DAYS);
		// Cache those tokens with user id
		cacheStore.putAny(SecurityUtils.buildTokenAccessKey(token.getAccessToken()), userDetails.getUserId(),
				ACCESS_TOKEN_EXPIRED_SECONDS, TimeUnit.SECONDS);
		cacheStore.putAny(SecurityUtils.buildTokenRefreshKey(token.getRefreshToken()), userDetails.getUserId(),
				REFRESH_TOKEN_EXPIRED_DAYS, TimeUnit.DAYS);
		response.setCharacterEncoding("utf-8");
		response.setStatus(HttpServletResponse.SC_OK);
		response.setContentType(MediaType.APPLICATION_JSON_VALUE);
		response.getWriter().write(JsonUtils.objectToJson(BaseResponse.ok("登录成功!", token)));
	}

LogoutSuccessHandler 刊出处理器

@Slf4j
public class CustomizeLogoutSuccessHandler implements LogoutSuccessHandler {
	private final AbstractStringCacheStore cacheStore;
	public CustomizeLogoutSuccessHandler(AbstractStringCacheStore cacheStore) {
		this.cacheStore = cacheStore;
	}
	@Override
	public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
		if (Objects.isNull(authentication)) {
			return;
		}
		AppUserDetails userDetails = (AppUserDetails) authentication.getPrincipal();
		// Clear access token
		cacheStore.getAny(SecurityUtils.buildAccessTokenKey(userDetails), String.class)
				.ifPresent(accessToken -> {
					// Delete token
					cacheStore.delete(SecurityUtils.buildTokenAccessKey(accessToken));
					cacheStore.delete(SecurityUtils.buildAccessTokenKey(userDetails));
				});
		// Clear refresh token
		cacheStore.getAny(SecurityUtils.buildRefreshTokenKey(userDetails), String.class)
				.ifPresent(refreshToken -> {
					cacheStore.delete(SecurityUtils.buildTokenRefreshKey(refreshToken));
					cacheStore.delete(SecurityUtils.buildRefreshTokenKey(userDetails));
				});
		response.setCharacterEncoding("utf-8");
		response.setStatus(HttpServletResponse.SC_OK);
		response.setContentType(MediaType.APPLICATION_JSON_VALUE);
		response.getWriter().write(JsonUtils.objectToJson(BaseResponse.ok("登出成功!", null)));
		log.info("You have been logged out, looking forward to your next visit!");
	}
}

AuthenticationTokenFilter Token认证过滤器

@Slf4j
public class AuthenticationTokenFilter extends OncePerRequestFilter {
	private static final String AUTHENTICATION_SCHEME_BEARER = "Bearer";
	private final AbstractStringCacheStore cacheStore;
	private final AppUserDetailsService appUserDetailsService;
	public AuthenticationTokenFilter(AbstractStringCacheStore cacheStore, AppUserDetailsService appUserDetailsService) {
		this.cacheStore = cacheStore;
		this.appUserDetailsService = appUserDetailsService;
	}
	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
		// Get token from request header
		String accessToken = request.getHeader(HttpHeaders.AUTHORIZATION);
		if (!StringUtils.hasText(accessToken)) {
			// Do filter
			filterChain.doFilter(request, response);
			return;
		}
		if (!StringUtils.startsWithIgnoreCase(accessToken, AUTHENTICATION_SCHEME_BEARER)) {
			throw new BadCredentialsException("Token 有必要以bearer开头");
		}
		if (accessToken.equalsIgnoreCase(AUTHENTICATION_SCHEME_BEARER)) {
			throw new BadCredentialsException("Token 不能为空");
		}
		// Get token body
		accessToken = accessToken.substring(AUTHENTICATION_SCHEME_BEARER.length() + 1);
		Optional<Long> optionalUserId = cacheStore.getAny(SecurityUtils.buildTokenAccessKey(accessToken), Long.class);
		if (!optionalUserId.isPresent()) {
			log.debug("Token 已过期或不存在 [{}]", accessToken);
			filterChain.doFilter(request, response);
			return;
		}
		UserDetails userDetails = appUserDetailsService.loadUserById(optionalUserId.get());
		UsernamePasswordAuthenticationToken authentication =
				new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
		SecurityContextHolder.getContext().setAuthentication(authentication);
		// Do filter
		filterChain.doFilter(request, response);
	}
}

CustomizeAuthenticationEntryPoint 认证反常处理器

@Slf4j
public class CustomizeAuthenticationEntryPoint implements AuthenticationEntryPoint {
	@Override
	public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
		BaseResponse<Object> errorDetail = handleBaseException(authException);
		errorDetail.setData(Collections.singletonMap("uri", request.getRequestURI()));
		response.setCharacterEncoding("utf-8");
		response.setStatus(HttpStatus.UNAUTHORIZED.value());
		response.setContentType(MediaType.APPLICATION_JSON_VALUE);
		response.getWriter().write(JsonUtils.objectToJson(errorDetail));
	}
	private BaseResponse<Object> handleBaseException(Throwable t) {
		Assert.notNull(t, "Throwable must not be null");
		BaseResponse<Object> errorDetail = new BaseResponse<>();
		errorDetail.setStatus(HttpStatus.UNAUTHORIZED.value());
		if (log.isDebugEnabled()) {
			errorDetail.setDevMessage(ExceptionUtils.getStackTrace(t));
		}
		if (t instanceof AccountExpiredException){
			errorDetail.setMessage("账户过期");
		} else if (t instanceof DisabledException){
			errorDetail.setMessage("账号被禁用");
		} else if (t instanceof LockedException){
			errorDetail.setMessage("账户被锁定");
		} else if (t instanceof AuthenticationCredentialsNotFoundException){
			errorDetail.setMessage("用户身份凭证未找到");
		} else if (t instanceof AuthenticationServiceException){
			errorDetail.setMessage("用户身份认证服务反常");
		} else if (t instanceof BadCredentialsException){
			errorDetail.setMessage(t.getMessage());
		} else {
			errorDetail.setMessage("拜访未授权");
		}
		return errorDetail;
	}
}

CustomizeAccessDeniedHandler 授权反常

public class CustomizeAccessDeniedHandler implements AccessDeniedHandler {
	@Override
	public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
		BaseResponse<Object> errorDetail = new BaseResponse<>();
		errorDetail.setStatus(HttpStatus.FORBIDDEN.value());
		errorDetail.setMessage("制止拜访");
		response.setCharacterEncoding("utf-8");
		response.setStatus(HttpServletResponse.SC_FORBIDDEN);
		response.setContentType(MediaType.APPLICATION_JSON_VALUE);
		response.getWriter().write(JsonUtils.objectToJson(errorDetail));
	}
}

PermissionAuthorizationManager 动态权限授权办理

@Slf4j
@Component
public class PermissionAuthorizationManager<T> implements AuthorizationManager<T> {
	private final AuthenticationTrustResolver trustResolver = new AuthenticationTrustResolverImpl();
	private final PermissionService permissionService;
	public PermissionAuthorizationManager(PermissionService permissionService) {
		this.permissionService = permissionService;
	}
	@Override
	public AuthorizationDecision check(Supplier<Authentication> authentication, T object) {
		// Determines if the current user is authorized by evaluating if the
		boolean granted = isGranted(authentication.get());
		if (!granted) {
			return new AuthorizationDecision(false);
		}
		Collection<? extends GrantedAuthority> authorities = authentication.get().getAuthorities();
		Set<String> authority = authorities
				.stream()
				.map(GrantedAuthority::getAuthority)
				.collect(Collectors.toSet());
		log.debug("username [{}] hav roles:[{}]", authentication.get().getName(), authority);
		RequestAuthorizationContext requestAuthorizationContext = (RequestAuthorizationContext)object;
		String servletPath = requestAuthorizationContext.getRequest().getRequestURI();
		log.debug("access url:{}", servletPath);
		AppUserDetails userDetails = (AppUserDetails)authentication.get().getPrincipal();
		List<Long> roleIds = userDetails.getRoles().stream().map(Role::getId).collect(Collectors.toList());
		List<Permission> permissions = permissionService.listByRoleIds(roleIds);
		boolean agreeFlag = permissions.stream()
				.anyMatch(permission -> isRouter(permission) && permission.getUrl().equals(servletPath));
		log.debug("check result:{}", agreeFlag);
		return new AuthorizationDecision(agreeFlag);
	}
	private boolean isGranted(Authentication authentication) {
		return authentication != null && isNotAnonymous(authentication) && authentication.isAuthenticated();
	}
	private boolean isNotAnonymous(Authentication authentication) {
		return !this.trustResolver.isAnonymous(authentication);
	}
	private boolean isRouter(Permission permission) {
		return "1".equals(permission.getType());
	}
}

五、示例

登录成功

POST /login?username=user&password=123456
Host: localhost:8080
response:
{
    "status": 200,
    "message": "登录成功!",
    "devMessage": null,
    "data": {
        "access_token": "8430064e7d9b497c8b786a33b0524bc5",
        "expired_in": 86400,
        "refresh_token": "8d2c6fb3489b47389a65cbf79f732f9a"
    }
}

登录失利

POST /login?username=user&password=123
Host: localhost:8080
response:
{
    "status": 401,
    "message": "用户名或暗码过错",
    "devMessage": "org.springframework.security.authentication.BadCredentialsException: 用户名或暗码过错...",
    "data": {
        "uri": "/login"
    }
}

登录刊出

POST /logout
Host: localhost:8080
Authorization: Bearer b6422e3462224126a67f876b5f1b3a1e
response:
{
    "status": 200,
    "message": "登出成功!",
    "devMessage": null,
    "data": null
}

未登录或Token过期

POST /logout
Host: localhost:8080
Authorization: Bearer b6422e3462224126a67f876b5f1b3a1e
response:
{
    "status": 401,
    "message": "拜访未授权",
    "devMessage": "org.springframework.security.authentication.InsufficientAuthenticationException: Full authentication is required to access this resource...",
    "data": {
        "uri": "/user/info"
    }
}

权限不足

GET /admin
Host: localhost:8080
Authorization: Bearer f7a542c4899a4e6ea5039002a8f19110
response:
{
    "status": 403,
    "message": "制止拜访",
    "devMessage": null,
    "data": null
}

六、小结

好了,就分享到这儿了,希望对大家有所帮助,另外如有理解过错的地方请多多指教。 Spring Security还有很多值得探索的功用,持续学习吧~

官方文档:spring-security 项目地址:gitee