SpringSecurity
简介
SpringSecurity 是 Spring 家族中的一个安全管理框架。相比与另外一个安全框架 Shiro,它提供了更丰富的功能,社区资源也比 Shiro丰富。一般来说中大型的项目都是使用 SpringSecurity 来做安全框架。小项目有 Shiro 的比较多,因为相比与 SpringSecurity,Shiro 的上手更加的简单。
一般 Web 应用都需要进行认证和授权:
- 认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户。
- 授权:经过认证后判断当前用户是否有权限进行某个操作。
而认证和授权也是 SpringSecurity 作为安全框架的核心功能。
认证
登录校验流程
前后端分离项目的登录流程一般为:前端先携带用户名密码访问后端登录接口,后端去和数据库种的用户名和密码进行比对,如果正确,使用用户名 / 用户 id 生成一个 jwt 返回给前端。后续,前端在登陆后访问后端的其他接口的时候需要携带这个 jwt token,后端在前端访问时也就会去校验 token 的正确性,实现认证功能。
SpringSecurity 认证流程
SpringSecurity 的原理其实就是一个过滤链,内部包含了各种功能的过滤器。最初始的 SpringSecurity 工程的认证流程主要有以下过滤器参与:
- UsernamePasswordAuthenticationFilter:负责处理在 SpringSecurity 默认登录页面填写用户名和密码后的登录请求。
- ExceptionTranslationFilter:处理过滤器链种抛出的任何 AccessDeniedException 和 AuthenticationException。
- FilterSecurityInterceptor:负责权限校验的过滤器。
Authentication
接口: 表示当前访问系统的用户,封装了用户相关信息。AuthenticationManager
接口:定义了认证 Authentication 的方法。UserDetailsService
接口:加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法。UserDetails
接口:提供核心用户信息。通过 UserDetailsService 根据用户名获取处理的用户信息要封装成 UserDetails 对象返回。然后将这些信息封装到 Authentication 对象中。
自定义认证流程
在默认的 SpringSecurity 中,最后一步是使用 InMemoryUserDetailManager 对用户权限进行判断,并且,这个查找操作是在内存中查找的。实际上,我们需要的是从数据库中获取数据,所以我们需要自己编写一个 UserDetailsService 实现 InMemoryUserDetailManager 来替换原本的操作。
其次的问题是,最后一步返回 Authentication 对象的时候,会返回到 Filter 当中,这是我们不希望看到的。我们希望能够返回一个 token 给前端,所以,UsernamePasswordAuthenticationFilter 也是需要我们重写的。
当然,后续前端对后端接口的请求还是需要自己编写过滤器来对 jwt 信息进行校验。
认证流程开发
数据库校验
之前我们说过,在默认的 SpringSecurity 中,最后一步是使用 InMemoryUserDetailManager 对用户权限进行判断,并且,这个查找操作是在内存中查找的。因此,我们需要替换这个 UserDetailsService 实现类,让其可以查找我们自己的数据库:
// 自己定义实现类实现UserDetailService,这样SpringSecurity就会自动使用我们自定义的类
@Service
public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> implements ISysUserService, UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 查询用户信息
// select * from sys_user where user_name = #{username}
SysUser user = lambdaQuery().eq(SysUser::getUserName, username).one();
if (user == null) {
throw new UsernameNotFoundException(username);
}
// TODO 查询对应的权限信息
// 封装UserDetails
return new LoginUser(user);
}
}
不过,其返回值是一个 UserDetails 接口,所以我们需要有一个实现类:
/**
* 实现UserDetails接口封装用户信息
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginUser implements UserDetails {
/**
* 直接封装SysUser内部类
*/
private SysUser user;
@JsonIgnore // 防止被序列化
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of();
}
@JsonIgnore // 防止被序列化
@Override
public String getPassword() {
return user.getPassword();
}
@JsonIgnore // 防止被序列化
@Override
public String getUsername() {
return user.getUserName();
}
// ...
}
完成上述步骤后,即可完成对数据库校验的自定义。
密码加密存储
实际项目中我们不会直接把密码存放入数据库中。
我们之前的 SpringSecurity 认证流程图中有提到:当我们通过 UserDetailsService 的实现类从数据库(默认是内存)中查找到用户信息并封装成 UserDetails 对象后,会返回给 DaoAuthenticationProvider 进行密码和权限的认证。
默认使用的 PasswordEncoder 要求数据库的密码格式为:{id}password
。它会根据 id 去判断密码的加密方式。但是我们一般不采用这种方式,所以我们需要替换默认的 PasswordEncoder。
我们一般使用 SpringSecurity 为我们提供的 BCryptPasswordEncoder:
@Configuration
@EnableWebSecurity // 代替了implements WebSecurityConfigurerAdapter
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
BCryptPasswordEncoder 的使用如下:
// Create an encoder with strength 16
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(16);
String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));
只需要在用户注册时,使用 BCryptPasswordEncoder 对密码进行加密即可。
登录接口实现
我们现在还是默认使用的 SpringSecurity 提供的登录接口,现在我们自己来实现一个登录的接口(默认的 SpringSecurity 会拦截各种请求,所以我们还需要对这个自定义的登录接口进行放行):
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Resource
private AuthenticationConfiguration authenticationConfiguration;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
/**
* 配置Spring Security的过滤链。
*
* @param http 用于构建安全配置的HttpSecurity对象。
* @return 返回配置好的SecurityFilterChain对象。
* @throws Exception 如果配置过程中发生错误,则抛出异常。
*/
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 禁用CSRF保护
.csrf(csrf -> csrf.disable())
// 设置会话创建策略为无状态
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 配置授权规则 指定login路径.允许匿名访问(未登录可访问已登陆不能访问). 其他路径需要身份认证
.authorizeHttpRequests(auth -> auth.requestMatchers("/sys-user/login", "/doc.html", "/v3/api-docs/**", "/swagger-ui.html", "/webjars/**").anonymous().anyRequest().authenticated());
// 开启跨域访问
// .cors(AbstractHttpConfigurer::disable);
// 构建并返回安全过滤链
return http.build();
}
}
@Service
public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> implements ISysUserService, UserDetailsService {
@Resource
private @Lazy AuthenticationManager authenticationManager;
@Resource
private StringRedisTemplate stringRedisTemplate;
// 通过setSerializationInclusion保证序列化所有字段,如果是使用DTO的话,可以不用设置
private final ObjectMapper mapper = new ObjectMapper()
.setSerializationInclusion(JsonInclude.Include.ALWAYS);
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 查询用户信息
// select * from sys_user where user_name = #{username}
SysUser user = lambdaQuery().eq(SysUser::getUserName, username).one();
if (user == null) {
throw new UsernameNotFoundException(username);
}
// TODO 查询对应的权限信息
// 封装UserDetails
return new LoginUser(user);
}
@Override
public Result<String> login(SysUser sysUser) {
// 封装authentication对象
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(sysUser.getUserName(), sysUser.getPassword());
// 进行用户认证,这个方法走到后面会去调用loadUserByUsername进行数据库信息的校验
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
if (Objects.isNull(authenticate)) {
throw new RuntimeException("登录失败");
}
// 登录验证通过,需要获取用户信息生成jwt
LoginUser user = (LoginUser) authenticate.getPrincipal();
Long userId = user.getUser().getId();
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
String token = JwtUtil.genToken(claims);
// 把SysUser存入redis中
try {
String userJson = mapper.writeValueAsString(user);
stringRedisTemplate.opsForValue().set("login:" + userId, userJson);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
return Result.success(token);
}
}
定义 jwt 认证的过滤器
我们需要自定义一个过滤器,这个过滤器会去获取请求头中的 token,对 token 进行解析取出其中的 userid(主要作用于除登录外的请求)。使用 userid 去 redis 中获取对应的 LoginUser 对象。然后封装 Authentication 对象存入 SecurityContextHolder。只要是收保护的路径访问,过滤器都会从 SecurityContextHolder 里先尝试拿到用户的相关信息,如果拿不到,就表示访问失效。
@Component
// OncePerRequestFilter特点是在处理单个HTTP请求时确保过滤器的doFilterInternal方法只被调用一次
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Resource
private StringRedisTemplate stringRedisTemplate;
private final ObjectMapper mapper = new ObjectMapper()
.setSerializationInclusion(JsonInclude.Include.ALWAYS);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 获取token
String token = request.getHeader("token");
if (!StringUtils.hasText(token)) {
// 放行,不需要解析token,SecurityContextHolder没有内容自然会被后面的filter拦截
filterChain.doFilter(request, response);
return; // 防止响应回来的时候走下方的代码解析token
}
// 解析token
Map<String, Object> claims = JwtUtil.parseToken(token);
Object userId = claims.get("userId");
// 从redis中获取用户信息
String redisKey = "login:" + userId;
String userJson = stringRedisTemplate.opsForValue().get(redisKey);
if (!StringUtils.hasText(userJson)) {
throw new RuntimeException("用户未登录");
}
LoginUser user = mapper.readValue(userJson, LoginUser.class);
// 存入SecurityContextHolder
// TODO 获取权限信息封装到Authentication
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user, null, null);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
// 放行
filterChain.doFilter(request, response);
}
}
编写好过滤器后,需要我们进行配置:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Resource
private AuthenticationConfiguration authenticationConfiguration;
@Resource
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
// ...
/**
* 配置Spring Security的过滤链。
*
* @param http 用于构建安全配置的HttpSecurity对象。
* @return 返回配置好的SecurityFilterChain对象。
* @throws Exception 如果配置过程中发生错误,则抛出异常。
*/
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// ...
// 配置过滤器,把jwtAuthenticationTokenFilter添加到UsernamePasswordAuthenticationFilter之前
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
// 构建并返回安全过滤链
return http.build();
}
}
退出登录
退出登录时只需要删除 Redis 中的登录信息即可:
@Override
public Result<String> logout() {
// 获取SecurityContextHolder中的用户信息
UsernamePasswordAuthenticationToken authentication = (UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
LoginUser user = (LoginUser) authentication.getPrincipal();
// 删除redis中的值
String redisKey = "login:" + user.getUser().getId();
stringRedisTemplate.delete(redisKey);
return Result.success();
}
授权
例如一个学校图书馆的管理系统,如果是普通学生登录就能看到借书还书相关的功能,不可能让他看到并且去使用添加书籍信息,删除书籍信息等功能。但是如果是一个图书馆管理员的账号登录了,应该就能看到并使用添加书籍信息,删除书籍信息等功能。
总结起来就是不同的用户可以使用不同的功能。这就是权限系统要去实现的效果。
我们不能只依赖前端去判断用户的权限来选择显示哪些菜单哪些按钮。因为如果只是这样,如果有人知道了对应功能的接口地址就可以不通过前端,直接去发送请求来实现相关功能操作。
所以我们还需要在后台进行用户权限的判断,判断当前用户是否有相应的权限,必须具有所需权限才能进行相应的操作。
授权的基本流程
在 SpringSecurity 中,会使用默认的 FilterSecurityInterceptor 来进行权限校验。在 FilterSecurityInterceptor 中会从 SecurityContextHolder 获取其中的 Authentication,然后获取其中的权限信息。当前用户是否拥有访问当前资源所需的权限。
所以我们在项目中只需要把当前登录用户的权限信息也存入 Authentication,然后设置我们的资源所需要的权限即可。
授权流程开发
限制访问资源所需权限
SpringSecurity 为我们提供了基于注解的权限控制方案,这也是我们项目中主要采用的方式。我们可以使用注解去指定访问对应的资源所需的权限,但是要使用它我们需要先在 SecurityConfig 上开启相关配置:
@EnableMethodSecurity
然后就可以使用 @PreAuthorize 进行权限限制:
@RequestMapping("hello")
// hasAuthority实际上是一个方法的调用,用来判断用户是否有user权限
@PreAuthorize("hasAuthority('user')")
public String hello(){
return "hello world";
}
封装权限信息
因为 UserDetails 中还涉及到权限的属性,所以我们需要进一步封装 permisson 属性:
@Data
@NoArgsConstructor
public class LoginUser implements UserDetails {
/**
* 直接封装SysUser内部类
*/
private SysUser user;
/**
* 存储权限信息
*/
private List<String> permission;
@JsonIgnore // 不需要序列化
private List<SimpleGrantedAuthority> authorities;
public LoginUser(SysUser user, List<String> permission) {
this.user = user;
this.permission = permission;
}
@JsonIgnore
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// 把permission中String类型的权限信息进行封装
if (authorities != null) {
return authorities;
}
return permission.stream().map(SimpleGrantedAuthority::new).toList();
}
@JsonIgnore
@Override
public String getPassword() {
return user.getPassword();
}
//...
}
RBAC 权限模型
我们的权限信息是需要存储到数据库的,这就需要我们在数据库中编写存储权限相关信息的表。我们这里使用 RBAC 模型(Role-Based Access Control),即:基于角色的权限控制。
首先,每个用户会有相对应的权限,所以在基础的用户表上就应该新添加一张权限表。但是一个用户有时拥有的权限过多,所以就引入了角色的概念。角色是一系列权限的集合,通过给用户分配角色来间接给用户分配权限:
代码实现
更改 UserServiceImpl 中对 loadUserByUsername 方法和 login 方法:
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 查询用户信息
// select * from sys_user where user_name = #{username}
SysUser user = lambdaQuery().eq(SysUser::getUserName, username).one();
if (user == null) {
throw new UsernameNotFoundException(username);
}
// 查询对应的权限信息
List<String> list = sysUserMapper.selectPermsByUserId(user.getId());
// 封装UserDetails
return new LoginUser(user, list);
}
JwtAuthenticationTokenFilter 当中需要存入用户权限信息:
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// ...
// 存入SecurityContextHolder
// 获取权限信息封装到Authentication
UsernamePasswordAuthenticationToken authenticationToken
= new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
// 放行
filterChain.doFilter(request, response);
}
自定义失败处理器
在项目中,我们希望在认证失败或者是授权失败的情况下也能和我们的接口一样返回相同结构的 json,这样可以让前端能对响应进行统一的处理。要实现这个功能我们需要知道 SpringSecurity 的异常处理机制。
在 SpringSecurity 中,如果我们在认证或者授权的过程中出现了异常会被 ExceptionTranslationFilter 捕获到。在 ExceptionTranslationFilter 中会去判断是认证失败还是授权失败出现的异常。
如果是认证过程中出现的异常会被封装成 AuthenticationException 然后调用 AuthenticationEntryPoint 对象的方法去进行异常处理。
如果是授权过程中出现的异常会被封装成 AccessDeniedException 然后调用 AccessDeniedHandler 对象的方法去进行异常处理。
所以如果我们需要自定义异常处理,我们只需要自定义 AuthenticationEntryPoint 和 AccessDeniedHandler 然后配置 SpringSecurity 即可。
先来一个工具类辅助我们操作:
public class WebUtils {
/**
* 将字符串渲染到客户端
* @param response 渲染对象
* @param string 待渲染的字符串
* @return null
*/
public static String renderString(HttpServletResponse response,
String string) {
try {
response.setStatus(200);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().print(string);
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
}
然后是对认证过程的异常处理:
/**
* 用来处理认证过程中出现的异常
*/
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
private final ObjectMapper mapper = new ObjectMapper();
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException)
throws IOException, ServletException {
// 如果有需要的话,响应状态码可以设置成401
Result<String> result = Result.error("用户认证失败");
String json = mapper.writeValueAsString(result);
WebUtils.renderString(response, json);
}
}
接着是对授权过程的异常处理:
@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
private final ObjectMapper mapper = new ObjectMapper();
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException)
throws IOException, ServletException {
// 如果有需要的话,响应状态码可以设置成403
Result<String> result = Result.error("权限不足");
String json = mapper.writeValueAsString(result);
WebUtils.renderString(response, json);
}
}
最后就是在 SecurityConfig 中配置失败处理器:
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
//...
// 配置异常处理器
http.exceptionHandling(
exception -> exception
.authenticationEntryPoint(new AuthenticationEntryPointImpl())
.accessDeniedHandler(new AccessDeniedHandlerImpl())
);
// 构建并返回安全过滤链
return http.build();
}
跨域问题
浏览器出于安全的考虑,使用 XMLHttpRequest 对象发起 HTTP 请求时必须遵守同源策略,否则就是跨域的 HTTP 请求,默认情况下是被禁止的。 同源策略要求源相同才能正常进行通信,即协议、域名、端口号都完全一致。
前后端分离项目,前端项目和后端项目一般都不是同源的,所以肯定会存在跨域请求的问题。
使用了 SpringSecurity 后,请求全部会交由 SpringSecurity 进行保护,对此我们需要实现后端的跨域配置。
先对 SpringBoot 进行配置:
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
// 设置允许跨域请求的域名
.allowedOriginPatterns("*")
// 是否允许cookie
.allowCredentials(true)
// 设置允许的请求方式
.allowedMethods("GET", "POST", "DELETE", "PUT")
// 设置允许的header属性
.allowedHeaders("*")
// 跨域允许时间
.maxAge(3600);
}
}
接下来是 SpringSecurity 的配置,在 SecurityConfig 中进行配置:
// 配置跨域请求
http.cors(Customizer.withDefaults());
其他补充
其他权限校验方法
我们前面都是使用 @PreAuthorize 注解,然后在在其中使用的是 hasAuthority 方法进行校验。
SpringSecurity 还为我们提供了其它方法例如:hasAnyAuthority,hasRole,hasAnyRole 等:
hasAnyAuthority方法可以传入多个权限,只有用户有其中任意一个权限都可以访问对应资源:
@PreAuthorize("hasAnyAuthority('admin','test','system:dept:list')") public String hello() { return "hello"; }
hasRole 要求有对应的角色才可以访问,但是它内部会把我们传入的参数拼接上
ROLE_
后再去比较。所以这种情况下要用用户对应的权限也要有
ROLE_
这个前缀才可以:@PreAuthorize("hasRole('system:dept:list')") // 这里实际上会去校验ROLE_system:dept:list权限 public String hello() { return "hello"; }
hasAnyRole 有任意的角色就可以访问。它内部也会把我们传入的参数拼接上
ROLE_
后再去比较。所以这种情况下要用用户对应的权限也要有ROLE_
这个前缀才可以:@PreAuthorize("hasAnyRole('admin','system:dept:list')") // 这里校验的就是ROLE_admin和ROLE_system:dept:list public String hello() { return "hello"; }
自定义权限校验方法
自定义校验方法可以更加灵活地配置权限校验,例如增加通配符校验等功能。先定义组件:
@Component("ex")
public class ExpressionRoot {
public boolean hasAuthority(String authority) {
// 获取当前用户的权限
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
LoginUser user = (LoginUser) authentication.getPrincipal();
List<String> permissions = user.getPermission();
// 判断用户权限集合中是否存在authority权限
return permissions.contains(authority);
}
}
然后使用组件名调用对应方法:
@GetMapping("/hello")
@PreAuthorize("@ex.hasAuthority('system:dept:list')")
public String hello() {
return "hello";
}
基于配置的权限控制
我们也可以在配置类中使用使用配置的方式对资源进行权限控制:
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/user/login").anonymous()
.antMatchers("/testCors").hasAuthority("system:dept:list222")
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
//添加过滤器
http.addFilterBefore(jwtAuthenticationTokenFilter,
UsernamePasswordAuthenticationFilter.class);
//配置异常处理器
http.exceptionHandling()
//配置认证失败处理器
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler);
//允许跨域
http.cors();
}
CSRF
CSRF 是指跨站请求伪造(Cross-site request forgery),是 web 常见的攻击之一。
SpringSecurity 去防止 CSRF 攻击的方式就是通过 csrf_token
。后端会生成一个 csrf_token
,前端发起请求的时候需要携带这个 csrf_token
,后端会有过滤器进行校验,如果没有携带或者是伪造的就不允许访问。
我们可以发现 CSRF 攻击依靠的是 cookie 中所携带的认证信息。但是在前后端分离的项目中我们的认证信息其实是 token,而 token 并不是存储中 cookie 中,并且需要前端代码去把 token 设置到请求头中才可以,所以 CSRF 攻击也就不用担心了。
http.csrf(AbstractHttpConfigurer::disable); // 禁用CSRF保护
登录成功处理器
实际上在 UsernamePasswordAuthenticationFilter 进行登录认证的时候,如果登录成功了是会调用 AuthenticationSuccessHandler 的方法进行认证成功后的处理的。AuthenticationSuccessHandler 就是登录成功处理器。
我们也可以自己去定义成功处理器进行成功后的相应处理。
public class SuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
System.out.println("认证成功了");
}
}
OAuth2
OAuth 全称为 Open Authorization(开放授权)。OAuth 协议为用户资源的授权提供了一个安全的、开放而又简易的标准。与以往的授权方式不同之处是 OAuth 的授权不会使第三方触及到用户的帐号信息(如用户名与密码),即第三方无需使用用户的用户名与密码就可以申请获得该用户资源的授权,因此 OAUTH 是安全的。
简单来说就是当第三方应用需要用户保存在其他应用上的资源时,比如网易云音乐的第三方登录功能,需要获取用户在其他应用上的用户名和头像等信息,这时通过 OAuth 开放协议以一种安全的方式授权第三方应用去获取这些资源。
(摘自Gent_倪博客)
OAuth2 的四种角色
- 资源所有者(Resource Owner):即用户,资源的拥有人,想要通过客户应用访问资源服务器上的资源。
- 客户应用(Client):通常是一个 Web 或者无线应用,它需要访问用户的受保护的资源。(即第三方应用,例如上述的网易云音乐)
- 资源服务器(Resource Server):存储受保护资源的服务器或定义了可以访问到资源的 API,接收并验证客户
端的访问令牌,以决定是否授权访问资源。 - 授权服务器(Authorization Server):负责验证资源所有者的身份并向客户端颁发访问令牌。
比如我们要使用微信第三方登录,我们一般只开发 “客户应用” 就好了,资源服务器和授权服务器由微信官方提供。
OAuth2 使用场景
在传统的身份验证中,用户需要提供用户名和密码,还有很多网站登录时,允许使用第三方网站的身份,这称
为 “第三方登录”。所谓第三方登录,实质就是 OAuth 授权。用户想要登录 A 网站,A 网站让用户提供第三方网站
的数据,证明自己的身份。获取第三方网站的身份数据,就需要 OAuth 授权。
OAuth2 的四种授权模式
详情见Gent_倪博客介绍。
授权模式的选择:
- 如果令牌是要给机器用的,就选凭证式(Client Credentials Grant)。
- 如果是通过浏览器来进行第三方访问登录,可以选择授权码模式(Authorization Code Grant)。
- 如果是单页应用,没有后端程序,且是第一方程序(企业内部使用),则使用密码模式(Resource Owner Password Credentials Grant)。
- 其余使用隐式授权(Implicit Grant)。
Spring 中的 OAuth2
Spring Security 的 OAuth 2.0 支持包括两个主要功能集:
这些功能集涵盖了 OAuth 2.0 授权框架 中定义的资源服务器和客户端角色,而授权服务器角色由 Spring 授权服务器 涵盖,Spring Authorization Server 是一个基于 Spring Security 构建的独立项目。
(以上摘自 Spring 官方文档)
接下来,我们以 GitHub 社交登录为例,来讲解 Spring 中 OAuth2 的应用。
OAuth2 GitHub 登录流程
- 用户请求登录:用户在我们自己的 Web 应用中选择使用 GitHub 登录。
- 重定向到 GitHub:应用将用户重定向到 GitHub 的授权页面,附带客户端 ID 和所需的权限范围。
- 用户授权:用户在 GitHub 上登录并授权我们的应用访问其信息。
- 获取授权码:GitHub 将用户重定向回 Web 应用,附带一个授权码。
- 请求访问令牌:Web 应用使用授权码向 GitHub 请求访问令牌。
- 访问资源:Web 应用使用访问令牌访问 GitHub 的 API,获取用户的资源。
其中,Web 应用是客户应用。它请求用户的授权,以访问 GitHub 的资源(如用户信息)。
GitHub 作为资源服务器,存储用户的资源(例如 GitHub 账户的信息、用户仓库、活动等)。当用户授权 Web 应用访问这些资源时,Web 应用可以使用访问令牌来请求 GitHub 的 API。
同时,GitHub 也是授权服务器,负责处理用户的身份验证和授权请求。当用户通过 GitHub 登录时,GitHub 会验证用户的身份并在用户同意授权后,颁发访问令牌(Access Token)给 Web 应用。
注册客户应用
在 GitHub 上注册一个新的 OAuth 应用程序的作用是创建一个唯一的身份标识,允许 Web 应用安全地与 GitHub 的授权和资源服务进行交互。
登录 github 后,右上角点击头像,然后转到 “settings”,在左侧最下方点击 “Developer Settings”,选择左侧的 “OAuth Apps”,而后点击 “Register a new application” 进行客户应用的注册。
在页面进行相关的配置,Homepage URL 配置为:http://localhost:8080/
,Authorization callback URL 配置为:http://localhost:8080/login/oauth2/code/github
(仅作示例用途)。注册完成之后会得到一个 Client ID,需要妥善保存。接着在下方生成 Client secrets,同样也要妥善保存。最后可以在下方上传一个 Web 应用的 logo。
创建客户应用
示例工程来自:spring-security-sample。
创建客户应用时需要包含 oauth2 client 和 SpringSecurity:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
接下来在 yml 文件中进行配置:
spring:
application:
name: oauth2
security:
oauth2:
client:
registration:
github:
client-id: xxx
client-secret: xxx
Controller 配置:
@Controller
public class IndexController {
@GetMapping("/")
public String index(Model model,
@RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient authorizedClient,
@AuthenticationPrincipal OAuth2User oauth2User) {
model.addAttribute("userName", oauth2User.getName());
model.addAttribute("clientName", authorizedClient.getClientRegistration().getClientName());
model.addAttribute("userAttributes", oauth2User.getAttributes());
return "index";
}
}
index.html
页面配置:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org" xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
<head>
<title>Spring Security - OAuth 2.0 Login</title>
<meta charset="utf-8" />
</head>
<body>
<!-- 登出表单 -->
<div style="float: right" th:fragment="logout" sec:authorize="isAuthenticated()">
<div style="float:left">
<span style="font-weight:bold">User: </span><span sec:authentication="name"></span>
</div>
<div style="float:none"> </div>
<div style="float:right">
<form action="#" th:action="@{/logout}" method="post">
<input type="submit" value="Logout" />
</form>
</div>
</div>
<!-- 登录成功 -->
<h1>OAuth 2.0 Login with Spring Security</h1>
<div>
You are successfully logged in <span style="font-weight:bold" th:text="${userName}"></span>
via the OAuth 2.0 Client <span style="font-weight:bold" th:text="${clientName}"></span>
</div>
<div> </div>
<!-- 展示用户信息 -->
<div>
<span style="font-weight:bold">User Attributes:</span>
<ul>
<li th:each="userAttribute : ${userAttributes}">
<span style="font-weight:bold" th:text="${userAttribute.key}"></span>: <span th:text="${userAttribute.value}"></span>
</li>
</ul>
</div>
</body>
</html>