前言:
Spring Security 是 Spring 家族中的一个框架,提供了一套 Web 应用安全性的完整解决方案。一般来说,系统的安全性包括用户认证(Authentication)和用户授权(Authorization)两个部分。
认证:验证当前访问系统的是不是本系统的用户,用户认证一般要求用户提供用户名和密码,或者手机号和验证码等形式。
授权:经过认证后判断当前用户是否有权限执行某个操作。在系统中,会对用户校色权限管理,不同的用户具有的权限是不同的。一般系统也是基于角色权限管理(RBAC),会为不同的用户分配不同的角色,而每个角色则对应一系列的权限。
一:登录流程
- 前端登录页面会携带用户名和密码调用后端登录接口
- 后端对用户名和密码进行数据库校验
- 校验成功后,会根据当前用户信息生成一个token
- 将token响应给前端
- 之后前端访问系统资源都需要请求头携带token访问后端
- 后端获取请求头token进行解析,解析用户信息。
- 校验用户是否有权限访问相关资源,有则访问资源响应给前端
二:Spring Security登录流程
1.过滤器链
- SecurityContextPersistenceFilter:维护安全上下文,确保线程间的安全信息传递。
- UsernamePasswordAuthenticationFilter:负责处理基于表单的登录请求,收集用户名和密码,调用AuthenticationManager进行验证。
- ConcurrentSessionFilter:管理并发会话,避免同一账号多处登录。
- ExceptionTranslationFilter:捕获并处理认证或授权失败的异常。
- FilterSecurityInterceptor:执行访问决策,依据用户权限判断是否允许访问特定资源。
2. 认证管理器
AuthenticationManager负责处理认证请求,它接受一个Authentication对象(包含用户凭证),并返回一个经过完全填充的(已验证的或未经验证的)Authentication对象。对于基于用户名和密码的登录,Spring Security提供了DaoAuthenticationProvider,它使用UserDetailsService来检索用户信息。
3. 用户详情服务
UserDetailsService接口定义了一个方法loadUserByUsername(String username),用于根据用户名加载用户信息。开发者需要实现这个接口,通常从数据库中查询用户信息。返回的是UserDetails对象,它包含用户的用户名、密码(通常是加密的)、权限等安全相关信息。
4. 用户详情
UserDetails表示用户安全信息的核心接口,包含用户名、密码、账号是否过期、凭证是否过期、账号是否锁定以及赋予用户的权限集合。一个典型的实现org.springframework.security.core.userdetails.User。
具体步骤:
登录流程的简化步骤:
-
用户提交登录表单,表单中通常包含用户名和密码。
-
客户端发送请求到服务器,请求到达
UsernamePasswordAuthenticationFilter
过滤器。 -
UsernamePasswordAuthenticationFilter
过滤器会尝试从请求中提取用户名和密码,并构造一个UsernamePasswordAuthenticationToken
对象,并转发给AuthenticationManager。 -
AuthenticationManager
接口的实现(通常是ProviderManager
)会根据配置的AuthenticationProvider
s来验证这个Authentication
对象。 -
实现UserDetailsService的服务来加载用户详情(UserDetails)使用PasswordEncoder比较提交的密码与数据库中存储的密码,验证密码是否正确。
-
如果验证成功,
AuthenticationManager
会返回一个包含用户详情的Authentication
对象,否则抛出认证失败异常。 -
在成功验证后,
SecurityContextHolder
会更新安全上下文,存储认证信息。 -
最后,用户会被重定向到登录成功页面或获取认证后的资源。
认证失败时,抛出异常,由ExceptionTranslationFilter捕获并处理,可能重定向到登录页面显示错误消息,或响应HTTP 401 Unauthorized。
访问控制:用户携带令牌访问受保护资源时,FilterSecurityInterceptor基于用户的角色和权限进行访问决策,决定是否允许访问。
代码演示;
注意:这里演示没有引入redis,用户信息都在内存操作。
1.相关依赖
<dependencies><!--web--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!--security--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency><!--测试使用--><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></dependency><!--测试使用--><dependency><groupId>cn.hutool</groupId><artifactId>hutool-all</artifactId><version>5.8.8</version></dependency></dependencies>
2.登录认证
登录:
- 自定义登录接口:调用 AuthenticationManager的方法进行认证,如果认证通过生成 jwt,把用户信息存入 redis 中(常规做法,这里我用内存)。
//登录接口
public interface ILoginService {/** 登录 */Result login(User user);/** 退出登录 */Result loginOut();
}
@Service
public class LoginServiceImpl implements ILoginService {@Autowiredprivate AuthenticationManager authenticationManager;private static Map<String, Object> cache = new HashMap();//登录@Overridepublic Result login(User user) {/** 用户名和密码的认证请求,并通过AuthenticationManager进行用户认证。 */UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUsername(),user.getPassword());Authentication authenticate = authenticationManager.authenticate(authenticationToken);// 判空if (Objects.isNull(authenticate)) {throw new RuntimeException("用户名或密码有误");}//从认证成功的Authentication对象中获取principal,转换为LoginUser类型,进而得到用户账号userName。LoginUser loginUser = (LoginUser) authenticate.getPrincipal();String userName= loginUser.getUser().getUsername();// 通过 hutool-Jwt工具类 使用 userName生成tokenHashMap<String, Object> payload = new HashMap<>();payload.put("name", userName);String token = JWTUtil.createToken(payload, "123456".getBytes());// 将token存入内存(这一步常规做法存redis)CacheUser.CACHE.put(name, loginUser);// 将token响应给前端HashMap<String, Object> map = new HashMap<>();map.put("token", token);return new Result(200, "登录成功", map);}//退出登录@Overridepublic Result loginOut() {// 从存储权限的集合SecurityContextHolder内将获取到Authentication对象。里面包含已认证的用户信息,权限集合等。Authentication authentication = SecurityContextHolder.getContext().getAuthentication();// 从 获取到的对象内 取出 已认证的主体LoginUser loginUser = (LoginUser) authentication.getPrincipal();// 从被认证的主体内取出用户名String userName= loginUser.getUser().getUsername();// 根据键为用户名userAccount删除对应的用户信息cache.remove(userName);return new Result(200, "退出成功", "");}
}
- 自定义 CustomUserDetailsService,这个实现类中去查询数据库(常规做法,这里我用内存)。
基础实体:
//登录用户@Data
@NoArgsConstructor
public class LoginUser implements UserDetails {private User user;public LoginUser(User user) {this.user = user;}// 存储Security所需要的权限信息的集合private List<GrantedAuthority> authorities;@Overridepublic Collection<? extends GrantedAuthority> getAuthorities() {return null;}@Overridepublic String getPassword() {return user.getPassword();}@Overridepublic String getUsername() {return user.getUsername();}@Overridepublic boolean isAccountNonExpired() {return true;}@Overridepublic boolean isAccountNonLocked() {return true;}@Overridepublic boolean isCredentialsNonExpired() {return true;}@Overridepublic boolean isEnabled() {return true;}
}//用户
@Data
public class User {//用户名private String username;//密码private String password;
}
实现UserDetailsService 接口:
@Service
public class CustomUserDetailsService implements UserDetailsService {@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {// 使用用户名获取用户全部信息(这一步常规做法,查数据库)User user = new User();user.setUsername(username);user.setPassword("$2a$10$GL84UYDv2.kQgREcw6wNQ.0eQWIAOno.gNnoI7UUSpYT6JdJ1QH2i");// 查询不到用户信息则抛出异常if (Objects.isNull(user)) {throw new RuntimeException("用户名或密码有误");}//(这一步常规做法,查权限添加到 LoginUser)return new LoginUser(user);}
}
校验:
-
定义 Jwt 认证过滤器,获取 token,解析 token 获取用户信息,从 redis 中获取用户信息(常规做法,这里我用内存),存入 SecurityContextHolder。
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)throws ServletException, IOException {// 从请求头获取tokenString token = request.getHeader("token");// 检查获取到的token是否为空或空白字符串if (!StringUtils.hasText(token)) {// 如果token为空,则直接放行请求到下一个过滤器,不做进一步处理并结束当前方法,不继续执行下面代码。filterChain.doFilter(request, response);return;}// 解析tokenString userName;try {userName = (String) JWTUtil.parseToken(token).getPayloads().get("name");} catch (Exception e) {e.printStackTrace();throw new RuntimeException("token非法");}// 从内存获取用户(这一步常规做法从redis中获取用户信息)LoginUser loginUser = CacheUser.CACHE.get(userName);if (Objects.isNull(loginUser)) {throw new RuntimeException("用户未登录");}//将用户信息存入 SecurityConText//UsernamePasswordAuthenticationToken 存储用户名 密码 权限的集合UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());//SecurityContextHolder是Spring Security用来存储当前线程安全的认证信息的容器。//将用户名 密码 权限的集合存入SecurityContextHolderSecurityContextHolder.getContext().setAuthentication(authenticationToken);//放行filterChain.doFilter(request, response);}
}
3.配置类:
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {@Autowiredprivate JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;@Overrideprotected void configure(HttpSecurity http) throws Exception {//后端测试要关闭csrf()http.csrf().disable()//不通过session获取SecurityContext.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and().authorizeRequests()// 登录接口公开访问.antMatchers("/sys/login").anonymous()// 除上面公开的接口外,所有的请求都需要鉴定认证.anyRequest().authenticated().and()// 添加 过滤器.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);// 允许跨域http.cors();}/*** AuthenticationManager 认证*/@Bean@Overridepublic AuthenticationManager authenticationManagerBean() throws Exception {return super.authenticationManagerBean();}//密码加密@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}
}
测试:
@RequestMapping("/sys")
@RestController
public class UserController {@Resourceprivate ILoginService loginService;@PostMapping("/login")public Result login(@RequestBody User user){return loginService.login(user);}@PostMapping("/logout")public Result logout(){return new Result(200,"成功");}
}
执行结果:
登录:
登出:
权限这边的测试大家可以自己演示一遍,博主这里就不演示了,将改点告诉大家
- 在登录用户实体加上权限字段
@Data
@NoArgsConstructor
public class LoginUser implements UserDetails {private User user;// 存储权限信息private List<String> permissions;public LoginUser(User user, List<String> permissions) {this.user = user;this.permissions = permissions;}// 存储 Security 所需要的权限信息的集合@JSONField(serialize = false)private List<GrantedAuthority> authorities;@Overridepublic Collection<? extends GrantedAuthority> getAuthorities() {if (authorities != null) {return authorities;}// 将 permissions 内字符串类型的权限信息转换为 GrantedAuthority 对象存入 authoritiesauthorities = permissions.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());return authorities;}
}
- 查询数据库这一步,通过之后查询权限
@Service
public class CustomUserDetailsService implements UserDetailsService {@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {// 使用用户名获取用户全部信息User user = new User();user.setUsername(username);user.setPassword("$2a$10$GL84UYDv2.kQgREcw6wNQ.0eQWIAOno.gNnoI7UUSpYT6JdJ1QH2i");// 查询不到用户信息则抛出异常if (Objects.isNull(user)) {throw new RuntimeException("用户名或密码有误");}// TODO 在授权时返回此处。 根据用户查询权限信息,再添加到 LoginUser 中。return new LoginUser(user,"权限");}
}