前言
当你想让系统实现登录校验的服务,那么你不可能在每个系统里都写认证和授权服务,那么这个时候就要做一套统一的认证框架。这里 Spring Security 就是专注于为 Java 应用程序提供身份验证和授权的框架。提供;验证、授权、防止会话固定、点击劫持、跨域请求等。
认证和授权的原理
在初次使用 Spring Security 框架的时候,会觉得复杂度有些高。其实在之前没有 SpringBoot 之前,Security 这个框架使用是更复杂的。这也主要是因为 Security 支持的灵活性更高,所以抽象的也更复杂。但其实能做一个完整的小案例,也就不会觉得有多复杂了。
其实 Spring Security 要做也就2件事,认证(Authentication)你是谁,授权(Authorization)你干啥。其实就算你不使用 Spring Security 你自己做一个登录的功能,以及允许登录的用户可以操作的流程,也要做这样的事情。
Spring Security 在内部维护一个过滤器链,其中每个过滤器都有特定的职责,并且根据所需的服务在配置中添加或删除过滤器。过滤器的顺序很重要,因为它们之间存在依赖关系。
正式工程案例
这是一套在 DDD 六边形分层结构中添加的 Spring Security 认证框架。如图,介绍了分层模块的使用。
GuavaConfig - 本地缓存模拟用户
@Slf4j
@Configuration
public class GuavaConfig {@Bean(name = "userCache")public Cache<String, UserEntity> userCache(PasswordEncoder passwordEncoder) {Cache<String, UserEntity> cache = CacheBuilder.newBuilder().expireAfterWrite(365, TimeUnit.DAYS).build();UserEntity userEntity01 = UserEntity.builder().userName("xiaomingge").password(passwordEncoder.encode("123456")).roles(Arrays.asList(RoleTypeEnum.ADMIN)).build();UserEntity userEntity02 = UserEntity.builder().userName("liergou").password(passwordEncoder.encode("123456")).roles(Arrays.asList(RoleTypeEnum.USER)).build();log.info("测试账密01 xiaofuge/123456 权限;admin");log.info("测试账密02 liergou/123456 权限;user");cache.put(userEntity01.getUserName(), userEntity01);cache.put(userEntity02.getUserName(), userEntity02);return cache;}}
程序启动后,模拟注册完成的用户用户测试验证。用户也可以在测试中自己在注册用户。
UserDetails 用户身份信息
身份实现
public class UserDetailAuthSecurity implements UserDetails {@Serialprivate static final long serialVersionUID = 931859819772024712L;private final UserEntity userEntity;public UserDetailAuthSecurity(UserEntity userEntity) {this.userEntity = userEntity;}@Overridepublic Collection<? extends GrantedAuthority> getAuthorities() {return userEntity.getRoles().stream().map(role -> new SimpleGrantedAuthority("ROLE_" + role.getCode())).collect(Collectors.toList());}@Overridepublic String getPassword() {return userEntity.getPassword();}@Overridepublic String getUsername() {return userEntity.getUserName();}// ...}
做授权校验是基于用户的 UserDetails 详细身份进行的。这东西就是一个依赖倒置,Spring 定义好接口标准,之后由使用方实现。
身份获取
@Service
public class UserDetailsServiceImpl implements UserDetailsService {@Resourceprivate Cache<String, UserEntity> userCache;@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {UserEntity userEntity = userCache.getIfPresent(username);if (null == userEntity) return null;return new UserDetailAuthSecurity(userEntity);}}
这里还需要对 UserDetails 包装一层提供一个 UserDetailsService 接口的实现类。
授权&校验处理
JwtAuthenticationProvider - 验证账密
public class JwtAuthenticationProvider implements AuthenticationProvider {private final PasswordEncoder passwordEncoder;private final UserDetailsService userDetailsService;public JwtAuthenticationProvider(PasswordEncoder passwordEncoder, UserDetailsService userDetailsService) {this.passwordEncoder = passwordEncoder;this.userDetailsService = userDetailsService;}@Overridepublic Authentication authenticate(Authentication authentication) throws AuthenticationException {String username = String.valueOf(authentication.getPrincipal());String password = String.valueOf(authentication.getCredentials());UserDetails userDetails = userDetailsService.loadUserByUsername(username);if (passwordEncoder.matches(password, userDetails.getPassword())) {return new UsernamePasswordAuthenticationToken(username, password, userDetails.getAuthorities());}throw new BadCredentialsException("Auth Error!");}@Overridepublic boolean supports(Class<?> authentication) {return UsernamePasswordAuthenticationToken.class.equals(authentication);}}
这一部分是获取用户名和密码,通过 userDetailsService 获取信息进行密码比对。这个就和我们自己要做一个登录校验的方式是一样的。
JwtAuthenticationTokenFilter - 校验登录
@Slf4j
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {private final static String AUTH_HEADER = "Authorization";private final static String AUTH_HEADER_TYPE = "Bearer";private final UserDetailsService userDetailsService;public JwtAuthenticationTokenFilter(UserDetailsService userDetailsService) {this.userDetailsService = userDetailsService;}@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {String authHeader = request.getHeader(AUTH_HEADER);if (Objects.isNull(authHeader) || !authHeader.startsWith(AUTH_HEADER_TYPE)){filterChain.doFilter(request,response);return;}String authToken = authHeader.split(" ")[1];log.info("authToken:{}" , authToken);if (!JWTUtil.verify(authToken, "key".getBytes(StandardCharsets.UTF_8))) {filterChain.doFilter(request,response);return;}final String userName = (String) JWTUtil.parseToken(authToken).getPayload("username");UserDetails userDetails = userDetailsService.loadUserByUsername(userName);UsernamePasswordAuthenticationToken authentication =new UsernamePasswordAuthenticationToken(userDetails.getUsername(), userDetails.getPassword(), userDetails.getAuthorities());authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));SecurityContextHolder.getContext().setAuthentication(authentication);filterChain.doFilter(request, response);}}
fetch(url, {method: 'POST',headers: {'Content-Type': 'application/json','Authorization': `Bearer ${token}` // Include the token in the request headers}
})
这一部分是对 http 请求信息中的 Authorization Bearer 后面带有的 token 信息进行解析校验。如代码中提供了一部分前端请求代码,就是这里的 Token
认证&授权配置
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SpringSecurityConfig {// 不拦截的 URLprivate final String[] requestMatchers = {"/api/auth/login", "/api/auth/register", "/api/auth/query_user_name", "/test/**"};@Beanpublic AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {return authConfig.getAuthenticationManager();}@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}@Beanpublic JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter(UserDetailsService userDetailsService) {return new JwtAuthenticationTokenFilter(userDetailsService);}@Beanpublic JwtAuthenticationProvider jwtAuthenticationProvider(PasswordEncoder passwordEncoder, UserDetailsService userDetailsService) {return new JwtAuthenticationProvider(passwordEncoder, userDetailsService);}@Beanpublic SecurityFilterChain filterChain(HttpSecurity httpSecurity,JwtAuthenticationProvider jwtAuthenticationProvider,JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter,AppUnauthorizedHandler appUnauthorizedHandler,AppAccessDeniedHandler appAccessDeniedHandler) throws Exception {// 使用JWT,可屏蔽csrf防护httpSecurity.csrf(CsrfConfigurer::disable)// 基于token存储到浏览器,不需要session.sessionManagement(sessionManagementConfigurer -> sessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)).authorizeHttpRequests(authorizationRegistry -> authorizationRegistry// 允许对于网站静态资源的无授权访问.requestMatchers(HttpMethod.GET, "/", "/*.html").permitAll()// 对登录注册允许匿名访问.requestMatchers(requestMatchers).permitAll()// 访问授权,所有 /user/** 路径下的请求需要 ADMIN 角色。注意;Spring Security在处理角色时,会自动为角色名添加"ROLE_"前缀。因此,"ADMIN"角色实际上对应权限"ROLE_ADMIN"。.requestMatchers("/api/mall/**").permitAll()// 跨域请求会先进行一次options请求.requestMatchers(HttpMethod.OPTIONS).permitAll()// 对所有请求开启授权保护.anyRequest()// 已认证的请求自动被授权.authenticated())// 禁用缓存.headers(headersConfigurer -> headersConfigurer.cacheControl(HeadersConfigurer.CacheControlConfig::disable))// 使用自定义 provider.authenticationProvider(jwtAuthenticationProvider)// 添加 JWT filter.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class)// 添加自定义未授权和未登录结果返回.exceptionHandling(exceptionConfigure -> exceptionConfigure.accessDeniedHandler(appAccessDeniedHandler).authenticationEntryPoint(appUnauthorizedHandler));return httpSecurity.build();}}
那么这里所做的就是认证授权的配置,对哪些URL进行放行,哪些是要做拦截。
appAccessDeniedHandler、appUnauthorizedHandler,是自定义的鉴权拦截,如果登录不通过,可以统一返回给前端一个固定的错误码,便于跳转登录。
注册登录
@Service
public class AuthService implements IAuthService {@Autowiredprivate PasswordEncoder passwordEncoder;@Resourceprivate Cache<String, UserEntity> userCache;@Autowiredprivate AuthenticationManager authenticationManager;@Overridepublic void register(String userName, String password) {UserEntity userEntity = UserEntity.builder().userName(userName).password(passwordEncoder.encode(password)).roles(Arrays.asList(RoleTypeEnum.USER, RoleTypeEnum.ADMIN)).build();userCache.put(userName, userEntity);}@Overridepublic String login(String userName, String password) {// 登录验证authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(userName, password));// 验证通过,获取 tokenString token = JWT.create().setExpiresAt(new Date(System.currentTimeMillis() + (1000 * 30))).setPayload("username", userName).setKey("key".getBytes(StandardCharsets.UTF_8)).sign();return token;}}
在 domain 模块中提供了一个简单的注册&登录服务。注册就是简单的像本地缓存 Guava 写入数据。登录校验会调用登录密码校验处理。在登录成功后返回 JWT 生成的 token 信息。
访问拦截
认证授权
@Slf4j
@CrossOrigin("*")
@RestController
@RequestMapping("/api/auth/")
public class AuthController {@Resourceprivate IAuthService authService;@Autowiredprivate AuthenticationManager authenticationManager;@PostMapping("query_user_name")public Response<String> queryUserName() {try {// 获取当前认证的用户信息Authentication authentication = SecurityContextHolder.getContext().getAuthentication();Object principal = authentication.getPrincipal();return Response.<String>builder().code(Response.ResponseCode.SUCCESS.getCode()).info(Response.ResponseCode.SUCCESS.getInfo()).data(principal.toString()).build();} catch (Exception e) {return Response.<String>builder().code(Response.ResponseCode.UN_ERROR.getCode()).info(Response.ResponseCode.UN_ERROR.getInfo()).build();}}@PostMapping("register")public Response<Boolean> register(@RequestParam String userName, @RequestParam String password) {try {log.info("注册用户:{}", userName);authService.register(userName, password);return Response.<Boolean>builder().code(Response.ResponseCode.SUCCESS.getCode()).info(Response.ResponseCode.SUCCESS.getInfo()).data(true).build();} catch (Exception e) {log.info("注册用户失败:{}", userName);return Response.<Boolean>builder().code(Response.ResponseCode.UN_ERROR.getCode()).info(Response.ResponseCode.UN_ERROR.getInfo()).build();}}@PostMapping("login")public Response<String> login(@RequestParam String userName, @RequestParam String password) {try {log.info("登录用户:{}", userName);// 登录获取 tokenString token = authService.login(userName, password);return Response.<String>builder().code(Response.ResponseCode.SUCCESS.getCode()).info(Response.ResponseCode.SUCCESS.getInfo()).data(token).build();} catch (Exception e) {log.info("登录用户失败:{}", userName);return Response.<String>builder().code(Response.ResponseCode.UN_ERROR.getCode()).info(Response.ResponseCode.UN_ERROR.getInfo()).build();}}}
提供注册、登录和查询用户信息接口。
查询用户有些场景是会通过路径地址获取用户id,再根据用户id查询。但一些安全级别较高的,甚至不会透彻用户id,而是校验登录token,之后缓存用户id在使用。
角色权限
@Slf4j
@CrossOrigin("*")
@RestController
@RequestMapping("/api/mall/")
public class MallController {@PreAuthorize("hasRole('ADMIN')")
// @PreAuthorize("hasRole('USER')")@RequestMapping(value = "create_pay_order", method = RequestMethod.POST)public Response<String> createPayOrder(@RequestBody CreatePayRequestDTO createPayRequestDTO) {try {// 获取当前认证的用户信息Authentication authentication = SecurityContextHolder.getContext().getAuthentication();Object principal = authentication.getPrincipal();String userName = (String) principal;String productId = createPayRequestDTO.getProductId();log.info("商品下单,根据商品ID创建支付单开始 userName:{} productId:{}", userName, productId);return Response.<String>builder().code(Response.ResponseCode.SUCCESS.getCode()).info(Response.ResponseCode.SUCCESS.getInfo()).data(userName + " 下单成功。单号:" + RandomStringUtils.randomAlphabetic(12)).build();} catch (Exception e) {log.error("商品下单,根据商品ID创建支付单开始 productId:{}", createPayRequestDTO.getProductId(), e);return Response.<String>builder().code(Response.ResponseCode.UN_ERROR.getCode()).info(Response.ResponseCode.UN_ERROR.getInfo()).build();}}}
用户登录完成后,提供一个下单接口。
注意,接口上有;ADMIN、USER 权限注解,我们在配置默认账号的时候,给xiaofuge是 ADMIN权限,liergou 是USER权限。配置不同的注解,会导致下单成功或者失败。
测试验证
测试前启动 SpringBoot 服务。
首次登录
function login() {const username = document.getElementById('username').value;const password = document.getElementById('password').value;fetch('http://127.0.0.1:8091/api/auth/login', {method: 'POST',headers: {'Content-Type': 'application/x-www-form-urlencoded'},body: new URLSearchParams({userName: username,password: password})}).then(response => response.json()).then(data => {if (data.code === '0000') {// Store token in localStorage on successful loginlocalStorage.setItem('xfg-dev-tech-spring-security-token', data.data);window.location.href = 'index.html'; // 假设登录成功后跳转到首页} else {alert('登录失败: ' + data.info);}}).catch(error => {console.error('Error during login:', error);alert('登录失败');});
}
测试账号;xiaomingge/123456、liergou/123456,xiaomingge是 admin 权限,liergou 是 user 权限,你可以分别测试验证。
你还可以自己注册新的账号进行验证。
首页下单
document.addEventListener("DOMContentLoaded", function () {var token = localStorage.getItem('xfg-dev-tech-spring-security-token');if (!token) {window.location.href = "login.html"; // Redirect to the login pagereturn;}var productId = "100010090091";var url = 'http://127.0.0.1:8091/api/auth/query_user_name';fetch(url, {method: 'POST',headers: {'Content-Type': 'application/json','Authorization': `Bearer ${token}` // Include the token in the request headers}}).then(response => response.json()) // Parse the JSON response.then(json => {const userNameDisplay = document.getElementById('userNameDisplay');if (json.code === "0000") {userNameDisplay.textContent = json.data;} else {userNameDisplay.textContent = '未登录';}}).catch(error => {console.error('Error fetching user name:', error);document.getElementById('userNameDisplay').textContent = '未登录';});});document.getElementById('orderButton').addEventListener('click', function() {var token = localStorage.getItem('dev-tech-spring-security-token');if (!token) {window.location.href = "login.html"; // Redirect to the login pagereturn;}var productId = "100010090091";var url = 'http://127.0.0.1:8091/api/mall/create_pay_order';var requestBody = {productId: productId};fetch(url, {method: 'POST',headers: {'Content-Type': 'application/json','Authorization': `Bearer ${token}` // Include the token in the request headers},body: JSON.stringify(requestBody) // Convert the request body to a JSON string}).then(response => response.json()) // Parse the JSON response.then(json => {if (json.code === "0000") { // Assume success code is "0000"alert(json.data);} else {alert("code:"+json.code +" "+json.info)console.error('Error:', json.info); // Output error information}}).catch(error => console.error('Error:', error));
});
1:登录成功后可以通过浏览器 F12 查看到登录的 Token,如果要取消登录,可以操作代码把 Token 删掉。
2:登录成功后就可以点击下单了。默认代码的权限配置的是只有 xiaomingge可以下单,liergou不能下单。
下单成功
下单失败
好了 Spring Security 学习结束了 友友们 点点关注不迷路 老铁们!!!!!